From ece83be364524ebde5b327019244872617bd6e9b Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 11:12:05 +0200 Subject: [PATCH 01/88] style: please clippy --- src/cache.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index cefa2ad..2d2d613 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -151,12 +151,11 @@ pub async fn hash_file(path: &Path) -> TsdlResult { } let result = hasher.finalize(); - Ok(result - .iter() - .fold(String::with_capacity(result.len() * 2), |mut acc, b| { - let _ = write!(acc, "{b:02x}"); - acc - })) + let mut hex = String::with_capacity(result.len() * 2); + for byte in result { + write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail"); + } + Ok(hex) } #[cfg(test)] From 93698f91a26f097253d0b94417ac0329495eb8c2 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 21 May 2026 17:25:29 +0200 Subject: [PATCH 02/88] display: move to ratatui backend --- Cargo.lock | 1832 ++++++++++++++++++++++++++++------------- Cargo.toml | 3 +- src/actors/display.rs | 579 ++++++++++--- src/actors/mod.rs | 44 +- src/app.rs | 6 +- src/build.rs | 77 +- src/display.rs | 670 +++++++++------ src/main.rs | 2 +- src/parser.rs | 17 +- src/selfupdate.rs | 27 +- src/tree_sitter.rs | 46 +- tests/cmd/build.rs | 16 +- tests/cmd/cache.rs | 14 +- tests/cmd/log.rs | 4 +- typos.toml | 2 + 15 files changed, 2231 insertions(+), 1108 deletions(-) create mode 100644 typos.toml diff --git a/Cargo.lock b/Cargo.lock index a912e35..f590399 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,13 +19,19 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -78,9 +84,18 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +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 = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "assert_cmd" @@ -113,9 +128,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -142,7 +157,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -179,9 +194,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "zeroize", @@ -189,14 +204,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -236,11 +252,32 @@ dependencies = [ "console 0.15.11", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" -version = "2.11.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -253,22 +290,22 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -277,11 +314,17 @@ 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 = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -291,15 +334,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "camino" -version = "1.2.2" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -325,14 +368,23 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.19", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", ] [[package]] name = "cc" -version = "1.2.63" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -348,15 +400,26 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -374,9 +437,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -386,14 +449,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -427,6 +490,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 = "compression-codecs" version = "0.4.38" @@ -458,9 +535,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -569,18 +646,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -588,18 +665,45 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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 = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] [[package]] name = "crypto-common" @@ -620,6 +724,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -644,9 +758,49 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -662,9 +816,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_more" @@ -685,7 +836,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -739,7 +890,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -750,19 +901,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -807,9 +958,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "encode_unicode" @@ -835,7 +986,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -854,11 +1005,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fiat-crypto" @@ -880,6 +1050,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -896,6 +1077,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -923,9 +1116,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -944,9 +1137,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -959,9 +1152,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -969,15 +1162,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -986,32 +1179,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-timer" @@ -1021,9 +1214,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1066,24 +1259,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1094,15 +1286,15 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -1117,16 +1309,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags", + "bitflags 2.13.1", "ignore", "walkdir", ] [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1143,10 +1335,12 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1155,6 +1349,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -1177,11 +1376,17 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1189,9 +1394,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1199,9 +1404,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1228,24 +1433,24 @@ dependencies = [ "serde", "serde_derive", "sysinfo 0.38.4", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "uuid", ] [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1383,10 +1588,10 @@ dependencies = [ ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" @@ -1411,9 +1616,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -1433,17 +1638,15 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "console 0.16.3", + "console 0.16.4", "portable-atomic", "unicode-width", "unit-prefix", @@ -1465,11 +1668,24 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[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.3", +] + [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1477,6 +1693,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1495,7 +1720,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -1510,7 +1735,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1529,31 +1754,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] +[[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 2.0.19", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1561,16 +1802,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "libc" -version = "0.2.186" +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] [[package]] name = "linux-raw-sys" @@ -1590,11 +1840,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "lru-slab" @@ -1602,11 +1870,42 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" @@ -1620,22 +1919,46 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] [[package]] -name = "normalize-line-endings" -version = "0.3.0" +name = "nix" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" - -[[package]] +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] name = "ntapi" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1669,9 +1992,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -1692,6 +2015,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1703,11 +2037,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -1742,6 +2075,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -1757,7 +2099,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -1774,7 +2116,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] @@ -1826,6 +2168,71 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[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 = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pear" version = "0.2.9" @@ -1846,7 +2253,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1855,6 +2262,100 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1871,11 +2372,17 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "potential_utf" @@ -1892,15 +2399,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "predicates" version = "3.1.4" @@ -1941,30 +2439,20 @@ dependencies = [ "yansi", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1977,7 +2465,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "version_check", "yansi", ] @@ -1993,9 +2481,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -2005,7 +2493,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -2013,21 +2501,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -2035,23 +2524,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2070,22 +2559,22 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "rand_chacha", - "rand_core 0.9.5", + "rand_core 0.6.4", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2099,18 +2588,133 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core 0.10.1", +] + +[[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-macros", + "ratatui-termina", + "ratatui-termwiz", + "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", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "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-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[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", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2120,9 +2724,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2131,9 +2735,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -2220,21 +2824,21 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-ident", ] [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2251,7 +2855,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -2260,9 +2864,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -2276,9 +2880,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2288,9 +2892,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2337,9 +2941,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2359,13 +2969,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -2430,9 +3046,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2440,29 +3056,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2526,6 +3142,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "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" @@ -2548,15 +3185,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -2568,6 +3205,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2576,15 +3219,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2617,12 +3260,39 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2648,9 +3318,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -2674,7 +3355,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2692,88 +3373,185 @@ dependencies = [ ] [[package]] -name = "sysinfo" -version = "0.39.3" +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "termwiz" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "objc2-open-directory", - "windows", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", ] [[package]] -name = "tar" -version = "0.4.46" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "filetime", - "libc", - "xattr", + "thiserror-impl 1.0.69", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "thiserror" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", + "thiserror-impl 2.0.19", ] [[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - -[[package]] -name = "thiserror" -version = "2.0.18" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2782,15 +3560,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2808,9 +3586,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -2823,9 +3601,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -2839,13 +3617,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -2860,13 +3638,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2885,9 +3664,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -2895,7 +3674,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -2932,23 +3711,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -2959,9 +3738,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -2984,7 +3763,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -3027,7 +3806,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror", + "thiserror 2.0.19", "time", "tracing-subscriber", ] @@ -3040,7 +3819,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3108,8 +3887,9 @@ dependencies = [ "cargo_metadata", "clap", "clap-verbosity-flag", - "console 0.16.3", + "console 0.16.4", "const-str", + "crossterm", "derive_more", "diff-struct", "enum_dispatch", @@ -3117,12 +3897,12 @@ dependencies = [ "futures", "human-panic", "ignore", - "indicatif", "indoc", "log", "num_cpus", "predicates", "pretty_assertions", + "ratatui", "reqwest", "rstest", "self_update", @@ -3130,10 +3910,10 @@ dependencies = [ "serde", "serde_json", "sha1", - "sysinfo 0.39.3", + "sysinfo 0.39.6", "tempfile", "tokio", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "tracing", "tracing-appender", "tracing-error", @@ -3148,6 +3928,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uncased" version = "0.9.10" @@ -3165,9 +3951,20 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] [[package]] name = "unicode-width" @@ -3266,11 +4063,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.2", + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -3285,6 +4085,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -3321,27 +4130,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -3352,9 +4152,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -3362,9 +4162,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3372,96 +4172,134 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "web-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ - "leb128fmt", - "wasmparser", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", + "rustls-pki-types", ] [[package]] -name = "web-sys" -version = "0.3.99" +name = "webpki-roots" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ - "js-sys", - "wasm-bindgen", + "rustls-pki-types", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "wezterm-bidi" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" dependencies = [ - "js-sys", - "wasm-bindgen", + "log", + "wezterm-dynamic", ] [[package]] -name = "webpki-root-certs" -version = "1.0.7" +name = "wezterm-blob-leases" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ - "rustls-pki-types", + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", ] [[package]] -name = "webpki-roots" -version = "1.0.7" +name = "wezterm-color-types" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" dependencies = [ - "rustls-pki-types", + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", ] [[package]] @@ -3548,7 +4386,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3559,7 +4397,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3602,7 +4440,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -3611,16 +4449,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3638,31 +4467,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3680,96 +4492,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -3781,107 +4545,19 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -3906,9 +4582,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3923,30 +4599,10 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -3964,15 +4620,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -4004,7 +4660,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4015,11 +4671,11 @@ checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" dependencies = [ "base64", "ed25519-dalek", - "thiserror", + "thiserror 2.0.19", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index e42374a..1ed704c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ better-panic = "0.3" clap = { version = "4.6", features = ["cargo", "derive", "env"] } clap-verbosity-flag = "3.0" console = "0.16" +crossterm = "0.29" derive_more = { version = "2", features = ["as_ref", "deref", "display"] } diff-struct = "0.5" enum_dispatch = "0.3" @@ -53,7 +54,7 @@ figment = { version = "0.10", features = ["toml", "env"] } futures = "0.3" human-panic = "2.0" ignore = "0.4" -indicatif = "0.18" +ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color", "macros"] } log = "0.4" num_cpus = "1.17" reqwest = { version = "0.13", default-features = false, features = [ diff --git a/src/actors/display.rs b/src/actors/display.rs index 00076d7..55c55cc 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -1,23 +1,23 @@ -use std::{collections::HashMap, sync::Arc}; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use ratatui::widgets::Paragraph; use tokio::sync::{mpsc, oneshot}; +use tokio::time; -use crate::{ - actors::{Addr, Response}, - display::{Progress, ProgressBar, UpdateKind}, - git::GitRef, - TsdlResult, -}; +use crate::actors::Addr; +use crate::display::{DisplayState, GrammarEntry, ItemOutcome, ItemStatus, Mode, RepoEntry}; +use crate::git::GitRef; -#[derive(Debug)] -#[allow(dead_code)] -enum DisplayResponseKind<'a> { - RegisterGrammar { language: &'a str, name: &'a str }, - RegisterLanguage { name: &'a str }, -} +// --------------------------------------------------------------------------- +// Message types +// --------------------------------------------------------------------------- #[derive(Debug)] pub enum DisplayMessage { + /// Register a repo-level progress line. Returns a ProgressAddr. RegisterLanguage { git_ref: GitRef, name: Arc, @@ -25,10 +25,7 @@ pub enum DisplayMessage { tx: oneshot::Sender, }, - Println { - msg: Arc, - }, - + /// Register a grammar-level progress line. Returns a ProgressAddr. RegisterGrammar { git_ref: GitRef, language: Arc, @@ -37,23 +34,40 @@ pub enum DisplayMessage { tx: oneshot::Sender, }, - UnregisterLanguage { - name: Arc, - }, - + /// Update a specific bar. Update { id: u64, kind: UpdateKind, - msg: String, + msg: Arc, }, + /// Advance the elapsed-time display and trigger a re-render. Tick, + + /// Flush and close the display actor. The response is sent after cleanup. + Shutdown { tx: oneshot::Sender<()> }, } -/// The Manager Handle: Only used to register/unregister tasks. +#[derive(Debug, Clone, Copy)] +pub enum UpdateKind { + Msg, + Step, + MarkCached, + MarkBuilt, + Cached, + Fin, + Err, +} + +// --------------------------------------------------------------------------- +// Handles +// --------------------------------------------------------------------------- + #[derive(Debug, Clone)] pub struct DisplayAddr { tx: mpsc::Sender, + #[allow(dead_code)] + mode: Mode, } impl Addr for DisplayAddr { @@ -70,20 +84,18 @@ impl Addr for DisplayAddr { impl DisplayAddr { #[must_use] - pub fn new(tx: mpsc::Sender) -> Self { - Self { tx } + pub fn new(tx: mpsc::Sender, mode: Mode) -> Self { + Self { tx, mode } } - pub async fn add_grammar>>( + pub async fn add_language>>( &self, git_ref: GitRef, - language: S, name: S, num_tasks: usize, ) -> ProgressAddr { - self.request(|tx| DisplayMessage::RegisterGrammar { + self.request(|tx| DisplayMessage::RegisterLanguage { git_ref, - language: language.into(), name: name.into(), num_tasks, tx, @@ -91,14 +103,16 @@ impl DisplayAddr { .await } - pub async fn add_language>>( + pub async fn add_grammar>>( &self, git_ref: GitRef, + language: S, name: S, num_tasks: usize, ) -> ProgressAddr { - self.request(|tx| DisplayMessage::RegisterLanguage { + self.request(|tx| DisplayMessage::RegisterGrammar { git_ref, + language: language.into(), name: name.into(), num_tasks, tx, @@ -106,22 +120,16 @@ impl DisplayAddr { .await } - pub async fn println>>(&self, msg: S) { - self.fire(DisplayMessage::Println { msg: msg.into() }).await; + pub async fn tick(&self) { + self.fire(DisplayMessage::Tick).await; } - pub async fn remove_language>>(&self, name: S) -> TsdlResult<()> { - self.fire(DisplayMessage::UnregisterLanguage { name: name.into() }) - .await; - Ok(()) - } - - pub async fn tick(&self) { - self.fire(DisplayMessage::Tick {}).await; + pub async fn shutdown(&self) { + self.request(|tx| DisplayMessage::Shutdown { tx }).await } } -/// The Task Handle: Dedicated to controlling a specific progress bar. +/// Handle for updating a specific progress bar (repo or grammar). #[derive(Debug, Clone)] pub struct ProgressAddr { id: u64, @@ -129,8 +137,7 @@ pub struct ProgressAddr { } impl ProgressAddr { - /// Takes Into directly as the message must be owned to be sent - pub fn msg>(&self, msg: S) { + pub fn msg>>(&self, msg: S) { let _ = self.tx.try_send(DisplayMessage::Update { id: self.id, kind: UpdateKind::Msg, @@ -138,7 +145,7 @@ impl ProgressAddr { }); } - pub fn step>(&self, msg: S) { + pub fn step>>(&self, msg: S) { let _ = self.tx.try_send(DisplayMessage::Update { id: self.id, kind: UpdateKind::Step, @@ -146,7 +153,31 @@ impl ProgressAddr { }); } - pub fn fin>(&self, msg: S) { + pub fn mark_cached(&self) { + let _ = self.tx.try_send(DisplayMessage::Update { + id: self.id, + kind: UpdateKind::MarkCached, + msg: Arc::from(""), + }); + } + + pub fn mark_built(&self) { + let _ = self.tx.try_send(DisplayMessage::Update { + id: self.id, + kind: UpdateKind::MarkBuilt, + msg: Arc::from(""), + }); + } + + pub fn cached>>(&self, msg: S) { + let _ = self.tx.try_send(DisplayMessage::Update { + id: self.id, + kind: UpdateKind::Cached, + msg: msg.into(), + }); + } + + pub fn fin>>(&self, msg: S) { let _ = self.tx.try_send(DisplayMessage::Update { id: self.id, kind: UpdateKind::Fin, @@ -154,7 +185,7 @@ impl ProgressAddr { }); } - pub fn err>(&self, msg: S) { + pub fn err>>(&self, msg: S) { let _ = self.tx.try_send(DisplayMessage::Update { id: self.id, kind: UpdateKind::Err, @@ -163,124 +194,426 @@ impl ProgressAddr { } } +// --------------------------------------------------------------------------- +// DisplayActor +// --------------------------------------------------------------------------- + pub struct DisplayActor { - handles: HashMap, + state: DisplayState, next_id: u64, - progress: Progress, rx: mpsc::Receiver, tx: mpsc::Sender, } impl DisplayActor { - fn finish(&mut self, id: u64, f: F) - where - F: FnOnce(&ProgressBar), - { - self.forward(id, f); - self.handles.remove(&id); + #[must_use] + pub fn spawn(mode: Mode, build_dir: Arc, out_dir: Arc) -> DisplayAddr { + let (tx, rx) = mpsc::channel(256); + let actor = Self { + state: DisplayState::new(mode, build_dir, out_dir), + next_id: 1, + rx, + tx: tx.clone(), + }; + + tokio::spawn(async move { + actor.run().await; + }); + + DisplayAddr::new(tx, mode) } - fn forward(&self, id: u64, f: F) - where - F: FnOnce(&ProgressBar), - { - if let Some(h) = self.handles.get(&id) { - f(h); + async fn run(mut self) { + if self.state.mode == Mode::Fancy { + self.run_fancy().await; + } else { + self.run_plain().await; } } - async fn run(mut self) { + // ── Fancy mode ──────────────────────────────────────────────────── + + async fn run_fancy(&mut self) { + let term_height = crossterm::terminal::size() + .map(|(_, h)| h as usize) + .unwrap_or(40); + let viewport_height = term_height.min(40); + + let mut terminal = ratatui::Terminal::with_options( + ratatui::backend::CrosstermBackend::new(io::stderr()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Inline(viewport_height as u16), + }, + ) + .expect("Failed to initialize ratatui terminal"); + + let _ = terminal.draw(|frame| self.render(frame)); + + let mut tick_interval = time::interval(Duration::from_millis(100)); + + let mut shutdown_tx = None; + + loop { + tokio::select! { + msg = self.rx.recv() => { + match msg { + Some(DisplayMessage::Shutdown { tx }) => { + shutdown_tx = Some(tx); + break; + } + Some(msg) => self.handle_message(msg), + None => break, + } + } + _ = tick_interval.tick() => { + // Ticks trigger re-renders for live elapsed-time updates. + } + } + + let _ = terminal.draw(|frame| self.render(frame)); + } + + let _ = terminal.draw(|frame| self.render(frame)); + ratatui::restore(); + eprintln!(); + + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + } + + fn render(&self, frame: &mut ratatui::Frame) { + let area = frame.area(); + let lines = self.state.render_lines(area.width); + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); + } + + // ── Plain mode ──────────────────────────────────────────────────── + + async fn run_plain(&mut self) { while let Some(msg) = self.rx.recv().await { match msg { DisplayMessage::RegisterLanguage { git_ref, - ref name, + name, num_tasks, tx, } => { - let res = self.register({ - let name = name.clone(); - move |p| p.register(name, git_ref, num_tasks) - }); - - Response { - tx, - kind: DisplayResponseKind::RegisterLanguage { name }, - } - .send(res); + let addr = self.register_repo(name, git_ref, num_tasks); + let _ = tx.send(addr); } - - DisplayMessage::Println { msg } => { - self.progress.prinltn(msg); - } - DisplayMessage::RegisterGrammar { git_ref, - ref language, - ref name, + language, + name, num_tasks, tx, } => { - let res = self.register(|p| { - let language = language.clone(); - let name = name.clone(); - p.register(format!("{language}/{name}").into(), git_ref, num_tasks) - }); - Response { - tx, - kind: DisplayResponseKind::RegisterGrammar { language, name }, + let addr = self.register_grammar(language, name, git_ref, num_tasks); + let _ = tx.send(addr); + } + DisplayMessage::Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + if matches!(kind, UpdateKind::MarkCached | UpdateKind::MarkBuilt) { + continue; + } + if let Some(repo) = self.state.repos.get(&id) { + println!( + " {} {} [{}/{}] {}", + repo.name, repo.git_ref, repo.step, repo.total, repo.msg, + ); + } else if let Some(grammar) = self.state.grammars.get(&id) { + println!( + " {}/{} {} [{}/{}] {}", + grammar.repo, + grammar.name, + grammar.git_ref, + grammar.step, + grammar.total, + grammar.msg, + ); } - .send(res); } - - DisplayMessage::UnregisterLanguage { name } => { - self.handles.retain(|_, h| name != h.name); + DisplayMessage::Tick => {} + DisplayMessage::Shutdown { tx } => { + let _ = tx.send(()); + break; } + } + } + } - DisplayMessage::Update { id, kind, ref msg } => match kind { - UpdateKind::Msg => self.forward(id, |h| h.msg(msg)), - UpdateKind::Step => self.forward(id, |h| h.step(msg)), - UpdateKind::Fin => self.finish(id, |h| h.fin(msg)), - UpdateKind::Err => self.finish(id, |h| h.err(msg)), - }, - - DisplayMessage::Tick => { - self.progress.tick(); - } + // ── Message handling ────────────────────────────────────────────── + + fn handle_message(&mut self, msg: DisplayMessage) { + match msg { + DisplayMessage::RegisterLanguage { + git_ref, + name, + num_tasks, + tx, + } => { + let addr = self.register_repo(name, git_ref, num_tasks); + let _ = tx.send(addr); + } + DisplayMessage::RegisterGrammar { + git_ref, + language, + name, + num_tasks, + tx, + } => { + let addr = self.register_grammar(language, name, git_ref, num_tasks); + let _ = tx.send(addr); + } + DisplayMessage::Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + } + DisplayMessage::Tick => {} + DisplayMessage::Shutdown { tx } => { + let _ = tx.send(()); } } } - fn register(&mut self, create: F) -> ProgressAddr - where - F: FnOnce(&mut Progress) -> ProgressBar, - { - // 1. Create inner handle - let inner = create(&mut self.progress); + fn register_repo(&mut self, name: Arc, git_ref: GitRef, num_tasks: usize) -> ProgressAddr { + let id = self.next_id; + self.next_id += 1; + + self.state.repos.insert( + id, + RepoEntry { + name, + git_ref, + status: ItemStatus::New, + outcome: ItemOutcome::Unknown, + msg: Arc::from(""), + step: 0, + total: num_tasks, + started_at: Instant::now(), + frozen_elapsed: None, + }, + ); + + ProgressAddr { + id, + tx: self.tx.clone(), + } + } - // 2. Register in actor state + fn register_grammar( + &mut self, + language: Arc, + name: Arc, + git_ref: GitRef, + num_tasks: usize, + ) -> ProgressAddr { let id = self.next_id; self.next_id += 1; - self.handles.insert(id, inner); - // 3. Return client handle + let repo_id = self + .state + .repos + .iter() + .find(|(_, r)| r.name == language) + .map(|(id, _)| *id) + .unwrap_or(0); + + self.state.grammars.insert( + id, + GrammarEntry { + repo: language, + repo_id, + name, + git_ref, + status: ItemStatus::New, + outcome: ItemOutcome::Unknown, + msg: Arc::from(""), + step: 0, + total: num_tasks, + started_at: Instant::now(), + frozen_elapsed: None, + }, + ); + ProgressAddr { id, tx: self.tx.clone(), } } - #[must_use] - pub fn spawn(progress: Progress) -> DisplayAddr { - let (tx, rx) = mpsc::channel(64); - let actor = Self { - handles: HashMap::new(), - next_id: 1, - progress, - rx, - tx: tx.clone(), - }; - tokio::spawn(actor.run()); - DisplayAddr::new(tx) + fn apply_update(&mut self, id: u64, kind: UpdateKind, msg: Arc) { + if let Some(repo) = self.state.repos.get_mut(&id) { + Self::apply_repo_update(repo, kind, msg); + return; + } + + let mut maybe_parent_id: Option = None; + if let Some(grammar) = self.state.grammars.get_mut(&id) { + Self::apply_grammar_update(grammar, kind, msg); + if matches!( + kind, + UpdateKind::Step + | UpdateKind::MarkCached + | UpdateKind::MarkBuilt + | UpdateKind::Cached + | UpdateKind::Fin + | UpdateKind::Err + ) { + maybe_parent_id = Some(grammar.repo_id); + } + } + + if let Some(repo_id) = maybe_parent_id { + self.sync_parent_repo(repo_id); + } + } + + fn apply_repo_update(repo: &mut RepoEntry, kind: UpdateKind, msg: Arc) { + match kind { + UpdateKind::Msg => { + repo.msg = msg; + } + UpdateKind::Step => { + repo.status = ItemStatus::InProgress; + repo.step += 1; + repo.msg = msg; + } + UpdateKind::MarkCached => { + repo.outcome = ItemOutcome::Cached; + } + UpdateKind::MarkBuilt => { + repo.outcome = ItemOutcome::Built; + } + UpdateKind::Cached => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.status = ItemStatus::Done; + repo.outcome = ItemOutcome::Cached; + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = msg; + } + UpdateKind::Fin => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.status = ItemStatus::Done; + if repo.outcome == ItemOutcome::Unknown { + repo.outcome = ItemOutcome::Built; + } + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = msg; + } + UpdateKind::Err => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.status = ItemStatus::Failed; + repo.msg = msg; + } + } + } + + fn apply_grammar_update(grammar: &mut GrammarEntry, kind: UpdateKind, msg: Arc) { + match kind { + UpdateKind::Msg => { + grammar.msg = msg; + } + UpdateKind::Step => { + grammar.status = ItemStatus::InProgress; + grammar.step += 1; + grammar.msg = msg; + } + UpdateKind::MarkCached => { + grammar.outcome = ItemOutcome::Cached; + } + UpdateKind::MarkBuilt => { + grammar.outcome = ItemOutcome::Built; + } + UpdateKind::Cached => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.status = ItemStatus::Done; + grammar.outcome = ItemOutcome::Cached; + grammar.step = grammar.total; + grammar.msg = msg; + } + UpdateKind::Fin => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.status = ItemStatus::Done; + if grammar.outcome == ItemOutcome::Unknown { + grammar.outcome = ItemOutcome::Built; + } + grammar.step = grammar.total; + grammar.msg = msg; + } + UpdateKind::Err => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.status = ItemStatus::Failed; + grammar.msg = msg; + } + } + } + + fn sync_parent_repo(&mut self, repo_id: u64) { + let has_any = self.state.grammars.values().any(|g| g.repo_id == repo_id); + if !has_any { + return; + } + + let any_failed = self + .state + .grammars + .values() + .any(|g| g.repo_id == repo_id && g.status == ItemStatus::Failed); + let any_active = self.state.grammars.values().any(|g| { + g.repo_id == repo_id + && (g.status == ItemStatus::New || g.status == ItemStatus::InProgress) + }); + let outcome = self.aggregate_child_outcome(repo_id); + + if let Some(repo) = self.state.repos.get_mut(&repo_id) { + repo.outcome = outcome; + if any_failed { + repo.status = ItemStatus::Failed; + repo.msg = Arc::from("failed"); + repo.frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); + } else if any_active { + repo.status = ItemStatus::InProgress; + repo.msg = Arc::from("building"); + repo.frozen_elapsed = None; + } else { + repo.status = ItemStatus::Done; + repo.msg = Arc::from("done"); + repo.frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); + } + } + } + + fn aggregate_child_outcome(&self, repo_id: u64) -> ItemOutcome { + let mut saw_cached = false; + let mut saw_unknown = false; + + for grammar in self + .state + .grammars + .values() + .filter(|g| g.repo_id == repo_id) + { + match grammar.outcome { + ItemOutcome::Built => return ItemOutcome::Built, + ItemOutcome::Cached => saw_cached = true, + ItemOutcome::Unknown => saw_unknown = true, + } + } + + if saw_unknown { + ItemOutcome::Unknown + } else if saw_cached { + ItemOutcome::Cached + } else { + ItemOutcome::Unknown + } } } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index debb364..2e67d41 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -70,6 +70,31 @@ pub async fn run( jobs: usize, languages: Vec, tree_sitter: &TreeSitter, +) -> TsdlResult<()> { + let result = run_inner( + build_dir, + cache, + display.clone(), + jobs, + languages, + tree_sitter, + ) + .await; + + // We need to shut down before returning the results to avoid display + // issues with the ratatui backend. + display.shutdown().await; + + result +} + +async fn run_inner( + build_dir: &PathBuf, + cache: CacheAddr, + display: DisplayAddr, + jobs: usize, + languages: Vec, + tree_sitter: &TreeSitter, ) -> TsdlResult<()> { let ts_cli = Arc::new(tree_sitter::prepare(build_dir, display.clone(), tree_sitter).await?); @@ -144,20 +169,29 @@ async fn discover_grammars( ts_cli: Arc, ) -> TsdlResult> { let progress = display - .add_language(language.spec.git_ref.clone(), language.name.clone(), 3) + .add_language(language.spec.git_ref.clone(), language.name.clone(), 2) .await; - // ... (Clone logic same as original) ... if cache .needs_clone(language.name.clone(), language.spec.clone()) .await { progress.step("cloning"); - language.clone().await?; + if let Err(e) = language.clone().await { + progress.err("clone failed"); + return Err(e); + } } progress.step("scanning"); - let grammars = language.discover_grammars().await?; + let grammars = match language.discover_grammars().await { + Ok(grammars) => grammars, + Err(e) => { + progress.err("scan failed"); + return Err(e); + } + }; + progress.fin("done"); // Map the raw discovery data into the Build struct immediately let mut builds = Vec::new(); @@ -169,8 +203,8 @@ async fn discover_grammars( let progress = display .add_grammar( language.spec.git_ref.clone(), - name_arc.clone(), language.name.clone(), + name_arc.clone(), 4, ) .await; diff --git a/src/app.rs b/src/app.rs index 9824f9c..387a07f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,7 @@ use crate::{args::Args, args::BuildCommand, config, display, TsdlResult}; pub struct App { pub command: BuildCommand, pub config_path: PathBuf, - pub progress: display::Progress, + pub progress_mode: display::Mode, pub verbose: Verbosity, } @@ -17,11 +17,11 @@ impl App { /// This resolves and merges all configuration sources (CLI, config file, defaults). pub fn new(args: &Args) -> TsdlResult { let command = config::current(&args.config, args.command.as_build())?; - let progress = display::current(&args.progress, &args.verbose); + let progress_mode = display::mode_from_args(&args.progress, &args.verbose); Ok(Self { command, - progress, + progress_mode, config_path: args.config.clone(), verbose: args.verbose, }) diff --git a/src/build.rs b/src/build.rs index e1d0a3c..f8dba90 100644 --- a/src/build.rs +++ b/src/build.rs @@ -6,16 +6,14 @@ use std::{ }; use serde::{Deserialize, Serialize}; -use tokio::time; use url::Url; use crate::{ - actors::{self, CacheActor, DisplayActor, DisplayAddr}, + actors::{self, CacheActor, DisplayActor}, app::App, args::{ParserConfig, Target, TreeSitter}, cache::Db, consts::TSDL_FROM, - display::{self, Progress, ProgressBar, TICK_CHARS}, error::{self, TsdlError}, git::GitRef, lock::{Lock, LockStatus}, @@ -43,50 +41,6 @@ pub struct OutputConfig { pub struct BuildContext { pub cache_hit: bool, pub force: bool, - pub progress: Option, -} - -impl BuildContext { - pub fn err(&self, msg: &str) { - if let Some(ref progress) = self.progress { - progress.err(msg); - } - } - - pub fn fin(&self, msg: &str) { - if let Some(ref progress) = self.progress { - progress.fin(msg); - } - } - - pub fn msg(&self, msg: &str) { - if let Some(ref progress) = self.progress { - progress.msg(msg); - } - } - - pub fn step(&self, msg: &str) { - if let Some(ref progress) = self.progress { - progress.step(msg); - } - } - - #[must_use] - pub fn is_done(&self) -> bool { - self.progress.as_ref().is_none_or(ProgressBar::is_done) - } - - pub fn start(&mut self, msg: &str) { - if let Some(ref mut progress) = self.progress { - progress.step(msg); - } - } - - pub fn tick(&self) { - if let Some(ref progress) = self.progress { - progress.tick(); - } - } } pub fn run(app: &mut App) -> TsdlResult<()> { @@ -94,15 +48,12 @@ pub fn run(app: &mut App) -> TsdlResult<()> { crate::config::show(&app.command)?; } - // Initialize the manager first with the build directory let lock = Lock::new(&app.command.build_dir); if app.command.unlock { lock.force_unlock()?; } - // Check lock status before clearing anything - let _guard = match lock.try_acquire()? { LockStatus::Acquired(lock) => lock, @@ -114,7 +65,6 @@ pub fn run(app: &mut App) -> TsdlResult<()> { LockStatus::LockedBy { pid, exe } => { eprintln!("Lock owned by different process: PID {pid} ({exe})"); if prompt_user("Proceed anyway?", false)? { - // Use the manager instance to force acquire lock.force_acquire()? } else { return Err(TsdlError::message("Lock acquisition cancelled by user")); @@ -147,9 +97,8 @@ pub fn run(app: &mut App) -> TsdlResult<()> { fn clear(app: &mut App) -> TsdlResult<()> { if app.command.fresh && app.command.build_dir.exists() { - let bar = app.progress.register("Fresh Build".into(), "".into(), 1); fs::remove_dir_all(&app.command.build_dir)?; - bar.fin(format!("Cleaned {}", app.command.build_dir.display())); + eprintln!("Cleaned {}", app.command.build_dir.display()); } fs::create_dir_all(&app.command.build_dir)?; @@ -180,7 +129,6 @@ fn get_language_coords( language: &str, defined_parsers: Option<&BTreeMap>, ) -> (Option, GitRef, TsdlResult) { - // Attempt to find the config; defaults to None if map or key is missing let config = defined_parsers.and_then(|parsers| parsers.get(language)); match config { @@ -221,12 +169,9 @@ fn ignite(app: &App) -> TsdlResult<()> { let result = rt.block_on(async move { let cache = CacheActor::spawn(db, app.command.force); - let display = DisplayActor::spawn(Progress::new(app.progress.mode)); - - let display2 = display.clone(); - tokio::spawn(async { - update_screen(display2).await; - }); + let build_dir: Arc = app.command.build_dir.canon()?.into(); + let out_dir: Arc = app.command.out_dir.canon()?.into(); + let display = DisplayActor::spawn(app.progress_mode, build_dir, out_dir); actors::run( &app.command.build_dir, @@ -281,7 +226,6 @@ fn unique_languages(app: &App) -> Vec> { BuildContext { force: app.command.force || app.command.fresh, cache_hit: false, - progress: None, // Progress is handled by DisplayActor }, Arc::new(BuildSpec { build_script, @@ -315,14 +259,3 @@ fn unique_languages(app: &App) -> Vec> { results } - -async fn update_screen(display: DisplayAddr) { - let mut interval = time::interval(time::Duration::from_millis( - 1000 / TICK_CHARS.chars().count() as u64, - )); - - loop { - interval.tick().await; - display.tick().await; - } -} diff --git a/src/display.rs b/src/display.rs index ace4dfa..d7fa541 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,26 +1,19 @@ -use std::{ - sync::atomic::Ordering, - sync::{atomic::AtomicU64, Arc}, - time, -}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use console::style; use log::Level; -use tokio::sync::OnceCell; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; -use crate::{args::ProgressStyle, error::TsdlError, format_duration, git::GitRef, TsdlResult}; +use crate::args::ProgressStyle; +use crate::git::GitRef; -#[derive(Debug, Clone, Copy)] -pub enum UpdateKind { - Msg, - Step, - Fin, - Err, -} - -/// Spinning sprite. -pub const TICK_CHARS: &str = "⠷⠯⠟⠻⠽⠾⠿"; +// --------------------------------------------------------------------------- +// Mode +// --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq)] pub enum Mode { @@ -28,306 +21,451 @@ pub enum Mode { Plain, } -#[derive(Debug, Clone)] -pub struct Progress { - multi: indicatif::MultiProgress, - pub mode: Mode, - // We store handles to ensure they aren't dropped prematurely if needed, - // mimicking the original `handles` vectors. - handles: Vec, -} - -impl Progress { - #[must_use] - pub fn new(mode: Mode) -> Self { - Self { - multi: indicatif::MultiProgress::new(), - mode, - handles: Vec::new(), - } - } - - pub fn clear(&self) -> TsdlResult<()> { - if self.mode == Mode::Fancy { - self.multi - .clear() - .map_err(|e| TsdlError::context("Clearing the multi-progress bar", e))?; +/// Determine the display mode from CLI flags and environment. +#[must_use] +pub fn mode_from_args(progress: &ProgressStyle, verbose: &Verbosity) -> Mode { + let mut mode = match progress { + ProgressStyle::Auto => { + if atty::is(atty::Stream::Stdout) { + Mode::Fancy + } else { + Mode::Plain + } } - Ok(()) - } + ProgressStyle::Fancy => Mode::Fancy, + ProgressStyle::Plain => Mode::Plain, + }; - pub fn is_done(&self) -> bool { - self.handles.iter().all(ProgressBar::is_done) + if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { + mode = Mode::Plain; } - pub fn prinltn(&self, msg: impl AsRef) { - println!("{}", msg.as_ref()); - } + mode +} - /// # Panics - /// - /// Will panic indicatif errs. - pub fn register(&mut self, name: Arc, git_ref: GitRef, num_tasks: usize) -> ProgressBar { - let bar = match self.mode { - Mode::Fancy => { - let bar = indicatif::ProgressBar::new(num_tasks as u64); - let bar = self.multi.add(bar); - let style = indicatif::ProgressStyle::with_template( - "{prefix:.bold.dim} {spinner} {wide_msg}", - ) - .unwrap_or_else(|_| { - panic!("cannot create spinner [?/{num_tasks}] {name} @ {git_ref}") - }) - .tick_chars(TICK_CHARS); - bar.set_style(style); - bar.set_prefix(format!("[?/{num_tasks}]")); - Some(bar) - } - Mode::Plain => None, - }; +// --------------------------------------------------------------------------- +// Item lifecycle + outcome +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemStatus { + /// Just registered, not yet started. + New, + /// In progress — cloning, generating, building, etc. + InProgress, + /// Successfully completed. + Done, + /// Failed. + Failed, +} - let handle = ProgressBar { - bar, - name, - git_ref, - num_tasks, - t_start: OnceCell::new(), - mode: self.mode, - current_step: Arc::new(AtomicU64::new(0)), - }; +impl ItemStatus { + fn icon(self) -> &'static str { + match self { + ItemStatus::New | ItemStatus::InProgress => "●", + ItemStatus::Done => "✓", + ItemStatus::Failed => "✗", + } + } - self.handles.push(handle.clone()); - handle + fn is_terminal(self) -> bool { + matches!(self, ItemStatus::Done | ItemStatus::Failed) } +} - pub fn tick(&self) { - // Only necessary for fancy bars in some terminals/configs, plain bars do nothing - if self.mode == Mode::Fancy { - for handle in &self.handles { - handle.tick(); - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemOutcome { + /// We do not yet know whether this item is cached or needs work. + Unknown, + /// Cache hit. + Cached, + /// Not cached; work was needed. + Built, +} + +impl ItemOutcome { + fn color(self) -> Color { + match self { + ItemOutcome::Unknown => Color::DarkGray, + ItemOutcome::Cached => Color::Yellow, + ItemOutcome::Built => Color::Blue, } } } -// Ensure bars are finished on drop -impl Drop for Progress { - fn drop(&mut self) { - for handle in &self.handles { - if !handle.is_done() { - if let Some(bar) = &handle.bar { - bar.finish(); - } - } - } +fn palette_color(status: ItemStatus, outcome: ItemOutcome) -> Color { + match status { + ItemStatus::Failed => Color::Red, + ItemStatus::New | ItemStatus::InProgress | ItemStatus::Done => outcome.color(), } } +// --------------------------------------------------------------------------- +// State entries +// --------------------------------------------------------------------------- + #[derive(Debug, Clone)] -pub struct ProgressBar { - bar: Option, +pub(crate) struct RepoEntry { pub name: Arc, - git_ref: GitRef, - num_tasks: usize, - t_start: OnceCell, - mode: Mode, - current_step: Arc, + pub git_ref: GitRef, + pub status: ItemStatus, + pub outcome: ItemOutcome, + pub msg: Arc, + pub step: usize, + pub total: usize, + /// Creation time for the whole item. This is not reset between steps. + pub started_at: Instant, + /// Frozen whole-item elapsed time for terminal states. + pub frozen_elapsed: Option, } -impl PartialEq for ProgressBar { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - && self.git_ref == other.git_ref - && self.num_tasks == other.num_tasks - } +#[derive(Debug, Clone)] +pub(crate) struct GrammarEntry { + pub repo: Arc, + pub repo_id: u64, + pub name: Arc, + pub git_ref: GitRef, + pub status: ItemStatus, + pub outcome: ItemOutcome, + pub msg: Arc, + pub step: usize, + pub total: usize, + /// Creation time for the whole item. This is not reset between steps. + pub started_at: Instant, + /// Frozen whole-item elapsed time for terminal states. + pub frozen_elapsed: Option, } -impl ProgressBar { - fn format_elapsed(&self) -> String { - self.t_start - .get() - .map(|start| { - let dur = format_duration(time::Instant::now().duration_since(*start)); - if self.mode == Mode::Fancy { - format!(" in {}", style(dur).yellow()) - } else { - format!(" in {dur}") - } - }) - .unwrap_or_default() - } +// --------------------------------------------------------------------------- +// DisplayState — the full renderable state +// --------------------------------------------------------------------------- - fn name_with_version(&self) -> String { - if self.git_ref.is_empty() { - self.name.to_string() - } else { - format!("{} {}", self.name, style(&self.git_ref).blue()) +pub(crate) struct DisplayState { + pub mode: Mode, + pub repos: HashMap, + pub grammars: HashMap, + pub build_dir: Arc, + pub out_dir: Arc, +} + +impl DisplayState { + pub fn new(mode: Mode, build_dir: Arc, out_dir: Arc) -> Self { + Self { + mode, + repos: HashMap::new(), + grammars: HashMap::new(), + build_dir, + out_dir, } } - /// Helper to print log lines in Plain mode (using bar.println to coordinate with `MultiProgress`) - pub fn println(&self, msg: String) { - match &self.bar { - Some(bar) => bar.println(msg), - None => println!("{msg}"), + /// Render the entire display as ratatui `Line`s. + pub fn render_lines(&self, width: u16) -> Vec> { + let w = width as usize; + let mut lines: Vec = Vec::new(); + + for (repo_id, repo) in self.sorted_repos() { + let repo_lines = self.format_repo_with_grammars(repo_id, repo, w); + lines.extend(repo_lines); } - } -} -impl ProgressBar { - pub fn err(&self, msg: impl AsRef) { - if let Some(bar) = &self.bar { - bar.abandon_with_message(format!( - "{} {} {}{}", - self.name_with_version(), - style(msg.as_ref()).blue(), - style("failed").red(), - self.format_elapsed() - )); - } else { - let cur = self.current_step.load(Ordering::SeqCst); - self.println(format!( - "[{}/{}] {} {} {}{}", - cur, - self.num_tasks, - self.name_with_version(), - msg.as_ref(), - style("failed").red(), - self.format_elapsed() - )); + if !lines.is_empty() { + lines.push(Line::from("")); } + + lines.push(self.format_footer_build()); + lines.push(self.format_footer_out()); + lines.push(Line::from("")); + lines.push(self.format_footer_counts()); + + lines } - pub fn fin(&self, msg: impl AsRef) { - if let Some(bar) = &self.bar { - bar.inc(1); - } else { - self.current_step.fetch_add(1, Ordering::SeqCst); - } + fn sorted_repos(&self) -> Vec<(u64, &RepoEntry)> { + let mut repos: Vec<(u64, &RepoEntry)> = self + .repos + .iter() + .map(|(repo_id, repo)| (*repo_id, repo)) + .collect(); + repos.sort_by_key(|(_, r)| r.started_at); + repos + } - if let Some(bar) = &self.bar { - let position = usize::try_from(bar.position()) - .unwrap_or(self.num_tasks) - .min(self.num_tasks); - bar.set_prefix(format!("[{}/{}]", position, self.num_tasks)); - - let message = if msg.as_ref().is_empty() { - format!( - "{} {}{}", - self.name_with_version(), - style("done").green(), - self.format_elapsed() - ) - } else { - format!( - "{} {} {}{}", - self.name_with_version(), - msg.as_ref(), - style("done").green(), - self.format_elapsed() - ) - }; - bar.finish_with_message(message); - } else { - let cur = self.current_step.load(Ordering::SeqCst); - if msg.as_ref().is_empty() { - self.println(format!( - "[{}/{}] {} {}{}", - cur, - self.num_tasks, - self.name_with_version(), - style("done").green(), - self.format_elapsed() + fn sorted_grammars_for_repo(&self, repo_id: u64) -> Vec<&GrammarEntry> { + let mut grammars: Vec<&GrammarEntry> = self + .grammars + .values() + .filter(|g| g.repo_id == repo_id) + .collect(); + grammars.sort_by_key(|g| g.started_at); + grammars + } + + fn format_repo_with_grammars( + &self, + repo_id: u64, + repo: &RepoEntry, + w: usize, + ) -> Vec> { + let mut lines: Vec = Vec::new(); + let repo_grammars = self.sorted_grammars_for_repo(repo_id); + + if repo_grammars.is_empty() { + lines.push(self.format_item_line( + repo.status, + repo.outcome, + &repo.name, + &repo.git_ref.to_string(), + &repo.msg, + repo.step, + repo.total, + repo.started_at, + repo.frozen_elapsed, + w, + "", + )); + } else if repo_grammars.len() == 1 { + let g = repo_grammars[0]; + if g.name == repo.name { + lines.push(self.format_item_line( + g.status, + g.outcome, + &repo.name, + &repo.git_ref.to_string(), + &g.msg, + g.step, + g.total, + g.started_at, + g.frozen_elapsed, + w, + "", )); } else { - self.println(format!( - "[{}/{}] {} {} {}{}", - cur, - self.num_tasks, - self.name_with_version(), - style(msg.as_ref()).blue(), - style("done").green(), - self.format_elapsed() + let label = format!("{}/{}", repo.name, g.name); + lines.push(self.format_item_line( + g.status, + g.outcome, + &label, + &g.git_ref.to_string(), + &g.msg, + g.step, + g.total, + g.started_at, + g.frozen_elapsed, + w, + "", + )); + } + } else { + lines.push(self.format_item_line( + repo.status, + repo.outcome, + &repo.name, + &repo.git_ref.to_string(), + &repo.msg, + repo.step, + repo.total, + repo.started_at, + repo.frozen_elapsed, + w, + "", + )); + for g in &repo_grammars { + lines.push(self.format_item_line( + g.status, + g.outcome, + &g.name, + &g.git_ref.to_string(), + &g.msg, + g.step, + g.total, + g.started_at, + g.frozen_elapsed, + w, + " ", )); } } - } - pub fn is_done(&self) -> bool { - self.bar - .as_ref() - .is_some_and(indicatif::ProgressBar::is_finished) + lines } - pub fn msg(&self, msg: impl AsRef) { - if let Some(bar) = &self.bar { - let position = usize::try_from(bar.position()) - .unwrap_or(self.num_tasks) - .min(self.num_tasks); - bar.set_prefix(format!("[{}/{}]", position, self.num_tasks)); - bar.set_message(format!("{} {}", self.name_with_version(), msg.as_ref())); - } else { - let cur = self.current_step.load(Ordering::SeqCst); - self.println(format!( - "[{}/{}] {}: {}", - cur, - self.num_tasks, - self.name_with_version(), - msg.as_ref() - )); - } + fn dim_line(&self, text: String) -> Line<'static> { + Line::from(Span::styled( + text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + )) } - pub fn step(&self, msg: impl AsRef) { - let _ = self.t_start.set(time::Instant::now()); - if let Some(bar) = &self.bar { - bar.inc(1); + fn format_item_line( + &self, + status: ItemStatus, + outcome: ItemOutcome, + name: &str, + git_ref: &str, + msg: &str, + step: usize, + total: usize, + started_at: Instant, + frozen_elapsed: Option, + width: usize, + indent: &str, + ) -> Line<'static> { + let terminal = status.is_terminal(); + let palette = palette_color(status, outcome); + let name_style = Style::default().fg(palette).add_modifier(Modifier::BOLD); + let progress_style = Style::default().fg(if terminal { palette } else { Color::White }); + let ref_style = Style::default().fg(Color::White); + let msg_style = Style::default().fg(Color::White); + + let elapsed = frozen_elapsed.unwrap_or_else(|| started_at.elapsed()); + let time = format_elapsed_duration(elapsed); + let time_col = format!("{time:<8}"); + let ref_col = format!("{:<12}", truncate_str(git_ref, 12)); + let step_col = if total > 0 { + format!("{:>7}", format!("[{}/{}]", step.min(total), total)) } else { - self.current_step.fetch_add(1, Ordering::SeqCst); - } + " ".to_string() + }; + let indented_name = format!("{indent}{name}"); + let name_col = format!("{:<28}", truncate_str(&indented_name, 28)); + + let fixed_width = 8 + 1 + 12 + 1 + 7 + 1 + 2 + 1 + 28 + 1; + let msg_width = width.saturating_sub(fixed_width); + let msg_col = truncate_str(msg, msg_width.max(1)); + + let mut spans: Vec = Vec::new(); + spans.push(Span::styled(time_col, progress_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(ref_col, ref_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(step_col, progress_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(status.icon(), progress_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(name_col, name_style)); + spans.push(Span::styled(msg_col, msg_style)); + + Line::from(spans) + } - if let Some(bar) = &self.bar { - let position = usize::try_from(bar.position()) - .unwrap_or(self.num_tasks) - .min(self.num_tasks); - bar.set_prefix(format!("[{}/{}]", position, self.num_tasks)); - bar.set_message(format!("{}: {}", self.name_with_version(), msg.as_ref())); - } else { - let cur = self.current_step.load(Ordering::SeqCst); - self.println(format!( - "[{}/{}] {} {}", - cur, - self.num_tasks, - self.name_with_version(), - msg.as_ref() - )); - } + fn format_footer_build(&self) -> Line<'static> { + self.dim_line(format!("build: {}", self.build_dir.display())) } - pub fn tick(&self) { - if let Some(bar) = &self.bar { - bar.tick(); - } + fn format_footer_out(&self) -> Line<'static> { + self.dim_line(format!("out: {}", self.out_dir.display())) } -} -#[must_use] -pub fn current(progress: &ProgressStyle, verbose: &Verbosity) -> Progress { - let mut mode = match progress { - ProgressStyle::Auto => { - if atty::is(atty::Stream::Stdout) { - Mode::Fancy + fn format_footer_counts(&self) -> Line<'static> { + let (cached, built, building, failed) = self.grammar_status_counts(); + + let mut spans: Vec = Vec::new(); + spans.push(Span::styled( + format!("✓ {cached} cached "), + Style::default().fg(if cached > 0 { + Color::Yellow } else { - Mode::Plain + Color::DarkGray + }), + )); + spans.push(Span::styled( + format!("● {built} built "), + Style::default().fg(if built > 0 { + Color::Blue + } else { + Color::DarkGray + }), + )); + spans.push(Span::styled( + format!("● {building} building "), + Style::default().fg(if building > 0 { + Color::White + } else { + Color::DarkGray + }), + )); + spans.push(Span::styled( + format!("✗ {failed} failed"), + Style::default().fg(if failed > 0 { + Color::Red + } else { + Color::DarkGray + }), + )); + + Line::from(spans) + } + + fn grammar_status_counts(&self) -> (usize, usize, usize, usize) { + let mut cached = 0; + let mut built = 0; + let mut building = 0; + let mut failed = 0; + + for grammar in self.grammars.values() { + count_grammar_status( + grammar.status, + grammar.outcome, + &mut cached, + &mut built, + &mut building, + &mut failed, + ); + } + + for (repo_id, repo) in self.sorted_repos() { + let has_grammars = self.grammars.values().any(|g| g.repo_id == repo_id); + if !has_grammars && repo.status == ItemStatus::Failed { + failed += 1; } } - ProgressStyle::Fancy => Mode::Fancy, - ProgressStyle::Plain => Mode::Plain, - }; - if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { - mode = Mode::Plain; + (cached, built, building, failed) } +} - Progress::new(mode) +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn count_grammar_status( + status: ItemStatus, + outcome: ItemOutcome, + cached: &mut usize, + built: &mut usize, + building: &mut usize, + failed: &mut usize, +) { + match status { + ItemStatus::New | ItemStatus::InProgress => *building += 1, + ItemStatus::Done => match outcome { + ItemOutcome::Cached => *cached += 1, + ItemOutcome::Built | ItemOutcome::Unknown => *built += 1, + }, + ItemStatus::Failed => *failed += 1, + } +} + +fn truncate_str(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else if max <= 1 { + "…".to_string() + } else { + let mut truncated: String = s.chars().take(max - 1).collect(); + truncated.push('…'); + truncated + } +} + +fn format_elapsed_duration(dur: Duration) -> String { + let secs = dur.as_secs_f64(); + if secs < 60.0 { + format!("{secs:.2}s") + } else { + format!("{:.2}m", secs / 60.0) + } } diff --git a/src/main.rs b/src/main.rs index cc4d6cf..c12fb91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,7 @@ fn run(app: &mut App, args: &args::Args) -> TsdlResult<()> { result } args::Command::Config { command } => tsdl::config::run(app, command), - args::Command::Selfupdate { force, target } => tsdl::selfupdate::run(app, *force, target), + args::Command::Selfupdate{ force, target} => tsdl::selfupdate::run(*force, target), } } diff --git a/src/parser.rs b/src/parser.rs index ff0e235..edbddee 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -55,29 +55,32 @@ impl GrammarBuild { let hit = !self.context.force && !self.needs_rebuild(&key); if hit { + self.progress.mark_cached(); // Install the binary from the build directory if let Err(e) = self.install().await { - self.progress.err("install"); + self.progress.err("install failed"); return Err(e); } - self.progress.fin("cached"); + self.progress.cached("done"); return Ok(None); } + self.progress.mark_built(); + // Use the grammar directory path provided if !self.dir.exists() { let err = TsdlError::message(format!( "Grammar directory not found: {}", self.dir.display() )); - self.progress.err(format!("{err}")); + self.progress.err("missing grammar directory"); return Err(err); } // Build the grammar if let Err(e) = self.build_grammar().await { - self.progress.err("build"); + self.progress.err("build failed"); return Err(e); } @@ -90,7 +93,7 @@ impl GrammarBuild { }, }; - self.progress.fin("build"); + self.progress.fin("done"); Ok(Some(update)) } @@ -273,9 +276,7 @@ impl GrammarBuild { // Report reinstallation when fixing broken hardlink if hardlink_broken { - if let Some(hnd) = self.context.progress.as_ref() { - hnd.msg("Reinstalled"); - } + self.progress.msg("Reinstalled"); } // Create the hardlink after removing the old one diff --git a/src/selfupdate.rs b/src/selfupdate.rs index cdab518..d165d2b 100644 --- a/src/selfupdate.rs +++ b/src/selfupdate.rs @@ -4,8 +4,7 @@ use self_update::self_replace; use semver::Version; use crate::{ - app::App, args::VersionBump, consts::TREE_SITTER_PLATFORM, error::TsdlError, prompt_user, - TsdlResult, + TsdlResult, args::VersionBump, consts::TREE_SITTER_PLATFORM, error::TsdlError, prompt_user }; enum UpdateTarget { @@ -16,7 +15,6 @@ enum UpdateTarget { fn download_and_replace( asset_name: &str, download_url: &str, - handle: &crate::display::ProgressBar, version: &Version, ) -> TsdlResult<()> { let tsdl = env!("CARGO_PKG_NAME"); @@ -26,7 +24,7 @@ fn download_and_replace( let tmp_gz = fs::File::create_new(&tmp_gz_path) .map_err(|e| TsdlError::context("Failed to create temporary file", e))?; - handle.step(format!("downloading {version}")); + eprintln!("downloading {version}"); self_update::Download::from_url(download_url) .set_header( reqwest::header::ACCEPT, @@ -37,7 +35,7 @@ fn download_and_replace( .download_to(&tmp_gz) .map_err(|e| TsdlError::context("Failed to download release asset", e))?; - handle.step(format!("extracting {version}")); + eprintln!("extracting {version}"); let tsdl_bin = PathBuf::from(tsdl); self_update::Extract::from_source(&tmp_gz_path) .archive(self_update::ArchiveKind::Plain(Some( @@ -50,7 +48,7 @@ fn download_and_replace( self_replace::self_replace(new_exe) .map_err(|e| TsdlError::context("Failed to replace current executable", e))?; - handle.fin(format!("{version}")); + eprintln!("{version}"); Ok(()) } @@ -65,15 +63,14 @@ fn parse_target(raw: &str) -> Result { } } -pub fn run(app: &mut App, force: bool, target: &str) -> TsdlResult<()> { +pub fn run(force: bool, target: &str) -> TsdlResult<()> { let update_target = parse_target(target).map_err(TsdlError::message)?; let tsdl = env!("CARGO_PKG_NAME"); let current_version = Version::parse(env!("CARGO_PKG_VERSION")) .map_err(|e| TsdlError::context("Failed to parse current version", e))?; - let handle = app.progress.register("selfupdate".into(), "".into(), 4); - handle.step("fetching releases"); + eprintln!("fetching releases"); let releases = self_update::backends::github::ReleaseList::configure() .repo_owner("stackmystack") .repo_name(tsdl) @@ -98,7 +95,7 @@ pub fn run(app: &mut App, force: bool, target: &str) -> TsdlResult<()> { }; if target_version == current_version { - handle.msg(format!("already at {target_version}")); + eprintln!("already at {target_version}"); return Ok(()); } @@ -110,7 +107,7 @@ pub fn run(app: &mut App, force: bool, target: &str) -> TsdlResult<()> { false, )? { - handle.msg("downgrade cancelled"); + eprintln!("downgrade cancelled"); return Ok(()); } @@ -144,12 +141,12 @@ pub fn run(app: &mut App, force: bool, target: &str) -> TsdlResult<()> { .as_ref() .is_some_and(|v| v > ¤t_version) { - handle.msg(format!( + eprintln!( "no compatible {bump} update (latest is {}; use `tsdl selfupdate major` to install it)", releases[0].version, - )); + ); } else { - handle.msg("already at the latest version"); + eprintln!("already at the latest version"); } return Ok(()); } @@ -168,5 +165,5 @@ pub fn run(app: &mut App, force: bool, target: &str) -> TsdlResult<()> { )); }; - download_and_replace(&asset.name, &asset.download_url, &handle, &version) + download_and_replace(&asset.name, &asset.download_url, &version) } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 00aa363..3dd28a1 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -37,7 +37,7 @@ async fn cli( let tag = match tag { Tag::Exact { label, .. } => Cow::Borrowed(label), Tag::Ref(git_ref) => { - handle.msg(format!("Figuring out the exact tag for {tag}")); + handle.msg(format!("resolving exact tag for {tag}")); let tree_sitter = PathBuf::new().join(build_dir).join("tree-sitter"); git::clone(repo, &tree_sitter).await?; Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) @@ -50,12 +50,16 @@ async fn cli( .canon()?; if !res.exists() { - handle.msg(format!("Downloading {tag}")); + handle.mark_built(); + handle.step("downloading"); let gz_basename = format!("{cli}.gz"); let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); let gz = PathBuf::new().join(build_dir).join(gz_basename); download_and_extract(&gz, &url, &res).await?; + } else { + handle.mark_cached(); + handle.step("cached"); } Ok(res) @@ -140,9 +144,9 @@ pub async fn prepare( ) -> TsdlResult { let progress = display .add_language( - "Preparing tree-sitter-cli".into(), - format!("v{}", tree_sitter.version), - 3, + display_tree_sitter_ref(&tree_sitter.version), + "tree-sitter-cli", + 2, ) .await; @@ -150,23 +154,43 @@ pub async fn prepare( .map_err(|e| TsdlError::context("Parsing the tree-sitter URL", e))?; let git_ref = &tree_sitter.version; - progress.step(format!("Figuring out tag from ref {git_ref}")); - let tag = tag(repo.as_str(), git_ref).await?; + progress.step(format!("resolving {git_ref}")); + let tag = match tag(repo.as_str(), git_ref).await { + Ok(tag) => tag, + Err(e) => { + progress.err("resolve failed"); + return Err(e); + } + }; - progress.step(format!("Fetching {tag}")); - let cli = cli( + let cli = match cli( build_dir, &progress, &tree_sitter.platform, &tree_sitter.repo, &tag, ) - .await?; - progress.fin(format!("{tag}")); + .await + { + Ok(cli) => cli, + Err(e) => { + progress.err("download failed"); + return Err(e); + } + }; + progress.fin("done"); Ok(cli) } +fn display_tree_sitter_ref(version: &str) -> GitRef { + if version.starts_with('v') || !version.split('.').all(|part| part.parse::().is_ok()) { + GitRef::from(version.to_string()) + } else { + GitRef::from(format!("v{version}")) + } +} + #[allow(clippy::missing_panics_doc)] pub async fn tag(repo: &str, version: &str) -> TsdlResult { let output = Command::new("git") diff --git a/tests/cmd/build.rs b/tests/cmd/build.rs index aada81f..acc2619 100644 --- a/tests/cmd/build.rs +++ b/tests/cmd/build.rs @@ -83,7 +83,7 @@ fn unknown_parser_should_fail(#[case] languages: Vec<&str>) { sandbox.cmd.arg("build").args(&languages); let mut assert = sandbox.cmd.assert().failure(); for lang in &languages { - assert = assert.stdout(p::str::contains(format!("{lang} HEAD cloning"))); + assert = assert.stdout(p::str::contains(format!("{lang} HEAD [1/2] cloning"))); } for lang in languages { sandbox @@ -169,7 +169,7 @@ fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { sandbox.cmd.arg("build").args(&languages); let mut assert = sandbox.cmd.assert().success(); for lang in &languages { - assert = assert.stdout(p::str::contains(format!("{lang} HEAD cloning"))); + assert = assert.stdout(p::str::contains(format!("{lang} HEAD [1/2] cloning"))); } for lang in &languages { let dylib = sandbox @@ -210,7 +210,7 @@ fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: & .assert() .success() .stdout(p::str::contains(format!( - "{language}/{language} {version} build done" + "{language}/{language} {version} [4/4] done" ))); let dylib = sandbox .tmp @@ -247,7 +247,7 @@ fn build_implicit_pinned_and_unpinned() { let mut out = sandbox.cmd.arg("build").assert().success(); for (language, version) in parsers { out = out.stdout(p::str::contains(format!( - "{language}/{language} {version} build done" + "{language}/{language} {version} [4/4] done" ))); } for (language, _version) in parsers { @@ -267,7 +267,9 @@ fn multi_parsers_no_cmd() { let mut sandbox = Sandbox::new(); let mut assert = sandbox.cmd.args(["build", java]).assert().success(); for language in languages { - assert = assert.stdout(p::str::contains(format!("{language} {version} cloning"))); + assert = assert.stdout(p::str::contains(format!( + "{language} {version} [1/2] cloning" + ))); } for language in languages { let dylib = sandbox @@ -298,7 +300,9 @@ fn multi_parsers_cmd() { let assert = sandbox.cmd.args(["build", typescript]).assert().success(); // Check for version in cloning step // TODO: dig for changes in this test and revert. - _ = assert.stdout(p::str::contains(format!("{typescript} v{version} cloning"))); + _ = assert.stdout(p::str::contains(format!( + "{typescript} v{version} [1/2] cloning" + ))); for language in languages { let dylib = sandbox .tmp diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index 99ffca7..e47c325 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -35,7 +35,7 @@ fn cache_hit_skips_build() { .arg("json") .assert() .success() - .stdout(p::str::contains("cached done")) + .stdout(p::str::contains("json/json HEAD [4/4] done")) .stdout(p::str::contains("cloning").not()); let second_inode = binary.metadata().unwrap().ino(); @@ -68,8 +68,8 @@ fn cache_miss_on_grammar_modification() { cmd.args(["build", "--force", "json"]) .assert() .success() - .stdout(p::str::contains("HEAD cloning")) - .stdout(p::str::contains("(cached)").not()); + .stdout(p::str::contains("HEAD [1/2] cloning")) + .stdout(p::str::contains("json/json HEAD [3/4] building")); } #[rstest] @@ -128,8 +128,8 @@ fn force_flag_bypasses_cache() { cmd.args(["build", "--force", "json"]) .assert() .success() - .stdout(p::str::contains("HEAD cloning")) - .stdout(p::str::contains("(cached)").not()); + .stdout(p::str::contains("HEAD [1/2] cloning")) + .stdout(p::str::contains("json/json HEAD [3/4] building")); let second_inode = binary.metadata().unwrap().ino(); assert_ne!( @@ -179,7 +179,7 @@ fn force_flag_reinstalls_hardlink() { cmd.args(["build", "--force", "json"]) .assert() .success() - .stdout(p::str::contains("json/json HEAD installing")); + .stdout(p::str::contains("json/json HEAD [4/4] installing")); let final_inode_out = binary.metadata().unwrap().ino(); let final_inode_build = build_binary.metadata().unwrap().ino(); @@ -213,7 +213,7 @@ fn multi_parser_independent_cache(#[case] languages: Vec<&str>) { let mut output = cmd.arg("build").args(&languages).assert().success(); for lang in &languages { - output = output.stdout(p::str::contains(format!("{lang} HEAD cached done"))); + output = output.stdout(p::str::contains(format!("{lang}/{lang} HEAD [4/4] done"))); } } diff --git a/tests/cmd/log.rs b/tests/cmd/log.rs index 91b4c94..537dda7 100644 --- a/tests/cmd/log.rs +++ b/tests/cmd/log.rs @@ -15,7 +15,7 @@ fn build_no_args_should_log_to_default_path() { .assert() .success() .stdout(p::str::contains(format!( - "tree-sitter-cli v{TREE_SITTER_VERSION} done" + "tree-sitter-cli v{TREE_SITTER_VERSION} [2/2] done" ))); assert!(!sandbox.is_empty()); sandbox @@ -39,7 +39,7 @@ fn build_w_specific_log_path(#[case] log: &str) { .assert() .success() .stdout(p::str::contains(format!( - "tree-sitter-cli v{TREE_SITTER_VERSION} done" + "tree-sitter-cli v{TREE_SITTER_VERSION} [2/2] done" ))); sandbox .tmp diff --git a/typos.toml b/typos.toml new file mode 100644 index 0000000..372dc95 --- /dev/null +++ b/typos.toml @@ -0,0 +1,2 @@ +[default.extend-words] +ratatui = "ratatui" From a1ae78217687b5e1c84a5e8415c8b4c6b180bde4 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 10:36:07 +0200 Subject: [PATCH 03/88] disaply: plain: be far less noisy --- src/actors/display.rs | 141 +++++++++++++-- src/actors/mod.rs | 12 ++ src/display.rs | 408 ++++++++++++++++++++++++++---------------- src/tree_sitter.rs | 2 +- tests/cmd/build.rs | 31 ++-- tests/cmd/cache.rs | 17 +- tests/cmd/log.rs | 10 +- 7 files changed, 414 insertions(+), 207 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index 55c55cc..904ee32 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -34,6 +34,9 @@ pub enum DisplayMessage { tx: oneshot::Sender, }, + /// Plain-mode reference line. Fancy mode ignores this because refs are rendered per row. + RegisterReference { git_ref: GitRef, name: Arc }, + /// Update a specific bar. Update { id: u64, @@ -120,6 +123,14 @@ impl DisplayAddr { .await } + pub async fn reference>>(&self, git_ref: GitRef, name: S) { + self.fire(DisplayMessage::RegisterReference { + git_ref, + name: name.into(), + }) + .await; + } + pub async fn tick(&self) { self.fire(DisplayMessage::Tick).await; } @@ -194,6 +205,45 @@ impl ProgressAddr { } } +// --------------------------------------------------------------------------- +// Plain output model +// --------------------------------------------------------------------------- + +struct PlainLine { + name: String, + step: usize, + total: usize, + message: String, +} + +fn plain_repo_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> String { + match kind { + UpdateKind::Err => format!("failed: {msg}"), + UpdateKind::Cached => "cached".to_string(), + UpdateKind::Fin => match outcome { + ItemOutcome::Cached => "done".to_string(), + ItemOutcome::Built => "done".to_string(), + ItemOutcome::Unknown => msg.to_string(), + }, + UpdateKind::Step => msg.to_string(), + UpdateKind::Msg | UpdateKind::MarkCached | UpdateKind::MarkBuilt => msg.to_string(), + } +} + +fn plain_grammar_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> String { + match kind { + UpdateKind::Err => format!("failed: {msg}"), + UpdateKind::Cached => "cached".to_string(), + UpdateKind::Fin => match outcome { + ItemOutcome::Cached => "cached".to_string(), + ItemOutcome::Built => "built".to_string(), + ItemOutcome::Unknown => msg.to_string(), + }, + UpdateKind::Step => msg.to_string(), + UpdateKind::Msg | UpdateKind::MarkCached | UpdateKind::MarkBuilt => msg.to_string(), + } +} + // --------------------------------------------------------------------------- // DisplayActor // --------------------------------------------------------------------------- @@ -201,6 +251,8 @@ impl ProgressAddr { pub struct DisplayActor { state: DisplayState, next_id: u64, + plain_name_width: usize, + plain_progress_started: bool, rx: mpsc::Receiver, tx: mpsc::Sender, } @@ -212,6 +264,8 @@ impl DisplayActor { let actor = Self { state: DisplayState::new(mode, build_dir, out_dir), next_id: 1, + plain_name_width: 16, + plain_progress_started: false, rx, tx: tx.clone(), }; @@ -292,6 +346,8 @@ impl DisplayActor { // ── Plain mode ──────────────────────────────────────────────────── async fn run_plain(&mut self) { + self.print_plain_metadata(); + while let Some(msg) = self.rx.recv().await { match msg { DisplayMessage::RegisterLanguage { @@ -310,33 +366,29 @@ impl DisplayActor { num_tasks, tx, } => { + let plain_name = format!("{language}/{name}"); + self.update_plain_name_width(&plain_name); let addr = self.register_grammar(language, name, git_ref, num_tasks); let _ = tx.send(addr); } + DisplayMessage::RegisterReference { git_ref, name } => { + self.print_plain_ref(&name, &git_ref.to_string()); + } DisplayMessage::Update { id, kind, msg } => { self.apply_update(id, kind, msg); - if matches!(kind, UpdateKind::MarkCached | UpdateKind::MarkBuilt) { + if matches!( + kind, + UpdateKind::Msg | UpdateKind::MarkCached | UpdateKind::MarkBuilt + ) { continue; } - if let Some(repo) = self.state.repos.get(&id) { - println!( - " {} {} [{}/{}] {}", - repo.name, repo.git_ref, repo.step, repo.total, repo.msg, - ); - } else if let Some(grammar) = self.state.grammars.get(&id) { - println!( - " {}/{} {} [{}/{}] {}", - grammar.repo, - grammar.name, - grammar.git_ref, - grammar.step, - grammar.total, - grammar.msg, - ); + if let Some(line) = self.plain_progress_line(id, kind) { + self.print_plain_progress(&line); } } DisplayMessage::Tick => {} DisplayMessage::Shutdown { tx } => { + self.print_plain_summary(); let _ = tx.send(()); break; } @@ -344,6 +396,62 @@ impl DisplayActor { } } + fn update_plain_name_width(&mut self, name: &str) -> usize { + self.plain_name_width = self.plain_name_width.max(name.chars().count()); + self.plain_name_width + } + + fn print_plain_metadata(&self) { + println!("build: {}", self.state.build_dir.display()); + println!("out: {}", self.state.out_dir.display()); + println!(); + } + + fn print_plain_ref(&mut self, name: &str, git_ref: &str) { + let width = self.update_plain_name_width(name); + println!("{name: Option { + if let Some(repo) = self.state.repos.get(&id) { + return Some(PlainLine { + name: repo.name.to_string(), + step: repo.step, + total: repo.total, + message: plain_repo_message(kind, repo.outcome, &repo.msg), + }); + } + + self.state.grammars.get(&id).map(|grammar| PlainLine { + name: format!("{}/{}", grammar.repo, grammar.name), + step: grammar.step, + total: grammar.total, + message: plain_grammar_message(kind, grammar.outcome, &grammar.msg), + }) + } + // ── Message handling ────────────────────────────────────────────── fn handle_message(&mut self, msg: DisplayMessage) { @@ -367,6 +475,7 @@ impl DisplayActor { let addr = self.register_grammar(language, name, git_ref, num_tasks); let _ = tx.send(addr); } + DisplayMessage::RegisterReference { .. } => {} DisplayMessage::Update { id, kind, msg } => { self.apply_update(id, kind, msg); } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 2e67d41..35adbd7 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -71,6 +71,18 @@ pub async fn run( languages: Vec, tree_sitter: &TreeSitter, ) -> TsdlResult<()> { + display + .reference( + tree_sitter::display_tree_sitter_ref(&tree_sitter.version), + "tree-sitter-cli", + ) + .await; + for language in &languages { + display + .reference(language.spec.git_ref.clone(), language.name.clone()) + .await; + } + let result = run_inner( build_dir, cache, diff --git a/src/display.rs b/src/display.rs index d7fa541..0c0b72f 100644 --- a/src/display.rs +++ b/src/display.rs @@ -67,10 +67,6 @@ impl ItemStatus { ItemStatus::Failed => "✗", } } - - fn is_terminal(self) -> bool { - matches!(self, ItemStatus::Done | ItemStatus::Failed) - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -93,13 +89,21 @@ impl ItemOutcome { } } -fn palette_color(status: ItemStatus, outcome: ItemOutcome) -> Color { +fn name_color(status: ItemStatus, outcome: ItemOutcome) -> Color { match status { ItemStatus::Failed => Color::Red, ItemStatus::New | ItemStatus::InProgress | ItemStatus::Done => outcome.color(), } } +fn indicator_color(status: ItemStatus) -> Color { + match status { + ItemStatus::Done => Color::Green, + ItemStatus::Failed => Color::Red, + ItemStatus::New | ItemStatus::InProgress => Color::White, + } +} + // --------------------------------------------------------------------------- // State entries // --------------------------------------------------------------------------- @@ -136,6 +140,84 @@ pub(crate) struct GrammarEntry { pub frozen_elapsed: Option, } +// --------------------------------------------------------------------------- +// Render rows +// --------------------------------------------------------------------------- + +struct RenderRow { + status: ItemStatus, + outcome: ItemOutcome, + name: String, + git_ref: String, + msg: String, + step: usize, + total: usize, + started_at: Instant, + frozen_elapsed: Option, + indent: &'static str, +} + +impl RenderRow { + fn elapsed(&self) -> Duration { + self.frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } + + fn time(&self) -> String { + format_elapsed_duration(self.elapsed()) + } + + fn step_text(&self) -> String { + if self.total > 0 { + format!("[{}/{}]", self.step.min(self.total), self.total) + } else { + String::new() + } + } + + fn display_name(&self) -> String { + format!("{}{}", self.indent, self.name) + } +} + +struct RenderLayout { + time_width: usize, + ref_width: usize, + step_width: usize, + name_width: usize, +} + +impl RenderLayout { + fn from_rows(rows: &[RenderRow]) -> Self { + Self { + time_width: rows + .iter() + .map(|row| row.time().chars().count()) + .max() + .unwrap_or(5) + .max(5), + ref_width: rows + .iter() + .map(|row| row.git_ref.chars().count()) + .max() + .unwrap_or(4) + .max(4), + step_width: rows + .iter() + .map(|row| row.step_text().chars().count()) + .max() + .unwrap_or(5) + .max(5), + name_width: rows + .iter() + .map(|row| row.display_name().chars().count()) + .max() + .unwrap_or(4) + .max(4), + } + } +} + // --------------------------------------------------------------------------- // DisplayState — the full renderable state // --------------------------------------------------------------------------- @@ -163,10 +245,11 @@ impl DisplayState { pub fn render_lines(&self, width: u16) -> Vec> { let w = width as usize; let mut lines: Vec = Vec::new(); + let rows = self.render_rows(); + let layout = RenderLayout::from_rows(&rows); - for (repo_id, repo) in self.sorted_repos() { - let repo_lines = self.format_repo_with_grammars(repo_id, repo, w); - lines.extend(repo_lines); + for row in &rows { + lines.push(self.format_item_line(row, &layout, w)); } if !lines.is_empty() { @@ -201,93 +284,38 @@ impl DisplayState { grammars } - fn format_repo_with_grammars( - &self, - repo_id: u64, - repo: &RepoEntry, - w: usize, - ) -> Vec> { - let mut lines: Vec = Vec::new(); - let repo_grammars = self.sorted_grammars_for_repo(repo_id); - - if repo_grammars.is_empty() { - lines.push(self.format_item_line( - repo.status, - repo.outcome, - &repo.name, - &repo.git_ref.to_string(), - &repo.msg, - repo.step, - repo.total, - repo.started_at, - repo.frozen_elapsed, - w, - "", - )); - } else if repo_grammars.len() == 1 { - let g = repo_grammars[0]; - if g.name == repo.name { - lines.push(self.format_item_line( - g.status, - g.outcome, - &repo.name, - &repo.git_ref.to_string(), - &g.msg, - g.step, - g.total, - g.started_at, - g.frozen_elapsed, - w, - "", - )); + fn render_rows(&self) -> Vec { + let mut rows = Vec::new(); + + for (repo_id, repo) in self.sorted_repos() { + let repo_grammars = self.sorted_grammars_for_repo(repo_id); + + if repo_grammars.is_empty() { + rows.push(RenderRow::from_repo(repo, "")); + } else if repo_grammars.len() == 1 { + let grammar = repo_grammars[0]; + if grammar.name == repo.name { + rows.push(RenderRow::from_grammar(grammar, repo.name.to_string(), "")); + } else { + rows.push(RenderRow::from_grammar( + grammar, + format!("{}/{}", repo.name, grammar.name), + "", + )); + } } else { - let label = format!("{}/{}", repo.name, g.name); - lines.push(self.format_item_line( - g.status, - g.outcome, - &label, - &g.git_ref.to_string(), - &g.msg, - g.step, - g.total, - g.started_at, - g.frozen_elapsed, - w, - "", - )); - } - } else { - lines.push(self.format_item_line( - repo.status, - repo.outcome, - &repo.name, - &repo.git_ref.to_string(), - &repo.msg, - repo.step, - repo.total, - repo.started_at, - repo.frozen_elapsed, - w, - "", - )); - for g in &repo_grammars { - lines.push(self.format_item_line( - g.status, - g.outcome, - &g.name, - &g.git_ref.to_string(), - &g.msg, - g.step, - g.total, - g.started_at, - g.frozen_elapsed, - w, - " ", - )); + rows.push(RenderRow::from_repo(repo, "")); + for grammar in repo_grammars { + rows.push(RenderRow::from_grammar( + grammar, + grammar.name.to_string(), + " ", + )); + } } } - lines + rows } fn dim_line(&self, text: String) -> Line<'static> { @@ -301,51 +329,53 @@ impl DisplayState { fn format_item_line( &self, - status: ItemStatus, - outcome: ItemOutcome, - name: &str, - git_ref: &str, - msg: &str, - step: usize, - total: usize, - started_at: Instant, - frozen_elapsed: Option, + row: &RenderRow, + layout: &RenderLayout, width: usize, - indent: &str, ) -> Line<'static> { - let terminal = status.is_terminal(); - let palette = palette_color(status, outcome); - let name_style = Style::default().fg(palette).add_modifier(Modifier::BOLD); - let progress_style = Style::default().fg(if terminal { palette } else { Color::White }); - let ref_style = Style::default().fg(Color::White); - let msg_style = Style::default().fg(Color::White); - - let elapsed = frozen_elapsed.unwrap_or_else(|| started_at.elapsed()); - let time = format_elapsed_duration(elapsed); - let time_col = format!("{time:<8}"); - let ref_col = format!("{:<12}", truncate_str(git_ref, 12)); - let step_col = if total > 0 { - format!("{:>7}", format!("[{}/{}]", step.min(total), total)) - } else { - " ".to_string() - }; - let indented_name = format!("{indent}{name}"); - let name_col = format!("{:<28}", truncate_str(&indented_name, 28)); - - let fixed_width = 8 + 1 + 12 + 1 + 7 + 1 + 2 + 1 + 28 + 1; + let time = row.time(); + let step = row.step_text(); + let name = row.display_name(); + let icon = row.status.icon(); + + let time_col = format!("{time:width$}", step, width = layout.step_width); + let name_col = format!("{: = Vec::new(); spans.push(Span::styled(time_col, progress_style)); spans.push(Span::raw(" ")); - spans.push(Span::styled(ref_col, ref_style)); + spans.push(Span::styled(ref_col, progress_style)); spans.push(Span::raw(" ")); spans.push(Span::styled(step_col, progress_style)); spans.push(Span::raw(" ")); - spans.push(Span::styled(status.icon(), progress_style)); + spans.push(Span::styled(icon, icon_style)); spans.push(Span::raw(" ")); spans.push(Span::styled(name_col, name_style)); + spans.push(Span::raw(" ")); spans.push(Span::styled(msg_col, msg_style)); Line::from(spans) @@ -360,46 +390,18 @@ impl DisplayState { } fn format_footer_counts(&self) -> Line<'static> { - let (cached, built, building, failed) = self.grammar_status_counts(); - + let (cached, built, building, failed) = self.summary_counts(); let mut spans: Vec = Vec::new(); - spans.push(Span::styled( - format!("✓ {cached} cached "), - Style::default().fg(if cached > 0 { - Color::Yellow - } else { - Color::DarkGray - }), - )); - spans.push(Span::styled( - format!("● {built} built "), - Style::default().fg(if built > 0 { - Color::Blue - } else { - Color::DarkGray - }), - )); - spans.push(Span::styled( - format!("● {building} building "), - Style::default().fg(if building > 0 { - Color::White - } else { - Color::DarkGray - }), - )); - spans.push(Span::styled( - format!("✗ {failed} failed"), - Style::default().fg(if failed > 0 { - Color::Red - } else { - Color::DarkGray - }), - )); + + push_summary_success(&mut spans, cached, "cached", Color::Yellow, true); + push_summary_success(&mut spans, built, "built", Color::Blue, false); + push_summary_building(&mut spans, building); + push_summary_failed(&mut spans, failed); Line::from(spans) } - fn grammar_status_counts(&self) -> (usize, usize, usize, usize) { + pub(crate) fn summary_counts(&self) -> (usize, usize, usize, usize) { let mut cached = 0; let mut built = 0; let mut building = 0; @@ -427,6 +429,38 @@ impl DisplayState { } } +impl RenderRow { + fn from_repo(repo: &RepoEntry, indent: &'static str) -> Self { + Self { + status: repo.status, + outcome: repo.outcome, + name: repo.name.to_string(), + git_ref: repo.git_ref.to_string(), + msg: repo.msg.to_string(), + step: repo.step, + total: repo.total, + started_at: repo.started_at, + frozen_elapsed: repo.frozen_elapsed, + indent, + } + } + + fn from_grammar(grammar: &GrammarEntry, name: String, indent: &'static str) -> Self { + Self { + status: grammar.status, + outcome: grammar.outcome, + name, + git_ref: grammar.git_ref.to_string(), + msg: grammar.msg.to_string(), + step: grammar.step, + total: grammar.total, + started_at: grammar.started_at, + frozen_elapsed: grammar.frozen_elapsed, + indent, + } + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -449,6 +483,62 @@ fn count_grammar_status( } } +fn push_summary_success( + spans: &mut Vec>, + count: usize, + label: &'static str, + label_color: Color, + leading: bool, +) { + if !leading { + spans.push(Span::raw(" ")); + } + + if count > 0 { + spans.push(Span::styled( + format!("✓ {count} "), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + label, + Style::default() + .fg(label_color) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!("✓ {count} {label}"), + Style::default().fg(Color::DarkGray), + )); + } +} + +fn push_summary_building(spans: &mut Vec>, count: usize) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{count} building"), + Style::default().fg(if count > 0 { + Color::White + } else { + Color::DarkGray + }), + )); +} + +fn push_summary_failed(spans: &mut Vec>, count: usize) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗ {count} failed"), + Style::default().fg(if count > 0 { + Color::Red + } else { + Color::DarkGray + }), + )); +} + fn truncate_str(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_string() diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 3dd28a1..698abb6 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -183,7 +183,7 @@ pub async fn prepare( Ok(cli) } -fn display_tree_sitter_ref(version: &str) -> GitRef { +pub(crate) fn display_tree_sitter_ref(version: &str) -> GitRef { if version.starts_with('v') || !version.split('.').all(|part| part.parse::().is_ok()) { GitRef::from(version.to_string()) } else { diff --git a/tests/cmd/build.rs b/tests/cmd/build.rs index acc2619..43a0845 100644 --- a/tests/cmd/build.rs +++ b/tests/cmd/build.rs @@ -25,7 +25,7 @@ fn no_args_should_download_tree_sitter_cli() { .assert() .success() .stdout(p::str::contains(format!( - "tree-sitter-cli v{TREE_SITTER_VERSION}" + "tree-sitter-cli @ v{TREE_SITTER_VERSION}" ))); assert!(!sandbox.is_empty()); let tree_sitter_cli = sandbox.tmp.child(TSDL_BUILD_DIR).child(format!( @@ -60,7 +60,7 @@ fn no_args_should_build_tree_sitter_with_specific_version( .cmd .assert() .success() - .stdout(p::str::contains(format!("tree-sitter-cli {version}"))); + .stdout(p::str::contains(format!("tree-sitter-cli @ {version}"))); let mut tree_sitter_cli = Command::new( sandbox .tmp @@ -83,7 +83,7 @@ fn unknown_parser_should_fail(#[case] languages: Vec<&str>) { sandbox.cmd.arg("build").args(&languages); let mut assert = sandbox.cmd.assert().failure(); for lang in &languages { - assert = assert.stdout(p::str::contains(format!("{lang} HEAD [1/2] cloning"))); + assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); } for lang in languages { sandbox @@ -169,7 +169,7 @@ fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { sandbox.cmd.arg("build").args(&languages); let mut assert = sandbox.cmd.assert().success(); for lang in &languages { - assert = assert.stdout(p::str::contains(format!("{lang} HEAD [1/2] cloning"))); + assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); } for lang in &languages { let dylib = sandbox @@ -187,7 +187,7 @@ fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { #[case::pinned_no_leading_v_json("json", "v0.21.0")] #[case::unpinned_rust("rust", "HEAD")] #[case::pinned::cmd::typescript("typescript", "v0.21.0")] -fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: &str) { +fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] _version: &str) { let config = indoc! { r#" [parsers] @@ -210,7 +210,8 @@ fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: & .assert() .success() .stdout(p::str::contains(format!( - "{language}/{language} {version} [4/4] done" + "{:<16} [4/4] built", + format!("{language}/{language}") ))); let dylib = sandbox .tmp @@ -245,10 +246,10 @@ fn build_implicit_pinned_and_unpinned() { .write_str(config) .unwrap(); let mut out = sandbox.cmd.arg("build").assert().success(); - for (language, version) in parsers { - out = out.stdout(p::str::contains(format!( - "{language}/{language} {version} [4/4] done" - ))); + for (language, _version) in parsers { + out = out + .stdout(p::str::contains(format!("{language}/{language}"))) + .stdout(p::str::contains("[4/4] built")); } for (language, _version) in parsers { let dylib = sandbox @@ -262,14 +263,12 @@ fn build_implicit_pinned_and_unpinned() { #[rstest] fn multi_parsers_no_cmd() { let java = "java"; - let version = "HEAD"; + let _version = "HEAD"; let languages = [java]; let mut sandbox = Sandbox::new(); let mut assert = sandbox.cmd.args(["build", java]).assert().success(); for language in languages { - assert = assert.stdout(p::str::contains(format!( - "{language} {version} [1/2] cloning" - ))); + assert = assert.stdout(p::str::contains(format!("{language:<16} [1/2] cloning"))); } for language in languages { let dylib = sandbox @@ -300,9 +299,7 @@ fn multi_parsers_cmd() { let assert = sandbox.cmd.args(["build", typescript]).assert().success(); // Check for version in cloning step // TODO: dig for changes in this test and revert. - _ = assert.stdout(p::str::contains(format!( - "{typescript} v{version} [1/2] cloning" - ))); + _ = assert.stdout(p::str::contains(format!("{typescript:<16} [1/2] cloning"))); for language in languages { let dylib = sandbox .tmp diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index e47c325..0639699 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -35,7 +35,7 @@ fn cache_hit_skips_build() { .arg("json") .assert() .success() - .stdout(p::str::contains("json/json HEAD [4/4] done")) + .stdout(p::str::contains("json/json [4/4] cached")) .stdout(p::str::contains("cloning").not()); let second_inode = binary.metadata().unwrap().ino(); @@ -68,8 +68,8 @@ fn cache_miss_on_grammar_modification() { cmd.args(["build", "--force", "json"]) .assert() .success() - .stdout(p::str::contains("HEAD [1/2] cloning")) - .stdout(p::str::contains("json/json HEAD [3/4] building")); + .stdout(p::str::contains("json [1/2] cloning")) + .stdout(p::str::contains("json/json [3/4] building")); } #[rstest] @@ -128,8 +128,8 @@ fn force_flag_bypasses_cache() { cmd.args(["build", "--force", "json"]) .assert() .success() - .stdout(p::str::contains("HEAD [1/2] cloning")) - .stdout(p::str::contains("json/json HEAD [3/4] building")); + .stdout(p::str::contains("json [1/2] cloning")) + .stdout(p::str::contains("json/json [3/4] building")); let second_inode = binary.metadata().unwrap().ino(); assert_ne!( @@ -179,7 +179,7 @@ fn force_flag_reinstalls_hardlink() { cmd.args(["build", "--force", "json"]) .assert() .success() - .stdout(p::str::contains("json/json HEAD [4/4] installing")); + .stdout(p::str::contains("json/json [4/4] installing")); let final_inode_out = binary.metadata().unwrap().ino(); let final_inode_build = build_binary.metadata().unwrap().ino(); @@ -213,7 +213,10 @@ fn multi_parser_independent_cache(#[case] languages: Vec<&str>) { let mut output = cmd.arg("build").args(&languages).assert().success(); for lang in &languages { - output = output.stdout(p::str::contains(format!("{lang}/{lang} HEAD [4/4] done"))); + output = output.stdout(p::str::contains(format!( + "{:<16} [4/4] cached", + format!("{lang}/{lang}") + ))); } } diff --git a/tests/cmd/log.rs b/tests/cmd/log.rs index 537dda7..24bb46d 100644 --- a/tests/cmd/log.rs +++ b/tests/cmd/log.rs @@ -2,7 +2,7 @@ use rstest::*; use assert_fs::prelude::*; use predicates::{self as p}; -use tsdl::consts::{TREE_SITTER_VERSION, TSDL_BUILD_DIR}; +use tsdl::consts::TSDL_BUILD_DIR; use crate::cmd::Sandbox; @@ -14,9 +14,7 @@ fn build_no_args_should_log_to_default_path() { .cmd .assert() .success() - .stdout(p::str::contains(format!( - "tree-sitter-cli v{TREE_SITTER_VERSION} [2/2] done" - ))); + .stdout(p::str::contains("tree-sitter-cli [2/2] done")); assert!(!sandbox.is_empty()); sandbox .tmp @@ -38,9 +36,7 @@ fn build_w_specific_log_path(#[case] log: &str) { .cmd .assert() .success() - .stdout(p::str::contains(format!( - "tree-sitter-cli v{TREE_SITTER_VERSION} [2/2] done" - ))); + .stdout(p::str::contains("tree-sitter-cli [2/2] done")); sandbox .tmp .child(log) From bad1ef55e141bc51e8dc464062b5efcba02e8520 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 12:47:41 +0200 Subject: [PATCH 04/88] output: color reflect the build status --- src/main.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index c12fb91..8e1cb60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use std::{process::ExitCode, time::Instant}; use clap::Parser; +use console::style; use tracing::{error, info}; use tsdl::{app::App, args, logging, TsdlResult}; @@ -28,7 +29,12 @@ fn run(app: &mut App, args: &args::Args) -> TsdlResult<()> { match &args.command { args::Command::Build(_) => { let (result, duration) = time(|| tsdl::build::run(app)); - println!("Done in {duration}"); + let done = format!("Done in {duration}"); + if result.is_ok() { + println!("{}", style(done).green()); + } else { + println!("{}", style(done).red()); + } result } args::Command::Config { command } => tsdl::config::run(app, command), From ad50235d658f914c87564f77d2f276d2c94de37d Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 12:53:26 +0200 Subject: [PATCH 05/88] display: better coloring for in progress and summary --- src/actors/display.rs | 4 ++-- src/display.rs | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index 904ee32..e9f8469 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -429,9 +429,9 @@ impl DisplayActor { } fn print_plain_summary(&self) { - let (cached, built, building, failed) = self.state.summary_counts(); + let (cached, built, _building, failed) = self.state.summary_counts(); println!(); - println!("✓ {cached} cached ● {built} built ● {building} building ✗ {failed} failed"); + println!("✓ {cached} cached ✓ {built} built ✗ {failed} failed"); } fn plain_progress_line(&self, id: u64, kind: UpdateKind) -> Option { diff --git a/src/display.rs b/src/display.rs index 0c0b72f..9e86572 100644 --- a/src/display.rs +++ b/src/display.rs @@ -100,7 +100,7 @@ fn indicator_color(status: ItemStatus) -> Color { match status { ItemStatus::Done => Color::Green, ItemStatus::Failed => Color::Red, - ItemStatus::New | ItemStatus::InProgress => Color::White, + ItemStatus::New | ItemStatus::InProgress => Color::DarkGray, } } @@ -395,7 +395,9 @@ impl DisplayState { push_summary_success(&mut spans, cached, "cached", Color::Yellow, true); push_summary_success(&mut spans, built, "built", Color::Blue, false); - push_summary_building(&mut spans, building); + if building > 0 { + push_summary_building(&mut spans, building); + } push_summary_failed(&mut spans, failed); Line::from(spans) From 176b36d9f4ae30a7d1ee4f6c6ba2770489dd4e0b Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 15:56:33 +0200 Subject: [PATCH 06/88] display: introduce cache --- src/actors/display.rs | 181 ++++++++++-- src/display.rs | 629 +++++++++++++++++++++++++++--------------- 2 files changed, 561 insertions(+), 249 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index e9f8469..6144949 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -3,12 +3,17 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; +use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use tokio::sync::{mpsc, oneshot}; use tokio::time; use crate::actors::Addr; -use crate::display::{DisplayState, GrammarEntry, ItemOutcome, ItemStatus, Mode, RepoEntry}; +use crate::display::{ + compute_icon_cell, compute_msg_cell, compute_name_cell, compute_ref_cell, compute_step_cell, + compute_time_cell, Column, DisplayState, GrammarEntry, GridCache, ItemOutcome, ItemStatus, + Mode, RepoEntry, RowSpec, +}; use crate::git::GitRef; // --------------------------------------------------------------------------- @@ -253,6 +258,9 @@ pub struct DisplayActor { next_id: u64, plain_name_width: usize, plain_progress_started: bool, + grid: GridCache, + row_specs: Vec, + rows_dirty: bool, rx: mpsc::Receiver, tx: mpsc::Sender, } @@ -266,6 +274,9 @@ impl DisplayActor { next_id: 1, plain_name_width: 16, plain_progress_started: false, + grid: GridCache::new(), + row_specs: Vec::new(), + rows_dirty: true, rx, tx: tx.clone(), }; @@ -301,46 +312,166 @@ impl DisplayActor { ) .expect("Failed to initialize ratatui terminal"); - let _ = terminal.draw(|frame| self.render(frame)); + // Initial render + let lines = self.materialize(terminal.size().map(|s| s.width).unwrap_or(80)); + let _ = terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + }); let mut tick_interval = time::interval(Duration::from_millis(100)); - let mut shutdown_tx = None; - loop { + // Drain all pending messages before rendering + let mut shutdown_tx = None; + while let Ok(msg) = self.rx.try_recv() { + match msg { + DisplayMessage::Shutdown { tx } => { + shutdown_tx = Some(tx); + break; + } + other => self.handle_message(other), + } + } + + if let Some(tx) = shutdown_tx { + // Final render before shutting down + let lines = self.materialize(terminal.size().map(|s| s.width).unwrap_or(80)); + let _ = terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + }); + ratatui::restore(); + eprintln!(); + let _ = tx.send(()); + return; + } + + // Render current state + let lines = self.materialize(terminal.size().map(|s| s.width).unwrap_or(80)); + let _ = terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + }); + + // Wait for next event tokio::select! { msg = self.rx.recv() => { match msg { Some(DisplayMessage::Shutdown { tx }) => { - shutdown_tx = Some(tx); - break; + // Final render then exit + let lines = self.materialize(terminal.size().map(|s| s.width).unwrap_or(80)); + let _ = terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + }); + ratatui::restore(); + eprintln!(); + let _ = tx.send(()); + return; } - Some(msg) => self.handle_message(msg), + Some(other) => self.handle_message(other), None => break, } } _ = tick_interval.tick() => { - // Ticks trigger re-renders for live elapsed-time updates. + // Tick — next materialize will update live clocks } } - - let _ = terminal.draw(|frame| self.render(frame)); } - let _ = terminal.draw(|frame| self.render(frame)); ratatui::restore(); eprintln!(); + } + + /// Assemble `Vec` from the grid cache. Rebuilds row order and layout + /// only when items have been added. Otherwise reuses the cached row order + /// and recomputes only stale cells (dirty items + running clocks). + fn materialize(&mut self, term_width: u16) -> Vec> { + let term_w = term_width as usize; - if let Some(tx) = shutdown_tx { - let _ = tx.send(()); + // Rebuild row order if items were added + if self.rows_dirty { + self.row_specs = self.state.compute_row_order(); + let new_layout = self.state.compute_layout(&self.row_specs); + + // Invalidate columns whose width changed + if new_layout.ref_width != self.grid.layout.ref_width { + self.grid.invalidate_column(Column::GitRef); + } + if new_layout.step_width != self.grid.layout.step_width { + self.grid.invalidate_column(Column::Step); + } + if new_layout.name_width != self.grid.layout.name_width { + self.grid.invalidate_column(Column::Name); + } + self.grid.layout = new_layout; + self.rows_dirty = false; } - } - fn render(&self, frame: &mut ratatui::Frame) { - let area = frame.area(); - let lines = self.state.render_lines(area.width); - let paragraph = Paragraph::new(lines); - frame.render_widget(paragraph, area); + // Assemble rows from the grid cache + let layout = self.grid.layout.clone(); + let mut lines: Vec> = Vec::with_capacity(self.row_specs.len()); + + for spec in &self.row_specs { + let info = self.state.get_item_info(spec); + let item_id = spec.id; + let is_dirty = self.grid.dirty_items.contains(&item_id); + + // TIME: stale when dirty OR clock is still running + let time_stale = + is_dirty || matches!(info.status(), ItemStatus::New | ItemStatus::InProgress); + let time = self.grid.cell(item_id, Column::Time, time_stale, || { + compute_time_cell(&info, &layout) + }); + + let gref = self.grid.cell(item_id, Column::GitRef, is_dirty, || { + compute_ref_cell(&info, &layout) + }); + + let step = self.grid.cell(item_id, Column::Step, is_dirty, || { + compute_step_cell(&info, &layout) + }); + + let icon = self + .grid + .cell(item_id, Column::Icon, is_dirty, || compute_icon_cell(&info)); + + let name = self.grid.cell(item_id, Column::Name, is_dirty, || { + compute_name_cell(&spec.display_name, spec.indent, &info, &layout) + }); + + let msg = self.grid.cell(item_id, Column::Msg, is_dirty, || { + compute_msg_cell(&info, &layout, term_w) + }); + + lines.push(Line::from(vec![ + time, + spacer(), + gref, + spacer(), + step, + spacer(), + icon, + spacer(), + name, + spacer(), + msg, + ])); + } + + // Footer + if !lines.is_empty() { + lines.push(Line::from("")); + } + lines.push(self.state.footer_build_line()); + lines.push(self.state.footer_out_line()); + lines.push(Line::from("")); + lines.push(self.state.format_footer_counts()); + + self.grid.clear_dirty(); + + lines } // ── Plain mode ──────────────────────────────────────────────────── @@ -505,6 +636,8 @@ impl DisplayActor { }, ); + self.rows_dirty = true; + ProgressAddr { id, tx: self.tx.clone(), @@ -546,6 +679,8 @@ impl DisplayActor { }, ); + self.rows_dirty = true; + ProgressAddr { id, tx: self.tx.clone(), @@ -555,12 +690,14 @@ impl DisplayActor { fn apply_update(&mut self, id: u64, kind: UpdateKind, msg: Arc) { if let Some(repo) = self.state.repos.get_mut(&id) { Self::apply_repo_update(repo, kind, msg); + self.grid.mark_dirty(id); return; } let mut maybe_parent_id: Option = None; if let Some(grammar) = self.state.grammars.get_mut(&id) { Self::apply_grammar_update(grammar, kind, msg); + self.grid.mark_dirty(id); if matches!( kind, UpdateKind::Step @@ -698,6 +835,8 @@ impl DisplayActor { .get_or_insert_with(|| repo.started_at.elapsed()); } } + + self.grid.mark_dirty(repo_id); } fn aggregate_child_outcome(&self, repo_id: u64) -> ItemOutcome { @@ -726,3 +865,7 @@ impl DisplayActor { } } } + +fn spacer() -> Span<'static> { + Span::raw(" ") +} diff --git a/src/display.rs b/src/display.rs index 9e86572..6185931 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -123,6 +123,13 @@ pub(crate) struct RepoEntry { pub frozen_elapsed: Option, } +impl RepoEntry { + pub(crate) fn elapsed(&self) -> Duration { + self.frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } +} + #[derive(Debug, Clone)] pub(crate) struct GrammarEntry { pub repo: Arc, @@ -140,177 +147,411 @@ pub(crate) struct GrammarEntry { pub frozen_elapsed: Option, } +impl GrammarEntry { + pub(crate) fn elapsed(&self) -> Duration { + self.frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } +} + // --------------------------------------------------------------------------- -// Render rows +// Column identifiers for the grid cache // --------------------------------------------------------------------------- -struct RenderRow { - status: ItemStatus, - outcome: ItemOutcome, - name: String, - git_ref: String, - msg: String, - step: usize, - total: usize, - started_at: Instant, - frozen_elapsed: Option, - indent: &'static str, -} - -impl RenderRow { - fn elapsed(&self) -> Duration { - self.frozen_elapsed - .unwrap_or_else(|| self.started_at.elapsed()) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Column { + Time, + GitRef, + Step, + Icon, + Name, + Msg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct CellKey { + item_id: u64, + column: Column, +} + +// --------------------------------------------------------------------------- +// Cached layout (column widths) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub(crate) struct CachedLayout { + pub time_width: usize, + pub ref_width: usize, + pub step_width: usize, + pub name_width: usize, + /// Total width consumed by non-message columns: time + sp + ref + sp + + /// step + sp + icon(1) + sp + name + sp. + pub fixed_width: usize, +} + +impl Default for CachedLayout { + fn default() -> Self { + Self { + time_width: 5, + ref_width: 4, + step_width: 5, + name_width: 4, + fixed_width: 5 + 1 + 4 + 1 + 5 + 1 + 1 + 1 + 4 + 1, + } } +} + +impl CachedLayout { + fn finalize(&mut self) { + self.time_width = self.time_width.max(5); + self.ref_width = self.ref_width.max(4); + self.step_width = self.step_width.max(5); + self.name_width = self.name_width.max(4); + // fixed = time + sp + ref + sp + step + sp + icon(1) + sp + name + sp + self.fixed_width = self.time_width + + 1 + + self.ref_width + + 1 + + self.step_width + + 1 + + 1 + + 1 + + self.name_width + + 1; + } +} + +// --------------------------------------------------------------------------- +// Grid cache +// --------------------------------------------------------------------------- - fn time(&self) -> String { - format_elapsed_duration(self.elapsed()) +pub(crate) struct GridCache { + cells: HashMap>, + pub dirty_items: HashSet, + pub layout: CachedLayout, +} + +impl GridCache { + pub fn new() -> Self { + Self { + cells: HashMap::new(), + dirty_items: HashSet::new(), + layout: CachedLayout::default(), + } } - fn step_text(&self) -> String { - if self.total > 0 { - format!("[{}/{}]", self.step.min(self.total), self.total) + /// Get a cell for the given item and column. If `stale` is true (or the + /// cell is not cached), `compute` is called, the result is cached, and + /// returned. Otherwise the cached cell is cloned. + pub fn cell( + &mut self, + item_id: u64, + column: Column, + stale: bool, + compute: impl FnOnce() -> Span<'static>, + ) -> Span<'static> { + let key = CellKey { item_id, column }; + if stale { + let span = compute(); + self.cells.insert(key, span.clone()); + span + } else if let Some(cached) = self.cells.get(&key) { + cached.clone() } else { - String::new() + let span = compute(); + self.cells.insert(key, span.clone()); + span } } - fn display_name(&self) -> String { - format!("{}{}", self.indent, self.name) + pub fn mark_dirty(&mut self, item_id: u64) { + self.dirty_items.insert(item_id); + } + + pub fn clear_dirty(&mut self) { + self.dirty_items.clear(); + } + + /// Invalidate all cached cells for a given column (e.g. when layout width + /// for that column changes). + pub fn invalidate_column(&mut self, column: Column) { + self.cells.retain(|key, _| key.column != column); } } -struct RenderLayout { - time_width: usize, - ref_width: usize, - step_width: usize, - name_width: usize, +// --------------------------------------------------------------------------- +// Row specification +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub(crate) enum RowKind { + Repo, + Grammar, +} + +#[derive(Debug, Clone)] +pub(crate) struct RowSpec { + pub id: u64, + pub kind: RowKind, + /// The text to display in the name column (already includes indent). + pub display_name: Arc, + /// Indentation prefix for the name column (applied before padding). + pub indent: &'static str, } -impl RenderLayout { - fn from_rows(rows: &[RenderRow]) -> Self { - Self { - time_width: rows - .iter() - .map(|row| row.time().chars().count()) - .max() - .unwrap_or(5) - .max(5), - ref_width: rows - .iter() - .map(|row| row.git_ref.chars().count()) - .max() - .unwrap_or(4) - .max(4), - step_width: rows - .iter() - .map(|row| row.step_text().chars().count()) - .max() - .unwrap_or(5) - .max(5), - name_width: rows - .iter() - .map(|row| row.display_name().chars().count()) - .max() - .unwrap_or(4) - .max(4), +// --------------------------------------------------------------------------- +// Unified item info (abstracts over RepoEntry / GrammarEntry) +// --------------------------------------------------------------------------- + +pub(crate) enum ItemInfo<'a> { + Repo(&'a RepoEntry), + Grammar(&'a GrammarEntry), +} + +impl ItemInfo<'_> { + pub(crate) fn status(&self) -> ItemStatus { + match self { + ItemInfo::Repo(r) => r.status, + ItemInfo::Grammar(g) => g.status, + } + } + + pub(crate) fn outcome(&self) -> ItemOutcome { + match self { + ItemInfo::Repo(r) => r.outcome, + ItemInfo::Grammar(g) => g.outcome, + } + } + + pub(crate) fn msg(&self) -> &str { + match self { + ItemInfo::Repo(r) => &r.msg, + ItemInfo::Grammar(g) => &g.msg, + } + } + + pub(crate) fn step(&self) -> usize { + match self { + ItemInfo::Repo(r) => r.step, + ItemInfo::Grammar(g) => g.step, + } + } + + pub(crate) fn total(&self) -> usize { + match self { + ItemInfo::Repo(r) => r.total, + ItemInfo::Grammar(g) => g.total, + } + } + + pub(crate) fn git_ref(&self) -> &GitRef { + match self { + ItemInfo::Repo(r) => &r.git_ref, + ItemInfo::Grammar(g) => &g.git_ref, + } + } + + pub(crate) fn elapsed(&self) -> Duration { + match self { + ItemInfo::Repo(r) => r.elapsed(), + ItemInfo::Grammar(g) => g.elapsed(), } } } // --------------------------------------------------------------------------- -// DisplayState — the full renderable state +// Cell factories — each produces a fully padded, fully styled Span // --------------------------------------------------------------------------- +const TIME_STYLE: Style = Style::new(); +const REF_STYLE: Style = Style::new(); +const MSG_STYLE: Style = Style::new(); + +pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { + let time = format_elapsed_duration(info.elapsed()); + // Right-aligned + let padded = format!("{:>width$}", time, width = layout.time_width); + Span::styled(padded, TIME_STYLE) +} + +pub(crate) fn compute_ref_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { + let ref_str = info.git_ref().to_string(); + let padded = format!("{:, layout: &CachedLayout) -> Span<'static> { + let step_str = if info.total() > 0 { + format!("[{}/{}]", info.step().min(info.total()), info.total()) + } else { + String::new() + }; + // Right-aligned + let padded = format!("{:>width$}", step_str, width = layout.step_width); + Span::styled(padded, REF_STYLE) +} + +pub(crate) fn compute_icon_cell(info: &ItemInfo<'_>) -> Span<'static> { + let status = info.status(); + Span::styled( + status.icon(), + Style::default() + .fg(indicator_color(status)) + .add_modifier(Modifier::BOLD), + ) +} + +pub(crate) fn compute_name_cell( + display_name: &str, + indent: &str, + info: &ItemInfo<'_>, + layout: &CachedLayout, +) -> Span<'static> { + let full = format!("{indent}{display_name}"); + let padded = format!("{:, + layout: &CachedLayout, + term_width: usize, +) -> Span<'static> { + let msg_width = term_width.saturating_sub(layout.fixed_width).max(1); + Span::styled(truncate_str(info.msg(), msg_width), MSG_STYLE) +} + +// --------------------------------------------------------------------------- +// DisplayState — the source of truth +// --------------------------------------------------------------------------- + +/// Pre-computed dimmed footer lines (static; never change). +fn dim_line(text: String) -> Line<'static> { + Line::from(Span::styled( + text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + )) +} + pub(crate) struct DisplayState { pub mode: Mode, pub repos: HashMap, pub grammars: HashMap, pub build_dir: Arc, pub out_dir: Arc, + footer_build: Line<'static>, + footer_out: Line<'static>, } impl DisplayState { pub fn new(mode: Mode, build_dir: Arc, out_dir: Arc) -> Self { + let footer_build = dim_line(format!("build: {}", build_dir.display())); + let footer_out = dim_line(format!("out: {}", out_dir.display())); Self { mode, repos: HashMap::new(), grammars: HashMap::new(), build_dir, out_dir, + footer_build, + footer_out, } } - /// Render the entire display as ratatui `Line`s. - pub fn render_lines(&self, width: u16) -> Vec> { - let w = width as usize; - let mut lines: Vec = Vec::new(); - let rows = self.render_rows(); - let layout = RenderLayout::from_rows(&rows); + // ── Footer lines ────────────────────────────────────────────── - for row in &rows { - lines.push(self.format_item_line(row, &layout, w)); - } + pub fn footer_build_line(&self) -> Line<'static> { + self.footer_build.clone() + } - if !lines.is_empty() { - lines.push(Line::from("")); - } + pub fn footer_out_line(&self) -> Line<'static> { + self.footer_out.clone() + } - lines.push(self.format_footer_build()); - lines.push(self.format_footer_out()); - lines.push(Line::from("")); - lines.push(self.format_footer_counts()); + pub fn format_footer_counts(&self) -> Line<'static> { + let (cached, built, building, failed) = self.summary_counts(); + let mut spans: Vec> = Vec::new(); - lines - } + push_summary_success(&mut spans, cached, "cached", Color::Yellow, true); + push_summary_success(&mut spans, built, "built", Color::Blue, false); + if building > 0 { + push_summary_building(&mut spans, building); + } + push_summary_failed(&mut spans, failed); - fn sorted_repos(&self) -> Vec<(u64, &RepoEntry)> { - let mut repos: Vec<(u64, &RepoEntry)> = self - .repos - .iter() - .map(|(repo_id, repo)| (*repo_id, repo)) - .collect(); - repos.sort_by_key(|(_, r)| r.started_at); - repos + Line::from(spans) } - fn sorted_grammars_for_repo(&self, repo_id: u64) -> Vec<&GrammarEntry> { - let mut grammars: Vec<&GrammarEntry> = self - .grammars - .values() - .filter(|g| g.repo_id == repo_id) - .collect(); - grammars.sort_by_key(|g| g.started_at); - grammars - } + // ── Row order ───────────────────────────────────────────────── - fn render_rows(&self) -> Vec { + /// Compute the sorted row order. Repos are sorted alphabetically by name; + /// grammars within each repo are sorted alphabetically by name. + /// Collapses single-grammar repos where the grammar name equals the repo + /// name into a single grammar row (no separate repo row). + pub fn compute_row_order(&self) -> Vec { let mut rows = Vec::new(); - for (repo_id, repo) in self.sorted_repos() { - let repo_grammars = self.sorted_grammars_for_repo(repo_id); + // Sort repos alphabetically by name + let mut sorted_repos: Vec<(u64, &RepoEntry)> = + self.repos.iter().map(|(id, r)| (*id, r)).collect(); + sorted_repos.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); + + // Gather grammar (id, entry) pairs grouped by repo, sorted by name + let mut grammars_by_repo: HashMap> = HashMap::new(); + for (gid, g) in &self.grammars { + grammars_by_repo + .entry(g.repo_id) + .or_default() + .push((*gid, g)); + } + for list in grammars_by_repo.values_mut() { + list.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); + } + + for (repo_id, repo) in &sorted_repos { + let repo_grammars = grammars_by_repo.remove(repo_id).unwrap_or_default(); if repo_grammars.is_empty() { - rows.push(RenderRow::from_repo(repo, "")); + rows.push(RowSpec { + id: *repo_id, + kind: RowKind::Repo, + display_name: repo.name.clone(), + indent: "", + }); } else if repo_grammars.len() == 1 { - let grammar = repo_grammars[0]; - if grammar.name == repo.name { - rows.push(RenderRow::from_grammar(grammar, repo.name.to_string(), "")); + let (gid, grammar) = repo_grammars[0]; + let display_name: Arc = if grammar.name == repo.name { + repo.name.clone() } else { - rows.push(RenderRow::from_grammar( - grammar, - format!("{}/{}", repo.name, grammar.name), - "", - )); - } + format!("{}/{}", repo.name, grammar.name).into() + }; + rows.push(RowSpec { + id: gid, + kind: RowKind::Grammar, + display_name, + indent: "", + }); } else { - rows.push(RenderRow::from_repo(repo, "")); - for grammar in repo_grammars { - rows.push(RenderRow::from_grammar( - grammar, - grammar.name.to_string(), - " ", - )); + rows.push(RowSpec { + id: *repo_id, + kind: RowKind::Repo, + display_name: repo.name.clone(), + indent: "", + }); + for (gid, grammar) in repo_grammars { + rows.push(RowSpec { + id: gid, + kind: RowKind::Grammar, + display_name: grammar.name.clone(), + indent: " ", + }); } } } @@ -318,98 +559,58 @@ impl DisplayState { rows } - fn dim_line(&self, text: String) -> Line<'static> { - Line::from(Span::styled( - text, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::DIM), - )) - } - - fn format_item_line( - &self, - row: &RenderRow, - layout: &RenderLayout, - width: usize, - ) -> Line<'static> { - let time = row.time(); - let step = row.step_text(); - let name = row.display_name(); - let icon = row.status.icon(); - - let time_col = format!("{time:width$}", step, width = layout.step_width); - let name_col = format!("{: = Vec::new(); - spans.push(Span::styled(time_col, progress_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled(ref_col, progress_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled(step_col, progress_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled(icon, icon_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled(name_col, name_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled(msg_col, msg_style)); - - Line::from(spans) - } - - fn format_footer_build(&self) -> Line<'static> { - self.dim_line(format!("build: {}", self.build_dir.display())) - } - - fn format_footer_out(&self) -> Line<'static> { - self.dim_line(format!("out: {}", self.out_dir.display())) + // ── Item access ─────────────────────────────────────────────── + + pub fn get_item_info(&self, spec: &RowSpec) -> ItemInfo<'_> { + match spec.kind { + RowKind::Repo => ItemInfo::Repo( + self.repos + .get(&spec.id) + .expect("repo not found for row spec"), + ), + RowKind::Grammar => ItemInfo::Grammar( + self.grammars + .get(&spec.id) + .expect("grammar not found for row spec"), + ), + } } - fn format_footer_counts(&self) -> Line<'static> { - let (cached, built, building, failed) = self.summary_counts(); - let mut spans: Vec = Vec::new(); - - push_summary_success(&mut spans, cached, "cached", Color::Yellow, true); - push_summary_success(&mut spans, built, "built", Color::Blue, false); - if building > 0 { - push_summary_building(&mut spans, building); + // ── Layout ──────────────────────────────────────────────────── + + /// Compute column layout from the current row specs and state. + pub fn compute_layout(&self, row_specs: &[RowSpec]) -> CachedLayout { + let mut layout = CachedLayout::default(); + for spec in row_specs { + let info = self.get_item_info(spec); + let time_str = format_elapsed_duration(info.elapsed()); + let ref_str = info.git_ref().to_string(); + let step_str = if info.total() > 0 { + format!("[{}/{}]", info.step().min(info.total()), info.total()) + } else { + String::new() + }; + let name_full = format!("{}{}", spec.indent, spec.display_name); + layout.time_width = layout.time_width.max(time_str.chars().count()); + layout.ref_width = layout.ref_width.max(ref_str.chars().count()); + layout.step_width = layout.step_width.max(step_str.chars().count()); + layout.name_width = layout.name_width.max(name_full.chars().count()); } - push_summary_failed(&mut spans, failed); - - Line::from(spans) + layout.finalize(); + layout } + // ── Summary counts ──────────────────────────────────────────── + pub(crate) fn summary_counts(&self) -> (usize, usize, usize, usize) { let mut cached = 0; let mut built = 0; let mut building = 0; let mut failed = 0; + let mut repos_with_grammars: HashSet = HashSet::new(); for grammar in self.grammars.values() { + repos_with_grammars.insert(grammar.repo_id); count_grammar_status( grammar.status, grammar.outcome, @@ -420,9 +621,9 @@ impl DisplayState { ); } - for (repo_id, repo) in self.sorted_repos() { - let has_grammars = self.grammars.values().any(|g| g.repo_id == repo_id); - if !has_grammars && repo.status == ItemStatus::Failed { + // Repos without grammars that failed + for (repo_id, repo) in &self.repos { + if !repos_with_grammars.contains(repo_id) && repo.status == ItemStatus::Failed { failed += 1; } } @@ -431,38 +632,6 @@ impl DisplayState { } } -impl RenderRow { - fn from_repo(repo: &RepoEntry, indent: &'static str) -> Self { - Self { - status: repo.status, - outcome: repo.outcome, - name: repo.name.to_string(), - git_ref: repo.git_ref.to_string(), - msg: repo.msg.to_string(), - step: repo.step, - total: repo.total, - started_at: repo.started_at, - frozen_elapsed: repo.frozen_elapsed, - indent, - } - } - - fn from_grammar(grammar: &GrammarEntry, name: String, indent: &'static str) -> Self { - Self { - status: grammar.status, - outcome: grammar.outcome, - name, - git_ref: grammar.git_ref.to_string(), - msg: grammar.msg.to_string(), - step: grammar.step, - total: grammar.total, - started_at: grammar.started_at, - frozen_elapsed: grammar.frozen_elapsed, - indent, - } - } -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- From 740266e4397fef6658330cb0231c0a4f65b9e39b Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 16:28:42 +0200 Subject: [PATCH 07/88] display: elapsed: fix width and format --- src/display.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/display.rs b/src/display.rs index 6185931..c49a7d2 100644 --- a/src/display.rs +++ b/src/display.rs @@ -192,18 +192,18 @@ pub(crate) struct CachedLayout { impl Default for CachedLayout { fn default() -> Self { Self { - time_width: 5, + time_width: 6, ref_width: 4, step_width: 5, name_width: 4, - fixed_width: 5 + 1 + 4 + 1 + 5 + 1 + 1 + 1 + 4 + 1, + fixed_width: 6 + 1 + 4 + 1 + 5 + 1 + 1 + 1 + 4 + 1, } } } impl CachedLayout { fn finalize(&mut self) { - self.time_width = self.time_width.max(5); + self.time_width = 6; self.ref_width = self.ref_width.max(4); self.step_width = self.step_width.max(5); self.name_width = self.name_width.max(4); @@ -583,7 +583,6 @@ impl DisplayState { let mut layout = CachedLayout::default(); for spec in row_specs { let info = self.get_item_info(spec); - let time_str = format_elapsed_duration(info.elapsed()); let ref_str = info.git_ref().to_string(); let step_str = if info.total() > 0 { format!("[{}/{}]", info.step().min(info.total()), info.total()) @@ -591,7 +590,6 @@ impl DisplayState { String::new() }; let name_full = format!("{}{}", spec.indent, spec.display_name); - layout.time_width = layout.time_width.max(time_str.chars().count()); layout.ref_width = layout.ref_width.max(ref_str.chars().count()); layout.step_width = layout.step_width.max(step_str.chars().count()); layout.name_width = layout.name_width.max(name_full.chars().count()); @@ -725,8 +723,10 @@ fn truncate_str(s: &str, max: usize) -> String { fn format_elapsed_duration(dur: Duration) -> String { let secs = dur.as_secs_f64(); if secs < 60.0 { - format!("{secs:.2}s") + format!("{:>5.2}s", secs) } else { - format!("{:.2}m", secs / 60.0) + let mins = secs as u64 / 60; + let remaining = secs as u64 % 60; + format!("{:>3}:{:02}", mins, remaining) } } From fd3320def7d870364536e88032b6fc8fc6020d38 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 17:02:00 +0200 Subject: [PATCH 08/88] display: remove magic numbers --- src/display.rs | 82 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/src/display.rs b/src/display.rs index c49a7d2..40f44e5 100644 --- a/src/display.rs +++ b/src/display.rs @@ -11,6 +11,38 @@ use ratatui::text::{Line, Span}; use crate::args::ProgressStyle; use crate::git::GitRef; +// ── Column geometry ────────────────────────────────────────────── +/// Fixed width of the time column (right-aligned, " 0.00s" … "59:59"). +const TIME_COL_WIDTH: usize = 6; +/// Minimum width of the git-ref column (left-aligned). +const MIN_REF_WIDTH: usize = 4; +/// Minimum width of the step column (right-aligned, e.g. "[1/4]"). +const MIN_STEP_WIDTH: usize = 5; +/// Minimum width of the name column (left-aligned). +const MIN_NAME_WIDTH: usize = 4; +/// Width of the icon column (●, ✓, ✗ are all single-width). +const ICON_COL_WIDTH: usize = 1; +/// Width of a spacer between columns (a single space). +const SPACER_WIDTH: usize = 1; +/// Minimum width for the message column. +const MIN_MSG_WIDTH: usize = 1; + +/// Pre-compute the fixed (non-message) portion of a row line. +/// +/// Columns in order: Time _ Ref _ Step _ Icon _ Name _ Msg +/// (5 spacers between 6 columns). +const fn fixed_width(time: usize, rf: usize, step: usize, name: usize) -> usize { + time + SPACER_WIDTH + + rf + + SPACER_WIDTH + + step + + SPACER_WIDTH + + ICON_COL_WIDTH + + SPACER_WIDTH + + name + + SPACER_WIDTH +} + // --------------------------------------------------------------------------- // Mode // --------------------------------------------------------------------------- @@ -192,32 +224,32 @@ pub(crate) struct CachedLayout { impl Default for CachedLayout { fn default() -> Self { Self { - time_width: 6, - ref_width: 4, - step_width: 5, - name_width: 4, - fixed_width: 6 + 1 + 4 + 1 + 5 + 1 + 1 + 1 + 4 + 1, + time_width: TIME_COL_WIDTH, + ref_width: MIN_REF_WIDTH, + step_width: MIN_STEP_WIDTH, + name_width: MIN_NAME_WIDTH, + fixed_width: fixed_width( + TIME_COL_WIDTH, + MIN_REF_WIDTH, + MIN_STEP_WIDTH, + MIN_NAME_WIDTH, + ), } } } impl CachedLayout { fn finalize(&mut self) { - self.time_width = 6; - self.ref_width = self.ref_width.max(4); - self.step_width = self.step_width.max(5); - self.name_width = self.name_width.max(4); - // fixed = time + sp + ref + sp + step + sp + icon(1) + sp + name + sp - self.fixed_width = self.time_width - + 1 - + self.ref_width - + 1 - + self.step_width - + 1 - + 1 - + 1 - + self.name_width - + 1; + self.time_width = TIME_COL_WIDTH; + self.ref_width = self.ref_width.max(MIN_REF_WIDTH); + self.step_width = self.step_width.max(MIN_STEP_WIDTH); + self.name_width = self.name_width.max(MIN_NAME_WIDTH); + self.fixed_width = fixed_width( + self.time_width, + self.ref_width, + self.step_width, + self.name_width, + ); } } @@ -422,7 +454,9 @@ pub(crate) fn compute_msg_cell( layout: &CachedLayout, term_width: usize, ) -> Span<'static> { - let msg_width = term_width.saturating_sub(layout.fixed_width).max(1); + let msg_width = term_width + .saturating_sub(layout.fixed_width) + .max(MIN_MSG_WIDTH); Span::styled(truncate_str(info.msg(), msg_width), MSG_STYLE) } @@ -723,10 +757,12 @@ fn truncate_str(s: &str, max: usize) -> String { fn format_elapsed_duration(dur: Duration) -> String { let secs = dur.as_secs_f64(); if secs < 60.0 { - format!("{:>5.2}s", secs) + format!("{secs:>5.2}s") } else { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let mins = secs as u64 / 60; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let remaining = secs as u64 % 60; - format!("{:>3}:{:02}", mins, remaining) + format!("{mins:>3}:{remaining:02}") } } From bc95e2dd588dbb50d9be674555a7e10b06963d1f Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 17:03:36 +0200 Subject: [PATCH 09/88] clippy: fix-now --- src/actors/display.rs | 20 +++++++++----------- src/tree_sitter.rs | 8 ++++---- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index 6144949..ebd2c6c 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -22,7 +22,7 @@ use crate::git::GitRef; #[derive(Debug)] pub enum DisplayMessage { - /// Register a repo-level progress line. Returns a ProgressAddr. + /// Register a repo-level progress line. Returns a `ProgressAddr`. RegisterLanguage { git_ref: GitRef, name: Arc, @@ -30,7 +30,7 @@ pub enum DisplayMessage { tx: oneshot::Sender, }, - /// Register a grammar-level progress line. Returns a ProgressAddr. + /// Register a grammar-level progress line. Returns a `ProgressAddr`. RegisterGrammar { git_ref: GitRef, language: Arc, @@ -141,7 +141,7 @@ impl DisplayAddr { } pub async fn shutdown(&self) { - self.request(|tx| DisplayMessage::Shutdown { tx }).await + self.request(|tx| DisplayMessage::Shutdown { tx }).await; } } @@ -300,8 +300,7 @@ impl DisplayActor { async fn run_fancy(&mut self) { let term_height = crossterm::terminal::size() - .map(|(_, h)| h as usize) - .unwrap_or(40); + .map_or(40, |(_, h)| h as usize); let viewport_height = term_height.min(40); let mut terminal = ratatui::Terminal::with_options( @@ -313,7 +312,7 @@ impl DisplayActor { .expect("Failed to initialize ratatui terminal"); // Initial render - let lines = self.materialize(terminal.size().map(|s| s.width).unwrap_or(80)); + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); let _ = terminal.draw(|frame| { let area = frame.area(); frame.render_widget(Paragraph::new(lines), area); @@ -336,7 +335,7 @@ impl DisplayActor { if let Some(tx) = shutdown_tx { // Final render before shutting down - let lines = self.materialize(terminal.size().map(|s| s.width).unwrap_or(80)); + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); let _ = terminal.draw(|frame| { let area = frame.area(); frame.render_widget(Paragraph::new(lines), area); @@ -348,7 +347,7 @@ impl DisplayActor { } // Render current state - let lines = self.materialize(terminal.size().map(|s| s.width).unwrap_or(80)); + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); let _ = terminal.draw(|frame| { let area = frame.area(); frame.render_widget(Paragraph::new(lines), area); @@ -360,7 +359,7 @@ impl DisplayActor { match msg { Some(DisplayMessage::Shutdown { tx }) => { // Final render then exit - let lines = self.materialize(terminal.size().map(|s| s.width).unwrap_or(80)); + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); let _ = terminal.draw(|frame| { let area = frame.area(); frame.render_widget(Paragraph::new(lines), area); @@ -659,8 +658,7 @@ impl DisplayActor { .repos .iter() .find(|(_, r)| r.name == language) - .map(|(id, _)| *id) - .unwrap_or(0); + .map_or(0, |(id, _)| *id); self.state.grammars.insert( id, diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 698abb6..4a2e21d 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -49,7 +49,10 @@ async fn cli( .join(format!("{cli}-{tag}")) .canon()?; - if !res.exists() { + if res.exists() { + handle.mark_cached(); + handle.step("cached"); + } else { handle.mark_built(); handle.step("downloading"); let gz_basename = format!("{cli}.gz"); @@ -57,9 +60,6 @@ async fn cli( let gz = PathBuf::new().join(build_dir).join(gz_basename); download_and_extract(&gz, &url, &res).await?; - } else { - handle.mark_cached(); - handle.step("cached"); } Ok(res) From 2150acc13a4bea145fc8cab473c1806d9b2966bb Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 17:10:05 +0200 Subject: [PATCH 10/88] clippy: fix by hand what's obvious --- src/actors/display.rs | 25 ++++++++++++------------ src/display.rs | 45 +++++++++++++++++++------------------------ 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index ebd2c6c..eca0abe 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -226,12 +226,12 @@ fn plain_repo_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> Stri UpdateKind::Err => format!("failed: {msg}"), UpdateKind::Cached => "cached".to_string(), UpdateKind::Fin => match outcome { - ItemOutcome::Cached => "done".to_string(), - ItemOutcome::Built => "done".to_string(), + ItemOutcome::Built | ItemOutcome::Cached => "done".to_string(), ItemOutcome::Unknown => msg.to_string(), }, - UpdateKind::Step => msg.to_string(), - UpdateKind::Msg | UpdateKind::MarkCached | UpdateKind::MarkBuilt => msg.to_string(), + UpdateKind::MarkBuilt | UpdateKind::MarkCached | UpdateKind::Msg | UpdateKind::Step => { + msg.to_string() + } } } @@ -244,8 +244,9 @@ fn plain_grammar_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> S ItemOutcome::Built => "built".to_string(), ItemOutcome::Unknown => msg.to_string(), }, - UpdateKind::Step => msg.to_string(), - UpdateKind::Msg | UpdateKind::MarkCached | UpdateKind::MarkBuilt => msg.to_string(), + UpdateKind::MarkBuilt | UpdateKind::MarkCached | UpdateKind::Msg | UpdateKind::Step => { + msg.to_string() + } } } @@ -299,8 +300,7 @@ impl DisplayActor { // ── Fancy mode ──────────────────────────────────────────────────── async fn run_fancy(&mut self) { - let term_height = crossterm::terminal::size() - .map_or(40, |(_, h)| h as usize); + let term_height = crossterm::terminal::size().map_or(40, |(_, h)| h as usize); let viewport_height = term_height.min(40); let mut terminal = ratatui::Terminal::with_options( @@ -395,13 +395,13 @@ impl DisplayActor { let new_layout = self.state.compute_layout(&self.row_specs); // Invalidate columns whose width changed - if new_layout.ref_width != self.grid.layout.ref_width { + if new_layout.ref_ != self.grid.layout.ref_ { self.grid.invalidate_column(Column::GitRef); } - if new_layout.step_width != self.grid.layout.step_width { + if new_layout.step != self.grid.layout.step { self.grid.invalidate_column(Column::Step); } - if new_layout.name_width != self.grid.layout.name_width { + if new_layout.name != self.grid.layout.name { self.grid.invalidate_column(Column::Name); } self.grid.layout = new_layout; @@ -605,11 +605,10 @@ impl DisplayActor { let addr = self.register_grammar(language, name, git_ref, num_tasks); let _ = tx.send(addr); } - DisplayMessage::RegisterReference { .. } => {} + DisplayMessage::RegisterReference { .. } | DisplayMessage::Tick => {} DisplayMessage::Update { id, kind, msg } => { self.apply_update(id, kind, msg); } - DisplayMessage::Tick => {} DisplayMessage::Shutdown { tx } => { let _ = tx.send(()); } diff --git a/src/display.rs b/src/display.rs index 40f44e5..6c96761 100644 --- a/src/display.rs +++ b/src/display.rs @@ -212,10 +212,10 @@ struct CellKey { #[derive(Debug, Clone)] pub(crate) struct CachedLayout { - pub time_width: usize, - pub ref_width: usize, - pub step_width: usize, - pub name_width: usize, + pub time: usize, + pub ref_: usize, + pub step: usize, + pub name: usize, /// Total width consumed by non-message columns: time + sp + ref + sp + /// step + sp + icon(1) + sp + name + sp. pub fixed_width: usize, @@ -224,10 +224,10 @@ pub(crate) struct CachedLayout { impl Default for CachedLayout { fn default() -> Self { Self { - time_width: TIME_COL_WIDTH, - ref_width: MIN_REF_WIDTH, - step_width: MIN_STEP_WIDTH, - name_width: MIN_NAME_WIDTH, + time: TIME_COL_WIDTH, + ref_: MIN_REF_WIDTH, + step: MIN_STEP_WIDTH, + name: MIN_NAME_WIDTH, fixed_width: fixed_width( TIME_COL_WIDTH, MIN_REF_WIDTH, @@ -240,16 +240,11 @@ impl Default for CachedLayout { impl CachedLayout { fn finalize(&mut self) { - self.time_width = TIME_COL_WIDTH; - self.ref_width = self.ref_width.max(MIN_REF_WIDTH); - self.step_width = self.step_width.max(MIN_STEP_WIDTH); - self.name_width = self.name_width.max(MIN_NAME_WIDTH); - self.fixed_width = fixed_width( - self.time_width, - self.ref_width, - self.step_width, - self.name_width, - ); + self.time = TIME_COL_WIDTH; + self.ref_ = self.ref_.max(MIN_REF_WIDTH); + self.step = self.step.max(MIN_STEP_WIDTH); + self.name = self.name.max(MIN_NAME_WIDTH); + self.fixed_width = fixed_width(self.time, self.ref_, self.step, self.name); } } @@ -402,13 +397,13 @@ const MSG_STYLE: Style = Style::new(); pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { let time = format_elapsed_duration(info.elapsed()); // Right-aligned - let padded = format!("{:>width$}", time, width = layout.time_width); + let padded = format!("{:>width$}", time, width = layout.time); Span::styled(padded, TIME_STYLE) } pub(crate) fn compute_ref_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { let ref_str = info.git_ref().to_string(); - let padded = format!("{:, layout: &CachedLayout) -> S String::new() }; // Right-aligned - let padded = format!("{:>width$}", step_str, width = layout.step_width); + let padded = format!("{:>width$}", step_str, width = layout.step); Span::styled(padded, REF_STYLE) } @@ -440,7 +435,7 @@ pub(crate) fn compute_name_cell( layout: &CachedLayout, ) -> Span<'static> { let full = format!("{indent}{display_name}"); - let padded = format!("{: Date: Fri, 22 May 2026 18:09:20 +0200 Subject: [PATCH 11/88] display: use stdout and do some fixes --- src/actors/display.rs | 80 ++++++++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index eca0abe..c6f8983 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; +use ratatui::backend::Backend; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use tokio::sync::{mpsc, oneshot}; @@ -254,6 +255,18 @@ fn plain_grammar_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> S // DisplayActor // --------------------------------------------------------------------------- +fn draw_lines>( + terminal: &mut ratatui::Terminal, + lines: Vec>, +) -> io::Result<()> { + terminal + .draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + }) + .map(|_| ()) +} + pub struct DisplayActor { state: DisplayState, next_id: u64, @@ -303,20 +316,29 @@ impl DisplayActor { let term_height = crossterm::terminal::size().map_or(40, |(_, h)| h as usize); let viewport_height = term_height.min(40); - let mut terminal = ratatui::Terminal::with_options( - ratatui::backend::CrosstermBackend::new(io::stderr()), + let mut terminal = match ratatui::Terminal::with_options( + ratatui::backend::CrosstermBackend::new(io::stdout()), ratatui::TerminalOptions { viewport: ratatui::Viewport::Inline(viewport_height as u16), }, - ) - .expect("Failed to initialize ratatui terminal"); + ) { + Ok(terminal) => terminal, + Err(err) => { + eprintln!("tsdl: fancy display unavailable; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } + }; // Initial render let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - let _ = terminal.draw(|frame| { - let area = frame.area(); - frame.render_widget(Paragraph::new(lines), area); - }); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } let mut tick_interval = time::interval(Duration::from_millis(100)); @@ -336,22 +358,28 @@ impl DisplayActor { if let Some(tx) = shutdown_tx { // Final render before shutting down let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - let _ = terminal.draw(|frame| { - let area = frame.area(); - frame.render_widget(Paragraph::new(lines), area); - }); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display final render failed: {err}"); + let _ = tx.send(()); + return; + } ratatui::restore(); - eprintln!(); + println!(); let _ = tx.send(()); return; } // Render current state let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - let _ = terminal.draw(|frame| { - let area = frame.area(); - frame.render_widget(Paragraph::new(lines), area); - }); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } // Wait for next event tokio::select! { @@ -360,12 +388,15 @@ impl DisplayActor { Some(DisplayMessage::Shutdown { tx }) => { // Final render then exit let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - let _ = terminal.draw(|frame| { - let area = frame.area(); - frame.render_widget(Paragraph::new(lines), area); - }); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display final render failed: {err}"); + let _ = tx.send(()); + return; + } ratatui::restore(); - eprintln!(); + println!(); let _ = tx.send(()); return; } @@ -380,7 +411,7 @@ impl DisplayActor { } ratatui::restore(); - eprintln!(); + println!(); } /// Assemble `Vec` from the grid cache. Rebuilds row order and layout @@ -677,6 +708,9 @@ impl DisplayActor { ); self.rows_dirty = true; + if repo_id != 0 { + self.sync_parent_repo(repo_id); + } ProgressAddr { id, From 8e034bc5d0d7353bdcdc491566c8afb91cae2a2c Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 18:12:48 +0200 Subject: [PATCH 12/88] display: fix stale cached message cells --- src/actors/display.rs | 84 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/actors/display.rs b/src/actors/display.rs index c6f8983..ed485e4 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -275,6 +275,7 @@ pub struct DisplayActor { grid: GridCache, row_specs: Vec, rows_dirty: bool, + last_term_width: Option, rx: mpsc::Receiver, tx: mpsc::Sender, } @@ -291,6 +292,7 @@ impl DisplayActor { grid: GridCache::new(), row_specs: Vec::new(), rows_dirty: true, + last_term_width: None, rx, tx: tx.clone(), }; @@ -420,6 +422,11 @@ impl DisplayActor { fn materialize(&mut self, term_width: u16) -> Vec> { let term_w = term_width as usize; + if self.last_term_width != Some(term_w) { + self.grid.invalidate_column(Column::Msg); + self.last_term_width = Some(term_w); + } + // Rebuild row order if items were added if self.rows_dirty { self.row_specs = self.state.compute_row_order(); @@ -435,6 +442,9 @@ impl DisplayActor { if new_layout.name != self.grid.layout.name { self.grid.invalidate_column(Column::Name); } + if new_layout.fixed_width != self.grid.layout.fixed_width { + self.grid.invalidate_column(Column::Msg); + } self.grid.layout = new_layout; self.rows_dirty = false; } @@ -900,3 +910,77 @@ impl DisplayActor { fn spacer() -> Span<'static> { Span::raw(" ") } + +#[cfg(test)] +mod tests { + use super::*; + + fn actor() -> DisplayActor { + let (tx, rx) = mpsc::channel(1); + DisplayActor { + state: DisplayState::new( + Mode::Fancy, + Arc::new(PathBuf::from("build")), + Arc::new(PathBuf::from("out")), + ), + next_id: 1, + plain_name_width: 16, + plain_progress_started: false, + grid: GridCache::new(), + row_specs: Vec::new(), + rows_dirty: true, + last_term_width: None, + rx, + tx, + } + } + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn materialize_recomputes_message_cells_when_terminal_width_changes() { + let mut actor = actor(); + let progress = actor.register_repo("json".into(), GitRef::from("HEAD"), 1); + let message = "abcdefghijklmnop"; + + actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); + + let narrow_lines = actor.materialize(30); + let narrow_row = line_text(&narrow_lines[0]); + assert!(narrow_row.contains("abcd…")); + assert!(!narrow_row.contains(message)); + + let wide_lines = actor.materialize(80); + let wide_row = line_text(&wide_lines[0]); + assert!(wide_row.contains(message)); + } + + #[test] + fn materialize_recomputes_message_cells_when_layout_width_changes() { + let mut actor = actor(); + let progress = actor.register_repo("json".into(), GitRef::from("HEAD"), 1); + let message = "abcdefghijklmnop"; + + actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); + + let initial_lines = actor.materialize(42); + let initial_row = line_text(&initial_lines[0]); + assert!(initial_row.contains(message)); + + actor.register_repo("zzzzzzzzzzzzzzzzzzzz".into(), GitRef::from("HEAD"), 1); + + let updated_lines = actor.materialize(42); + let updated_row = updated_lines + .iter() + .map(line_text) + .find(|line| line.contains("json")) + .expect("json row should still be rendered"); + assert!(updated_row.contains('…')); + assert!(!updated_row.contains(message)); + } +} From 4ef293464bb799481eb96dc45b31fd0e0c32daa6 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 22:01:40 +0200 Subject: [PATCH 13/88] display: get rid of truncation --- src/actors/display.rs | 92 +++++++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index ed485e4..82e8ec1 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -1,4 +1,4 @@ -use std::io; +use std::io::{self, Write}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -267,6 +267,40 @@ fn draw_lines>( .map(|_| ()) } +fn draw_lines_with_cursor_at_top>( + terminal: &mut ratatui::Terminal, + lines: Vec>, +) -> io::Result<()> { + terminal + .draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + frame.set_cursor_position((area.x, area.y)); + }) + .map(|_| ()) +} + +fn line_to_plain_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +fn print_lines_from_cursor(lines: &[String]) -> io::Result<()> { + let mut stdout = io::stdout(); + crossterm::execute!( + stdout, + crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown) + )?; + + for line in lines { + writeln!(stdout, "{line}")?; + } + writeln!(stdout)?; + stdout.flush() +} + pub struct DisplayActor { state: DisplayState, next_id: u64, @@ -358,18 +392,7 @@ impl DisplayActor { } if let Some(tx) = shutdown_tx { - // Final render before shutting down - let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - if let Err(err) = draw_lines(&mut terminal, lines) { - ratatui::restore(); - println!(); - eprintln!("tsdl: fancy display final render failed: {err}"); - let _ = tx.send(()); - return; - } - ratatui::restore(); - println!(); - let _ = tx.send(()); + self.finish_fancy(&mut terminal, tx); return; } @@ -388,18 +411,7 @@ impl DisplayActor { msg = self.rx.recv() => { match msg { Some(DisplayMessage::Shutdown { tx }) => { - // Final render then exit - let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - if let Err(err) = draw_lines(&mut terminal, lines) { - ratatui::restore(); - println!(); - eprintln!("tsdl: fancy display final render failed: {err}"); - let _ = tx.send(()); - return; - } - ratatui::restore(); - println!(); - let _ = tx.send(()); + self.finish_fancy(&mut terminal, tx); return; } Some(other) => self.handle_message(other), @@ -416,6 +428,36 @@ impl DisplayActor { println!(); } + fn finish_fancy>( + &mut self, + terminal: &mut ratatui::Terminal, + tx: oneshot::Sender<()>, + ) { + let term_width = terminal.size().map_or(80, |s| s.width); + let final_lines = self.materialize(term_width); + let final_text = final_lines + .iter() + .map(line_to_plain_text) + .collect::>(); + + match draw_lines_with_cursor_at_top(terminal, final_lines) { + Ok(()) => { + ratatui::restore(); + if let Err(err) = print_lines_from_cursor(&final_text) { + println!(); + eprintln!("tsdl: fancy display final report failed: {err}"); + } + } + Err(err) => { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display final render failed: {err}"); + } + } + + let _ = tx.send(()); + } + /// Assemble `Vec` from the grid cache. Rebuilds row order and layout /// only when items have been added. Otherwise reuses the cached row order /// and recomputes only stale cells (dirty items + running clocks). From 9929b1d588086ebda36c58ffc60636c0877b3566 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 22:32:36 +0200 Subject: [PATCH 14/88] display: better flush the terminal with proper colors --- src/actors/display.rs | 173 +++++++++++++++++++++++++++++++++--------- 1 file changed, 138 insertions(+), 35 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index 82e8ec1..ca7e08c 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -1,11 +1,11 @@ -use std::io::{self, Write}; +use std::io; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use ratatui::backend::Backend; use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Clear, Paragraph, Widget}; use tokio::sync::{mpsc, oneshot}; use tokio::time; @@ -255,10 +255,10 @@ fn plain_grammar_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> S // DisplayActor // --------------------------------------------------------------------------- -fn draw_lines>( +fn draw_lines( terminal: &mut ratatui::Terminal, lines: Vec>, -) -> io::Result<()> { +) -> Result<(), B::Error> { terminal .draw(|frame| { let area = frame.area(); @@ -267,38 +267,81 @@ fn draw_lines>( .map(|_| ()) } -fn draw_lines_with_cursor_at_top>( +fn current_viewport_height( + terminal: &mut ratatui::Terminal, +) -> Result { + terminal.autoresize()?; + Ok(terminal.get_frame().area().height.max(1)) +} + +fn split_lines_for_viewport( + mut lines: Vec>, + viewport_height: u16, +) -> (Vec>, Vec>) { + let suffix_len = usize::from(viewport_height).min(lines.len()); + let split_at = lines.len() - suffix_len; + let suffix = lines.split_off(split_at); + (lines, suffix) +} + +fn insert_lines_before_viewport( + terminal: &mut ratatui::Terminal, + mut lines: Vec>, +) -> Result<(), B::Error> { + while !lines.is_empty() { + let rest = if lines.len() > usize::from(u16::MAX) { + lines.split_off(usize::from(u16::MAX)) + } else { + Vec::new() + }; + let height = u16::try_from(lines.len()).unwrap_or(u16::MAX); + + terminal.insert_before(height, move |buf| { + Paragraph::new(lines).render(buf.area, buf); + })?; + + lines = rest; + } + + Ok(()) +} + +fn draw_lines_in_viewport( terminal: &mut ratatui::Terminal, lines: Vec>, -) -> io::Result<()> { +) -> Result<(), B::Error> { + let line_count = lines.len(); + terminal - .draw(|frame| { + .draw(move |frame| { let area = frame.area(); + frame.render_widget(Clear, area); frame.render_widget(Paragraph::new(lines), area); - frame.set_cursor_position((area.x, area.y)); + + let cursor_offset = line_count + .saturating_sub(1) + .min(usize::from(area.height.saturating_sub(1))); + let cursor_y = area.y + u16::try_from(cursor_offset).unwrap_or(0); + frame.set_cursor_position((area.x, cursor_y)); }) .map(|_| ()) } -fn line_to_plain_text(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() +fn render_final_report( + terminal: &mut ratatui::Terminal, + lines: Vec>, +) -> Result<(), B::Error> { + let viewport_height = current_viewport_height(terminal)?; + let (prefix, suffix) = split_lines_for_viewport(lines, viewport_height); + insert_lines_before_viewport(terminal, prefix)?; + draw_lines_in_viewport(terminal, suffix) } -fn print_lines_from_cursor(lines: &[String]) -> io::Result<()> { - let mut stdout = io::stdout(); +fn clear_from_cursor_down() -> io::Result<()> { crossterm::execute!( - stdout, + io::stdout(), crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown) - )?; - - for line in lines { - writeln!(stdout, "{line}")?; - } - writeln!(stdout)?; - stdout.flush() + ) } pub struct DisplayActor { @@ -349,13 +392,12 @@ impl DisplayActor { // ── Fancy mode ──────────────────────────────────────────────────── async fn run_fancy(&mut self) { - let term_height = crossterm::terminal::size().map_or(40, |(_, h)| h as usize); - let viewport_height = term_height.min(40); + let viewport_height = crossterm::terminal::size().map_or(40, |(_, h)| h).min(40); let mut terminal = match ratatui::Terminal::with_options( ratatui::backend::CrosstermBackend::new(io::stdout()), ratatui::TerminalOptions { - viewport: ratatui::Viewport::Inline(viewport_height as u16), + viewport: ratatui::Viewport::Inline(viewport_height), }, ) { Ok(terminal) => terminal, @@ -434,24 +476,21 @@ impl DisplayActor { tx: oneshot::Sender<()>, ) { let term_width = terminal.size().map_or(80, |s| s.width); - let final_lines = self.materialize(term_width); - let final_text = final_lines - .iter() - .map(line_to_plain_text) - .collect::>(); + let mut final_lines = self.materialize(term_width); + final_lines.push(Line::from("")); - match draw_lines_with_cursor_at_top(terminal, final_lines) { + match render_final_report(terminal, final_lines) { Ok(()) => { ratatui::restore(); - if let Err(err) = print_lines_from_cursor(&final_text) { + if let Err(err) = clear_from_cursor_down() { println!(); - eprintln!("tsdl: fancy display final report failed: {err}"); + eprintln!("tsdl: fancy display cleanup failed: {err}"); } } Err(err) => { ratatui::restore(); println!(); - eprintln!("tsdl: fancy display final render failed: {err}"); + eprintln!("tsdl: fancy display final report failed: {err}"); } } @@ -984,6 +1023,70 @@ mod tests { .collect() } + #[test] + fn split_lines_for_viewport_keeps_the_visible_suffix() { + let lines = vec![ + Line::from("one"), + Line::from("two"), + Line::from("three"), + Line::from("four"), + ]; + + let (prefix, suffix) = split_lines_for_viewport(lines, 2); + + assert_eq!(line_text(&prefix[0]), "one"); + assert_eq!(line_text(&prefix[1]), "two"); + assert_eq!(line_text(&suffix[0]), "three"); + assert_eq!(line_text(&suffix[1]), "four"); + } + + #[test] + fn render_final_report_inserts_prefix_and_replaces_viewport() { + use ratatui::backend::TestBackend; + use ratatui::style::{Color, Style}; + use ratatui::{Terminal, TerminalOptions, Viewport}; + + let backend = TestBackend::new(20, 5); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(2), + }, + ) + .unwrap(); + + draw_lines( + &mut terminal, + vec![Line::from("old viewport 1"), Line::from("old viewport 2")], + ) + .unwrap(); + + render_final_report( + &mut terminal, + vec![ + Line::from("final 1"), + Line::from("final 2"), + Line::from("final 3"), + Line::from(Span::styled("final 4", Style::default().fg(Color::Blue))), + Line::from(""), + ], + ) + .unwrap(); + + terminal.backend().assert_buffer_lines(vec![ + Line::from("final 1 "), + Line::from("final 2 "), + Line::from("final 3 "), + Line::from(vec![ + Span::styled("final 4", Style::default().fg(Color::Blue)), + Span::raw(" "), + ]), + Line::from(" "), + ]); + terminal.backend().assert_scrollback_empty(); + terminal.backend_mut().assert_cursor_position((0, 4)); + } + #[test] fn materialize_recomputes_message_cells_when_terminal_width_changes() { let mut actor = actor(); From 824145e2c1bfc86469cba7b08ab1d10a28ee1b3c Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 23:02:34 +0200 Subject: [PATCH 15/88] display: fix summary counts and cleanup --- src/actors/display.rs | 89 +++++++++++++++++++++++++++++++++++-------- src/display.rs | 14 +++++-- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index ca7e08c..5de148b 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -50,9 +50,6 @@ pub enum DisplayMessage { msg: Arc, }, - /// Advance the elapsed-time display and trigger a re-render. - Tick, - /// Flush and close the display actor. The response is sent after cleanup. Shutdown { tx: oneshot::Sender<()> }, } @@ -137,10 +134,6 @@ impl DisplayAddr { .await; } - pub async fn tick(&self) { - self.fire(DisplayMessage::Tick).await; - } - pub async fn shutdown(&self) { self.request(|tx| DisplayMessage::Shutdown { tx }).await; } @@ -461,7 +454,7 @@ impl DisplayActor { } } _ = tick_interval.tick() => { - // Tick — next materialize will update live clocks + // Next materialize will update live clocks. } } } @@ -638,7 +631,6 @@ impl DisplayActor { self.print_plain_progress(&line); } } - DisplayMessage::Tick => {} DisplayMessage::Shutdown { tx } => { self.print_plain_summary(); let _ = tx.send(()); @@ -727,7 +719,7 @@ impl DisplayActor { let addr = self.register_grammar(language, name, git_ref, num_tasks); let _ = tx.send(addr); } - DisplayMessage::RegisterReference { .. } | DisplayMessage::Tick => {} + DisplayMessage::RegisterReference { .. } => {} DisplayMessage::Update { id, kind, msg } => { self.apply_update(id, kind, msg); } @@ -941,15 +933,15 @@ impl DisplayActor { if let Some(repo) = self.state.repos.get_mut(&repo_id) { repo.outcome = outcome; - if any_failed { + if any_active { + repo.status = ItemStatus::InProgress; + repo.msg = Arc::from("building"); + repo.frozen_elapsed = None; + } else if any_failed { repo.status = ItemStatus::Failed; repo.msg = Arc::from("failed"); repo.frozen_elapsed .get_or_insert_with(|| repo.started_at.elapsed()); - } else if any_active { - repo.status = ItemStatus::InProgress; - repo.msg = Arc::from("building"); - repo.frozen_elapsed = None; } else { repo.status = ItemStatus::Done; repo.msg = Arc::from("done"); @@ -1087,6 +1079,73 @@ mod tests { terminal.backend_mut().assert_cursor_position((0, 4)); } + #[test] + fn summary_counts_include_repo_only_rows() { + let mut actor = actor(); + + let cached = actor.register_repo("tree-sitter-cli".into(), GitRef::from("HEAD"), 2); + actor.apply_update(cached.id, UpdateKind::MarkCached, Arc::from("")); + actor.apply_update(cached.id, UpdateKind::Fin, Arc::from("done")); + + let built = actor.register_repo("standalone".into(), GitRef::from("HEAD"), 1); + actor.apply_update(built.id, UpdateKind::MarkBuilt, Arc::from("")); + actor.apply_update(built.id, UpdateKind::Fin, Arc::from("done")); + + let active = actor.register_repo("active".into(), GitRef::from("HEAD"), 1); + actor.apply_update(active.id, UpdateKind::Step, Arc::from("working")); + + let failed = actor.register_repo("failed".into(), GitRef::from("HEAD"), 1); + actor.apply_update(failed.id, UpdateKind::Err, Arc::from("failed")); + + assert_eq!(actor.state.summary_counts(), (1, 1, 1, 1)); + } + + #[test] + fn summary_counts_do_not_double_count_parent_repos_with_grammars() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 2); + actor.apply_update(repo.id, UpdateKind::MarkBuilt, Arc::from("")); + actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); + + let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::from("HEAD"), 4); + actor.apply_update(grammar.id, UpdateKind::MarkCached, Arc::from("")); + actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); + + assert_eq!(actor.state.summary_counts(), (1, 0, 0, 0)); + } + + #[test] + fn parent_repo_stays_active_while_any_child_is_active() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), GitRef::from("HEAD"), 2); + let failing = actor.register_grammar( + "typescript".into(), + "typescript".into(), + GitRef::from("HEAD"), + 4, + ); + let pending = + actor.register_grammar("typescript".into(), "tsx".into(), GitRef::from("HEAD"), 4); + + actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.status, ItemStatus::InProgress); + assert_eq!(repo_entry.msg.as_ref(), "building"); + assert!(repo_entry.frozen_elapsed.is_none()); + + actor.apply_update(pending.id, UpdateKind::MarkBuilt, Arc::from("")); + actor.apply_update(pending.id, UpdateKind::Fin, Arc::from("done")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.status, ItemStatus::Failed); + assert_eq!(repo_entry.msg.as_ref(), "failed"); + assert!(repo_entry.frozen_elapsed.is_some()); + } + #[test] fn materialize_recomputes_message_cells_when_terminal_width_changes() { let mut actor = actor(); diff --git a/src/display.rs b/src/display.rs index 6c96761..dc79f89 100644 --- a/src/display.rs +++ b/src/display.rs @@ -648,10 +648,18 @@ impl DisplayState { ); } - // Repos without grammars that failed + // Repos without grammars are visible terminal rows too (for example, + // tree-sitter-cli or a language that failed before grammar discovery). for (repo_id, repo) in &self.repos { - if !repos_with_grammars.contains(repo_id) && repo.status == ItemStatus::Failed { - failed += 1; + if !repos_with_grammars.contains(repo_id) { + count_grammar_status( + repo.status, + repo.outcome, + &mut cached, + &mut built, + &mut building, + &mut failed, + ); } } From 149f433f43ba7871c705fffd7f6558ffafbec58c Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 22 May 2026 23:17:41 +0200 Subject: [PATCH 16/88] lint: fix --- tests/cmd/build.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/cmd/build.rs b/tests/cmd/build.rs index 43a0845..ba287eb 100644 --- a/tests/cmd/build.rs +++ b/tests/cmd/build.rs @@ -187,7 +187,7 @@ fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { #[case::pinned_no_leading_v_json("json", "v0.21.0")] #[case::unpinned_rust("rust", "HEAD")] #[case::pinned::cmd::typescript("typescript", "v0.21.0")] -fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] _version: &str) { +fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: &str) { let config = indoc! { r#" [parsers] @@ -209,6 +209,7 @@ fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] _version: .args(["build", language]) .assert() .success() + .stdout(p::str::contains(format!("{language:<16} @ {version}"))) .stdout(p::str::contains(format!( "{:<16} [4/4] built", format!("{language}/{language}") @@ -263,10 +264,15 @@ fn build_implicit_pinned_and_unpinned() { #[rstest] fn multi_parsers_no_cmd() { let java = "java"; - let _version = "HEAD"; + let version = "HEAD"; let languages = [java]; let mut sandbox = Sandbox::new(); - let mut assert = sandbox.cmd.args(["build", java]).assert().success(); + let mut assert = sandbox + .cmd + .args(["build", java]) + .assert() + .success() + .stdout(p::str::contains(format!("{java:<16} @ {version}"))); for language in languages { assert = assert.stdout(p::str::contains(format!("{language:<16} [1/2] cloning"))); } From 4e4dc449ed51fcc4634fe5438311ab1b5dc6c234 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sat, 23 May 2026 13:30:25 +0200 Subject: [PATCH 17/88] news: display --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a921ea8..aef2657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - `tsdl selfupdate patch` / `minor` / `major` filters compatible releases. - `tsdl selfupdate 2.5.0` installs an exact version with downgrade confirmation. +### UI/UX + +The display backend moved to ratatui. The information displayed are more +informative and more coherent. + ## [2.0.0] - 2026-02-20 This is a major rewrite, moving to a single-threaded async runtime, with actors. From 89afae87de070fc6b2423f3050ac414776fe6470 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sat, 23 May 2026 13:30:25 +0200 Subject: [PATCH 18/88] build: implement os-level lock --- CHANGELOG.md | 6 + Cargo.lock | 11 ++ Cargo.toml | 2 + build.rs | 8 + src/args.rs | 10 +- src/build.rs | 110 +++++++++----- src/lock.rs | 408 ++++++++++++++++++++++++++++++++++++++++----------- 7 files changed, 429 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aef2657..16e1667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ The display backend moved to ratatui. The information displayed are more informative and more coherent. +### Bug Fixes + +- **lock**: Use OS-level build locks to avoid concurrent lock acquisition races, + and replace `--unlock` with an interactive SIGTERM takeover flow controlled by + `--unlock-timeout`. + ## [2.0.0] - 2026-02-20 This is a major rewrite, moving to a single-threaded async runtime, with actors. diff --git a/Cargo.lock b/Cargo.lock index f590399..f5ed130 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,6 +1129,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3894,6 +3904,7 @@ dependencies = [ "diff-struct", "enum_dispatch", "figment", + "fs2", "futures", "human-panic", "ignore", diff --git a/Cargo.toml b/Cargo.toml index 1ed704c..876119c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ prefix = "libtree-sitter-" ref = "master" show-config = false sys = false +unlock-timeout = 10 [package.metadata.tree-sitter] # TODO: make it accept true git refs. For now it only uses versions. @@ -51,6 +52,7 @@ derive_more = { version = "2", features = ["as_ref", "deref", "display"] } diff-struct = "0.5" enum_dispatch = "0.3" figment = { version = "0.10", features = ["toml", "env"] } +fs2 = "0.4" futures = "0.3" human-panic = "2.0" ignore = "0.4" diff --git a/build.rs b/build.rs index 4792153..1a4abba 100644 --- a/build.rs +++ b/build.rs @@ -55,6 +55,13 @@ macro_rules! generate_consts { writeln!($buf, "pub const {}: bool = {};", stringify!($name), val).unwrap(); }; + // Case: JSON u64 + (@expand $buf:expr, $name:ident, u64, json($obj:expr, $key:literal)) => { + let val = $obj.get($key).expect(concat!("Key not found: ", $key)) + .as_u64().expect(concat!("Key not an unsigned integer: ", $key)); + writeln!($buf, "pub const {}: u64 = {};", stringify!($name), val).unwrap(); + }; + // Case: Raw Expression (String) (@expand $buf:expr, $name:ident, str, expr($val:expr)) => { writeln!($buf, "pub const {}: &str = {:?};", stringify!($name), $val).unwrap(); @@ -105,6 +112,7 @@ fn main() { TSDL_FROM : str = json(tsdl, "from"), TSDL_LOCK_FILE : str = json(tsdl, "lock-file"), TSDL_OUT_DIR : str = json(tsdl, "out-dir"), + TSDL_UNLOCK_TIMEOUT: u64 = json(tsdl, "unlock-timeout"), TSDL_PREFIX : str = json(tsdl, "prefix"), TSDL_REF : str = json(tsdl, "ref"), TSDL_SHOW_CONFIG : bool = json(tsdl, "show-config"), diff --git a/src/args.rs b/src/args.rs index 4c0bbac..4a7aedb 100644 --- a/src/args.rs +++ b/src/args.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::consts::{ TREE_SITTER_PLATFORM, TREE_SITTER_REPO, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_CONFIG_FILE, - TSDL_FORCE, TSDL_FRESH, TSDL_OUT_DIR, TSDL_PREFIX, TSDL_SHOW_CONFIG, + TSDL_FORCE, TSDL_FRESH, TSDL_OUT_DIR, TSDL_PREFIX, TSDL_SHOW_CONFIG, TSDL_UNLOCK_TIMEOUT, }; const TSDL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/tsdl.version")); @@ -229,10 +229,10 @@ pub struct BuildCommand { #[serde(default)] pub tree_sitter: TreeSitter, - /// Force unlock the build directory. - #[arg(long, default_value_t = false)] + /// Seconds to wait after terminating a lock owner for the build lock to be released. + #[arg(long, env = "TSDL_UNLOCK_TIMEOUT", default_value_t = TSDL_UNLOCK_TIMEOUT)] #[serde(default)] - pub unlock: bool, + pub unlock_timeout: u64, } impl Default for BuildCommand { @@ -249,7 +249,7 @@ impl Default for BuildCommand { show_config: TSDL_SHOW_CONFIG, target: Target::default(), tree_sitter: TreeSitter::default(), - unlock: false, + unlock_timeout: TSDL_UNLOCK_TIMEOUT, } } } diff --git a/src/build.rs b/src/build.rs index f8dba90..f3794fb 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,8 +1,10 @@ use std::{ collections::{BTreeMap, HashSet}, - fs::{self, create_dir_all}, - path::PathBuf, + ffi::OsStr, + fs, + path::{Path, PathBuf}, sync::Arc, + time::Duration, }; use serde::{Deserialize, Serialize}; @@ -13,10 +15,11 @@ use crate::{ app::App, args::{ParserConfig, Target, TreeSitter}, cache::Db, - consts::TSDL_FROM, + consts::{TSDL_FROM, TSDL_LOCK_FILE}, error::{self, TsdlError}, + format_duration, git::GitRef, - lock::{Lock, LockStatus}, + lock::{Lock, LockGuard, LockOwner, LockStatus}, parser::LanguageBuild, prompt_user, SafeCanonicalize, TsdlResult, }; @@ -49,55 +52,67 @@ pub fn run(app: &mut App) -> TsdlResult<()> { } let lock = Lock::new(&app.command.build_dir); + let _guard = acquire_lock(&lock, Duration::from_secs(app.command.unlock_timeout))?; - if app.command.unlock { - lock.force_unlock()?; - } + clear(app)?; + ignite(app)?; + Ok(()) +} - let _guard = match lock.try_acquire()? { - LockStatus::Acquired(lock) => lock, +fn acquire_lock(lock: &Lock, unlock_timeout: Duration) -> TsdlResult { + match lock.try_acquire()? { + LockStatus::Acquired(lock) => Ok(lock), LockStatus::Cyclic => { eprintln!("Lock already held by this process. This should not happen."); - return Err(TsdlError::message("1+ lock acquisition")); + Err(TsdlError::message("1+ lock acquisition")) } - LockStatus::LockedBy { pid, exe } => { - eprintln!("Lock owned by different process: PID {pid} ({exe})"); - if prompt_user("Proceed anyway?", false)? { - lock.force_acquire()? - } else { - return Err(TsdlError::message("Lock acquisition cancelled by user")); - } - } - - LockStatus::Stale(pid) => { - eprintln!("Found stale lock from PID {pid} (process no longer exists)"); - if prompt_user("Take over lock?", true)? { - lock.force_acquire()? - } else { - return Err(TsdlError::message("Lock acquisition cancelled by user")); - } - } + LockStatus::LockedBy(owner) => handle_locked_by(lock, &owner, unlock_timeout), LockStatus::Unknown { pid, reason } => { - eprintln!("Could not verify lock owner PID {pid}: {reason}"); - if prompt_user("Take over lock?", false)? { - lock.force_acquire()? - } else { - return Err(TsdlError::message("Lock acquisition cancelled by user")); + match pid { + Some(pid) => eprintln!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"), + None => eprintln!("Build directory is locked, but tsdl could not identify the owner: {reason}"), } + Err(TsdlError::message(format!( + "Could not identify build lock owner: {reason}" + ))) } - }; + } +} - clear(app)?; - ignite(app)?; - Ok(()) +fn handle_locked_by( + lock: &Lock, + owner: &LockOwner, + unlock_timeout: Duration, +) -> TsdlResult { + eprintln!("Build directory is locked by another process:\n"); + eprintln!("{owner}"); + eprintln!(); + eprintln!( + "If you continue, tsdl will send SIGTERM to PID {} and wait up to {} \ + (--unlock-timeout {}) for the build lock to be released.", + owner.pid, + format_duration(unlock_timeout), + unlock_timeout.as_secs() + ); + + if !prompt_user("Terminate this process and continue?", false)? { + return Err(TsdlError::message("Lock acquisition cancelled by user")); + } + + lock.terminate_owner(owner)?; + eprintln!( + "Sent SIGTERM to PID {}. Waiting for the build lock to be released...", + owner.pid + ); + lock.wait_for_release(owner, unlock_timeout) } fn clear(app: &mut App) -> TsdlResult<()> { if app.command.fresh && app.command.build_dir.exists() { - fs::remove_dir_all(&app.command.build_dir)?; + clear_build_dir(&app.command.build_dir)?; eprintln!("Cleaned {}", app.command.build_dir.display()); } @@ -106,6 +121,25 @@ fn clear(app: &mut App) -> TsdlResult<()> { Ok(()) } +fn clear_build_dir(build_dir: &Path) -> TsdlResult<()> { + for entry in fs::read_dir(build_dir)? { + let entry = entry?; + if entry.file_name().as_os_str() == OsStr::new(TSDL_LOCK_FILE) { + continue; + } + + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + fs::remove_dir_all(&path)?; + } else { + fs::remove_file(&path)?; + } + } + + Ok(()) +} + fn collect_languages(app: &App) -> Result, error::LanguageCollection> { let results = unique_languages(app); let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); @@ -156,7 +190,7 @@ fn get_language_coords( } fn ignite(app: &App) -> TsdlResult<()> { - create_dir_all(&app.command.out_dir)?; + fs::create_dir_all(&app.command.out_dir)?; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/src/lock.rs b/src/lock.rs index cee6393..c638be2 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -1,48 +1,106 @@ use std::{ - fs, + fmt, + fs::{self, File, OpenOptions}, + io::{self, Seek, SeekFrom, Write}, path::{Path, PathBuf}, process, + sync::mpsc, + thread, + time::{Duration, Instant}, }; -use sysinfo::{Pid, ProcessesToUpdate, System}; +use fs2::FileExt; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, UpdateKind}; use tracing::info; -use crate::{consts::TSDL_LOCK_FILE, error::TsdlError, TsdlResult}; +use crate::{consts::TSDL_LOCK_FILE, error::TsdlError, format_duration, TsdlResult}; -/// Result of checking lock status +/// Information about the process currently holding the build lock. +#[derive(Debug, Clone)] +pub struct LockOwner { + pub pid: Pid, + pub name: String, + pub command: Option, + pub exe: Option, + pub cwd: Option, + pub status: String, + pub run_time: u64, + pub start_time: u64, +} + +impl fmt::Display for LockOwner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, " pid: {}", self.pid)?; + writeln!(f, " process: {}", self.name)?; + writeln!( + f, + " command: {}", + self.command.as_deref().unwrap_or("") + )?; + writeln!( + f, + " exe: {}", + self.exe + .as_ref() + .map_or_else(|| "".to_string(), |p| p.display().to_string()) + )?; + writeln!( + f, + " cwd: {}", + self.cwd + .as_ref() + .map_or_else(|| "".to_string(), |p| p.display().to_string()) + )?; + writeln!( + f, + " runtime: {}", + format_duration(Duration::from_secs(self.run_time)) + )?; + write!(f, " status: {}", self.status) + } +} + +/// Result of checking lock status. #[derive(Debug)] pub enum LockStatus { - /// Lock acquired successfully + /// Lock acquired successfully. Acquired(LockGuard), - /// Acquired lock is cyclic (same process) + /// Acquired lock is cyclic (same process). Cyclic, - /// Lock exists from a different process - LockedBy { pid: Pid, exe: String }, - /// Lock exists from a stale (dead) process - Stale(Pid), - /// Not enough privileges to check process status - Unknown { pid: Pid, reason: String }, + /// Lock is held by a different process. + LockedBy(LockOwner), + /// Lock is held, but the owner could not be identified. + Unknown { pid: Option, reason: String }, } -/// A guard that holds an exclusive lock on the build directory. -/// The lock is automatically released when this guard is dropped. +/// A guard that holds an exclusive OS lock on the build directory lock file. +/// +/// The lock is released when this guard is dropped. The lock file itself is +/// intentionally left on disk because OS locks are tied to open file handles, +/// not to path existence. #[derive(Debug)] pub struct LockGuard { - lock: PathBuf, + file: File, } impl Drop for LockGuard { fn drop(&mut self) { - let _ = fs::remove_file(&self.lock); + let _ = self.file.unlock(); } } /// Manages lock configuration and acquisition. +#[derive(Clone)] pub struct Lock { current_pid: Pid, lock_path: PathBuf, } +enum UnlockEvent { + LockAcquired(Result), + OwnerExited, +} + impl Lock { #[must_use] pub fn new(build_dir: &Path) -> Self { @@ -52,86 +110,176 @@ impl Lock { } } - /// Acquire a new lock by creating the lock file with current PID. - fn acquire(&self) -> TsdlResult { - if let Some(parent) = self.lock_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - TsdlError::context(format!("Creating build directory {}", parent.display()), e) - })?; + /// Check lock status and acquire the OS lock if available. + pub fn try_acquire(&self) -> TsdlResult { + let file = self.open_lock_file()?; + + match file.try_lock_exclusive() { + Ok(()) => self.activate(file).map(LockStatus::Acquired), + Err(err) if is_lock_contention(&err) => Ok(self.lock_status()), + Err(err) => Err(TsdlError::context( + format!("Acquiring build lock {}", self.lock_path.display()), + err, + )), } + } + + /// Send SIGTERM to the process that held the lock when `owner` was captured. + pub fn terminate_owner(&self, owner: &LockOwner) -> TsdlResult<()> { + let system = Self::system_for_pid(owner.pid); + let process = system.process(owner.pid).ok_or_else(|| { + TsdlError::message(format!( + "Lock owner PID {} is no longer running; retry lock acquisition", + owner.pid + )) + })?; - self.write()?; + if process.start_time() != owner.start_time { + return Err(TsdlError::message(format!( + "Refusing to send SIGTERM to PID {} because it no longer matches the process that owns the lock", + owner.pid + ))); + } - info!("Acquired lock on build directory"); - Ok(LockGuard { - lock: self.lock_path.clone(), - }) + match process.kill_with(Signal::Term) { + Some(true) => Ok(()), + Some(false) => Err(TsdlError::message(format!( + "Failed to send SIGTERM to lock owner PID {}", + owner.pid + ))), + None => Err(TsdlError::message( + "SIGTERM is not supported on this platform; cannot terminate lock owner", + )), + } } - /// Force acquire a lock, overwriting any existing lock. + /// Wait for the build lock to become available after the lock owner was terminated. /// - /// This will replace any existing lock file. - pub fn force_acquire(&self) -> TsdlResult { - self.force_unlock()?; - self.acquire() + /// This races two signals: the owner process exiting and this process acquiring + /// the OS lock. The returned guard is the proof of exclusive ownership. + pub fn wait_for_release(&self, owner: &LockOwner, timeout: Duration) -> TsdlResult { + let (tx, rx) = mpsc::channel(); + + let lock = self.clone(); + let lock_tx = tx.clone(); + thread::spawn(move || { + let result = lock.wait_acquire().map_err(|err| err.to_string()); + let _ = lock_tx.send(UnlockEvent::LockAcquired(result)); + }); + + let owner_for_wait = owner.clone(); + thread::spawn(move || { + Self::wait_for_owner_exit(&owner_for_wait); + let _ = tx.send(UnlockEvent::OwnerExited); + }); + + let deadline = Instant::now() + timeout; + let mut owner_exited = false; + + loop { + let now = Instant::now(); + if now >= deadline { + return Err(timeout_error(owner, timeout, owner_exited)); + } + + match rx.recv_timeout(deadline.saturating_duration_since(now)) { + Ok(UnlockEvent::OwnerExited) => { + owner_exited = true; + } + Ok(UnlockEvent::LockAcquired(Ok(guard))) => return Ok(guard), + Ok(UnlockEvent::LockAcquired(Err(err))) => return Err(TsdlError::message(err)), + Err(mpsc::RecvTimeoutError::Timeout) => { + return Err(timeout_error(owner, timeout, owner_exited)) + } + + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(TsdlError::message( + "Stopped waiting for lock release because waiter threads disconnected", + )) + } + } + } } - /// Force unlock the build directory by removing the lock file. - /// - /// This does not verify ownership. - pub fn force_unlock(&self) -> TsdlResult<()> { - if self.lock_path.exists() { - fs::remove_file(&self.lock_path).map_err(|e| { - TsdlError::context( - format!("Removing lock file {}", self.lock_path.display()), - e, - ) + fn activate(&self, mut file: File) -> TsdlResult { + self.write_metadata(&mut file)?; + info!("Acquired lock on build directory"); + Ok(LockGuard { file }) + } + + fn open_lock_file(&self) -> TsdlResult { + if let Some(parent) = self.lock_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + TsdlError::context(format!("Creating build directory {}", parent.display()), e) })?; - info!("Lock removed from build directory"); - } else { - info!("No lock file found"); } - Ok(()) + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&self.lock_path) + .map_err(|e| { + TsdlError::context(format!("Opening lock file {}", self.lock_path.display()), e) + }) } - /// Helper for checking process status and determining lock conflicts - fn lock_status(&self) -> TsdlResult { - let lock_pid = self.read()?; + /// Blocking acquisition used after terminating a lock owner. + fn wait_acquire(&self) -> TsdlResult { + let file = self.open_lock_file()?; + file.lock_exclusive().map_err(|e| { + TsdlError::context( + format!("Waiting for build lock {}", self.lock_path.display()), + e, + ) + })?; + self.activate(file) + } + + /// Helper for checking process status and determining lock conflicts. + fn lock_status(&self) -> LockStatus { + let lock_pid = match self.read_pid() { + Ok(pid) => pid, + Err(err) => { + return LockStatus::Unknown { + pid: None, + reason: format!("lock is held, but owner metadata could not be read: {err}"), + }; + } + }; if lock_pid == self.current_pid { - return Ok(LockStatus::Cyclic); + return LockStatus::Cyclic; } - // Refresh only the PIDs we care about - let mut system = System::new(); - system.refresh_processes(ProcessesToUpdate::Some(&[self.current_pid, lock_pid]), true); - - match (system.process(lock_pid), system.process(self.current_pid)) { - (Some(lock_process), Some(current_process)) => { - match (lock_process.exe(), current_process.exe()) { - (Some(lock), Some(current)) if lock == current => Err(TsdlError::message( - format!("Build already in progress (PID {})", lock_process.pid()), - )), - (Some(lock), _) => Ok(LockStatus::LockedBy { - pid: lock_process.pid(), - exe: lock.to_string_lossy().to_string(), - }), - (None, _) => Ok(LockStatus::Unknown { - pid: lock_process.pid(), - reason: "Insufficient privileges to read process.exe".to_string(), - }), - } - } - (None, _) => Ok(LockStatus::Stale(lock_pid)), - (_, None) => Ok(LockStatus::Unknown { - pid: self.current_pid, - reason: "Insufficient privileges to read process information".to_string(), - }), + match Self::owner_for_pid(lock_pid) { + Some(owner) => LockStatus::LockedBy(owner), + None => LockStatus::Unknown { + pid: Some(lock_pid), + reason: "lock is held, but the metadata PID is not running or cannot be inspected" + .to_string(), + }, } } - fn read(&self) -> TsdlResult { + fn owner_for_pid(pid: Pid) -> Option { + let system = Self::system_for_pid(pid); + let process = system.process(pid)?; + + Some(LockOwner { + pid: process.pid(), + name: process.name().to_string_lossy().to_string(), + command: command_line(process.cmd()), + exe: process.exe().map(Path::to_path_buf), + cwd: process.cwd().map(Path::to_path_buf), + status: format!("{:?}", process.status()), + run_time: process.run_time(), + start_time: process.start_time(), + }) + } + + fn read_pid(&self) -> TsdlResult { let content = fs::read_to_string(&self.lock_path).map_err(|e| { TsdlError::context(format!("Reading lock file {}", self.lock_path.display()), e) })?; @@ -147,17 +295,44 @@ impl Lock { Ok(Pid::from(pid)) } - /// Check lock status and acquire if available. - pub fn try_acquire(&self) -> TsdlResult { - if !self.lock_path.exists() { - return self.acquire().map(LockStatus::Acquired); + fn system_for_pid(pid: Pid) -> System { + let mut system = System::new(); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + true, + ProcessRefreshKind::nothing() + .with_cmd(UpdateKind::Always) + .with_cwd(UpdateKind::Always) + .with_exe(UpdateKind::Always) + .with_user(UpdateKind::Always), + ); + system + } + + fn wait_for_owner_exit(owner: &LockOwner) { + let system = Self::system_for_pid(owner.pid); + let Some(process) = system.process(owner.pid) else { + return; + }; + + if process.start_time() != owner.start_time { + return; } - self.lock_status() + let _ = process.wait(); } - fn write(&self) -> TsdlResult<()> { - fs::write(&self.lock_path, self.current_pid.as_u32().to_string()).map_err(|e| { + fn write_metadata(&self, file: &mut File) -> TsdlResult<()> { + file.set_len(0).map_err(|e| { + TsdlError::context( + format!("Truncating lock file {}", self.lock_path.display()), + e, + ) + })?; + file.seek(SeekFrom::Start(0)).map_err(|e| { + TsdlError::context(format!("Seeking lock file {}", self.lock_path.display()), e) + })?; + write!(file, "{}", self.current_pid.as_u32()).map_err(|e| { TsdlError::context( format!( "Writing lock file {} with PID {}", @@ -166,6 +341,73 @@ impl Lock { ), e, ) + })?; + file.sync_all().map_err(|e| { + TsdlError::context(format!("Syncing lock file {}", self.lock_path.display()), e) }) } } + +fn command_line(cmd: &[std::ffi::OsString]) -> Option { + if cmd.is_empty() { + return None; + } + + Some( + cmd.iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "), + ) +} + +fn is_lock_contention(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::WouldBlock +} + +fn timeout_error(owner: &LockOwner, timeout: Duration, owner_exited: bool) -> TsdlError { + let timeout = format_duration(timeout); + if owner_exited { + TsdlError::message(format!( + "PID {} exited, but tsdl did not acquire the build lock within {timeout}. \ + Another process may have acquired it first. Retry the command or use a larger \ + --unlock-timeout if shutdown is slow.", + owner.pid + )) + } else { + TsdlError::message(format!( + "Timed out after {timeout} waiting for PID {} to exit and release the build lock. \ + The process may be ignoring SIGTERM or still shutting down. Stop it manually or \ + retry with a larger --unlock-timeout.", + owner.pid + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consts::TSDL_LOCK_FILE; + + fn unused_pid() -> usize { + let system = System::new_all(); + (1_000_000..10_000_000) + .find(|pid| system.process(Pid::from(*pid)).is_none()) + .expect("could not find an unused PID for lock test") + } + + #[test] + fn stale_lock_metadata_does_not_prevent_acquiring_free_os_lock() { + let temp = tempfile::tempdir().unwrap(); + let lock_file = temp.path().join(TSDL_LOCK_FILE); + fs::write(&lock_file, unused_pid().to_string()).unwrap(); + + let lock = Lock::new(temp.path()); + let status = lock.try_acquire().unwrap(); + + assert!( + matches!(status, LockStatus::Acquired(_)), + "stale lock metadata should not block acquiring a free OS lock" + ); + } +} From 16c1e7736565b0a19c99f371bde7bf6fc47797bf Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sat, 23 May 2026 14:42:58 +0200 Subject: [PATCH 19/88] lock: handle sigterm --- CHANGELOG.md | 2 + Cargo.lock | 1 + Cargo.toml | 2 + justfile | 10 +++ src/actors/display.rs | 52 ++++++++--- src/actors/mod.rs | 28 +++++- src/args.rs | 2 +- src/build.rs | 113 ++++++++++++----------- src/display.rs | 29 +++++- src/lib.rs | 1 + src/lock.rs | 10 ++- src/parser.rs | 26 +++++- src/sh.rs | 66 ++++++++++++-- src/shutdown.rs | 173 ++++++++++++++++++++++++++++++++++++ src/tree_sitter.rs | 7 +- src/walk.rs | 3 +- tests/cmd/mod.rs | 1 + tests/test_lock_takeover.sh | 153 +++++++++++++++++++++++++++++++ tests/test_second_signal.sh | 158 ++++++++++++++++++++++++++++++++ tests/test_signal.sh | 107 ++++++++++++++++++++++ 20 files changed, 857 insertions(+), 87 deletions(-) create mode 100644 src/shutdown.rs create mode 100644 tests/test_lock_takeover.sh create mode 100644 tests/test_second_signal.sh create mode 100755 tests/test_signal.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e1667..1fdb910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ informative and more coherent. - **lock**: Use OS-level build locks to avoid concurrent lock acquisition races, and replace `--unlock` with an interactive SIGTERM takeover flow controlled by `--unlock-timeout`. +- **shutdown**: Handle `SIGTERM`/`SIGINT` gracefully by terminating running build + command process groups before releasing the build lock. ## [2.0.0] - 2026-02-20 diff --git a/Cargo.lock b/Cargo.lock index f5ed130..b36453a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3909,6 +3909,7 @@ dependencies = [ "human-panic", "ignore", "indoc", + "libc", "log", "num_cpus", "predicates", diff --git a/Cargo.toml b/Cargo.toml index 876119c..59d5223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ enum_dispatch = "0.3" figment = { version = "0.10", features = ["toml", "env"] } fs2 = "0.4" futures = "0.3" +libc = "0.2" human-panic = "2.0" ignore = "0.4" ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color", "macros"] } @@ -77,6 +78,7 @@ tokio = { version = "1", features = [ "fs", "macros", "process", + "signal", "sync", "time", ] } diff --git a/justfile b/justfile index 664e0ef..8475921 100644 --- a/justfile +++ b/justfile @@ -40,6 +40,16 @@ setup: test *args="--retries 0": cargo nextest run {{ args }} +test-signals: + @echo "=== signal-handling smoke test ===" + @bash tests/test_signal.sh + + @echo "=== lock takeover smoke test ===" + @sh tests/test_lock_takeover.sh + + @echo "=== second-signal escalation test ===" + @sh tests/test_second_signal.sh + typos: typos --sort diff --git a/src/actors/display.rs b/src/actors/display.rs index 5de148b..f5ab991 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -60,6 +60,7 @@ pub enum UpdateKind { Step, MarkCached, MarkBuilt, + MarkCancelled, Cached, Fin, Err, @@ -171,10 +172,19 @@ impl ProgressAddr { }); } + pub fn mark_cancelled(&self) { + let _ = self.tx.try_send(DisplayMessage::Update { + id: self.id, + kind: UpdateKind::MarkCancelled, + msg: Arc::from(""), + }); + } + pub fn mark_built(&self) { let _ = self.tx.try_send(DisplayMessage::Update { id: self.id, kind: UpdateKind::MarkBuilt, + msg: Arc::from(""), }); } @@ -221,11 +231,13 @@ fn plain_repo_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> Stri UpdateKind::Cached => "cached".to_string(), UpdateKind::Fin => match outcome { ItemOutcome::Built | ItemOutcome::Cached => "done".to_string(), - ItemOutcome::Unknown => msg.to_string(), + ItemOutcome::Unknown | ItemOutcome::Cancelled => msg.to_string(), }, - UpdateKind::MarkBuilt | UpdateKind::MarkCached | UpdateKind::Msg | UpdateKind::Step => { - msg.to_string() - } + UpdateKind::MarkBuilt + | UpdateKind::MarkCached + | UpdateKind::MarkCancelled + | UpdateKind::Msg + | UpdateKind::Step => msg.to_string(), } } @@ -236,11 +248,13 @@ fn plain_grammar_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> S UpdateKind::Fin => match outcome { ItemOutcome::Cached => "cached".to_string(), ItemOutcome::Built => "built".to_string(), - ItemOutcome::Unknown => msg.to_string(), + ItemOutcome::Unknown | ItemOutcome::Cancelled => msg.to_string(), }, - UpdateKind::MarkBuilt | UpdateKind::MarkCached | UpdateKind::Msg | UpdateKind::Step => { - msg.to_string() - } + UpdateKind::MarkBuilt + | UpdateKind::MarkCached + | UpdateKind::MarkCancelled + | UpdateKind::Msg + | UpdateKind::Step => msg.to_string(), } } @@ -623,7 +637,10 @@ impl DisplayActor { self.apply_update(id, kind, msg); if matches!( kind, - UpdateKind::Msg | UpdateKind::MarkCached | UpdateKind::MarkBuilt + UpdateKind::Msg + | UpdateKind::MarkCached + | UpdateKind::MarkBuilt + | UpdateKind::MarkCancelled ) { continue; } @@ -673,9 +690,9 @@ impl DisplayActor { } fn print_plain_summary(&self) { - let (cached, built, _building, failed) = self.state.summary_counts(); + let (cached, built, _building, failed, cancelled) = self.state.summary_counts(); println!(); - println!("✓ {cached} cached ✓ {built} built ✗ {failed} failed"); + println!("✓ {cached} cached ✓ {built} built ✗ {cancelled} cancelled ✗ {failed} failed"); } fn plain_progress_line(&self, id: u64, kind: UpdateKind) -> Option { @@ -843,6 +860,10 @@ impl DisplayActor { UpdateKind::MarkCached => { repo.outcome = ItemOutcome::Cached; } + UpdateKind::MarkCancelled => { + repo.outcome = ItemOutcome::Cancelled; + } + UpdateKind::MarkBuilt => { repo.outcome = ItemOutcome::Built; } @@ -887,6 +908,10 @@ impl DisplayActor { UpdateKind::MarkCached => { grammar.outcome = ItemOutcome::Cached; } + UpdateKind::MarkCancelled => { + grammar.outcome = ItemOutcome::Cancelled; + } + UpdateKind::MarkBuilt => { grammar.outcome = ItemOutcome::Built; } @@ -967,6 +992,7 @@ impl DisplayActor { ItemOutcome::Built => return ItemOutcome::Built, ItemOutcome::Cached => saw_cached = true, ItemOutcome::Unknown => saw_unknown = true, + ItemOutcome::Cancelled => (), } } @@ -1097,7 +1123,7 @@ mod tests { let failed = actor.register_repo("failed".into(), GitRef::from("HEAD"), 1); actor.apply_update(failed.id, UpdateKind::Err, Arc::from("failed")); - assert_eq!(actor.state.summary_counts(), (1, 1, 1, 1)); + assert_eq!(actor.state.summary_counts(), (1, 1, 1, 1, 0)); } #[test] @@ -1112,7 +1138,7 @@ mod tests { actor.apply_update(grammar.id, UpdateKind::MarkCached, Arc::from("")); actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); - assert_eq!(actor.state.summary_counts(), (1, 0, 0, 0)); + assert_eq!(actor.state.summary_counts(), (1, 0, 0, 0, 0)); } #[test] diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 35adbd7..f89c912 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -8,11 +8,13 @@ pub use display::{DisplayActor, DisplayAddr, DisplayMessage, ProgressAddr}; use futures::{stream, StreamExt}; use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, info}; + use crate::{ args::TreeSitter, error::TsdlError, parser::{GrammarBuild, LanguageBuild}, - tree_sitter, TsdlResult, + shutdown, tree_sitter, TsdlResult, }; pub trait Addr { @@ -97,6 +99,13 @@ pub async fn run( // issues with the ratatui backend. display.shutdown().await; + // If shutdown was signalled, suppress build errors — they're expected + // cancellation artefacts, not real failures. + if shutdown::current().map_or(false, |s| s.is_cancelled()) { + info!("pipeline shutdown signalled, suppressing errors"); + return Ok(()); + } + result } @@ -151,10 +160,14 @@ async fn run_inner( .fold(Vec::new(), |mut errors, result| { let cache = cache.clone(); async move { + let is_shutdown = shutdown::current().map_or(false, |s| s.is_cancelled()); match result { - Ok(Some(update)) => cache.update(update).await, // Side-effect: Cache Update - Ok(None) => {} // Cache hit - Err(e) => errors.push(e), // Accumulate Error + Ok(Some(update)) => cache.update(update).await, + Ok(None) => {} + Err(e) if is_shutdown => { + debug!("[pipeline:fold] shutdown, dropping error: {e}"); + } + Err(e) => errors.push(e), } errors } @@ -180,6 +193,10 @@ async fn discover_grammars( language: LanguageBuild, ts_cli: Arc, ) -> TsdlResult> { + shutdown::test_delay().await; + shutdown::check()?; + debug!("[discover] lang={}", language.name); + let progress = display .add_language(language.spec.git_ref.clone(), language.name.clone(), 2) .await; @@ -208,6 +225,9 @@ async fn discover_grammars( // Map the raw discovery data into the Build struct immediately let mut builds = Vec::new(); for (name, dir, hash) in grammars { + shutdown::test_delay().await; + shutdown::check()?; + let key = format!("{}/{}", language.name, name); let entry = cache.get(key).await; let name_arc: std::sync::Arc = name.into(); diff --git a/src/args.rs b/src/args.rs index 4a7aedb..dc8a4f3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -230,7 +230,7 @@ pub struct BuildCommand { pub tree_sitter: TreeSitter, /// Seconds to wait after terminating a lock owner for the build lock to be released. - #[arg(long, env = "TSDL_UNLOCK_TIMEOUT", default_value_t = TSDL_UNLOCK_TIMEOUT)] + #[arg(long, env = "TSDL_UNLOCK_TIMEOUT", default_value_t = TSDL_UNLOCK_TIMEOUT, value_parser = clap::value_parser!(u64).range(1..))] #[serde(default)] pub unlock_timeout: u64, } diff --git a/src/build.rs b/src/build.rs index f3794fb..d883272 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,13 +1,13 @@ use std::{ collections::{BTreeMap, HashSet}, - ffi::OsStr, fs, - path::{Path, PathBuf}, + path::PathBuf, sync::Arc, time::Duration, }; use serde::{Deserialize, Serialize}; +use tracing::info; use url::Url; use crate::{ @@ -15,13 +15,15 @@ use crate::{ app::App, args::{ParserConfig, Target, TreeSitter}, cache::Db, - consts::{TSDL_FROM, TSDL_LOCK_FILE}, + consts::TSDL_FROM, error::{self, TsdlError}, format_duration, git::GitRef, lock::{Lock, LockGuard, LockOwner, LockStatus}, parser::LanguageBuild, - prompt_user, SafeCanonicalize, TsdlResult, + prompt_user, + shutdown::{self, Shutdown}, + SafeCanonicalize, TsdlResult, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -60,36 +62,55 @@ pub fn run(app: &mut App) -> TsdlResult<()> { } fn acquire_lock(lock: &Lock, unlock_timeout: Duration) -> TsdlResult { - match lock.try_acquire()? { - LockStatus::Acquired(lock) => Ok(lock), - - LockStatus::Cyclic => { - eprintln!("Lock already held by this process. This should not happen."); - Err(TsdlError::message("1+ lock acquisition")) - } - - LockStatus::LockedBy(owner) => handle_locked_by(lock, &owner, unlock_timeout), + // Loop because the lock owner may exit naturally between the prompt and + // SIGTERM, or another process may replace it. Each iteration re-checks + // the lock status and presents the current owner to the user. + loop { + match lock.try_acquire()? { + LockStatus::Acquired(guard) => return Ok(guard), + + LockStatus::Cyclic => { + info!("Lock already held by this process (cyclic)."); + return Err(TsdlError::message("1+ lock acquisition")); + } - LockStatus::Unknown { pid, reason } => { - match pid { - Some(pid) => eprintln!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"), - None => eprintln!("Build directory is locked, but tsdl could not identify the owner: {reason}"), + LockStatus::LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { + Ok(guard) => return Ok(guard), + Err(ref e) if is_retryable_lock_error(e) => { + info!("Lock owner changed; re-checking lock status..."); + // continue to the next loop iteration + } + Err(e) => return Err(e), + }, + + LockStatus::Unknown { pid, reason } => { + match pid { + Some(pid) => info!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"), + None => info!("Build directory is locked, but tsdl could not identify the owner: {reason}"), + } + return Err(TsdlError::message(format!( + "Could not identify build lock owner: {reason}" + ))); } - Err(TsdlError::message(format!( - "Could not identify build lock owner: {reason}" - ))) } } } +/// Returns `true` when the error indicates that the lock owner disappeared +/// between inspection and the takeover attempt. In that case the caller +/// should re-check lock status instead of failing. +fn is_retryable_lock_error(err: &TsdlError) -> bool { + let msg = err.to_string(); + msg.contains("no longer running") || msg.contains("no longer matches the process") +} + fn handle_locked_by( lock: &Lock, owner: &LockOwner, unlock_timeout: Duration, ) -> TsdlResult { - eprintln!("Build directory is locked by another process:\n"); - eprintln!("{owner}"); - eprintln!(); + info!("Build directory is locked by another process:"); + info!("{owner}"); eprintln!( "If you continue, tsdl will send SIGTERM to PID {} and wait up to {} \ (--unlock-timeout {}) for the build lock to be released.", @@ -103,7 +124,7 @@ fn handle_locked_by( } lock.terminate_owner(owner)?; - eprintln!( + info!( "Sent SIGTERM to PID {}. Waiting for the build lock to be released...", owner.pid ); @@ -112,8 +133,8 @@ fn handle_locked_by( fn clear(app: &mut App) -> TsdlResult<()> { if app.command.fresh && app.command.build_dir.exists() { - clear_build_dir(&app.command.build_dir)?; - eprintln!("Cleaned {}", app.command.build_dir.display()); + fs::remove_dir_all(&app.command.build_dir)?; + info!("Cleaned {}", app.command.build_dir.display()); } fs::create_dir_all(&app.command.build_dir)?; @@ -121,25 +142,6 @@ fn clear(app: &mut App) -> TsdlResult<()> { Ok(()) } -fn clear_build_dir(build_dir: &Path) -> TsdlResult<()> { - for entry in fs::read_dir(build_dir)? { - let entry = entry?; - if entry.file_name().as_os_str() == OsStr::new(TSDL_LOCK_FILE) { - continue; - } - - let path = entry.path(); - let file_type = entry.file_type()?; - if file_type.is_dir() { - fs::remove_dir_all(&path)?; - } else { - fs::remove_file(&path)?; - } - } - - Ok(()) -} - fn collect_languages(app: &App) -> Result, error::LanguageCollection> { let results = unique_languages(app); let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); @@ -202,19 +204,24 @@ fn ignite(app: &App) -> TsdlResult<()> { let languages = collect_languages(app)?; let result = rt.block_on(async move { + let shutdown = Shutdown::new(); + let _signals = shutdown.spawn_signal_listener()?; let cache = CacheActor::spawn(db, app.command.force); let build_dir: Arc = app.command.build_dir.canon()?.into(); let out_dir: Arc = app.command.out_dir.canon()?.into(); let display = DisplayActor::spawn(app.progress_mode, build_dir, out_dir); - actors::run( - &app.command.build_dir, - cache, - display, - app.command.jobs, - languages, - &app.command.tree_sitter, - ) + shutdown::scope(shutdown, async move { + actors::run( + &app.command.build_dir, + cache, + display, + app.command.jobs, + languages, + &app.command.tree_sitter, + ) + .await + }) .await?; Ok(()) diff --git a/src/display.rs b/src/display.rs index dc79f89..f036276 100644 --- a/src/display.rs +++ b/src/display.rs @@ -108,6 +108,8 @@ pub enum ItemOutcome { /// Cache hit. Cached, /// Not cached; work was needed. + /// Shut down before completion. + Cancelled, Built, } @@ -117,6 +119,7 @@ impl ItemOutcome { ItemOutcome::Unknown => Color::DarkGray, ItemOutcome::Cached => Color::Yellow, ItemOutcome::Built => Color::Blue, + ItemOutcome::Cancelled => Color::DarkGray, } } } @@ -505,11 +508,14 @@ impl DisplayState { } pub fn format_footer_counts(&self) -> Line<'static> { - let (cached, built, building, failed) = self.summary_counts(); + let (cached, built, building, failed, cancelled) = self.summary_counts(); let mut spans: Vec> = Vec::new(); push_summary_success(&mut spans, cached, "cached", Color::Yellow, true); push_summary_success(&mut spans, built, "built", Color::Blue, false); + if cancelled > 0 { + push_summary_cancelled(&mut spans, cancelled, false); + } if building > 0 { push_summary_building(&mut spans, building); } @@ -629,11 +635,12 @@ impl DisplayState { // ── Summary counts ──────────────────────────────────────────── - pub(crate) fn summary_counts(&self) -> (usize, usize, usize, usize) { + pub(crate) fn summary_counts(&self) -> (usize, usize, usize, usize, usize) { let mut cached = 0; let mut built = 0; let mut building = 0; let mut failed = 0; + let mut cancelled = 0; let mut repos_with_grammars: HashSet = HashSet::new(); for grammar in self.grammars.values() { @@ -645,6 +652,7 @@ impl DisplayState { &mut built, &mut building, &mut failed, + &mut cancelled, ); } @@ -659,11 +667,12 @@ impl DisplayState { &mut built, &mut building, &mut failed, + &mut cancelled, ); } } - (cached, built, building, failed) + (cached, built, building, failed, cancelled) } } @@ -678,12 +687,14 @@ fn count_grammar_status( built: &mut usize, building: &mut usize, failed: &mut usize, + cancelled: &mut usize, ) { match status { ItemStatus::New | ItemStatus::InProgress => *building += 1, ItemStatus::Done => match outcome { ItemOutcome::Cached => *cached += 1, ItemOutcome::Built | ItemOutcome::Unknown => *built += 1, + ItemOutcome::Cancelled => *cancelled += 1, }, ItemStatus::Failed => *failed += 1, } @@ -769,3 +780,15 @@ fn format_elapsed_duration(dur: Duration) -> String { format!("{mins:>3}:{remaining:02}") } } + +fn push_summary_cancelled(spans: &mut Vec>, count: usize, _leading: bool) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗ {count} cancelled"), + Style::default().fg(if count > 0 { + Color::Yellow + } else { + Color::DarkGray + }), + )); +} diff --git a/src/lib.rs b/src/lib.rs index 3b69fa6..e1bf2c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -76,6 +76,7 @@ pub mod parser; #[macro_use] pub mod sh; pub mod selfupdate; +pub mod shutdown; pub mod tree_sitter; pub mod walk; diff --git a/src/lock.rs b/src/lock.rs index c638be2..df42cbe 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -126,6 +126,10 @@ impl Lock { /// Send SIGTERM to the process that held the lock when `owner` was captured. pub fn terminate_owner(&self, owner: &LockOwner) -> TsdlResult<()> { + info!( + "Sending SIGTERM to lock owner PID {} ({})", + owner.pid, owner.name + ); let system = Self::system_for_pid(owner.pid); let process = system.process(owner.pid).ok_or_else(|| { TsdlError::message(format!( @@ -142,7 +146,10 @@ impl Lock { } match process.kill_with(Signal::Term) { - Some(true) => Ok(()), + Some(true) => { + info!("Sent SIGTERM to lock owner PID {}", owner.pid); + Ok(()) + } Some(false) => Err(TsdlError::message(format!( "Failed to send SIGTERM to lock owner PID {}", owner.pid @@ -158,6 +165,7 @@ impl Lock { /// This races two signals: the owner process exiting and this process acquiring /// the OS lock. The returned guard is the proof of exclusive ownership. pub fn wait_for_release(&self, owner: &LockOwner, timeout: Duration) -> TsdlResult { + info!("Waiting up to {} for lock release from PID {}", crate::format_duration(timeout), owner.pid); let (tx, rx) = mpsc::channel(); let lock = self.clone(); diff --git a/src/parser.rs b/src/parser.rs index edbddee..6729422 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -6,7 +6,7 @@ use std::{ }; use tokio::{fs, process::Command}; -use tracing::warn; +use tracing::{debug, warn}; use crate::{ actors::ProgressAddr, @@ -15,6 +15,7 @@ use crate::{ error::{self, TsdlError}, git::clone_fast, sh::{Exec, Script}, + shutdown, walk::collect_grammar_paths, TsdlResult, }; @@ -48,6 +49,13 @@ impl GrammarBuild { /// Build this grammar, returning a cache update if it was built. /// Uses the language's progress handle for progress reporting. pub async fn build(&self) -> TsdlResult> { + shutdown::test_delay().await; + shutdown::check()?; + debug!( + "[grammar:build] start: lang={} grammar={}", + self.language, self.name + ); + self.progress.step("checking cache"); let key = format!("{}/{}", self.language, self.name); @@ -80,7 +88,11 @@ impl GrammarBuild { // Build the grammar if let Err(e) = self.build_grammar().await { - self.progress.err("build failed"); + if shutdown::current().map_or(false, |s| s.is_cancelled()) { + self.progress.mark_cancelled(); + } else { + self.progress.err("build failed"); + } return Err(e); } @@ -115,6 +127,9 @@ impl GrammarBuild { } async fn build_grammar(&self) -> TsdlResult<()> { + shutdown::test_delay().await; + shutdown::check()?; + // Generate parser if no custom build script self.progress.step("generating"); if self.spec.build_script.is_none() { @@ -135,6 +150,13 @@ impl GrammarBuild { } async fn build_target(&self, ext: &str) -> TsdlResult<()> { + shutdown::test_delay().await; + shutdown::check()?; + debug!( + "[grammar:build_target] lang={} grammar={} ext={ext}", + self.language, self.name + ); + let output_name = self.parser_name_and_ext(ext); let mut cmd = self.build_command(ext, &output_name); diff --git a/src/sh.rs b/src/sh.rs index b410263..06ceb56 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -1,9 +1,10 @@ +use std::os::unix::process::CommandExt as _; use std::{env, fmt::Write, os::unix::process::ExitStatusExt, process::Output}; use tokio::process::Command; -use tracing::{error, trace}; +use tracing::{debug, error, info, trace}; -use crate::{error, TsdlResult}; +use crate::{error, shutdown, TsdlResult}; pub trait Exec { fn display(&self) -> TsdlResult; @@ -47,14 +48,63 @@ impl Exec for Command { #[tracing::instrument(skip(self))] async fn exec(&mut self) -> TsdlResult { let cmd_full = self.display_full()?; - trace!("{}", cmd_full); + let cmd_short = self.display()?; + trace!("{cmd_full}"); - let cmd = self.display()?; - let output = self - .output() - .await + // Check for shutdown before spawning + shutdown::check()?; + + // Capture stdout/stderr (mimics `self.output()` which we can't use + // because we need to spawn+race rather than spawn+wait). + self.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + // Put child in its own process group so signals can target the whole + // sub-tree (e.g. `tree-sitter build` → `node` → `cc`). + self.as_std_mut().process_group(0); + + let child = self + .spawn() .map_err(|e| error::TsdlError::context("Failed to execute command", e))?; + let child_pid = child.id(); + debug!("spawned pid={child_pid:?} cmd={cmd_short}"); + + // Register PGID for second-signal SIGKILL escalation + if let (Some(pid), Some(s)) = (child_pid, shutdown::current()) { + s.set_pgid(pid as i32); + debug!("registered pgid={pid}"); + + // Test hook: pause while the child is running so an external + // SIGTERM has a guaranteed window to arrive mid-execution. + shutdown::test_delay().await; + } + + // Race: child completion vs. shutdown signal. + // `child_pid` is captured before the select! so the shutdown branch + // can kill the process group even though `child` is moved into the + // `wait_with_output` future (which is dropped on cancellation). + let output = tokio::select! { + output = child.wait_with_output() => { + debug!("child exited pid={child_pid:?}"); + output + } + () = shutdown::cancelled() => { + if let Some(pid) = child_pid { + info!("SHUTDOWN killing pgid={pid}"); + // Kill the entire process group (negative PID) + unsafe { libc::kill(-(pid as i32), libc::SIGKILL); } + } + return Err(error::TsdlError::message(format!( + "Command cancelled (shutdown): {cmd_short}" + ))); + } + }; + + let output = + output.map_err(|e| error::TsdlError::context("Failed to execute command", e))?; + if output.status.success() { return Ok(output); } @@ -63,7 +113,7 @@ impl Exec for Command { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let msg = match output.status.code() { - Some(code) => format!("{cmd} failed with exit status {code}."), + Some(code) => format!("{cmd_short} failed with exit status {code}."), None => format!( "{} interrupted by signal {}.", program, diff --git a/src/shutdown.rs b/src/shutdown.rs new file mode 100644 index 0000000..abf586d --- /dev/null +++ b/src/shutdown.rs @@ -0,0 +1,173 @@ +use std::future::Future; +use std::sync::{Arc, Mutex}; + +use tokio::sync::watch; +use tracing::{debug, info}; + +use crate::{error::TsdlError, TsdlResult}; + +tokio::task_local! { + static CURRENT_SHUTDOWN: Shutdown; +} + +/// Test helper: when `TSDL_TEST_DELAY_MS` is set, inserts a sleep at every step +/// boundary so signal delivery can be observed deterministically. +/// +/// Only active in debug/test builds; a noop in release. +#[cfg(debug_assertions)] +pub async fn test_delay() { + if let Ok(ms) = std::env::var("TSDL_TEST_DELAY_MS") { + if let Ok(ms) = ms.parse::() { + let d = std::time::Duration::from_millis(ms); + debug!("[tsdl:test_delay] sleeping {ms}ms"); + tokio::time::sleep(d).await; + } + } +} + +#[cfg(not(debug_assertions))] +pub async fn test_delay() { + // noop in release +} + +/// Cooperative shutdown signal shared across build tasks. +#[derive(Clone, Debug)] +pub struct Shutdown { + tx: watch::Sender, + rx: watch::Receiver, + /// The process group ID of the most-recently-spawned build command. + /// Set by [`Shutdown::set_pgid`] and read by the signal listener. + pgid: Arc>>, +} + +impl Shutdown { + #[must_use] + pub fn new() -> Self { + let (tx, rx) = watch::channel(false); + Self { + tx, + rx, + pgid: Arc::new(Mutex::new(None)), + } + } + + pub fn cancel(&self) { + let _ = self.tx.send(true); + } + + /// Store the process group ID so the signal listener can + /// escalate to SIGKILL on a second signal. + pub fn set_pgid(&self, pgid: i32) { + *self + .pgid + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pgid); + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + *self.rx.borrow() + } + + pub async fn cancelled(&self) { + if self.is_cancelled() { + return; + } + + let mut rx = self.rx.clone(); + loop { + if rx.changed().await.is_err() || *rx.borrow() { + return; + } + } + } + + pub fn check(&self) -> TsdlResult<()> { + if self.is_cancelled() { + Err(TsdlError::message("Shutdown requested")) + } else { + Ok(()) + } + } + + #[cfg(unix)] + pub fn spawn_signal_listener(&self) -> TsdlResult> { + use std::process; + + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(|e| TsdlError::context("Installing SIGTERM handler", e))?; + let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .map_err(|e| TsdlError::context("Installing SIGINT handler", e))?; + let shutdown = self.clone(); + + Ok(tokio::spawn(async move { + let mut graceful = true; + + loop { + let signal = tokio::select! { + s = sigterm.recv() => s.map(|()| "SIGTERM"), + s = sigint.recv() => s.map(|()| "SIGINT"), + }; + + let Some(signal) = signal else { + // Stream closed; tokio should restore default handlers. + return; + }; + + if graceful { + graceful = false; + info!("Received {signal}; stopping running build commands..."); + shutdown.cancel(); + } else { + // Second signal: escalate. Kill the process group if we + // know one, then force-exit. + info!("Received second {signal}; forcing exit."); + let pgid = shutdown + .pgid + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(pgid) = pgid { + let _ = unsafe { libc::kill(-pgid, libc::SIGKILL) }; + } + let code = if signal == "SIGINT" { 130 } else { 143 }; + process::exit(code); + } + } + })) + } +} + +impl Default for Shutdown { + fn default() -> Self { + Self::new() + } +} + +pub async fn scope(shutdown: Shutdown, future: F) -> F::Output +where + F: Future, +{ + CURRENT_SHUTDOWN.scope(shutdown, future).await +} + +#[must_use] +pub fn current() -> Option { + CURRENT_SHUTDOWN.try_with(Clone::clone).ok() +} + +#[must_use] +pub fn current_or_default() -> Shutdown { + current().unwrap_or_default() +} + +pub fn check() -> TsdlResult<()> { + current().map_or(Ok(()), |shutdown| shutdown.check()) +} + +pub async fn cancelled() { + match current() { + Some(shutdown) => shutdown.cancelled().await, + None => std::future::pending::<()>().await, + } +} diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 4a2e21d..65767e0 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -6,12 +6,13 @@ use std::str::FromStr; use async_compression::tokio::bufread::GzipDecoder; use tokio::{fs, io, process::Command}; -use tracing::trace; +use tracing::{debug, trace}; use url::Url; use crate::actors::{DisplayAddr, ProgressAddr}; use crate::args::TreeSitter; use crate::git::{self, GitRef}; +use crate::shutdown; use crate::SafeCanonicalize; use crate::{error::TsdlError, TsdlResult}; use crate::{git::Tag, sh::Exec}; @@ -142,6 +143,10 @@ pub async fn prepare( display: DisplayAddr, tree_sitter: &TreeSitter, ) -> TsdlResult { + shutdown::test_delay().await; + shutdown::check()?; + debug!("[prepare] tree-sitter-cli version={}", tree_sitter.version); + let progress = display .add_language( display_tree_sitter_ref(&tree_sitter.version), diff --git a/src/walk.rs b/src/walk.rs index 922581d..7f71b8c 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; -use crate::cache; +use crate::{cache, shutdown}; /// Holds the immutable rules for the traversal. struct FilterContext { @@ -149,6 +149,7 @@ pub async fn collect_grammar_paths( let mut results = Vec::new(); for file in files { + shutdown::check()?; let full_path = root.join(&file); let hash = cache::hash_file(&full_path).await?; results.push((full_path, hash)); diff --git a/tests/cmd/mod.rs b/tests/cmd/mod.rs index 2c9131e..06787ac 100644 --- a/tests/cmd/mod.rs +++ b/tests/cmd/mod.rs @@ -6,6 +6,7 @@ mod cache; mod config; #[cfg(test)] mod log; +#[cfg(test)] use std::{env, fs, path::Path}; diff --git a/tests/test_lock_takeover.sh b/tests/test_lock_takeover.sh new file mode 100644 index 0000000..3800aa5 --- /dev/null +++ b/tests/test_lock_takeover.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# ------------------------------------------------------------------ +# Lock takeover smoke test. +# +# Spawns two tsdl processes sharing the same build directory. +# Process A acquires the lock and starts building. Process B +# discovers the lock is held, prompts the user whether to continue, +# sends SIGTERM to A, waits for A to exit, then acquires the lock +# and runs its own build. +# +# Assertions (checked against log files): +# 1. A acquired the lock +# 2. A received SIGTERM and shut down gracefully +# 3. B sent SIGTERM to A via lock takeover +# 4. B acquired the lock after A released it +# 5. Both exit code 0 +# +# Run: sh tests/test_lock_takeover.sh +# Requires: debug build (cargo build), Unix. +# ------------------------------------------------------------------ +set -eu + +cd "$(dirname "$0")/.." +ROOT="$(pwd)" + +echo "=== cleaning build dir ===" +rm -rf tmp 2>/dev/null +mkdir -p tmp/test parsers + +cargo build 2>&1 | tail -1 + +# ------------------------------------------------------------------ +# Process A: start building (acquires the lock, runs subprocesses). +# Use -v for debug-level file logging and a generous unlock_timeout +# so B doesn't time out waiting for A to shut down. +# ------------------------------------------------------------------ +echo "=== starting process A (lock owner) ===" +LOG_A="$ROOT/tmp/test/test_lock_a.log" +TSDL_TEST_DELAY_MS=500 \ +./target/debug/tsdl -v b json rust toml --force --progress plain \ + --log "$LOG_A" --log-color no --unlock-timeout 30 & +PID_A=$! + +# Wait for A to acquire the lock. +echo "=== waiting for A to acquire lock ===" +DEADLINE=$(($(date +%s) + 15)) +while [ $(date +%s) -lt $DEADLINE ]; do + if grep -q "Acquired lock" "$LOG_A" 2>/dev/null; then + echo " A acquired lock (PID $PID_A)" + break + fi + sleep 0.5 +done + +if ! grep -q "Acquired lock" "$LOG_A" 2>/dev/null; then + echo " [FAIL] A never acquired lock" + kill $PID_A 2>/dev/null || true + exit 1 +fi + +# Wait for A to actually start building subprocesses before B tries +# to steal the lock (ensures B SIGTERM exercises the subprocess-kill path). +echo "=== waiting for A to start building ===" +DEADLINE=$(($(date +%s) + 15)) +while [ $(date +%s) -lt $DEADLINE ]; do + if grep -q "tree-sitter-macos-arm64" "$LOG_A" 2>/dev/null; then + echo " A has spawned subprocesses" + break + fi + sleep 0.5 +done + +# ------------------------------------------------------------------ +# Process B: try to build on the same directory. Pipe "y\n" +# to stdin so it accepts the prompt and takes over the lock. +# ------------------------------------------------------------------ +echo "=== starting process B (lock taker) ===" +LOG_B="$ROOT/tmp/test/test_lock_b.log" +echo "y" | \ +TSDL_TEST_DELAY_MS=500 \ +./target/debug/tsdl -v b json rust --force --progress plain \ + --log "$LOG_B" --log-color no --unlock-timeout 30 & +PID_B=$! + +# Wait for both to finish. +wait $PID_A 2>/dev/null; EXIT_A=$? +wait $PID_B 2>/dev/null; EXIT_B=$? + +echo "" +echo "=== A exit code: $EXIT_A ===" +echo "=== B exit code: $EXIT_B ===" +echo "" + +# ------------------------------------------------------------------ +# Report +# ------------------------------------------------------------------ +failures=0 + +echo "--- process A (lock owner) ---" +if [ -f "$LOG_A" ]; then + grep -q "Acquired lock" "$LOG_A" \ + && echo " [PASS] acquired lock" \ + || { echo " [FAIL] acquired lock — log: $LOG_A"; failures=$((failures+1)); } + grep -q "Received SIGTERM" "$LOG_A" \ + && echo " [PASS] received SIGTERM" \ + || { echo " [FAIL] received SIGTERM — log: $LOG_A"; failures=$((failures+1)); } + grep -q "SHUTDOWN killing pgid=" "$LOG_A" \ + && echo " [PASS] child killed mid-exec" \ + || echo " [WARN] child killed mid-exec (may have completed before signal)" +else + echo " [FAIL] log file not created: $LOG_A" + failures=$((failures+1)) +fi + +echo "--- process B (lock taker) ---" +if [ -f "$LOG_B" ]; then + grep -q "Sending SIGTERM to lock owner" "$LOG_B" \ + && echo " [PASS] sent SIGTERM to A" \ + || { echo " [FAIL] sent SIGTERM to A — log: $LOG_B"; failures=$((failures+1)); } + grep -q "Acquired lock" "$LOG_B" \ + && echo " [PASS] acquired lock" \ + || { echo " [FAIL] acquired lock — log: $LOG_B"; failures=$((failures+1)); } + grep -q "panicked" "$LOG_B" && { + echo " [FAIL] panic in B log — log: $LOG_B"; failures=$((failures+1)); + } +else + echo " [FAIL] log file not created: $LOG_B" + failures=$((failures+1)) +fi + +echo "" +if [ $EXIT_A -eq 0 ]; then + echo " [PASS] A exit code 0" +else + echo " [WARN] A exit code $EXIT_A (may be 143 if SIGTERM killed tsdl before graceful shutdown)" +fi + +if [ $EXIT_B -eq 0 ]; then + echo " [PASS] B exit code 0 (graceful)" +else + echo " [FAIL] B exit code $EXIT_B (expected 0)" + failures=$((failures+1)) +fi + +if [ $failures -eq 0 ]; then + echo "" + echo "All assertions passed." + exit 0 +else + echo "" + echo "$failures assertion(s) FAILED." + exit 1 +fi diff --git a/tests/test_second_signal.sh b/tests/test_second_signal.sh new file mode 100644 index 0000000..a0bd0e3 --- /dev/null +++ b/tests/test_second_signal.sh @@ -0,0 +1,158 @@ +#!/bin/sh +# ------------------------------------------------------------------ +# Second-signal escalation smoke test. +# +# Verifies that when tsdl receives two signals in succession: +# 1. First SIGTERM → graceful shutdown: cancel() + cooperative exit +# 2. Second SIGTERM → escalation: libc::kill(-pgid, SIGKILL) + exit(143) +# +# Design: +# TSDL_TEST_DELAY_MS=500 guarantees every spawned child has a 500ms +# window mid-execution. The first SIGTERM triggers graceful shutdown +# but the delay cascade keeps the process alive for 1-2 seconds. +# We send the second signal 500ms later — reliably during the graceful +# shutdown window — forcing the escalation path. +# +# process::exit() kills the process with code 143 and flushes stderr, +# but may not flush the tracing log file. We capture stderr for the +# escalation message and use the tracing log file for the first-signal +# assertions. +# +# Assertions: +# 1. Exit code is 143 (SIGTERM escalation, not 0 = graceful) +# 2. stderr contains "Received SIGTERM" (first signal) +# 3. stderr contains "Received second SIGTERM; forcing exit" (escalation) +# 4. File log contains "SHUTDOWN killing pgid=" (optional, best-effort) +# +# Run: sh tests/test_second_signal.sh +# Requires: debug build (cargo build), Unix. +# ------------------------------------------------------------------ +set -eu + +cd "$(dirname "$0")/.." +ROOT="$(pwd)" + +echo "=== cleaning build dir ===" +rm -rf tmp 2>/dev/null +mkdir -p tmp/test parsers + +cargo build 2>&1 | tail -1 + +# ------------------------------------------------------------------ +# Start tsdl. Capture stderr to a separate file because process::exit +# flushes C stdio but not the tracing non-blocking writer. +# The tracing log file (--log) is also written for first-signal assertions. +# ------------------------------------------------------------------ +echo "=== starting tsdl (will receive 2 signals) ===" +LOG="$ROOT/tmp/test/test_2sig.log" +STDERR="$ROOT/tmp/test/test_2sig.stderr" +TSDL_TEST_DELAY_MS=500 \ +./target/debug/tsdl -v b json rust toml php --force --progress plain \ + --log "$LOG" --log-color no \ + 2>"$STDERR" & +PID=$! + +# ------------------------------------------------------------------ +# Wait for builds to be underway (spawned pid line in log). +# ------------------------------------------------------------------ +echo "=== waiting for builds to start ===" +DEADLINE=$(($(date +%s) + 30)) +while [ $(date +%s) -lt $DEADLINE ]; do + if grep -q "spawned pid=Some(" "$LOG" 2>/dev/null; then + echo " build underway" + break + fi + sleep 0.5 +done + +if ! grep -q "spawned pid=Some(" "$LOG" 2>/dev/null; then + echo " [FAIL] build never started" + kill $PID 2>/dev/null || true + exit 1 +fi + +# ------------------------------------------------------------------ +# First SIGTERM: graceful shutdown begins. The test_delay cascade +# keeps the process alive for 1-2 seconds. +# ------------------------------------------------------------------ +echo "=== sending first SIGTERM ===" +kill -TERM $PID 2>/dev/null || true + +# 500ms is shorter than any single test_delay, so the second signal +# will reliably arrive during the graceful shutdown cascade. +sleep 0.5 + +# ------------------------------------------------------------------ +# Second SIGTERM: escalation path. process::exit(143) kills the +# process immediately after SIGKILLing child process groups. +# ------------------------------------------------------------------ +echo "=== sending second SIGTERM ===" +kill -TERM $PID 2>/dev/null || true + +# Wait for the process to die (escalation is immediate). +sleep 2 +if kill -0 $PID 2>/dev/null; then + echo "=== still alive, sending SIGKILL ===" + kill -KILL $PID +fi + +# process::exit(143) means wait returns 143. set +e so the script +# doesn't abort on the non-zero exit. +set +e +wait $PID 2>/dev/null +EXIT=$? +set -e + +echo "" +echo "=== exit code: $EXIT ===" +echo "" + +# ------------------------------------------------------------------ +# Assertions +# ------------------------------------------------------------------ +failures=0 + +echo "--- first signal (tracing log) ---" +if [ -f "$LOG" ]; then + grep -q "Received SIGTERM" "$LOG" \ + && echo " [PASS] first signal received" \ + || { echo " [FAIL] first signal not in log — $LOG"; failures=$((failures+1)); } + # Best-effort: child kill evidence (may not appear if escalation fires first) + grep -q "SHUTDOWN killing pgid=" "$LOG" \ + && echo " [PASS] child killed mid-exec" \ + || echo " [WARN] no child kill in log (escalation may have preempted)" +else + echo " [FAIL] tracing log not created: $LOG" + failures=$((failures+1)) +fi + +echo "--- second signal (stderr capture) ---" +if [ -f "$STDERR" ]; then + grep -q "Received SIGTERM" "$STDERR" \ + && echo " [PASS] first signal on stderr" \ + || { echo " [WARN] first signal not on stderr (may race with exit)"; } + grep -q "Received second SIGTERM" "$STDERR" \ + && echo " [PASS] second signal escalation message" \ + || { echo " [FAIL] escalation message not on stderr — $STDERR"; failures=$((failures+1)); } +else + echo " [FAIL] stderr capture not created: $STDERR" + failures=$((failures+1)) +fi + +echo "" +if [ $EXIT -eq 143 ]; then + echo " [PASS] exit code 143 (SIGTERM escalation)" +else + echo " [FAIL] exit code $EXIT (expected 143)" + failures=$((failures+1)) +fi + +if [ $failures -eq 0 ]; then + echo "" + echo "All assertions passed." + exit 0 +else + echo "" + echo "$failures assertion(s) FAILED." + exit 1 +fi diff --git a/tests/test_signal.sh b/tests/test_signal.sh new file mode 100755 index 0000000..46d7dea --- /dev/null +++ b/tests/test_signal.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------ +# Manual signal-handling smoke test. +# +# Starts tsdl building several parsers with --force, sends SIGTERM +# mid-build, and checks that: +# 1. "Received SIGTERM" appears in the log +# 2. "SHUTDOWN killing pgid=" appears (child killed mid-execution) +# 3. "Command cancelled (shutdown)" appears +# 4. "pipeline shutdown signalled" appears +# 5. Exit code is 0 (graceful) +# +# Polls the log for a spawned subprocess (rather than a fixed sleep) +# so the test is not sensitive to network speed or CLI download time. +# +# Run: bash tests/test_signal.sh +# Requires: debug build (cargo build), Unix. +# ------------------------------------------------------------------ +set -euo pipefail + +cd "$(dirname "$0")/.." +root="$(pwd)" + +echo "=== cleaning build dir ===" +rm -rf tmp 2>/dev/null +mkdir -p tmp/test parsers + +cargo build 2>&1 | tail -1 + +# ------------------------------------------------------------------ +# 4 parsers with --force, TSDL_TEST_DELAY_MS=500 gives each spawned +# child a 500ms window where it is definitely running. We poll the +# log for a spawned line, then send SIGTERM immediately. +# ------------------------------------------------------------------ +echo "=== starting tsdl (4 parsers, SIGTERM when builds underway) ===" +log_file="$root/tmp/test/test_signal.log" +TSDL_TEST_DELAY_MS=500 \ +./target/debug/tsdl -v b json rust toml php --force --progress plain \ + --log "$log_file" --log-color no & +pid=$! + +# Poll until a subprocess has been spawned. +echo "=== waiting for builds to start ===" +deadline=$(( $(date +%s) + 60 )) +while (( $(date +%s) < deadline )); do + if grep -q "tree-sitter-macos-arm64" "$log_file" 2>/dev/null; then + echo " build underway" + break + fi + sleep 0.5 +done + +if ! grep -q "tree-sitter-macos-arm64" "$log_file" 2>/dev/null; then + echo " [FAIL] build never started" + kill "$pid" 2>/dev/null || true + exit 1 +fi + +echo "" +echo "=== sending SIGTERM to $pid ===" +kill -TERM "$pid" + +sleep 5 + +if kill -0 "$pid" 2>/dev/null; then + echo "=== sending SIGKILL ===" + kill -KILL "$pid" +fi + +wait "$pid" 2>/dev/null +exit_code=$? + +echo "" +echo "=== exit code: $exit_code ===" +echo "" + +# ------------------------------------------------------------------ +# Report +# ------------------------------------------------------------------ +if [[ -f "$log_file" ]]; then + echo "--- log assertions ---" + grep -q "Received SIGTERM" "$log_file" \ + && echo " [PASS] signal receipt" \ + || echo " [FAIL] signal receipt — log: $log_file" + grep -q "SHUTDOWN killing pgid=" "$log_file" \ + && echo " [PASS] child killed mid-exec" \ + || echo " [FAIL] child killed mid-exec — log: $log_file" + grep -q "Command cancelled (shutdown)" "$log_file" \ + && echo " [PASS] cancellation error" \ + || echo " [FAIL] cancellation error — log: $log_file" + grep -q "pipeline shutdown signalled" "$log_file" \ + && echo " [PASS] pipeline suppression" \ + || echo " [FAIL] pipeline suppression — log: $log_file" + if grep -q "panicked" "$log_file"; then + echo " [FAIL] panic found in log — log: $log_file" + else + echo " [PASS] no panics" + fi +else + echo " [FAIL] log file not created: $log_file" +fi + +if (( exit_code == 0 )); then + echo " [PASS] exit code 0 (graceful)" +else + echo " [FAIL] exit code $exit_code (expected 0)" +fi From 1c51ec5b210f20a0c4f0315655956f8ad667df53 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sun, 24 May 2026 11:36:18 +0200 Subject: [PATCH 20/88] build: preserve lock+log filess on --fresh --- Cargo.toml | 1 + build.rs | 1 + src/build.rs | 9 +++--- src/lock.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/logging.rs | 6 ++-- 5 files changed, 88 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 59d5223..74f99cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ force = false fresh = false from = "https://github.com/tree-sitter/tree-sitter-" lock-file = "tsdl.lock" +log-file = "log" out-dir = "parsers" prefix = "libtree-sitter-" ref = "master" diff --git a/build.rs b/build.rs index 1a4abba..6a1536e 100644 --- a/build.rs +++ b/build.rs @@ -111,6 +111,7 @@ fn main() { TSDL_FRESH : bool = json(tsdl, "fresh"), TSDL_FROM : str = json(tsdl, "from"), TSDL_LOCK_FILE : str = json(tsdl, "lock-file"), + TSDL_LOG_FILE : str = json(tsdl, "log-file"), TSDL_OUT_DIR : str = json(tsdl, "out-dir"), TSDL_UNLOCK_TIMEOUT: u64 = json(tsdl, "unlock-timeout"), TSDL_PREFIX : str = json(tsdl, "prefix"), diff --git a/src/build.rs b/src/build.rs index d883272..cff406d 100644 --- a/src/build.rs +++ b/src/build.rs @@ -54,9 +54,9 @@ pub fn run(app: &mut App) -> TsdlResult<()> { } let lock = Lock::new(&app.command.build_dir); - let _guard = acquire_lock(&lock, Duration::from_secs(app.command.unlock_timeout))?; + let guard = acquire_lock(&lock, Duration::from_secs(app.command.unlock_timeout))?; - clear(app)?; + clear(app, &guard)?; ignite(app)?; Ok(()) } @@ -131,10 +131,9 @@ fn handle_locked_by( lock.wait_for_release(owner, unlock_timeout) } -fn clear(app: &mut App) -> TsdlResult<()> { +fn clear(app: &mut App, guard: &LockGuard) -> TsdlResult<()> { if app.command.fresh && app.command.build_dir.exists() { - fs::remove_dir_all(&app.command.build_dir)?; - info!("Cleaned {}", app.command.build_dir.display()); + guard.clear_directory()?; } fs::create_dir_all(&app.command.build_dir)?; diff --git a/src/lock.rs b/src/lock.rs index df42cbe..289dc6f 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -13,7 +13,11 @@ use fs2::FileExt; use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, UpdateKind}; use tracing::info; -use crate::{consts::TSDL_LOCK_FILE, error::TsdlError, format_duration, TsdlResult}; +use crate::{ + consts::{TSDL_LOCK_FILE, TSDL_LOG_FILE}, + error::TsdlError, + format_duration, TsdlResult, +}; /// Information about the process currently holding the build lock. #[derive(Debug, Clone)] @@ -81,6 +85,7 @@ pub enum LockStatus { #[derive(Debug)] pub struct LockGuard { file: File, + lock_path: PathBuf, } impl Drop for LockGuard { @@ -89,6 +94,70 @@ impl Drop for LockGuard { } } +impl LockGuard { + /// Delete every entry in the build directory except the lock file itself + /// and the log file (which is held open by the tracing subscriber). + /// + /// The build directory itself is preserved; only its children are removed. + /// This is used by `--fresh` to clean the build directory without + /// invalidating the OS lock (which is tied to the lock file's inode). + pub fn clear_directory(&self) -> TsdlResult<()> { + const PROTECTED: &[&str] = &[TSDL_LOCK_FILE, TSDL_LOG_FILE]; + + let build_dir = self.lock_path.parent().ok_or_else(|| { + TsdlError::message(format!( + "Lock path has no parent directory: {}", + self.lock_path.display() + )) + })?; + + let lock_name = self + .lock_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(TSDL_LOCK_FILE); + + for entry in fs::read_dir(build_dir).map_err(|e| { + TsdlError::context( + format!("Reading build directory {}", build_dir.display()), + e, + ) + })? { + let entry = entry.map_err(|e| { + TsdlError::context( + format!("Reading directory entry in {}", build_dir.display()), + e, + ) + })?; + + let name = entry.file_name(); + let name_str = name.to_str().unwrap_or(""); + + if name_str == lock_name || PROTECTED.contains(&name_str) { + continue; + } + + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|e| TsdlError::context(format!("Statting {}", path.display()), e))?; + + if file_type.is_dir() { + fs::remove_dir_all(&path).map_err(|e| { + TsdlError::context(format!("Removing directory {}", path.display()), e) + })?; + } else { + fs::remove_file(&path).map_err(|e| { + TsdlError::context(format!("Removing file {}", path.display()), e) + })?; + } + } + + info!("Cleaned {}", build_dir.display()); + Ok(()) + } +} + /// Manages lock configuration and acquisition. #[derive(Clone)] pub struct Lock { @@ -165,7 +234,11 @@ impl Lock { /// This races two signals: the owner process exiting and this process acquiring /// the OS lock. The returned guard is the proof of exclusive ownership. pub fn wait_for_release(&self, owner: &LockOwner, timeout: Duration) -> TsdlResult { - info!("Waiting up to {} for lock release from PID {}", crate::format_duration(timeout), owner.pid); + info!( + "Waiting up to {} for lock release from PID {}", + crate::format_duration(timeout), + owner.pid + ); let (tx, rx) = mpsc::channel(); let lock = self.clone(); @@ -212,7 +285,10 @@ impl Lock { fn activate(&self, mut file: File) -> TsdlResult { self.write_metadata(&mut file)?; info!("Acquired lock on build directory"); - Ok(LockGuard { file }) + Ok(LockGuard { + file, + lock_path: self.lock_path.clone(), + }) } fn open_lock_file(&self) -> TsdlResult { diff --git a/src/logging.rs b/src/logging.rs index 2cd1711..93bd953 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -11,7 +11,7 @@ use tracing_subscriber::{layer::SubscriberExt, Layer}; use crate::{ args::{Args, LogColor}, config::current, - consts::TSDL_BUILD_DIR, + consts::{TSDL_BUILD_DIR, TSDL_LOG_FILE}, error::TsdlError, TsdlResult, }; @@ -67,8 +67,8 @@ fn init_log_file(args: &Args) -> TsdlResult { let log = args.log.as_ref().map_or_else( || { current(&args.config, args.command.as_build()).map_or_else( - |_| PathBuf::from(TSDL_BUILD_DIR).join("log"), - |c| c.build_dir.clone().join("log"), + |_| PathBuf::from(TSDL_BUILD_DIR).join(TSDL_LOG_FILE), + |c| c.build_dir.clone().join(TSDL_LOG_FILE), ) }, std::clone::Clone::clone, From d9ec4e6bee6f4b520fbd0179ee5ab9f8cfa3a2eb Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sun, 24 May 2026 12:28:07 +0200 Subject: [PATCH 21/88] lock: wait on main thread for lock release --- src/lock.rs | 99 +++++++++++++++++++---------------------------------- 1 file changed, 36 insertions(+), 63 deletions(-) diff --git a/src/lock.rs b/src/lock.rs index 289dc6f..c3dbfdc 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -3,9 +3,7 @@ use std::{ fs::{self, File, OpenOptions}, io::{self, Seek, SeekFrom, Write}, path::{Path, PathBuf}, - process, - sync::mpsc, - thread, + process, thread, time::{Duration, Instant}, }; @@ -159,17 +157,11 @@ impl LockGuard { } /// Manages lock configuration and acquisition. -#[derive(Clone)] pub struct Lock { current_pid: Pid, lock_path: PathBuf, } -enum UnlockEvent { - LockAcquired(Result), - OwnerExited, -} - impl Lock { #[must_use] pub fn new(build_dir: &Path) -> Self { @@ -231,57 +223,63 @@ impl Lock { /// Wait for the build lock to become available after the lock owner was terminated. /// - /// This races two signals: the owner process exiting and this process acquiring - /// the OS lock. The returned guard is the proof of exclusive ownership. + /// Polls the OS lock with exponential backoff while periodically checking whether + /// the owner process has exited. No background threads are spawned. pub fn wait_for_release(&self, owner: &LockOwner, timeout: Duration) -> TsdlResult { info!( "Waiting up to {} for lock release from PID {}", crate::format_duration(timeout), owner.pid ); - let (tx, rx) = mpsc::channel(); - - let lock = self.clone(); - let lock_tx = tx.clone(); - thread::spawn(move || { - let result = lock.wait_acquire().map_err(|err| err.to_string()); - let _ = lock_tx.send(UnlockEvent::LockAcquired(result)); - }); - - let owner_for_wait = owner.clone(); - thread::spawn(move || { - Self::wait_for_owner_exit(&owner_for_wait); - let _ = tx.send(UnlockEvent::OwnerExited); - }); let deadline = Instant::now() + timeout; let mut owner_exited = false; + // Open once; retry try_lock_exclusive on the same handle to avoid + // repeated open syscalls. + let file = self.open_lock_file()?; + + // Polling delay: start short, back off up to a ceiling. + let mut delay = Duration::from_millis(50); + loop { let now = Instant::now(); if now >= deadline { return Err(timeout_error(owner, timeout, owner_exited)); } - match rx.recv_timeout(deadline.saturating_duration_since(now)) { - Ok(UnlockEvent::OwnerExited) => { - owner_exited = true; - } - Ok(UnlockEvent::LockAcquired(Ok(guard))) => return Ok(guard), - Ok(UnlockEvent::LockAcquired(Err(err))) => return Err(TsdlError::message(err)), - Err(mpsc::RecvTimeoutError::Timeout) => { - return Err(timeout_error(owner, timeout, owner_exited)) + match file.try_lock_exclusive() { + Ok(()) => return self.activate(file), + + Err(ref err) if is_lock_contention(err) => { + // Still locked. Check whether the owner has exited (once). + if !owner_exited { + let system = Self::system_for_pid(owner.pid); + owner_exited = system + .process(owner.pid) + .is_none_or(|p| p.start_time() != owner.start_time); + } + + // If the owner exited, don't sleep. Retry immediately. + if owner_exited { + delay = Duration::from_millis(5); + continue; + } } - Err(mpsc::RecvTimeoutError::Disconnected) => { - return Err(TsdlError::message( - "Stopped waiting for lock release because waiter threads disconnected", - )) + Err(err) => { + return Err(TsdlError::context( + format!("Acquiring build lock {}", self.lock_path.display()), + err, + )); } } + + let remaining = deadline.saturating_duration_since(Instant::now()); + thread::sleep(delay.min(remaining)); + delay = delay.saturating_mul(2).min(Duration::from_millis(500)); } } - fn activate(&self, mut file: File) -> TsdlResult { self.write_metadata(&mut file)?; info!("Acquired lock on build directory"); @@ -309,18 +307,6 @@ impl Lock { }) } - /// Blocking acquisition used after terminating a lock owner. - fn wait_acquire(&self) -> TsdlResult { - let file = self.open_lock_file()?; - file.lock_exclusive().map_err(|e| { - TsdlError::context( - format!("Waiting for build lock {}", self.lock_path.display()), - e, - ) - })?; - self.activate(file) - } - /// Helper for checking process status and determining lock conflicts. fn lock_status(&self) -> LockStatus { let lock_pid = match self.read_pid() { @@ -393,19 +379,6 @@ impl Lock { system } - fn wait_for_owner_exit(owner: &LockOwner) { - let system = Self::system_for_pid(owner.pid); - let Some(process) = system.process(owner.pid) else { - return; - }; - - if process.start_time() != owner.start_time { - return; - } - - let _ = process.wait(); - } - fn write_metadata(&self, file: &mut File) -> TsdlResult<()> { file.set_len(0).map_err(|e| { TsdlError::context( From 8ad48fe7e94eead10e5407cf8c2fddb0e10f3260 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sun, 24 May 2026 15:50:59 +0200 Subject: [PATCH 22/88] shutdown: support more signals, better --- src/actors/display.rs | 33 ++++- src/actors/mod.rs | 23 ++-- src/build.rs | 7 +- src/display.rs | 12 +- src/error.rs | 12 +- src/main.rs | 13 +- src/parser.rs | 2 +- src/sh.rs | 107 ++++++++++++----- src/shutdown.rs | 232 +++++++++++++++++++++++++++++------- src/tree_sitter.rs | 12 +- tests/cmd/mod.rs | 1 - tests/test_lock_takeover.sh | 19 +-- tests/test_second_signal.sh | 13 +- tests/test_signal.sh | 73 ++++++++---- 14 files changed, 422 insertions(+), 137 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index f5ab991..0ab256e 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -176,7 +176,7 @@ impl ProgressAddr { let _ = self.tx.try_send(DisplayMessage::Update { id: self.id, kind: UpdateKind::MarkCancelled, - msg: Arc::from(""), + msg: Arc::from("cancelled"), }); } @@ -637,10 +637,7 @@ impl DisplayActor { self.apply_update(id, kind, msg); if matches!( kind, - UpdateKind::Msg - | UpdateKind::MarkCached - | UpdateKind::MarkBuilt - | UpdateKind::MarkCancelled + UpdateKind::Msg | UpdateKind::MarkCached | UpdateKind::MarkBuilt ) { continue; } @@ -834,6 +831,7 @@ impl DisplayActor { UpdateKind::Step | UpdateKind::MarkCached | UpdateKind::MarkBuilt + | UpdateKind::MarkCancelled | UpdateKind::Cached | UpdateKind::Fin | UpdateKind::Err @@ -861,7 +859,13 @@ impl DisplayActor { repo.outcome = ItemOutcome::Cached; } UpdateKind::MarkCancelled => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.status = ItemStatus::Cancelled; repo.outcome = ItemOutcome::Cancelled; + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = msg; } UpdateKind::MarkBuilt => { @@ -909,7 +913,11 @@ impl DisplayActor { grammar.outcome = ItemOutcome::Cached; } UpdateKind::MarkCancelled => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.status = ItemStatus::Cancelled; grammar.outcome = ItemOutcome::Cancelled; + grammar.step = grammar.total; + grammar.msg = msg; } UpdateKind::MarkBuilt => { @@ -950,6 +958,11 @@ impl DisplayActor { .grammars .values() .any(|g| g.repo_id == repo_id && g.status == ItemStatus::Failed); + let any_cancelled = self + .state + .grammars + .values() + .any(|g| g.repo_id == repo_id && g.status == ItemStatus::Cancelled); let any_active = self.state.grammars.values().any(|g| { g.repo_id == repo_id && (g.status == ItemStatus::New || g.status == ItemStatus::InProgress) @@ -967,6 +980,11 @@ impl DisplayActor { repo.msg = Arc::from("failed"); repo.frozen_elapsed .get_or_insert_with(|| repo.started_at.elapsed()); + } else if any_cancelled { + repo.status = ItemStatus::Cancelled; + repo.msg = Arc::from("cancelled"); + repo.frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); } else { repo.status = ItemStatus::Done; repo.msg = Arc::from("done"); @@ -980,6 +998,7 @@ impl DisplayActor { fn aggregate_child_outcome(&self, repo_id: u64) -> ItemOutcome { let mut saw_cached = false; + let mut saw_cancelled = false; let mut saw_unknown = false; for grammar in self @@ -991,13 +1010,15 @@ impl DisplayActor { match grammar.outcome { ItemOutcome::Built => return ItemOutcome::Built, ItemOutcome::Cached => saw_cached = true, + ItemOutcome::Cancelled => saw_cancelled = true, ItemOutcome::Unknown => saw_unknown = true, - ItemOutcome::Cancelled => (), } } if saw_unknown { ItemOutcome::Unknown + } else if saw_cancelled { + ItemOutcome::Cancelled } else if saw_cached { ItemOutcome::Cached } else { diff --git a/src/actors/mod.rs b/src/actors/mod.rs index f89c912..03849ed 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -100,10 +100,11 @@ pub async fn run( display.shutdown().await; // If shutdown was signalled, suppress build errors — they're expected - // cancellation artefacts, not real failures. - if shutdown::current().map_or(false, |s| s.is_cancelled()) { - info!("pipeline shutdown signalled, suppressing errors"); - return Ok(()); + // cancellation artefacts, not real failures — but still return the + // interruption so the top-level process exits with the signal status. + if let Some(signal) = shutdown::current().and_then(|s| s.reason()) { + info!("pipeline shutdown signalled by {signal}, suppressing build errors"); + return Err(TsdlError::Interrupted(signal)); } result @@ -160,7 +161,7 @@ async fn run_inner( .fold(Vec::new(), |mut errors, result| { let cache = cache.clone(); async move { - let is_shutdown = shutdown::current().map_or(false, |s| s.is_cancelled()); + let is_shutdown = shutdown::current().is_some_and(|s| s.is_cancelled()); match result { Ok(Some(update)) => cache.update(update).await, Ok(None) => {} @@ -207,7 +208,11 @@ async fn discover_grammars( { progress.step("cloning"); if let Err(e) = language.clone().await { - progress.err("clone failed"); + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.mark_cancelled(); + } else { + progress.err("clone failed"); + } return Err(e); } } @@ -216,7 +221,11 @@ async fn discover_grammars( let grammars = match language.discover_grammars().await { Ok(grammars) => grammars, Err(e) => { - progress.err("scan failed"); + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.mark_cancelled(); + } else { + progress.err("scan failed"); + } return Err(e); } }; diff --git a/src/build.rs b/src/build.rs index cff406d..139996f 100644 --- a/src/build.rs +++ b/src/build.rs @@ -84,9 +84,10 @@ fn acquire_lock(lock: &Lock, unlock_timeout: Duration) -> TsdlResult }, LockStatus::Unknown { pid, reason } => { - match pid { - Some(pid) => info!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"), - None => info!("Build directory is locked, but tsdl could not identify the owner: {reason}"), + if let Some(pid) = pid { + info!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"); + } else { + info!("Build directory is locked, but tsdl could not identify the owner: {reason}"); } return Err(TsdlError::message(format!( "Could not identify build lock owner: {reason}" diff --git a/src/display.rs b/src/display.rs index f036276..3460d0d 100644 --- a/src/display.rs +++ b/src/display.rs @@ -87,6 +87,8 @@ pub enum ItemStatus { InProgress, /// Successfully completed. Done, + /// Cancelled by shutdown. + Cancelled, /// Failed. Failed, } @@ -96,7 +98,7 @@ impl ItemStatus { match self { ItemStatus::New | ItemStatus::InProgress => "●", ItemStatus::Done => "✓", - ItemStatus::Failed => "✗", + ItemStatus::Cancelled | ItemStatus::Failed => "✗", } } } @@ -107,19 +109,18 @@ pub enum ItemOutcome { Unknown, /// Cache hit. Cached, - /// Not cached; work was needed. /// Shut down before completion. Cancelled, + /// Not cached; work was needed. Built, } impl ItemOutcome { fn color(self) -> Color { match self { - ItemOutcome::Unknown => Color::DarkGray, + ItemOutcome::Unknown | ItemOutcome::Cancelled => Color::DarkGray, ItemOutcome::Cached => Color::Yellow, ItemOutcome::Built => Color::Blue, - ItemOutcome::Cancelled => Color::DarkGray, } } } @@ -127,6 +128,7 @@ impl ItemOutcome { fn name_color(status: ItemStatus, outcome: ItemOutcome) -> Color { match status { ItemStatus::Failed => Color::Red, + ItemStatus::Cancelled => Color::Yellow, ItemStatus::New | ItemStatus::InProgress | ItemStatus::Done => outcome.color(), } } @@ -134,6 +136,7 @@ fn name_color(status: ItemStatus, outcome: ItemOutcome) -> Color { fn indicator_color(status: ItemStatus) -> Color { match status { ItemStatus::Done => Color::Green, + ItemStatus::Cancelled => Color::Yellow, ItemStatus::Failed => Color::Red, ItemStatus::New | ItemStatus::InProgress => Color::DarkGray, } @@ -696,6 +699,7 @@ fn count_grammar_status( ItemOutcome::Built | ItemOutcome::Unknown => *built += 1, ItemOutcome::Cancelled => *cancelled += 1, }, + ItemStatus::Cancelled => *cancelled += 1, ItemStatus::Failed => *failed += 1, } } diff --git a/src/error.rs b/src/error.rs index caf63f1..629e0dc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,6 +4,8 @@ use std::sync::Arc; use derive_more::derive::Display; +use crate::shutdown::ShutdownSignal; + /// Macro for creating Step errors with common patterns #[macro_export] macro_rules! step_error { @@ -301,6 +303,9 @@ pub enum TsdlError { /// Generic IO error Io(std::io::Error), + /// Build was interrupted by a Unix signal. + Interrupted(ShutdownSignal), + /// Simple error message Message(String), @@ -331,6 +336,7 @@ impl fmt::Display for TsdlError { TsdlError::Config(msg) => write!(f, "Configuration error: {msg}"), TsdlError::Context(kind) => write!(f, "{kind}"), TsdlError::Io(e) => write!(f, "IO error: {e}"), + TsdlError::Interrupted(signal) => write!(f, "Interrupted by {signal}"), TsdlError::Language(e) => write!(f, "{e}"), TsdlError::LanguageCollection(e) => write!(f, "{e}"), TsdlError::Message(msg) => write!(f, "{msg}"), @@ -343,7 +349,10 @@ impl fmt::Display for TsdlError { impl std::error::Error for TsdlError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - TsdlError::Build(_) | TsdlError::Config(_) | TsdlError::Message(_) => None, + TsdlError::Build(_) + | TsdlError::Config(_) + | TsdlError::Interrupted(_) + | TsdlError::Message(_) => None, TsdlError::Command(e) => Some(e), TsdlError::Context(kind) => Some(&kind.error), TsdlError::Io(e) => Some(e), @@ -522,6 +531,7 @@ impl TsdlError { ) } TsdlError::Io(e) => write!(w, "{prefix}IO error: {e}"), + TsdlError::Interrupted(signal) => write!(w, "{prefix}Interrupted by {signal}"), TsdlError::Language(e) => e.format(w, indent), TsdlError::LanguageCollection(e) => write!(w, "{prefix}{e}"), TsdlError::Message(msg) => write!(w, "{prefix}{msg}"), diff --git a/src/main.rs b/src/main.rs index 8e1cb60..ee713b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ fn main() -> ExitCode { } else { info!("Starting"); match App::new(&args).and_then(|mut app| run(&mut app, &args)) { + Err(TsdlError::Interrupted(signal)) => ExitCode::from(signal.shell_exit_code()), Err(e) => { eprintln!("{e}"); ExitCode::FAILURE @@ -29,11 +30,13 @@ fn run(app: &mut App, args: &args::Args) -> TsdlResult<()> { match &args.command { args::Command::Build(_) => { let (result, duration) = time(|| tsdl::build::run(app)); - let done = format!("Done in {duration}"); - if result.is_ok() { - println!("{}", style(done).green()); - } else { - println!("{}", style(done).red()); + match &result { + Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), + Err(TsdlError::Interrupted(signal)) => println!( + "{}", + style(format!("Interrupted by {signal} after {duration}")).yellow() + ), + Err(_) => println!("{}", style(format!("Done in {duration}")).red()), } result } diff --git a/src/parser.rs b/src/parser.rs index 6729422..50c682c 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -88,7 +88,7 @@ impl GrammarBuild { // Build the grammar if let Err(e) = self.build_grammar().await { - if shutdown::current().map_or(false, |s| s.is_cancelled()) { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { self.progress.mark_cancelled(); } else { self.progress.err("build failed"); diff --git a/src/sh.rs b/src/sh.rs index 06ceb56..16c5db5 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -1,8 +1,8 @@ use std::os::unix::process::CommandExt as _; -use std::{env, fmt::Write, os::unix::process::ExitStatusExt, process::Output}; +use std::{env, fmt::Write, os::unix::process::ExitStatusExt, process::Output, time::Duration}; -use tokio::process::Command; -use tracing::{debug, error, info, trace}; +use tokio::{process::Command, time}; +use tracing::{debug, error, info, trace, warn}; use crate::{error, shutdown, TsdlResult}; @@ -69,39 +69,78 @@ impl Exec for Command { .map_err(|e| error::TsdlError::context("Failed to execute command", e))?; let child_pid = child.id(); + let pgid = child_pid.map(i32::try_from).transpose().map_err(|e| { + error::TsdlError::message(format!("Child PID does not fit in a process group id: {e}")) + })?; debug!("spawned pid={child_pid:?} cmd={cmd_short}"); - // Register PGID for second-signal SIGKILL escalation - if let (Some(pid), Some(s)) = (child_pid, shutdown::current()) { - s.set_pgid(pid as i32); - debug!("registered pgid={pid}"); + let pgid_guard = match (pgid, shutdown::current()) { + (Some(pgid), Some(shutdown)) => { + debug!("registered pgid={pgid}"); + Some(shutdown.register_pgid(pgid)) + } + _ => None, + }; // Test hook: pause while the child is running so an external - // SIGTERM has a guaranteed window to arrive mid-execution. + // shutdown signal has a guaranteed window to arrive mid-execution. shutdown::test_delay().await; - } - // Race: child completion vs. shutdown signal. - // `child_pid` is captured before the select! so the shutdown branch - // can kill the process group even though `child` is moved into the - // `wait_with_output` future (which is dropped on cancellation). + // Race: child completion vs. shutdown signal. The wait future is kept + // alive after cancellation so the child can be reaped before returning. + let wait = child.wait_with_output(); + tokio::pin!(wait); + let output = tokio::select! { - output = child.wait_with_output() => { + output = &mut wait => { debug!("child exited pid={child_pid:?}"); output } - () = shutdown::cancelled() => { - if let Some(pid) = child_pid { - info!("SHUTDOWN killing pgid={pid}"); - // Kill the entire process group (negative PID) - unsafe { libc::kill(-(pid as i32), libc::SIGKILL); } + signal = shutdown::cancelled() => { + let grace = Duration::from_secs(5); + info!("Command interrupted by {signal}; waiting up to {grace:?} for pid={child_pid:?}: {cmd_short}"); + match time::timeout(grace, &mut wait).await { + Ok(Ok(_)) => { + debug!("child exited after {signal} pid={child_pid:?}"); + } + Ok(Err(err)) => { + debug!("child wait after {signal} failed pid={child_pid:?}: {err}"); + } + Err(_) => { + if let (Some(pgid), Some(shutdown)) = (pgid, shutdown::current()) { + shutdown.kill_pgid(pgid); + + let kill_grace = Duration::from_secs(2); + match time::timeout(kill_grace, &mut wait).await { + Ok(Ok(_)) => { + debug!("child reaped after SIGKILL pid={child_pid:?}"); + } + Ok(Err(err)) => { + debug!("child wait after SIGKILL failed pid={child_pid:?}: {err}"); + } + Err(_) => { + warn!( + "Timed out waiting {kill_grace:?} to reap pid={child_pid:?} after SIGKILL; returning interruption" + ); + } + } + } else { + warn!("Timed out waiting for pid={child_pid:?}, but no process group was registered; returning interruption"); + } + } } - return Err(error::TsdlError::message(format!( - "Command cancelled (shutdown): {cmd_short}" - ))); + + // If a second signal arrives before we drop the pgid_guard, + // `kill_children()` could target a stale PGID. PGID reuse in such a + // tiny window is rare, but this is exactly the kind of edge case + // process-group code tries to avoid. + drop(pgid_guard); + return Err(error::TsdlError::Interrupted(signal)); } }; + drop(pgid_guard); + let output = output.map_err(|e| error::TsdlError::context("Failed to execute command", e))?; @@ -112,13 +151,14 @@ impl Exec for Command { let program = self.as_std().get_program().to_string_lossy(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let msg = match output.status.code() { Some(code) => format!("{cmd_short} failed with exit status {code}."), - None => format!( - "{} interrupted by signal {}.", - program, - output.status.signal().unwrap() - ), + None => { + let sig = signal_display(output.status.signal().expect("a proper status code")) + .unwrap_or_else(|| "UNKNOWN".to_string()); + format!("{} interrupted by signal {}.", program, sig) + } }; error!("{msg}\nStdOut:\n{stdout}\nStdErr\n{stderr}"); @@ -140,3 +180,16 @@ impl Script for Command { cmd } } + +fn signal_display(number: i32) -> Option { + let ptr = unsafe { libc::strsignal(number) }; + if ptr.is_null() { + None + } else { + Some( + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) + } +} diff --git a/src/shutdown.rs b/src/shutdown.rs index abf586d..55a1d05 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -1,5 +1,9 @@ -use std::future::Future; -use std::sync::{Arc, Mutex}; +use std::{ + collections::HashSet, + fmt, + future::Future, + sync::{Arc, Mutex}, +}; use tokio::sync::watch; use tracing::{debug, info}; @@ -30,74 +34,207 @@ pub async fn test_delay() { // noop in release } +/// A Unix signal that requested shutdown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShutdownSignal { + pub number: i32, + pub name: &'static str, +} + +impl ShutdownSignal { + pub const HUP: Self = Self { + number: libc::SIGHUP, + name: "SIGHUP", + }; + pub const INT: Self = Self { + number: libc::SIGINT, + name: "SIGINT", + }; + pub const QUIT: Self = Self { + number: libc::SIGQUIT, + name: "SIGQUIT", + }; + pub const TERM: Self = Self { + number: libc::SIGTERM, + name: "SIGTERM", + }; + pub const KILL: Self = Self { + number: libc::SIGKILL, + name: "SIGKILL", + }; + + /// Conventional shell exit status for commands terminated by signal N. + #[must_use] + pub fn shell_exit_code(self) -> u8 { + u8::try_from(128 + self.number).unwrap_or(255) + } +} + +impl fmt::Display for ShutdownSignal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } +} + /// Cooperative shutdown signal shared across build tasks. #[derive(Clone, Debug)] pub struct Shutdown { - tx: watch::Sender, - rx: watch::Receiver, - /// The process group ID of the most-recently-spawned build command. - /// Set by [`Shutdown::set_pgid`] and read by the signal listener. - pgid: Arc>>, + tx: watch::Sender>, + rx: watch::Receiver>, + /// Active child process group IDs. Build commands are put in their own + /// process groups so a signal can target each command's whole subprocess tree. + active_pgids: Arc>>, +} + +/// Registration guard for an active child process group. +/// +/// Dropping the guard removes the PGID from the shutdown escalation set. +#[derive(Debug)] +pub struct PgidGuard { + shutdown: Shutdown, + pgid: i32, +} + +impl Drop for PgidGuard { + fn drop(&mut self) { + self.shutdown.unregister_pgid(self.pgid); + } } impl Shutdown { #[must_use] pub fn new() -> Self { - let (tx, rx) = watch::channel(false); + let (tx, rx) = watch::channel(None); Self { tx, rx, - pgid: Arc::new(Mutex::new(None)), + active_pgids: Arc::new(Mutex::new(HashSet::new())), + } + } + + /// Record shutdown and forward the received signal to all active children. + pub fn cancel_with_signal(&self, signal: ShutdownSignal) { + if self.reason().is_none() { + let _ = self.tx.send(Some(signal)); } + self.signal_children(signal); } - pub fn cancel(&self) { - let _ = self.tx.send(true); + /// Register an active child process group. + /// + /// If shutdown was already requested, the process group is immediately sent + /// the recorded shutdown signal so late-spawned children cannot escape it. + #[must_use] + pub fn register_pgid(&self, pgid: i32) -> PgidGuard { + { + let mut active = self + .active_pgids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + active.insert(pgid); + } + + if let Some(signal) = self.reason() { + self.signal_pgid(pgid, signal); + } + + PgidGuard { + shutdown: self.clone(), + pgid, + } } - /// Store the process group ID so the signal listener can - /// escalate to SIGKILL on a second signal. - pub fn set_pgid(&self, pgid: i32) { - *self - .pgid + fn unregister_pgid(&self, pgid: i32) { + let mut active = self + .active_pgids .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pgid); + .unwrap_or_else(std::sync::PoisonError::into_inner); + active.remove(&pgid); } #[must_use] pub fn is_cancelled(&self) -> bool { + self.reason().is_some() + } + + #[must_use] + pub fn reason(&self) -> Option { *self.rx.borrow() } - pub async fn cancelled(&self) { - if self.is_cancelled() { - return; + pub async fn cancelled(&self) -> ShutdownSignal { + if let Some(signal) = self.reason() { + return signal; } let mut rx = self.rx.clone(); loop { - if rx.changed().await.is_err() || *rx.borrow() { - return; + if rx.changed().await.is_err() { + return std::future::pending::().await; + } + if let Some(signal) = *rx.borrow() { + return signal; } } } pub fn check(&self) -> TsdlResult<()> { - if self.is_cancelled() { - Err(TsdlError::message("Shutdown requested")) + if let Some(signal) = self.reason() { + Err(TsdlError::Interrupted(signal)) } else { Ok(()) } } + /// Send a signal to every active child process group. + pub fn signal_children(&self, signal: ShutdownSignal) { + for pgid in self.active_pgids() { + self.signal_pgid(pgid, signal); + } + } + + /// Send a signal to one child process group. + pub fn signal_pgid(&self, pgid: i32, signal: ShutdownSignal) { + info!("SHUTDOWN sending {} to pgid={pgid}", signal.name); + signal_process_group(pgid, signal); + } + + /// Send SIGKILL to every active child process group. + pub fn kill_children(&self) { + for pgid in self.active_pgids() { + self.kill_pgid(pgid); + } + } + + /// Send SIGKILL to one child process group. + pub fn kill_pgid(&self, pgid: i32) { + info!("SHUTDOWN killing pgid={pgid}"); + signal_process_group(pgid, ShutdownSignal::KILL); + } + + fn active_pgids(&self) -> Vec { + self.active_pgids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .copied() + .collect() + } + #[cfg(unix)] pub fn spawn_signal_listener(&self) -> TsdlResult> { use std::process; - let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .map_err(|e| TsdlError::context("Installing SIGTERM handler", e))?; - let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + use tokio::signal::unix::{signal, SignalKind}; + + let mut sighup = signal(SignalKind::from_raw(ShutdownSignal::HUP.number)) + .map_err(|e| TsdlError::context("Installing SIGHUP handler", e))?; + let mut sigint = signal(SignalKind::from_raw(ShutdownSignal::INT.number)) .map_err(|e| TsdlError::context("Installing SIGINT handler", e))?; + let mut sigquit = signal(SignalKind::from_raw(ShutdownSignal::QUIT.number)) + .map_err(|e| TsdlError::context("Installing SIGQUIT handler", e))?; + let mut sigterm = signal(SignalKind::from_raw(ShutdownSignal::TERM.number)) + .map_err(|e| TsdlError::context("Installing SIGTERM handler", e))?; let shutdown = self.clone(); Ok(tokio::spawn(async move { @@ -105,33 +242,27 @@ impl Shutdown { loop { let signal = tokio::select! { - s = sigterm.recv() => s.map(|()| "SIGTERM"), - s = sigint.recv() => s.map(|()| "SIGINT"), + s = sighup.recv() => s.map(|()| ShutdownSignal::HUP), + s = sigint.recv() => s.map(|()| ShutdownSignal::INT), + s = sigquit.recv() => s.map(|()| ShutdownSignal::QUIT), + s = sigterm.recv() => s.map(|()| ShutdownSignal::TERM), }; let Some(signal) = signal else { - // Stream closed; tokio should restore default handlers. + // Signal stream ended unexpectedly; stop listening. return; }; if graceful { graceful = false; info!("Received {signal}; stopping running build commands..."); - shutdown.cancel(); + shutdown.cancel_with_signal(signal); } else { - // Second signal: escalate. Kill the process group if we - // know one, then force-exit. + // Second signal: escalate. Kill every known child process group, + // then force-exit with the conventional signal status. info!("Received second {signal}; forcing exit."); - let pgid = shutdown - .pgid - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - if let Some(pgid) = pgid { - let _ = unsafe { libc::kill(-pgid, libc::SIGKILL) }; - } - let code = if signal == "SIGINT" { 130 } else { 143 }; - process::exit(code); + shutdown.kill_children(); + process::exit(signal.shell_exit_code().into()); } } })) @@ -144,6 +275,17 @@ impl Default for Shutdown { } } +fn signal_process_group(pgid: i32, signal: ShutdownSignal) { + let rc = unsafe { libc::kill(-pgid, signal.number) }; + if rc != 0 { + debug!( + "failed to send {} to pgid={pgid}: {}", + signal.name, + std::io::Error::last_os_error() + ); + } +} + pub async fn scope(shutdown: Shutdown, future: F) -> F::Output where F: Future, @@ -165,9 +307,9 @@ pub fn check() -> TsdlResult<()> { current().map_or(Ok(()), |shutdown| shutdown.check()) } -pub async fn cancelled() { +pub async fn cancelled() -> ShutdownSignal { match current() { Some(shutdown) => shutdown.cancelled().await, - None => std::future::pending::<()>().await, + None => std::future::pending::().await, } } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 65767e0..58df503 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -163,7 +163,11 @@ pub async fn prepare( let tag = match tag(repo.as_str(), git_ref).await { Ok(tag) => tag, Err(e) => { - progress.err("resolve failed"); + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.mark_cancelled(); + } else { + progress.err("resolve failed"); + } return Err(e); } }; @@ -179,7 +183,11 @@ pub async fn prepare( { Ok(cli) => cli, Err(e) => { - progress.err("download failed"); + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.mark_cancelled(); + } else { + progress.err("download failed"); + } return Err(e); } }; diff --git a/tests/cmd/mod.rs b/tests/cmd/mod.rs index 06787ac..0fffa5c 100644 --- a/tests/cmd/mod.rs +++ b/tests/cmd/mod.rs @@ -7,7 +7,6 @@ mod config; #[cfg(test)] mod log; #[cfg(test)] - use std::{env, fs, path::Path}; use assert_cmd::{cargo::cargo_bin_cmd, Command}; diff --git a/tests/test_lock_takeover.sh b/tests/test_lock_takeover.sh index 3800aa5..ee2aeca 100644 --- a/tests/test_lock_takeover.sh +++ b/tests/test_lock_takeover.sh @@ -13,7 +13,7 @@ # 2. A received SIGTERM and shut down gracefully # 3. B sent SIGTERM to A via lock takeover # 4. B acquired the lock after A released it -# 5. Both exit code 0 +# 5. A exits 143 (128 + SIGTERM); B exits 0 # # Run: sh tests/test_lock_takeover.sh # Requires: debug build (cargo build), Unix. @@ -63,7 +63,7 @@ fi echo "=== waiting for A to start building ===" DEADLINE=$(($(date +%s) + 15)) while [ $(date +%s) -lt $DEADLINE ]; do - if grep -q "tree-sitter-macos-arm64" "$LOG_A" 2>/dev/null; then + if grep -q "spawned pid=Some(" "$LOG_A" 2>/dev/null; then echo " A has spawned subprocesses" break fi @@ -83,8 +83,10 @@ TSDL_TEST_DELAY_MS=500 \ PID_B=$! # Wait for both to finish. +set +e wait $PID_A 2>/dev/null; EXIT_A=$? wait $PID_B 2>/dev/null; EXIT_B=$? +set -e echo "" echo "=== A exit code: $EXIT_A ===" @@ -104,9 +106,9 @@ if [ -f "$LOG_A" ]; then grep -q "Received SIGTERM" "$LOG_A" \ && echo " [PASS] received SIGTERM" \ || { echo " [FAIL] received SIGTERM — log: $LOG_A"; failures=$((failures+1)); } - grep -q "SHUTDOWN killing pgid=" "$LOG_A" \ - && echo " [PASS] child killed mid-exec" \ - || echo " [WARN] child killed mid-exec (may have completed before signal)" + grep -q "SHUTDOWN sending SIGTERM" "$LOG_A" \ + && echo " [PASS] signal forwarded to child" \ + || echo " [WARN] signal forwarded to child (may have completed before signal)" else echo " [FAIL] log file not created: $LOG_A" failures=$((failures+1)) @@ -129,10 +131,11 @@ else fi echo "" -if [ $EXIT_A -eq 0 ]; then - echo " [PASS] A exit code 0" +if [ $EXIT_A -eq 143 ]; then + echo " [PASS] A exit code 143 (SIGTERM)" else - echo " [WARN] A exit code $EXIT_A (may be 143 if SIGTERM killed tsdl before graceful shutdown)" + echo " [FAIL] A exit code $EXIT_A (expected 143)" + failures=$((failures+1)) fi if [ $EXIT_B -eq 0 ]; then diff --git a/tests/test_second_signal.sh b/tests/test_second_signal.sh index a0bd0e3..6ccbc1a 100644 --- a/tests/test_second_signal.sh +++ b/tests/test_second_signal.sh @@ -7,11 +7,10 @@ # 2. Second SIGTERM → escalation: libc::kill(-pgid, SIGKILL) + exit(143) # # Design: -# TSDL_TEST_DELAY_MS=500 guarantees every spawned child has a 500ms +# TSDL_TEST_DELAY_MS=2000 guarantees every spawned child has a long # window mid-execution. The first SIGTERM triggers graceful shutdown -# but the delay cascade keeps the process alive for 1-2 seconds. -# We send the second signal 500ms later — reliably during the graceful -# shutdown window — forcing the escalation path. +# but the delay cascade keeps the process alive long enough for the +# second signal to reliably hit the escalation path. # # process::exit() kills the process with code 143 and flushes stderr, # but may not flush the tracing log file. We capture stderr for the @@ -46,7 +45,7 @@ cargo build 2>&1 | tail -1 echo "=== starting tsdl (will receive 2 signals) ===" LOG="$ROOT/tmp/test/test_2sig.log" STDERR="$ROOT/tmp/test/test_2sig.stderr" -TSDL_TEST_DELAY_MS=500 \ +TSDL_TEST_DELAY_MS=2000 \ ./target/debug/tsdl -v b json rust toml php --force --progress plain \ --log "$LOG" --log-color no \ 2>"$STDERR" & @@ -78,9 +77,9 @@ fi echo "=== sending first SIGTERM ===" kill -TERM $PID 2>/dev/null || true -# 500ms is shorter than any single test_delay, so the second signal +# This is shorter than any single test_delay, so the second signal # will reliably arrive during the graceful shutdown cascade. -sleep 0.5 +sleep 0.2 # ------------------------------------------------------------------ # Second SIGTERM: escalation path. process::exit(143) kills the diff --git a/tests/test_signal.sh b/tests/test_signal.sh index 46d7dea..3550f4e 100755 --- a/tests/test_signal.sh +++ b/tests/test_signal.sh @@ -5,10 +5,10 @@ # Starts tsdl building several parsers with --force, sends SIGTERM # mid-build, and checks that: # 1. "Received SIGTERM" appears in the log -# 2. "SHUTDOWN killing pgid=" appears (child killed mid-execution) -# 3. "Command cancelled (shutdown)" appears +# 2. "SHUTDOWN sending SIGTERM" appears (signal forwarded to child) +# 3. "Command interrupted by SIGTERM" appears # 4. "pipeline shutdown signalled" appears -# 5. Exit code is 0 (graceful) +# 5. Exit code is 143 (128 + SIGTERM) # # Polls the log for a spawned subprocess (rather than a fixed sleep) # so the test is not sensitive to network speed or CLI download time. @@ -43,14 +43,14 @@ pid=$! echo "=== waiting for builds to start ===" deadline=$(( $(date +%s) + 60 )) while (( $(date +%s) < deadline )); do - if grep -q "tree-sitter-macos-arm64" "$log_file" 2>/dev/null; then + if grep -q "spawned pid=Some(" "$log_file" 2>/dev/null; then echo " build underway" break fi sleep 0.5 done -if ! grep -q "tree-sitter-macos-arm64" "$log_file" 2>/dev/null; then +if ! grep -q "spawned pid=Some(" "$log_file" 2>/dev/null; then echo " [FAIL] build never started" kill "$pid" 2>/dev/null || true exit 1 @@ -67,8 +67,10 @@ if kill -0 "$pid" 2>/dev/null; then kill -KILL "$pid" fi +set +e wait "$pid" 2>/dev/null exit_code=$? +set -e echo "" echo "=== exit code: $exit_code ===" @@ -77,31 +79,62 @@ echo "" # ------------------------------------------------------------------ # Report # ------------------------------------------------------------------ +failures=0 + if [[ -f "$log_file" ]]; then echo "--- log assertions ---" - grep -q "Received SIGTERM" "$log_file" \ - && echo " [PASS] signal receipt" \ - || echo " [FAIL] signal receipt — log: $log_file" - grep -q "SHUTDOWN killing pgid=" "$log_file" \ - && echo " [PASS] child killed mid-exec" \ - || echo " [FAIL] child killed mid-exec — log: $log_file" - grep -q "Command cancelled (shutdown)" "$log_file" \ - && echo " [PASS] cancellation error" \ - || echo " [FAIL] cancellation error — log: $log_file" - grep -q "pipeline shutdown signalled" "$log_file" \ - && echo " [PASS] pipeline suppression" \ - || echo " [FAIL] pipeline suppression — log: $log_file" + if grep -q "Received SIGTERM" "$log_file"; then + echo " [PASS] signal receipt" + else + echo " [FAIL] signal receipt — log: $log_file" + failures=$((failures + 1)) + fi + + if grep -q "SHUTDOWN sending SIGTERM" "$log_file"; then + echo " [PASS] signal forwarded to child" + else + echo " [FAIL] signal forwarded to child — log: $log_file" + failures=$((failures + 1)) + fi + + if grep -q "Command interrupted by SIGTERM" "$log_file"; then + echo " [PASS] interruption logged" + else + echo " [FAIL] interruption logged — log: $log_file" + failures=$((failures + 1)) + fi + + if grep -q "pipeline shutdown signalled" "$log_file"; then + echo " [PASS] pipeline suppression" + else + echo " [FAIL] pipeline suppression — log: $log_file" + failures=$((failures + 1)) + fi + if grep -q "panicked" "$log_file"; then echo " [FAIL] panic found in log — log: $log_file" + failures=$((failures + 1)) else echo " [PASS] no panics" fi else echo " [FAIL] log file not created: $log_file" + failures=$((failures + 1)) fi -if (( exit_code == 0 )); then - echo " [PASS] exit code 0 (graceful)" +if (( exit_code == 143 )); then + echo " [PASS] exit code 143 (SIGTERM)" else - echo " [FAIL] exit code $exit_code (expected 0)" + echo " [FAIL] exit code $exit_code (expected 143)" + failures=$((failures + 1)) +fi + +if (( failures == 0 )); then + echo "" + echo "All assertions passed." + exit 0 +else + echo "" + echo "$failures assertion(s) FAILED." + exit 1 fi From f568292e990ddc395a0310f37c4d3f691c66c299 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sun, 24 May 2026 17:41:26 +0200 Subject: [PATCH 23/88] shutdwon: use a PgId wrapper --- src/sh.rs | 23 ++++++---- src/shutdown.rs | 120 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 120 insertions(+), 23 deletions(-) diff --git a/src/sh.rs b/src/sh.rs index 16c5db5..dc755df 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -4,7 +4,11 @@ use std::{env, fmt::Write, os::unix::process::ExitStatusExt, process::Output, ti use tokio::{process::Command, time}; use tracing::{debug, error, info, trace, warn}; -use crate::{error, shutdown, TsdlResult}; +use crate::{ + error, + shutdown::{self, PgId}, + TsdlResult, +}; pub trait Exec { fn display(&self) -> TsdlResult; @@ -69,8 +73,8 @@ impl Exec for Command { .map_err(|e| error::TsdlError::context("Failed to execute command", e))?; let child_pid = child.id(); - let pgid = child_pid.map(i32::try_from).transpose().map_err(|e| { - error::TsdlError::message(format!("Child PID does not fit in a process group id: {e}")) + let pgid = child_pid.map(PgId::try_from).transpose().map_err(|e| { + error::TsdlError::message(format!("Invalid child process group id: {e}")) })?; debug!("spawned pid={child_pid:?} cmd={cmd_short}"); @@ -152,13 +156,12 @@ impl Exec for Command { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let msg = match output.status.code() { - Some(code) => format!("{cmd_short} failed with exit status {code}."), - None => { - let sig = signal_display(output.status.signal().expect("a proper status code")) - .unwrap_or_else(|| "UNKNOWN".to_string()); - format!("{} interrupted by signal {}.", program, sig) - } + let msg = if let Some(code) = output.status.code() { + format!("{cmd_short} failed with exit status {code}.") + } else { + let sig = signal_display(output.status.signal().expect("a proper signal code")) + .unwrap_or_else(|| "UNKNOWN".to_string()); + format!("{program} interrupted by signal {sig}.") }; error!("{msg}\nStdOut:\n{stdout}\nStdErr\n{stderr}"); diff --git a/src/shutdown.rs b/src/shutdown.rs index 55a1d05..c8bfa9f 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -2,6 +2,7 @@ use std::{ collections::HashSet, fmt, future::Future, + num::NonZeroU32, sync::{Arc, Mutex}, }; @@ -76,6 +77,64 @@ impl fmt::Display for ShutdownSignal { } } +/// A positive Unix process group ID. +/// +/// `PgId` rejects zero and values that do not fit in `libc::pid_t`, so callers +/// cannot accidentally use `killpg(0, ...)`/`kill(-0, ...)` semantics and signal +/// the current process group. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct PgId(NonZeroU32); + +impl PgId { + /// Return the raw positive process group ID value. + #[must_use] + pub fn get(self) -> u32 { + self.0.get() + } + + fn as_pid_t(self) -> libc::pid_t { + ::try_from(self.0.get()).expect("PgId invariant: value fits in libc::pid_t") + } +} + +impl fmt::Display for PgId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.get()) + } +} + +impl TryFrom for PgId { + type Error = PgIdError; + + fn try_from(value: u32) -> Result { + let value = NonZeroU32::new(value).ok_or(PgIdError::Zero)?; + ::try_from(value.get()).map_err(|_| PgIdError::OutOfRange(value.get()))?; + Ok(Self(value)) + } +} + +/// Error returned when constructing a [`PgId`] from a raw value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PgIdError { + /// Process group ID zero would target the current process group. + Zero, + /// The value cannot be represented by the platform's `pid_t` type. + OutOfRange(u32), +} + +impl fmt::Display for PgIdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Zero => write!(f, "process group id cannot be zero"), + Self::OutOfRange(value) => { + write!(f, "process group id {value} does not fit in libc::pid_t") + } + } + } +} + +impl std::error::Error for PgIdError {} + /// Cooperative shutdown signal shared across build tasks. #[derive(Clone, Debug)] pub struct Shutdown { @@ -83,19 +142,24 @@ pub struct Shutdown { rx: watch::Receiver>, /// Active child process group IDs. Build commands are put in their own /// process groups so a signal can target each command's whole subprocess tree. - active_pgids: Arc>>, + active_pgids: Arc>>, } /// Registration guard for an active child process group. /// -/// Dropping the guard removes the PGID from the shutdown escalation set. +/// Keep this guard alive while the child process group may still contain live +/// processes. Dropping it removes the [`PgId`] from shutdown's active set, so a +/// later second signal will no longer send `SIGKILL` to that process group. +/// Drop it promptly after the child has exited and been reaped; this narrows the +/// window where a stale, OS-reused process group ID could be signalled by a +/// later shutdown escalation. #[derive(Debug)] -pub struct PgidGuard { +pub struct PgIdGuard { shutdown: Shutdown, - pgid: i32, + pgid: PgId, } -impl Drop for PgidGuard { +impl Drop for PgIdGuard { fn drop(&mut self) { self.shutdown.unregister_pgid(self.pgid); } @@ -125,7 +189,7 @@ impl Shutdown { /// If shutdown was already requested, the process group is immediately sent /// the recorded shutdown signal so late-spawned children cannot escape it. #[must_use] - pub fn register_pgid(&self, pgid: i32) -> PgidGuard { + pub fn register_pgid(&self, pgid: PgId) -> PgIdGuard { { let mut active = self .active_pgids @@ -138,13 +202,13 @@ impl Shutdown { self.signal_pgid(pgid, signal); } - PgidGuard { + PgIdGuard { shutdown: self.clone(), pgid, } } - fn unregister_pgid(&self, pgid: i32) { + fn unregister_pgid(&self, pgid: PgId) { let mut active = self .active_pgids .lock() @@ -194,7 +258,7 @@ impl Shutdown { } /// Send a signal to one child process group. - pub fn signal_pgid(&self, pgid: i32, signal: ShutdownSignal) { + pub fn signal_pgid(&self, pgid: PgId, signal: ShutdownSignal) { info!("SHUTDOWN sending {} to pgid={pgid}", signal.name); signal_process_group(pgid, signal); } @@ -207,12 +271,12 @@ impl Shutdown { } /// Send SIGKILL to one child process group. - pub fn kill_pgid(&self, pgid: i32) { + pub fn kill_pgid(&self, pgid: PgId) { info!("SHUTDOWN killing pgid={pgid}"); signal_process_group(pgid, ShutdownSignal::KILL); } - fn active_pgids(&self) -> Vec { + fn active_pgids(&self) -> Vec { self.active_pgids .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -275,8 +339,8 @@ impl Default for Shutdown { } } -fn signal_process_group(pgid: i32, signal: ShutdownSignal) { - let rc = unsafe { libc::kill(-pgid, signal.number) }; +fn signal_process_group(pgid: PgId, signal: ShutdownSignal) { + let rc = unsafe { libc::killpg(pgid.as_pid_t(), signal.number) }; if rc != 0 { debug!( "failed to send {} to pgid={pgid}: {}", @@ -313,3 +377,33 @@ pub async fn cancelled() -> ShutdownSignal { None => std::future::pending::().await, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pgid_rejects_zero() { + assert_eq!(PgId::try_from(0), Err(PgIdError::Zero)); + } + + #[test] + fn pgid_accepts_positive_value() { + let pgid = PgId::try_from(1).unwrap(); + assert_eq!(pgid.get(), 1); + } + + #[test] + fn pgid_rejects_values_that_do_not_fit_pid_t() { + let too_large = u32::try_from(::MAX) + .unwrap_or(u32::MAX) + .saturating_add(1); + + if ::try_from(too_large).is_err() { + assert_eq!( + PgId::try_from(too_large), + Err(PgIdError::OutOfRange(too_large)) + ); + } + } +} From 4e18210803748c772e2642292a80763149fbb96d Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sun, 24 May 2026 19:01:49 +0200 Subject: [PATCH 24/88] lock: be more explicit on different cases --- src/build.rs | 20 ++-- src/lock.rs | 255 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 194 insertions(+), 81 deletions(-) diff --git a/src/build.rs b/src/build.rs index 139996f..44380e5 100644 --- a/src/build.rs +++ b/src/build.rs @@ -19,7 +19,7 @@ use crate::{ error::{self, TsdlError}, format_duration, git::GitRef, - lock::{Lock, LockGuard, LockOwner, LockStatus}, + lock::{Lock, LockGuard, LockOwner, LockStatus, LockTakeoverError}, parser::LanguageBuild, prompt_user, shutdown::{self, Shutdown}, @@ -76,11 +76,11 @@ fn acquire_lock(lock: &Lock, unlock_timeout: Duration) -> TsdlResult LockStatus::LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { Ok(guard) => return Ok(guard), - Err(ref e) if is_retryable_lock_error(e) => { - info!("Lock owner changed; re-checking lock status..."); + Err(ref err) if err.is_retryable() => { + info!("{err}; re-checking lock status..."); // continue to the next loop iteration } - Err(e) => return Err(e), + Err(err) => return Err(err.into()), }, LockStatus::Unknown { pid, reason } => { @@ -97,19 +97,11 @@ fn acquire_lock(lock: &Lock, unlock_timeout: Duration) -> TsdlResult } } -/// Returns `true` when the error indicates that the lock owner disappeared -/// between inspection and the takeover attempt. In that case the caller -/// should re-check lock status instead of failing. -fn is_retryable_lock_error(err: &TsdlError) -> bool { - let msg = err.to_string(); - msg.contains("no longer running") || msg.contains("no longer matches the process") -} - fn handle_locked_by( lock: &Lock, owner: &LockOwner, unlock_timeout: Duration, -) -> TsdlResult { +) -> Result { info!("Build directory is locked by another process:"); info!("{owner}"); eprintln!( @@ -121,7 +113,7 @@ fn handle_locked_by( ); if !prompt_user("Terminate this process and continue?", false)? { - return Err(TsdlError::message("Lock acquisition cancelled by user")); + return Err(TsdlError::message("Lock acquisition cancelled by user").into()); } lock.terminate_owner(owner)?; diff --git a/src/lock.rs b/src/lock.rs index c3dbfdc..be90b7d 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -75,6 +75,134 @@ pub enum LockStatus { Unknown { pid: Option, reason: String }, } +/// Last observed state while waiting for a lock takeover to complete. +#[derive(Debug, Clone)] +pub enum LockObservation { + /// The lock is still held by an identifiable owner. + LockedBy(Box), + /// The lock is still held, but owner metadata could not be resolved. + Unknown { pid: Option, reason: String }, +} + +impl fmt::Display for LockObservation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LockedBy(owner) => { + write!(f, "lock is held by PID {} ({})", owner.pid, owner.name) + } + Self::Unknown { pid, reason } => { + if let Some(pid) = pid { + write!( + f, + "lock is held by PID {pid}, but owner is unknown: {reason}" + ) + } else { + write!(f, "lock is held by an unknown owner: {reason}") + } + } + } + } +} + +/// Error returned while terminating a lock owner or waiting for its lock to release. +#[derive(Debug)] +pub enum LockTakeoverError { + /// The previously observed owner exited before it could be signalled. + OwnerDisappeared { previous: Box }, + /// The lock owner changed while takeover was in progress. + OwnerChanged { + previous: Box, + current: Box, + }, + /// Lock status became cyclic while waiting. + Cyclic, + /// The lock remained held by an unidentifiable owner. + Unknown { pid: Option, reason: String }, + /// Sending SIGTERM to the owner failed. + SignalFailed { owner: Box }, + /// SIGTERM is unavailable on this platform. + SignalUnsupported { owner: Box }, + /// The timeout elapsed before the lock could be acquired. + Timeout { + previous: Box, + timeout: Duration, + last_observation: Box, + }, + /// An underlying tsdl error occurred while taking over the lock. + Source(TsdlError), +} + +impl LockTakeoverError { + /// Whether the caller should re-check lock status and retry the outer takeover loop. + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::OwnerDisappeared { .. } | Self::OwnerChanged { .. } + ) + } +} + +impl fmt::Display for LockTakeoverError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OwnerDisappeared { previous } => write!( + f, + "Lock owner PID {} ({}) is no longer running; retry lock acquisition", + previous.pid, previous.name + ), + Self::OwnerChanged { previous, current } => write!( + f, + "Lock owner changed while taking over: previous PID {} ({}), current PID {} ({})", + previous.pid, previous.name, current.pid, current.name + ), + Self::Cyclic => write!(f, "Lock became cyclic while waiting for release"), + Self::Unknown { pid, reason } => { + if let Some(pid) = pid { + write!(f, "Could not identify build lock owner PID {pid}: {reason}") + } else { + write!(f, "Could not identify build lock owner: {reason}") + } + } + Self::SignalFailed { owner } => write!( + f, + "Failed to send SIGTERM to lock owner PID {} ({})", + owner.pid, owner.name + ), + Self::SignalUnsupported { .. } => write!( + f, + "SIGTERM is not supported on this platform; cannot terminate lock owner" + ), + Self::Timeout { + previous, + timeout, + last_observation, + } => write!( + f, + "Timed out after {} waiting for PID {} ({}) to release the build lock; last observation: {last_observation}", + format_duration(*timeout), + previous.pid, + previous.name + ), + Self::Source(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for LockTakeoverError {} + +impl From for LockTakeoverError { + fn from(err: TsdlError) -> Self { + Self::Source(err) + } +} + +impl From for TsdlError { + fn from(err: LockTakeoverError) -> Self { + Self::message(err.to_string()) + } +} + /// A guard that holds an exclusive OS lock on the build directory lock file. /// /// The lock is released when this guard is dropped. The lock file itself is @@ -186,24 +314,29 @@ impl Lock { } /// Send SIGTERM to the process that held the lock when `owner` was captured. - pub fn terminate_owner(&self, owner: &LockOwner) -> TsdlResult<()> { + pub fn terminate_owner(&self, owner: &LockOwner) -> Result<(), LockTakeoverError> { info!( "Sending SIGTERM to lock owner PID {} ({})", owner.pid, owner.name ); let system = Self::system_for_pid(owner.pid); - let process = system.process(owner.pid).ok_or_else(|| { - TsdlError::message(format!( - "Lock owner PID {} is no longer running; retry lock acquisition", - owner.pid - )) - })?; + let process = + system + .process(owner.pid) + .ok_or_else(|| LockTakeoverError::OwnerDisappeared { + previous: Box::new(owner.clone()), + })?; if process.start_time() != owner.start_time { - return Err(TsdlError::message(format!( - "Refusing to send SIGTERM to PID {} because it no longer matches the process that owns the lock", - owner.pid - ))); + let current = Self::owner_for_pid(owner.pid).ok_or_else(|| { + LockTakeoverError::OwnerDisappeared { + previous: Box::new(owner.clone()), + } + })?; + return Err(LockTakeoverError::OwnerChanged { + previous: Box::new(owner.clone()), + current: Box::new(current), + }); } match process.kill_with(Signal::Term) { @@ -211,21 +344,25 @@ impl Lock { info!("Sent SIGTERM to lock owner PID {}", owner.pid); Ok(()) } - Some(false) => Err(TsdlError::message(format!( - "Failed to send SIGTERM to lock owner PID {}", - owner.pid - ))), - None => Err(TsdlError::message( - "SIGTERM is not supported on this platform; cannot terminate lock owner", - )), + Some(false) => Err(LockTakeoverError::SignalFailed { + owner: Box::new(owner.clone()), + }), + None => Err(LockTakeoverError::SignalUnsupported { + owner: Box::new(owner.clone()), + }), } } /// Wait for the build lock to become available after the lock owner was terminated. /// - /// Polls the OS lock with exponential backoff while periodically checking whether - /// the owner process has exited. No background threads are spawned. - pub fn wait_for_release(&self, owner: &LockOwner, timeout: Duration) -> TsdlResult { + /// The OS lock remains the source of truth. Each poll tries to acquire it first; + /// if it is still held, owner metadata is re-read so races with a new lock owner + /// are reported explicitly instead of waiting on stale information. + pub fn wait_for_release( + &self, + owner: &LockOwner, + timeout: Duration, + ) -> Result { info!( "Waiting up to {} for lock release from PID {}", crate::format_duration(timeout), @@ -233,45 +370,44 @@ impl Lock { ); let deadline = Instant::now() + timeout; - let mut owner_exited = false; - - // Open once; retry try_lock_exclusive on the same handle to avoid - // repeated open syscalls. - let file = self.open_lock_file()?; - - // Polling delay: start short, back off up to a ceiling. let mut delay = Duration::from_millis(50); + let mut last_observation = LockObservation::LockedBy(Box::new(owner.clone())); loop { let now = Instant::now(); if now >= deadline { - return Err(timeout_error(owner, timeout, owner_exited)); + return Err(LockTakeoverError::Timeout { + previous: Box::new(owner.clone()), + timeout, + last_observation: Box::new(last_observation), + }); } - match file.try_lock_exclusive() { - Ok(()) => return self.activate(file), + match self.try_acquire()? { + LockStatus::Acquired(guard) => return Ok(guard), - Err(ref err) if is_lock_contention(err) => { - // Still locked. Check whether the owner has exited (once). - if !owner_exited { - let system = Self::system_for_pid(owner.pid); - owner_exited = system - .process(owner.pid) - .is_none_or(|p| p.start_time() != owner.start_time); - } + LockStatus::Cyclic => return Err(LockTakeoverError::Cyclic), - // If the owner exited, don't sleep. Retry immediately. - if owner_exited { - delay = Duration::from_millis(5); - continue; + LockStatus::LockedBy(current) => { + if same_owner(¤t, owner) { + last_observation = LockObservation::LockedBy(Box::new(current)); + } else { + return Err(LockTakeoverError::OwnerChanged { + previous: Box::new(owner.clone()), + current: Box::new(current), + }); } } - Err(err) => { - return Err(TsdlError::context( - format!("Acquiring build lock {}", self.lock_path.display()), - err, - )); + LockStatus::Unknown { pid, reason } => { + // If metadata now points at a different PID that we cannot inspect, + // stop waiting on the previous owner. Otherwise keep polling until + // timeout so transient metadata reads or inherited lock handles have + // time to settle. + if pid.is_some_and(|pid| pid != owner.pid) { + return Err(LockTakeoverError::Unknown { pid, reason }); + } + last_observation = LockObservation::Unknown { pid, reason }; } } @@ -418,27 +554,12 @@ fn command_line(cmd: &[std::ffi::OsString]) -> Option { ) } -fn is_lock_contention(err: &io::Error) -> bool { - err.kind() == io::ErrorKind::WouldBlock +fn same_owner(current: &LockOwner, previous: &LockOwner) -> bool { + current.pid == previous.pid && current.start_time == previous.start_time } -fn timeout_error(owner: &LockOwner, timeout: Duration, owner_exited: bool) -> TsdlError { - let timeout = format_duration(timeout); - if owner_exited { - TsdlError::message(format!( - "PID {} exited, but tsdl did not acquire the build lock within {timeout}. \ - Another process may have acquired it first. Retry the command or use a larger \ - --unlock-timeout if shutdown is slow.", - owner.pid - )) - } else { - TsdlError::message(format!( - "Timed out after {timeout} waiting for PID {} to exit and release the build lock. \ - The process may be ignoring SIGTERM or still shutting down. Stop it manually or \ - retry with a larger --unlock-timeout.", - owner.pid - )) - } +fn is_lock_contention(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::WouldBlock } #[cfg(test)] From 7d822da31ccde58bbded319c12a06c66102c89aa Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sun, 24 May 2026 19:47:26 +0200 Subject: [PATCH 25/88] log: harden guards against its removal with --fresh --- src/app.rs | 4 +- src/args.rs | 1 + src/build.rs | 2 +- src/lib.rs | 34 +++++++++++++++- src/lock.rs | 32 +++++++++------ src/logging.rs | 100 ++++++++++++++++++++++++++++++++++++++++------- src/main.rs | 27 +++++++------ tests/cmd/log.rs | 39 ++++++++++++++++++ 8 files changed, 197 insertions(+), 42 deletions(-) diff --git a/src/app.rs b/src/app.rs index 387a07f..f66aeea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use crate::{args::Args, args::BuildCommand, config, display, TsdlResult}; pub struct App { pub command: BuildCommand, pub config_path: PathBuf, + pub log_path: PathBuf, pub progress_mode: display::Mode, pub verbose: Verbosity, } @@ -15,12 +16,13 @@ pub struct App { impl App { /// Create application from CLI arguments. /// This resolves and merges all configuration sources (CLI, config file, defaults). - pub fn new(args: &Args) -> TsdlResult { + pub fn new(args: &Args, log_path: PathBuf) -> TsdlResult { let command = config::current(&args.config, args.command.as_build())?; let progress_mode = display::mode_from_args(&args.progress, &args.verbose); Ok(Self { command, + log_path, progress_mode, config_path: args.config.clone(), verbose: args.verbose, diff --git a/src/args.rs b/src/args.rs index dc8a4f3..f5b012a 100644 --- a/src/args.rs +++ b/src/args.rs @@ -34,6 +34,7 @@ pub struct Args { pub config: PathBuf, /// Path to the logging file. If unspecified, it will go to `build-dir/log`. + /// If the path is inside `build-dir`, it must be directly under that directory. #[arg(short, long, env = "TSDL_LOG", global = true)] pub log: Option, diff --git a/src/build.rs b/src/build.rs index 44380e5..dd6aa7f 100644 --- a/src/build.rs +++ b/src/build.rs @@ -126,7 +126,7 @@ fn handle_locked_by( fn clear(app: &mut App, guard: &LockGuard) -> TsdlResult<()> { if app.command.fresh && app.command.build_dir.exists() { - guard.clear_directory()?; + guard.clear_directory(std::slice::from_ref(&app.log_path))?; } fs::create_dir_all(&app.command.build_dir)?; diff --git a/src/lib.rs b/src/lib.rs index e1bf2c8..de18b5a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,7 +52,7 @@ use std::{ env, io::{self, Write}, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, time::Duration, }; @@ -102,6 +102,38 @@ impl SafeCanonicalize for PathBuf { } } +/// Convert a path to an absolute, lexically-normalized path without requiring +/// the final path to exist. +pub fn absolute_normalize(path: &Path) -> TsdlResult { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .map_err(|e| TsdlError::context("Failed to get current directory", e))? + .join(path) + }; + + Ok(normalize_components(&absolute)) +} + +fn normalize_components(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + } + } + + normalized +} + #[must_use] pub fn format_duration(duration: Duration) -> String { let total_seconds = duration.as_secs(); diff --git a/src/lock.rs b/src/lock.rs index be90b7d..16e6bb8 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -1,4 +1,6 @@ use std::{ + collections::HashSet, + ffi::OsString, fmt, fs::{self, File, OpenOptions}, io::{self, Seek, SeekFrom, Write}, @@ -12,9 +14,7 @@ use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, Update use tracing::info; use crate::{ - consts::{TSDL_LOCK_FILE, TSDL_LOG_FILE}, - error::TsdlError, - format_duration, TsdlResult, + absolute_normalize, consts::TSDL_LOCK_FILE, error::TsdlError, format_duration, TsdlResult, }; /// Information about the process currently holding the build lock. @@ -222,26 +222,35 @@ impl Drop for LockGuard { impl LockGuard { /// Delete every entry in the build directory except the lock file itself - /// and the log file (which is held open by the tracing subscriber). + /// and explicitly protected root-level files, such as the active log file. /// /// The build directory itself is preserved; only its children are removed. /// This is used by `--fresh` to clean the build directory without - /// invalidating the OS lock (which is tied to the lock file's inode). - pub fn clear_directory(&self) -> TsdlResult<()> { - const PROTECTED: &[&str] = &[TSDL_LOCK_FILE, TSDL_LOG_FILE]; - + /// invalidating OS locks or unlinking files held open by the current process. + pub fn clear_directory(&self, protected_files: &[PathBuf]) -> TsdlResult<()> { let build_dir = self.lock_path.parent().ok_or_else(|| { TsdlError::message(format!( "Lock path has no parent directory: {}", self.lock_path.display() )) })?; + let build_dir_abs = absolute_normalize(build_dir)?; + let mut protected_names = HashSet::::new(); let lock_name = self .lock_path .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(TSDL_LOCK_FILE); + .map_or_else(|| OsString::from(TSDL_LOCK_FILE), OsString::from); + protected_names.insert(lock_name); + + for protected in protected_files { + let protected_abs = absolute_normalize(protected)?; + if protected_abs.parent() == Some(build_dir_abs.as_path()) { + if let Some(name) = protected_abs.file_name() { + protected_names.insert(name.to_os_string()); + } + } + } for entry in fs::read_dir(build_dir).map_err(|e| { TsdlError::context( @@ -257,9 +266,8 @@ impl LockGuard { })?; let name = entry.file_name(); - let name_str = name.to_str().unwrap_or(""); - if name_str == lock_name || PROTECTED.contains(&name_str) { + if protected_names.contains(&name) { continue; } diff --git a/src/logging.rs b/src/logging.rs index 93bd953..25ae218 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,4 +1,5 @@ use std::{ + ffi::OsStr, fs::{self, File}, path::{Path, PathBuf}, }; @@ -9,14 +10,20 @@ use tracing_log::AsTrace; use tracing_subscriber::{layer::SubscriberExt, Layer}; use crate::{ + absolute_normalize, args::{Args, LogColor}, config::current, - consts::{TSDL_BUILD_DIR, TSDL_LOG_FILE}, + consts::{TSDL_BUILD_DIR, TSDL_CACHE_FILE, TSDL_LOCK_FILE, TSDL_LOG_FILE}, error::TsdlError, TsdlResult, }; -pub fn init(args: &Args) -> TsdlResult { +pub struct Logging { + pub path: PathBuf, + _guard: WorkerGuard, +} + +pub fn init(args: &Args) -> TsdlResult { let color = match args.log_color { LogColor::Auto => atty::is(atty::Stream::Stdout), LogColor::No => false, @@ -24,8 +31,13 @@ pub fn init(args: &Args) -> TsdlResult { }; console::set_colors_enabled(color); let filter = args.verbose.log_level_filter().as_trace(); - let file = init_log_file(args)?; - Ok(init_tracing(file, color, filter)) + let path = resolve_log_path(args)?; + let file = open_log_file(&path)?; + let guard = init_tracing(file, color, filter); + Ok(Logging { + path, + _guard: guard, + }) } fn init_tracing(file: File, color: bool, filter: LevelFilter) -> WorkerGuard { @@ -63,19 +75,77 @@ fn init_tracing(file: File, color: bool, filter: LevelFilter) -> WorkerGuard { guard } -fn init_log_file(args: &Args) -> TsdlResult { - let log = args.log.as_ref().map_or_else( - || { - current(&args.config, args.command.as_build()).map_or_else( - |_| PathBuf::from(TSDL_BUILD_DIR).join(TSDL_LOG_FILE), - |c| c.build_dir.clone().join(TSDL_LOG_FILE), - ) - }, - std::clone::Clone::clone, - ); +fn resolve_log_path(args: &Args) -> TsdlResult { + let command = current(&args.config, args.command.as_build()).ok(); + let build_dir = command + .as_ref() + .map_or_else(|| PathBuf::from(TSDL_BUILD_DIR), |c| c.build_dir.clone()); + let log = args + .log + .as_ref() + .map_or_else(|| build_dir.join(TSDL_LOG_FILE), std::clone::Clone::clone); + + validate_log_path(&build_dir, &log) +} + +fn validate_log_path(build_dir: &Path, log: &Path) -> TsdlResult { + let build_dir = absolute_normalize(build_dir)?; + let log = absolute_normalize(log)?; + + if log == build_dir { + return Err(TsdlError::message(format!( + "--log must be a file path, not the build directory {}", + build_dir.display() + ))); + } + + if log.is_dir() { + return Err(TsdlError::message(format!( + "--log must be a file path, not a directory: {}", + log.display() + ))); + } + + if log.starts_with(&build_dir) { + let relative = log.strip_prefix(&build_dir).map_err(|e| { + TsdlError::message(format!( + "Could not compare log path {} with build directory {}: {e}", + log.display(), + build_dir.display() + )) + })?; + let component_count = relative.components().count(); + + if component_count != 1 { + return Err(TsdlError::message(format!( + "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", + log.display(), + build_dir.display() + ))); + } + + let Some(name) = relative.file_name() else { + return Err(TsdlError::message(format!( + "--log must be a file path: {}", + log.display() + ))); + }; + + if name == OsStr::new(TSDL_LOCK_FILE) || name == OsStr::new(TSDL_CACHE_FILE) { + return Err(TsdlError::message(format!( + "--log path {} conflicts with a tsdl runtime/build file", + log.display() + ))); + } + } + + Ok(log) +} + +fn open_log_file(log: &Path) -> TsdlResult { let parent = log.parent().unwrap_or(Path::new(".")); if !parent.exists() { fs::create_dir_all(parent).map_err(|e| TsdlError::context("Preparing log directory", e))?; } - File::create(&log).map_err(|e| TsdlError::context("Creating log file", e)) + File::create(log).map_err(|e| TsdlError::context("Creating log file", e)) } diff --git a/src/main.rs b/src/main.rs index ee713b1..b9b7b21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,19 +10,22 @@ fn main() -> ExitCode { set_panic_hook(); let args = args::Args::parse(); - if let Err(e) = logging::init(&args) { - eprintln!("Could not initialize logging: {e}"); - ExitCode::FAILURE - } else { - info!("Starting"); - match App::new(&args).and_then(|mut app| run(&mut app, &args)) { - Err(TsdlError::Interrupted(signal)) => ExitCode::from(signal.shell_exit_code()), - Err(e) => { - eprintln!("{e}"); - ExitCode::FAILURE - } - Ok(()) => ExitCode::SUCCESS, + let logging = match logging::init(&args) { + Ok(logging) => logging, + Err(e) => { + eprintln!("Could not initialize logging: {e}"); + return ExitCode::FAILURE; + } + }; + + info!("Starting"); + match App::new(&args, logging.path.clone()).and_then(|mut app| run(&mut app, &args)) { + Err(TsdlError::Interrupted(signal)) => ExitCode::from(signal.shell_exit_code()), + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE } + Ok(()) => ExitCode::SUCCESS, } } diff --git a/tests/cmd/log.rs b/tests/cmd/log.rs index 24bb46d..2a83b3c 100644 --- a/tests/cmd/log.rs +++ b/tests/cmd/log.rs @@ -27,6 +27,7 @@ fn build_no_args_should_log_to_default_path() { #[rstest] #[case::cwd("tsdl.log")] #[case::child_dir("here/log")] +#[case::build_dir_root("tmp/custom.log")] #[case::absolute("/tmp/tsdl.log")] #[case::parent("../tsdl.log")] fn build_w_specific_log_path(#[case] log: &str) { @@ -43,3 +44,41 @@ fn build_w_specific_log_path(#[case] log: &str) { .assert(p::path::exists()) .assert(p::path::is_file()); } + +#[rstest] +#[case::nested_in_build_dir("tmp/logs/tsdl.log", "nested inside --build-dir")] +#[case::lock_file("tmp/tsdl.lock", "conflicts with a tsdl runtime/build file")] +#[case::cache_file("tmp/cache.toml", "conflicts with a tsdl runtime/build file")] +fn build_rejects_log_paths_that_conflict_with_fresh_cleanup( + #[case] log: &str, + #[case] expected: &str, +) { + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["build", "--log", log]); + sandbox + .cmd + .assert() + .failure() + .stderr(p::str::contains("Could not initialize logging")) + .stderr(p::str::contains(expected)); +} + +#[rstest] +fn fresh_preserves_root_level_custom_log_and_removes_build_entries() { + let mut sandbox = Sandbox::new(); + let stale = sandbox.tmp.child(TSDL_BUILD_DIR).child("stale.txt"); + stale.write_str("stale").unwrap(); + + sandbox + .cmd + .args(["build", "--fresh", "--log", "tmp/custom.log"]); + sandbox.cmd.assert().success(); + + sandbox + .tmp + .child(TSDL_BUILD_DIR) + .child("custom.log") + .assert(p::path::exists()) + .assert(p::path::is_file()); + stale.assert(p::path::missing()); +} From 533311361ab340192ff978d143f47da952875bb1 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sun, 24 May 2026 20:17:02 +0200 Subject: [PATCH 26/88] display: unify item status and improve displayed info --- src/actors/display.rs | 546 +++++++++++++++++++++++++++++++----------- src/actors/mod.rs | 26 +- src/display.rs | 153 ++++++------ src/parser.rs | 16 +- src/tree_sitter.rs | 14 +- 5 files changed, 508 insertions(+), 247 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index 0ab256e..e9bd7ca 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -8,12 +8,13 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Clear, Paragraph, Widget}; use tokio::sync::{mpsc, oneshot}; use tokio::time; +use tracing::error; use crate::actors::Addr; use crate::display::{ compute_icon_cell, compute_msg_cell, compute_name_cell, compute_ref_cell, compute_step_cell, - compute_time_cell, Column, DisplayState, GrammarEntry, GridCache, ItemOutcome, ItemStatus, - Mode, RepoEntry, RowSpec, + compute_time_cell, Column, DisplayState, GrammarEntry, GridCache, ItemState, Mode, RepoEntry, + RowSpec, SuccessOutcome, }; use crate::git::GitRef; @@ -51,16 +52,19 @@ pub enum DisplayMessage { }, /// Flush and close the display actor. The response is sent after cleanup. - Shutdown { tx: oneshot::Sender<()> }, + Shutdown { + interrupted: bool, + tx: oneshot::Sender<()>, + }, } #[derive(Debug, Clone, Copy)] pub enum UpdateKind { Msg, Step, - MarkCached, - MarkBuilt, - MarkCancelled, + SetOutcomeCached, + SetOutcomeBuilt, + Cancel, Cached, Fin, Err, @@ -135,8 +139,9 @@ impl DisplayAddr { .await; } - pub async fn shutdown(&self) { - self.request(|tx| DisplayMessage::Shutdown { tx }).await; + pub async fn shutdown(&self, interrupted: bool) { + self.request(|tx| DisplayMessage::Shutdown { interrupted, tx }) + .await; } } @@ -164,53 +169,42 @@ impl ProgressAddr { }); } - pub fn mark_cached(&self) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::MarkCached, - msg: Arc::from(""), - }); + async fn send_state_update(&self, kind: UpdateKind, msg: Arc) { + let _ = self + .tx + .send(DisplayMessage::Update { + id: self.id, + kind, + msg, + }) + .await; } - pub fn mark_cancelled(&self) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::MarkCancelled, - msg: Arc::from("cancelled"), - }); + pub async fn set_outcome_cached(&self) { + self.send_state_update(UpdateKind::SetOutcomeCached, Arc::from("")) + .await; } - pub fn mark_built(&self) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::MarkBuilt, + pub async fn cancel(&self) { + self.send_state_update(UpdateKind::Cancel, Arc::from("cancelled")) + .await; + } - msg: Arc::from(""), - }); + pub async fn set_outcome_built(&self) { + self.send_state_update(UpdateKind::SetOutcomeBuilt, Arc::from("")) + .await; } - pub fn cached>>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::Cached, - msg: msg.into(), - }); + pub async fn cached>>(&self, msg: S) { + self.send_state_update(UpdateKind::Cached, msg.into()).await; } - pub fn fin>>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::Fin, - msg: msg.into(), - }); + pub async fn fin>>(&self, msg: S) { + self.send_state_update(UpdateKind::Fin, msg.into()).await; } - pub fn err>>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::Err, - msg: msg.into(), - }); + pub async fn err>>(&self, msg: S) { + self.send_state_update(UpdateKind::Err, msg.into()).await; } } @@ -225,34 +219,40 @@ struct PlainLine { message: String, } -fn plain_repo_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> String { +fn plain_repo_message(kind: UpdateKind, state: ItemState, msg: &str) -> String { match kind { UpdateKind::Err => format!("failed: {msg}"), UpdateKind::Cached => "cached".to_string(), - UpdateKind::Fin => match outcome { - ItemOutcome::Built | ItemOutcome::Cached => "done".to_string(), - ItemOutcome::Unknown | ItemOutcome::Cancelled => msg.to_string(), + UpdateKind::Fin => match state { + ItemState::Done(_) => "done".to_string(), + ItemState::New + | ItemState::InProgress(_) + | ItemState::Cancelled + | ItemState::Failed => msg.to_string(), }, - UpdateKind::MarkBuilt - | UpdateKind::MarkCached - | UpdateKind::MarkCancelled + UpdateKind::SetOutcomeBuilt + | UpdateKind::SetOutcomeCached + | UpdateKind::Cancel | UpdateKind::Msg | UpdateKind::Step => msg.to_string(), } } -fn plain_grammar_message(kind: UpdateKind, outcome: ItemOutcome, msg: &str) -> String { +fn plain_grammar_message(kind: UpdateKind, state: ItemState, msg: &str) -> String { match kind { UpdateKind::Err => format!("failed: {msg}"), UpdateKind::Cached => "cached".to_string(), - UpdateKind::Fin => match outcome { - ItemOutcome::Cached => "cached".to_string(), - ItemOutcome::Built => "built".to_string(), - ItemOutcome::Unknown | ItemOutcome::Cancelled => msg.to_string(), + UpdateKind::Fin => match state { + ItemState::Done(SuccessOutcome::Cached) => "cached".to_string(), + ItemState::Done(SuccessOutcome::Built) => "built".to_string(), + ItemState::New + | ItemState::InProgress(_) + | ItemState::Cancelled + | ItemState::Failed => msg.to_string(), }, - UpdateKind::MarkBuilt - | UpdateKind::MarkCached - | UpdateKind::MarkCancelled + UpdateKind::SetOutcomeBuilt + | UpdateKind::SetOutcomeCached + | UpdateKind::Cancel | UpdateKind::Msg | UpdateKind::Step => msg.to_string(), } @@ -429,19 +429,19 @@ impl DisplayActor { loop { // Drain all pending messages before rendering - let mut shutdown_tx = None; + let mut shutdown = None; while let Ok(msg) = self.rx.try_recv() { match msg { - DisplayMessage::Shutdown { tx } => { - shutdown_tx = Some(tx); + DisplayMessage::Shutdown { interrupted, tx } => { + shutdown = Some((interrupted, tx)); break; } other => self.handle_message(other), } } - if let Some(tx) = shutdown_tx { - self.finish_fancy(&mut terminal, tx); + if let Some((interrupted, tx)) = shutdown { + self.finish_fancy(&mut terminal, interrupted, tx); return; } @@ -459,8 +459,8 @@ impl DisplayActor { tokio::select! { msg = self.rx.recv() => { match msg { - Some(DisplayMessage::Shutdown { tx }) => { - self.finish_fancy(&mut terminal, tx); + Some(DisplayMessage::Shutdown { interrupted, tx }) => { + self.finish_fancy(&mut terminal, interrupted, tx); return; } Some(other) => self.handle_message(other), @@ -480,8 +480,12 @@ impl DisplayActor { fn finish_fancy>( &mut self, terminal: &mut ratatui::Terminal, + interrupted: bool, tx: oneshot::Sender<()>, ) { + if interrupted { + self.cancel_live_rows(); + } let term_width = terminal.size().map_or(80, |s| s.width); let mut final_lines = self.materialize(term_width); final_lines.push(Line::from("")); @@ -547,8 +551,7 @@ impl DisplayActor { let is_dirty = self.grid.dirty_items.contains(&item_id); // TIME: stale when dirty OR clock is still running - let time_stale = - is_dirty || matches!(info.status(), ItemStatus::New | ItemStatus::InProgress); + let time_stale = is_dirty || info.state().is_live(); let time = self.grid.cell(item_id, Column::Time, time_stale, || { compute_time_cell(&info, &layout) }); @@ -637,7 +640,9 @@ impl DisplayActor { self.apply_update(id, kind, msg); if matches!( kind, - UpdateKind::Msg | UpdateKind::MarkCached | UpdateKind::MarkBuilt + UpdateKind::Msg + | UpdateKind::SetOutcomeCached + | UpdateKind::SetOutcomeBuilt ) { continue; } @@ -645,7 +650,10 @@ impl DisplayActor { self.print_plain_progress(&line); } } - DisplayMessage::Shutdown { tx } => { + DisplayMessage::Shutdown { interrupted, tx } => { + if interrupted { + self.cancel_live_rows(); + } self.print_plain_summary(); let _ = tx.send(()); break; @@ -698,7 +706,7 @@ impl DisplayActor { name: repo.name.to_string(), step: repo.step, total: repo.total, - message: plain_repo_message(kind, repo.outcome, &repo.msg), + message: plain_repo_message(kind, repo.state, &repo.msg), }); } @@ -706,7 +714,7 @@ impl DisplayActor { name: format!("{}/{}", grammar.repo, grammar.name), step: grammar.step, total: grammar.total, - message: plain_grammar_message(kind, grammar.outcome, &grammar.msg), + message: plain_grammar_message(kind, grammar.state, &grammar.msg), }) } @@ -737,7 +745,10 @@ impl DisplayActor { DisplayMessage::Update { id, kind, msg } => { self.apply_update(id, kind, msg); } - DisplayMessage::Shutdown { tx } => { + DisplayMessage::Shutdown { interrupted, tx } => { + if interrupted { + self.cancel_live_rows(); + } let _ = tx.send(()); } } @@ -752,8 +763,7 @@ impl DisplayActor { RepoEntry { name, git_ref, - status: ItemStatus::New, - outcome: ItemOutcome::Unknown, + state: ItemState::New, msg: Arc::from(""), step: 0, total: num_tasks, @@ -794,8 +804,7 @@ impl DisplayActor { repo_id, name, git_ref, - status: ItemStatus::New, - outcome: ItemOutcome::Unknown, + state: ItemState::New, msg: Arc::from(""), step: 0, total: num_tasks, @@ -815,6 +824,41 @@ impl DisplayActor { } } + fn cancel_live_rows(&mut self) { + let mut parent_ids = Vec::new(); + + for (id, grammar) in &mut self.state.grammars { + if grammar.state.is_live() { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = ItemState::Cancelled; + grammar.step = grammar.total; + grammar.msg = Arc::from("cancelled"); + parent_ids.push(grammar.repo_id); + self.grid.mark_dirty(*id); + } + } + + parent_ids.sort_unstable(); + parent_ids.dedup(); + for repo_id in parent_ids { + if repo_id != 0 { + self.sync_parent_repo(repo_id); + } + } + + for (id, repo) in &mut self.state.repos { + if repo.state.is_live() { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = ItemState::Cancelled; + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = Arc::from("cancelled"); + self.grid.mark_dirty(*id); + } + } + } + fn apply_update(&mut self, id: u64, kind: UpdateKind, msg: Arc) { if let Some(repo) = self.state.repos.get_mut(&id) { Self::apply_repo_update(repo, kind, msg); @@ -828,13 +872,13 @@ impl DisplayActor { self.grid.mark_dirty(id); if matches!( kind, - UpdateKind::Step - | UpdateKind::MarkCached - | UpdateKind::MarkBuilt - | UpdateKind::MarkCancelled - | UpdateKind::Cached - | UpdateKind::Fin + UpdateKind::Cached | UpdateKind::Err + | UpdateKind::Fin + | UpdateKind::SetOutcomeBuilt + | UpdateKind::SetOutcomeCached + | UpdateKind::Cancel + | UpdateKind::Step ) { maybe_parent_id = Some(grammar.repo_id); } @@ -846,35 +890,36 @@ impl DisplayActor { } fn apply_repo_update(repo: &mut RepoEntry, kind: UpdateKind, msg: Arc) { + if !repo.state.is_live() { + return; + } + match kind { UpdateKind::Msg => { repo.msg = msg; } UpdateKind::Step => { - repo.status = ItemStatus::InProgress; + repo.state = start_item(repo.state); repo.step += 1; repo.msg = msg; } - UpdateKind::MarkCached => { - repo.outcome = ItemOutcome::Cached; + UpdateKind::SetOutcomeCached => { + repo.state = mark_success(repo.state, SuccessOutcome::Cached); } - UpdateKind::MarkCancelled => { + UpdateKind::Cancel => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.status = ItemStatus::Cancelled; - repo.outcome = ItemOutcome::Cancelled; + repo.state = ItemState::Cancelled; if repo.total > 0 { repo.step = repo.total; } repo.msg = msg; } - - UpdateKind::MarkBuilt => { - repo.outcome = ItemOutcome::Built; + UpdateKind::SetOutcomeBuilt => { + repo.state = mark_success(repo.state, SuccessOutcome::Built); } UpdateKind::Cached => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.status = ItemStatus::Done; - repo.outcome = ItemOutcome::Cached; + repo.state = ItemState::Done(SuccessOutcome::Cached); if repo.total > 0 { repo.step = repo.total; } @@ -882,66 +927,77 @@ impl DisplayActor { } UpdateKind::Fin => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.status = ItemStatus::Done; - if repo.outcome == ItemOutcome::Unknown { - repo.outcome = ItemOutcome::Built; + match finish_item(repo.state) { + Ok(state) => { + repo.state = state; + repo.msg = msg; + } + Err(message) => { + repo.state = ItemState::Failed; + repo.msg = message.into(); + } } if repo.total > 0 { repo.step = repo.total; } - repo.msg = msg; } UpdateKind::Err => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.status = ItemStatus::Failed; + repo.state = ItemState::Failed; repo.msg = msg; } } } fn apply_grammar_update(grammar: &mut GrammarEntry, kind: UpdateKind, msg: Arc) { + if !grammar.state.is_live() { + return; + } + match kind { UpdateKind::Msg => { grammar.msg = msg; } UpdateKind::Step => { - grammar.status = ItemStatus::InProgress; + grammar.state = start_item(grammar.state); grammar.step += 1; grammar.msg = msg; } - UpdateKind::MarkCached => { - grammar.outcome = ItemOutcome::Cached; + UpdateKind::SetOutcomeCached => { + grammar.state = mark_success(grammar.state, SuccessOutcome::Cached); } - UpdateKind::MarkCancelled => { + UpdateKind::Cancel => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.status = ItemStatus::Cancelled; - grammar.outcome = ItemOutcome::Cancelled; + grammar.state = ItemState::Cancelled; grammar.step = grammar.total; grammar.msg = msg; } - - UpdateKind::MarkBuilt => { - grammar.outcome = ItemOutcome::Built; + UpdateKind::SetOutcomeBuilt => { + grammar.state = mark_success(grammar.state, SuccessOutcome::Built); } UpdateKind::Cached => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.status = ItemStatus::Done; - grammar.outcome = ItemOutcome::Cached; + grammar.state = ItemState::Done(SuccessOutcome::Cached); grammar.step = grammar.total; grammar.msg = msg; } UpdateKind::Fin => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.status = ItemStatus::Done; - if grammar.outcome == ItemOutcome::Unknown { - grammar.outcome = ItemOutcome::Built; + match finish_item(grammar.state) { + Ok(state) => { + grammar.state = state; + grammar.msg = msg; + } + Err(message) => { + grammar.state = ItemState::Failed; + grammar.msg = message.into(); + } } grammar.step = grammar.total; - grammar.msg = msg; } UpdateKind::Err => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.status = ItemStatus::Failed; + grammar.state = ItemState::Failed; grammar.msg = msg; } } @@ -957,36 +1013,38 @@ impl DisplayActor { .state .grammars .values() - .any(|g| g.repo_id == repo_id && g.status == ItemStatus::Failed); + .any(|g| g.repo_id == repo_id && g.state == ItemState::Failed); let any_cancelled = self .state .grammars .values() - .any(|g| g.repo_id == repo_id && g.status == ItemStatus::Cancelled); - let any_active = self.state.grammars.values().any(|g| { - g.repo_id == repo_id - && (g.status == ItemStatus::New || g.status == ItemStatus::InProgress) - }); - let outcome = self.aggregate_child_outcome(repo_id); + .any(|g| g.repo_id == repo_id && g.state == ItemState::Cancelled); + let any_active = self + .state + .grammars + .values() + .any(|g| g.repo_id == repo_id && g.state.is_live()); + + let live_outcome = self.aggregate_child_live_outcome(repo_id); + let done_outcome = self.aggregate_child_done_outcome(repo_id); if let Some(repo) = self.state.repos.get_mut(&repo_id) { - repo.outcome = outcome; if any_active { - repo.status = ItemStatus::InProgress; + repo.state = ItemState::InProgress(live_outcome); repo.msg = Arc::from("building"); repo.frozen_elapsed = None; } else if any_failed { - repo.status = ItemStatus::Failed; + repo.state = ItemState::Failed; repo.msg = Arc::from("failed"); repo.frozen_elapsed .get_or_insert_with(|| repo.started_at.elapsed()); } else if any_cancelled { - repo.status = ItemStatus::Cancelled; + repo.state = ItemState::Cancelled; repo.msg = Arc::from("cancelled"); repo.frozen_elapsed .get_or_insert_with(|| repo.started_at.elapsed()); } else { - repo.status = ItemStatus::Done; + repo.state = ItemState::Done(done_outcome.unwrap_or(SuccessOutcome::Built)); repo.msg = Arc::from("done"); repo.frozen_elapsed .get_or_insert_with(|| repo.started_at.elapsed()); @@ -996,9 +1054,8 @@ impl DisplayActor { self.grid.mark_dirty(repo_id); } - fn aggregate_child_outcome(&self, repo_id: u64) -> ItemOutcome { + fn aggregate_child_live_outcome(&self, repo_id: u64) -> Option { let mut saw_cached = false; - let mut saw_cancelled = false; let mut saw_unknown = false; for grammar in self @@ -1007,26 +1064,83 @@ impl DisplayActor { .values() .filter(|g| g.repo_id == repo_id) { - match grammar.outcome { - ItemOutcome::Built => return ItemOutcome::Built, - ItemOutcome::Cached => saw_cached = true, - ItemOutcome::Cancelled => saw_cancelled = true, - ItemOutcome::Unknown => saw_unknown = true, + match grammar.state { + ItemState::InProgress(Some(SuccessOutcome::Built)) + | ItemState::Done(SuccessOutcome::Built) => return Some(SuccessOutcome::Built), + ItemState::InProgress(Some(SuccessOutcome::Cached)) + | ItemState::Done(SuccessOutcome::Cached) => saw_cached = true, + ItemState::New | ItemState::InProgress(None) => saw_unknown = true, + ItemState::Cancelled | ItemState::Failed => {} } } if saw_unknown { - ItemOutcome::Unknown - } else if saw_cancelled { - ItemOutcome::Cancelled + None } else if saw_cached { - ItemOutcome::Cached + Some(SuccessOutcome::Cached) } else { - ItemOutcome::Unknown + None + } + } + + fn aggregate_child_done_outcome(&self, repo_id: u64) -> Option { + let mut saw_cached = false; + + for grammar in self + .state + .grammars + .values() + .filter(|g| g.repo_id == repo_id) + { + match grammar.state { + ItemState::Done(SuccessOutcome::Built) => return Some(SuccessOutcome::Built), + ItemState::Done(SuccessOutcome::Cached) => saw_cached = true, + ItemState::New + | ItemState::InProgress(_) + | ItemState::Cancelled + | ItemState::Failed => {} + } } + + saw_cached.then_some(SuccessOutcome::Cached) + } +} + +fn start_item(state: ItemState) -> ItemState { + match state { + ItemState::New => ItemState::InProgress(None), + ItemState::InProgress(outcome) => ItemState::InProgress(outcome), + ItemState::Done(_) | ItemState::Cancelled | ItemState::Failed => state, } } +fn mark_success(state: ItemState, outcome: SuccessOutcome) -> ItemState { + match state { + ItemState::New | ItemState::InProgress(_) => ItemState::InProgress(Some(outcome)), + ItemState::Done(_) | ItemState::Cancelled | ItemState::Failed => state, + } +} + +fn finish_item(state: ItemState) -> Result { + match state { + ItemState::InProgress(Some(outcome)) => Ok(ItemState::Done(outcome)), + ItemState::New | ItemState::InProgress(None) => invalid_finish( + "finish update received before cached/built path was set", + state, + ), + ItemState::Done(_) | ItemState::Cancelled | ItemState::Failed => { + invalid_finish("finish update received for terminal item state", state) + } + } +} + +fn invalid_finish(reason: &str, state: ItemState) -> Result { + let message = format!("{reason}: {state:?}"); + error!("{message}"); + debug_assert!(matches!(state, ItemState::InProgress(Some(_))), "{message}"); + Err(message) +} + fn spacer() -> Span<'static> { Span::raw(" ") } @@ -1131,11 +1245,11 @@ mod tests { let mut actor = actor(); let cached = actor.register_repo("tree-sitter-cli".into(), GitRef::from("HEAD"), 2); - actor.apply_update(cached.id, UpdateKind::MarkCached, Arc::from("")); + actor.apply_update(cached.id, UpdateKind::SetOutcomeCached, Arc::from("")); actor.apply_update(cached.id, UpdateKind::Fin, Arc::from("done")); let built = actor.register_repo("standalone".into(), GitRef::from("HEAD"), 1); - actor.apply_update(built.id, UpdateKind::MarkBuilt, Arc::from("")); + actor.apply_update(built.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); actor.apply_update(built.id, UpdateKind::Fin, Arc::from("done")); let active = actor.register_repo("active".into(), GitRef::from("HEAD"), 1); @@ -1152,16 +1266,156 @@ mod tests { let mut actor = actor(); let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 2); - actor.apply_update(repo.id, UpdateKind::MarkBuilt, Arc::from("")); + actor.apply_update(repo.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::from("HEAD"), 4); - actor.apply_update(grammar.id, UpdateKind::MarkCached, Arc::from("")); + actor.apply_update(grammar.id, UpdateKind::SetOutcomeCached, Arc::from("")); actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); assert_eq!(actor.state.summary_counts(), (1, 0, 0, 0, 0)); } + #[test] + fn cancelled_grammar_is_terminal_and_updates_parent_repo() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 2); + let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::from("HEAD"), 4); + + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(grammar_entry.state, ItemState::Cancelled); + assert!(!grammar_entry.state.is_live()); + assert_eq!(grammar_entry.step, grammar_entry.total); + assert!(grammar_entry.frozen_elapsed.is_some()); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, ItemState::Cancelled); + assert!(!repo_entry.state.is_live()); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + assert!(repo_entry.frozen_elapsed.is_some()); + + assert_eq!(actor.state.summary_counts(), (0, 0, 0, 0, 1)); + } + + #[test] + fn cancelled_child_does_not_cancel_parent_while_sibling_is_active() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), GitRef::from("HEAD"), 2); + let cancelled = actor.register_grammar( + "typescript".into(), + "typescript".into(), + GitRef::from("HEAD"), + 4, + ); + let active = + actor.register_grammar("typescript".into(), "tsx".into(), GitRef::from("HEAD"), 4); + + actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(active.id, UpdateKind::Step, Arc::from("building")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, ItemState::InProgress(None)); + assert_eq!(repo_entry.msg.as_ref(), "building"); + assert!(repo_entry.frozen_elapsed.is_none()); + + actor.apply_update(active.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, ItemState::Cancelled); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + assert!(repo_entry.frozen_elapsed.is_some()); + } + + #[test] + fn failed_child_wins_over_cancelled_child_when_parent_terminal() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), GitRef::from("HEAD"), 2); + let failing = actor.register_grammar( + "typescript".into(), + "typescript".into(), + GitRef::from("HEAD"), + 4, + ); + let cancelled = + actor.register_grammar("typescript".into(), "tsx".into(), GitRef::from("HEAD"), 4); + + actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); + actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, ItemState::Failed); + assert_eq!(repo_entry.msg.as_ref(), "failed"); + assert!(repo_entry.frozen_elapsed.is_some()); + + assert_eq!(actor.state.summary_counts(), (0, 0, 0, 1, 1)); + } + + #[test] + #[should_panic(expected = "finish update received before cached/built path was set")] + fn finish_without_cached_or_built_path_panics() { + let mut actor = actor(); + let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 2); + + actor.apply_update(repo.id, UpdateKind::Step, Arc::from("scanning")); + actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); + } + + #[test] + fn interrupted_shutdown_cancels_live_rows() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), GitRef::from("HEAD"), 2); + let grammar = + actor.register_grammar("typescript".into(), "tsx".into(), GitRef::from("HEAD"), 4); + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + + actor.cancel_live_rows(); + + let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(grammar_entry.state, ItemState::Cancelled); + assert_eq!(grammar_entry.step, grammar_entry.total); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, ItemState::Cancelled); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + + assert_eq!(actor.state.summary_counts(), (0, 0, 0, 0, 1)); + } + + #[test] + fn terminal_direct_updates_are_absorbing() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 4); + actor.apply_update(repo.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(repo.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(repo.id, UpdateKind::Cached, Arc::from("done")); + actor.apply_update(repo.id, UpdateKind::Err, Arc::from("failed")); + + let entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(entry.state, ItemState::Cancelled); + assert_eq!(entry.msg.as_ref(), "cancelled"); + + let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::from("HEAD"), 4); + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); + actor.apply_update(grammar.id, UpdateKind::Err, Arc::from("failed")); + + let entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(entry.state, ItemState::Cancelled); + assert_eq!(entry.msg.as_ref(), "cancelled"); + } + #[test] fn parent_repo_stays_active_while_any_child_is_active() { let mut actor = actor(); @@ -1180,15 +1434,15 @@ mod tests { actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.status, ItemStatus::InProgress); + assert_eq!(repo_entry.state, ItemState::InProgress(None)); assert_eq!(repo_entry.msg.as_ref(), "building"); assert!(repo_entry.frozen_elapsed.is_none()); - actor.apply_update(pending.id, UpdateKind::MarkBuilt, Arc::from("")); + actor.apply_update(pending.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); actor.apply_update(pending.id, UpdateKind::Fin, Arc::from("done")); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.status, ItemStatus::Failed); + assert_eq!(repo_entry.state, ItemState::Failed); assert_eq!(repo_entry.msg.as_ref(), "failed"); assert!(repo_entry.frozen_elapsed.is_some()); } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 03849ed..632e7df 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -95,14 +95,16 @@ pub async fn run( ) .await; + let interrupted = shutdown::current().and_then(|s| s.reason()); + // We need to shut down before returning the results to avoid display // issues with the ratatui backend. - display.shutdown().await; + display.shutdown(interrupted.is_some()).await; // If shutdown was signalled, suppress build errors — they're expected // cancellation artefacts, not real failures — but still return the // interruption so the top-level process exits with the signal status. - if let Some(signal) = shutdown::current().and_then(|s| s.reason()) { + if let Some(signal) = interrupted { info!("pipeline shutdown signalled by {signal}, suppressing build errors"); return Err(TsdlError::Interrupted(signal)); } @@ -202,19 +204,23 @@ async fn discover_grammars( .add_language(language.spec.git_ref.clone(), language.name.clone(), 2) .await; - if cache + let needs_clone = cache .needs_clone(language.name.clone(), language.spec.clone()) - .await - { + .await; + + if needs_clone { + progress.set_outcome_built().await; progress.step("cloning"); if let Err(e) = language.clone().await { if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.mark_cancelled(); + progress.cancel().await; } else { - progress.err("clone failed"); + progress.err("clone failed").await; } return Err(e); } + } else { + progress.set_outcome_cached().await; } progress.step("scanning"); @@ -222,14 +228,14 @@ async fn discover_grammars( Ok(grammars) => grammars, Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.mark_cancelled(); + progress.cancel().await; } else { - progress.err("scan failed"); + progress.err("scan failed").await; } return Err(e); } }; - progress.fin("done"); + progress.fin("done").await; // Map the raw discovery data into the Build struct immediately let mut builds = Vec::new(); diff --git a/src/display.rs b/src/display.rs index 3460d0d..ac5021e 100644 --- a/src/display.rs +++ b/src/display.rs @@ -80,65 +80,81 @@ pub fn mode_from_args(progress: &ProgressStyle, verbose: &Verbosity) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ItemStatus { +pub enum SuccessOutcome { + /// Cache hit. + Cached, + /// Not cached; work was needed. + Built, +} + +impl SuccessOutcome { + fn color(self) -> Color { + match self { + SuccessOutcome::Cached => Color::Yellow, + SuccessOutcome::Built => Color::Blue, + } + } +} + +/// Complete lifecycle state for a display row. +/// +/// This intentionally keeps terminality and success classification in one +/// field, so a row cannot be both "cancelled" and "still building". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemState { /// Just registered, not yet started. New, - /// In progress — cloning, generating, building, etc. - InProgress, + /// In progress. The optional outcome is known after cache classification: + /// cached rows stay live while they are re-installed, and built rows stay + /// live while they are generated/built/installed. + InProgress(Option), /// Successfully completed. - Done, + Done(SuccessOutcome), /// Cancelled by shutdown. Cancelled, /// Failed. Failed, } -impl ItemStatus { - fn icon(self) -> &'static str { +impl ItemState { + #[must_use] + pub fn is_live(self) -> bool { + matches!(self, Self::New | Self::InProgress(_)) + } + + #[must_use] + pub fn success_outcome(self) -> Option { match self { - ItemStatus::New | ItemStatus::InProgress => "●", - ItemStatus::Done => "✓", - ItemStatus::Cancelled | ItemStatus::Failed => "✗", + Self::InProgress(outcome) => outcome, + Self::Done(outcome) => Some(outcome), + Self::New | Self::Cancelled | Self::Failed => None, } } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ItemOutcome { - /// We do not yet know whether this item is cached or needs work. - Unknown, - /// Cache hit. - Cached, - /// Shut down before completion. - Cancelled, - /// Not cached; work was needed. - Built, -} -impl ItemOutcome { - fn color(self) -> Color { + fn icon(self) -> &'static str { match self { - ItemOutcome::Unknown | ItemOutcome::Cancelled => Color::DarkGray, - ItemOutcome::Cached => Color::Yellow, - ItemOutcome::Built => Color::Blue, + Self::New | Self::InProgress(_) => "●", + Self::Done(_) => "✓", + Self::Cancelled | Self::Failed => "✗", } } -} -fn name_color(status: ItemStatus, outcome: ItemOutcome) -> Color { - match status { - ItemStatus::Failed => Color::Red, - ItemStatus::Cancelled => Color::Yellow, - ItemStatus::New | ItemStatus::InProgress | ItemStatus::Done => outcome.color(), + fn name_color(self) -> Color { + match self { + Self::Failed => Color::Red, + Self::Cancelled => Color::Yellow, + Self::New | Self::InProgress(None) => Color::DarkGray, + Self::InProgress(Some(outcome)) | Self::Done(outcome) => outcome.color(), + } } -} -fn indicator_color(status: ItemStatus) -> Color { - match status { - ItemStatus::Done => Color::Green, - ItemStatus::Cancelled => Color::Yellow, - ItemStatus::Failed => Color::Red, - ItemStatus::New | ItemStatus::InProgress => Color::DarkGray, + fn indicator_color(self) -> Color { + match self { + Self::Done(_) => Color::Green, + Self::Cancelled => Color::Yellow, + Self::Failed => Color::Red, + Self::New | Self::InProgress(_) => Color::DarkGray, + } } } @@ -150,8 +166,7 @@ fn indicator_color(status: ItemStatus) -> Color { pub(crate) struct RepoEntry { pub name: Arc, pub git_ref: GitRef, - pub status: ItemStatus, - pub outcome: ItemOutcome, + pub state: ItemState, pub msg: Arc, pub step: usize, pub total: usize, @@ -174,8 +189,7 @@ pub(crate) struct GrammarEntry { pub repo_id: u64, pub name: Arc, pub git_ref: GitRef, - pub status: ItemStatus, - pub outcome: ItemOutcome, + pub state: ItemState, pub msg: Arc, pub step: usize, pub total: usize, @@ -342,17 +356,10 @@ pub(crate) enum ItemInfo<'a> { } impl ItemInfo<'_> { - pub(crate) fn status(&self) -> ItemStatus { - match self { - ItemInfo::Repo(r) => r.status, - ItemInfo::Grammar(g) => g.status, - } - } - - pub(crate) fn outcome(&self) -> ItemOutcome { + pub(crate) fn state(&self) -> ItemState { match self { - ItemInfo::Repo(r) => r.outcome, - ItemInfo::Grammar(g) => g.outcome, + ItemInfo::Repo(r) => r.state, + ItemInfo::Grammar(g) => g.state, } } @@ -425,11 +432,11 @@ pub(crate) fn compute_step_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> S } pub(crate) fn compute_icon_cell(info: &ItemInfo<'_>) -> Span<'static> { - let status = info.status(); + let state = info.state(); Span::styled( - status.icon(), + state.icon(), Style::default() - .fg(indicator_color(status)) + .fg(state.indicator_color()) .add_modifier(Modifier::BOLD), ) } @@ -445,7 +452,7 @@ pub(crate) fn compute_name_cell( Span::styled( padded, Style::default() - .fg(name_color(info.status(), info.outcome())) + .fg(info.state().name_color()) .add_modifier(Modifier::BOLD), ) } @@ -648,9 +655,8 @@ impl DisplayState { let mut repos_with_grammars: HashSet = HashSet::new(); for grammar in self.grammars.values() { repos_with_grammars.insert(grammar.repo_id); - count_grammar_status( - grammar.status, - grammar.outcome, + count_item_state( + grammar.state, &mut cached, &mut built, &mut building, @@ -663,9 +669,8 @@ impl DisplayState { // tree-sitter-cli or a language that failed before grammar discovery). for (repo_id, repo) in &self.repos { if !repos_with_grammars.contains(repo_id) { - count_grammar_status( - repo.status, - repo.outcome, + count_item_state( + repo.state, &mut cached, &mut built, &mut building, @@ -683,24 +688,20 @@ impl DisplayState { // Helpers // --------------------------------------------------------------------------- -fn count_grammar_status( - status: ItemStatus, - outcome: ItemOutcome, +fn count_item_state( + state: ItemState, cached: &mut usize, built: &mut usize, building: &mut usize, failed: &mut usize, cancelled: &mut usize, ) { - match status { - ItemStatus::New | ItemStatus::InProgress => *building += 1, - ItemStatus::Done => match outcome { - ItemOutcome::Cached => *cached += 1, - ItemOutcome::Built | ItemOutcome::Unknown => *built += 1, - ItemOutcome::Cancelled => *cancelled += 1, - }, - ItemStatus::Cancelled => *cancelled += 1, - ItemStatus::Failed => *failed += 1, + match state { + ItemState::New | ItemState::InProgress(_) => *building += 1, + ItemState::Done(SuccessOutcome::Cached) => *cached += 1, + ItemState::Done(SuccessOutcome::Built) => *built += 1, + ItemState::Cancelled => *cancelled += 1, + ItemState::Failed => *failed += 1, } } diff --git a/src/parser.rs b/src/parser.rs index 50c682c..da1781b 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -63,18 +63,18 @@ impl GrammarBuild { let hit = !self.context.force && !self.needs_rebuild(&key); if hit { - self.progress.mark_cached(); + self.progress.set_outcome_cached().await; // Install the binary from the build directory if let Err(e) = self.install().await { - self.progress.err("install failed"); + self.progress.err("install failed").await; return Err(e); } - self.progress.cached("done"); + self.progress.cached("done").await; return Ok(None); } - self.progress.mark_built(); + self.progress.set_outcome_built().await; // Use the grammar directory path provided if !self.dir.exists() { @@ -82,16 +82,16 @@ impl GrammarBuild { "Grammar directory not found: {}", self.dir.display() )); - self.progress.err("missing grammar directory"); + self.progress.err("missing grammar directory").await; return Err(err); } // Build the grammar if let Err(e) = self.build_grammar().await { if shutdown::current().is_some_and(|s| s.is_cancelled()) { - self.progress.mark_cancelled(); + self.progress.cancel().await; } else { - self.progress.err("build failed"); + self.progress.err("build failed").await; } return Err(e); } @@ -105,7 +105,7 @@ impl GrammarBuild { }, }; - self.progress.fin("done"); + self.progress.fin("done").await; Ok(Some(update)) } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 58df503..73db154 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -51,10 +51,10 @@ async fn cli( .canon()?; if res.exists() { - handle.mark_cached(); + handle.set_outcome_cached().await; handle.step("cached"); } else { - handle.mark_built(); + handle.set_outcome_built().await; handle.step("downloading"); let gz_basename = format!("{cli}.gz"); let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); @@ -164,9 +164,9 @@ pub async fn prepare( Ok(tag) => tag, Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.mark_cancelled(); + progress.cancel().await; } else { - progress.err("resolve failed"); + progress.err("resolve failed").await; } return Err(e); } @@ -184,14 +184,14 @@ pub async fn prepare( Ok(cli) => cli, Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.mark_cancelled(); + progress.cancel().await; } else { - progress.err("download failed"); + progress.err("download failed").await; } return Err(e); } }; - progress.fin("done"); + progress.fin("done").await; Ok(cli) } From e268c8dd19f3edc62310ae621216756b4f5777f3 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 00:18:23 +0200 Subject: [PATCH 27/88] install: avoid replacing existing output files when uncertain --- CHANGELOG.md | 3 + src/build.rs | 6 +- src/parser.rs | 370 ++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 334 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fdb910..fb0b107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ informative and more coherent. `--unlock-timeout`. - **shutdown**: Handle `SIGTERM`/`SIGINT` gracefully by terminating running build command process groups before releasing the build lock. +- **install**: Avoid replacing existing output files unless they are already the + same file, have identical contents, or `--force` is used; replacements are now + installed through visible temporary hardlinks. ## [2.0.0] - 2026-02-20 diff --git a/src/build.rs b/src/build.rs index dd6aa7f..dc6e781 100644 --- a/src/build.rs +++ b/src/build.rs @@ -45,7 +45,8 @@ pub struct OutputConfig { #[derive(Debug, Clone, PartialEq)] pub struct BuildContext { pub cache_hit: bool, - pub force: bool, + pub ignore_cache: bool, + pub overwrite_output: bool, } pub fn run(app: &mut App) -> TsdlResult<()> { @@ -257,8 +258,9 @@ fn unique_languages(app: &App) -> Vec> { let result = match url { Ok(repo) => Ok(LanguageBuild::new( BuildContext { - force: app.command.force || app.command.fresh, cache_hit: false, + ignore_cache: app.command.force || app.command.fresh, + overwrite_output: app.command.force, }, Arc::new(BuildSpec { build_script, diff --git a/src/parser.rs b/src/parser.rs index da1781b..001c67a 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,8 +1,12 @@ use std::{ env::consts::DLL_EXTENSION, + fs::Metadata, + io, os::unix::fs::MetadataExt, path::{Path, PathBuf}, + process, sync::Arc, + time::{SystemTime, UNIX_EPOCH}, }; use tokio::{fs, process::Command}; @@ -11,7 +15,7 @@ use tracing::{debug, warn}; use crate::{ actors::ProgressAddr, build::{BuildContext, BuildSpec, OutputConfig}, - cache::{Entry, Update}, + cache::{self, Entry, Update}, error::{self, TsdlError}, git::clone_fast, sh::{Exec, Script}, @@ -60,7 +64,7 @@ impl GrammarBuild { let key = format!("{}/{}", self.language, self.name); // Check cache: if cached and definitions match, skip build but still install - let hit = !self.context.force && !self.needs_rebuild(&key); + let hit = !self.context.ignore_cache && !self.needs_rebuild(&key); if hit { self.progress.set_outcome_cached().await; @@ -190,7 +194,14 @@ impl GrammarBuild { async fn create_hardlink(&self, src: &Path, dst: &Path) -> TsdlResult<()> { fs::hard_link(src, dst).await.map_err(|e| { - TsdlError::context(format!("Linking {} -> {}", src.display(), dst.display()), e) + TsdlError::context( + format!( + "Could not hardlink {} to {}. build-dir and out-dir must be on the same filesystem", + src.display(), + dst.display() + ), + e, + ) }) } @@ -266,49 +277,97 @@ impl GrammarBuild { async fn install_binary(&self, ext: &str) -> TsdlResult<()> { let src = self.find_parser_binary(ext).await?; let dst = self.output.out_dir.join(self.parser_name_and_ext(ext)); + let src_metadata = fs::metadata(&src) + .await + .map_err(|e| TsdlError::context(format!("Reading {}", src.display()), e))?; - // Check if different file exists - if dst.exists() { - let src_metadata = fs::metadata(&src) - .await - .map_err(|e| TsdlError::context(format!("Reading {}", src.display()), e))?; - let dst_metadata = fs::metadata(&dst) - .await - .map_err(|e| TsdlError::context(format!("Reading {}", dst.display()), e))?; - - let src_size = src_metadata.size(); - let dst_size = dst_metadata.size(); - let src_inode = src_metadata.ino(); - let dst_inode = dst_metadata.ino(); - - // Check if hardlink is broken (inodes don't match when they should) - let hardlink_broken = src_inode != dst_inode; - - if src_size != dst_size || hardlink_broken { - if src_size != dst_size && !self.context.force { - return Err(TsdlError::message(format!( - "Binary differs at {}. Use --force to overwrite", - dst.display() - ))); - } - - fs::remove_file(&dst) - .await - .map_err(|e| TsdlError::context(format!("Removing {}", dst.display()), e))?; - - // Report reinstallation when fixing broken hardlink - if hardlink_broken { - self.progress.msg("Reinstalled"); - } - - // Create the hardlink after removing the old one + let dst_link_metadata = match fs::symlink_metadata(&dst).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => { self.create_hardlink(&src, &dst).await?; - } else { - // Inodes match and sizes match - hardlink is already correct, skip + return Ok(()); } - } else { - // Destination doesn't exist, create the hardlink - self.create_hardlink(&src, &dst).await?; + Err(err) => { + return Err(TsdlError::context( + format!("Reading {}", dst.display()), + err, + )); + } + }; + + self.install_over_existing(&src, &src_metadata, &dst, &dst_link_metadata) + .await + } + + async fn install_over_existing( + &self, + src: &Path, + src_metadata: &Metadata, + dst: &Path, + dst_link_metadata: &Metadata, + ) -> TsdlResult<()> { + let dst_file_type = dst_link_metadata.file_type(); + + if dst_file_type.is_dir() { + return Err(TsdlError::message(format!( + "Output path is a directory and cannot be replaced: {}", + dst.display() + ))); + } + + if dst_file_type.is_symlink() { + if !self.context.overwrite_output { + return Err(TsdlError::message(format!( + "Output path is a symlink and will not be replaced without --force: {}", + dst.display() + ))); + } + + self.replace_with_hardlink(src, dst).await?; + self.progress.msg("Reinstalled"); + return Ok(()); + } + + if !dst_file_type.is_file() { + return Err(TsdlError::message(format!( + "Output path is not a regular file and cannot be replaced: {}", + dst.display() + ))); + } + + let dst_metadata = fs::metadata(dst) + .await + .map_err(|e| TsdlError::context(format!("Reading {}", dst.display()), e))?; + + if same_file_identity(src_metadata, &dst_metadata) { + return Ok(()); + } + + let same_contents = + same_regular_file_contents(src, src_metadata, dst, &dst_metadata).await?; + + if !same_contents && !self.context.overwrite_output { + return Err(TsdlError::message(format!( + "Output already exists and differs from the built parser: {}. Use --force to replace it.", + dst.display() + ))); + } + + self.replace_with_hardlink(src, dst).await?; + self.progress.msg("reinstalled"); + Ok(()) + } + + async fn replace_with_hardlink(&self, src: &Path, dst: &Path) -> TsdlResult<()> { + let tmp = temp_install_path(dst)?; + self.create_hardlink(src, &tmp).await?; + + if let Err(err) = fs::rename(&tmp, dst).await { + let _ = fs::remove_file(&tmp).await; + return Err(TsdlError::context( + format!("Installing {} to {}", src.display(), dst.display()), + err, + )); } Ok(()) @@ -414,6 +473,46 @@ impl LanguageBuild { } } +fn same_file_identity(a: &Metadata, b: &Metadata) -> bool { + a.dev() == b.dev() && a.ino() == b.ino() +} + +async fn same_regular_file_contents( + src: &Path, + src_metadata: &Metadata, + dst: &Path, + dst_metadata: &Metadata, +) -> TsdlResult { + if src_metadata.len() != dst_metadata.len() { + return Ok(false); + } + + let src_hash = cache::hash_file(src).await?; + let dst_hash = cache::hash_file(dst).await?; + Ok(src_hash == dst_hash) +} + +fn temp_install_path(dst: &Path) -> TsdlResult { + let file_name = dst.file_name().ok_or_else(|| { + TsdlError::message(format!( + "Could not create temporary install path for {}", + dst.display() + )) + })?; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| { + TsdlError::message(format!( + "System clock is before UNIX epoch while creating temporary install path: {e}" + )) + })? + .as_nanos(); + let mut tmp_name = file_name.to_os_string(); + tmp_name.push(format!(".tsdl-{}-{nanos}", process::id())); + + Ok(dst.with_file_name(tmp_name)) +} + fn extract_dir_name(dir: &Path) -> TsdlResult { dir.file_name() .map(|n| n.to_string_lossy().to_string()) @@ -430,6 +529,14 @@ fn extract_grammar_name(dir: &Path) -> TsdlResult { #[cfg(test)] mod tests { use super::*; + use std::os::unix::fs::symlink; + + use crate::{ + actors::{DisplayActor, DisplayAddr}, + args::{Target, TreeSitter}, + display::Mode, + git::GitRef, + }; use tempfile::TempDir; /// Extract directory name from a path @@ -468,6 +575,55 @@ mod tests { } } + async fn test_grammar_build( + grammar_dir: PathBuf, + out_dir: PathBuf, + overwrite_output: bool, + ) -> (GrammarBuild, DisplayAddr) { + let display = DisplayActor::spawn( + Mode::Plain, + Arc::new(grammar_dir.clone()), + Arc::new(out_dir.clone()), + ); + let progress = display + .add_grammar(GitRef::from("HEAD"), "rust", "rust", 1) + .await; + let build = GrammarBuild { + context: BuildContext { + cache_hit: false, + ignore_cache: false, + overwrite_output, + }, + dir: grammar_dir.clone().into(), + entry: None, + hash: "test".into(), + language: "rust".into(), + name: "rust".into(), + output: OutputConfig { + build_dir: grammar_dir.into(), + out_dir: out_dir.into(), + }, + progress, + spec: Arc::new(BuildSpec { + build_script: None, + git_ref: GitRef::from("HEAD"), + prefix: String::new(), + repo: "https://example.com/tree-sitter-rust".parse().unwrap(), + target: Target::Native, + tree_sitter: TreeSitter::default(), + }), + ts_cli: Arc::new(PathBuf::from("tree-sitter")), + }; + + (build, display) + } + + fn same_identity(a: &Path, b: &Path) -> bool { + let a = std::fs::metadata(a).unwrap(); + let b = std::fs::metadata(b).unwrap(); + same_file_identity(&a, &b) + } + #[test] fn test_make_cache_key() { let path = PathBuf::from("/tmp/build/tree-sitter-typescript/grammar.js"); @@ -520,6 +676,134 @@ mod tests { assert_eq!(name, "libtypescript.so"); } + #[tokio::test] + async fn install_missing_destination_creates_hardlink() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let src = grammar_dir.join("rust.so"); + let dst = out_dir.join("rust.so"); + tokio::fs::write(&src, b"parser").await.unwrap(); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + build.install_binary("so").await.unwrap(); + display.shutdown(false).await; + + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_content_relinks_without_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let src = grammar_dir.join("rust.so"); + let dst = out_dir.join("rust.so"); + tokio::fs::write(&src, b"parser").await.unwrap(); + tokio::fs::write(&dst, b"parser").await.unwrap(); + assert!(!same_identity(&src, &dst)); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + build.install_binary("so").await.unwrap(); + display.shutdown(false).await; + + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_size_different_content_requires_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let src = grammar_dir.join("rust.so"); + let dst = out_dir.join("rust.so"); + tokio::fs::write(&src, b"new").await.unwrap(); + tokio::fs::write(&dst, b"old").await.unwrap(); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let err = build.install_binary("so").await.unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("differs from the built parser")); + assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"old"); + assert!(!same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_size_different_content_with_force_replaces() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let src = grammar_dir.join("rust.so"); + let dst = out_dir.join("rust.so"); + tokio::fs::write(&src, b"new").await.unwrap(); + tokio::fs::write(&dst, b"old").await.unwrap(); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; + build.install_binary("so").await.unwrap(); + display.shutdown(false).await; + + assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"new"); + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_symlink_requires_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let src = grammar_dir.join("rust.so"); + let dst = out_dir.join("rust.so"); + let target = out_dir.join("target.so"); + tokio::fs::write(&src, b"new").await.unwrap(); + tokio::fs::write(&target, b"old").await.unwrap(); + symlink(&target, &dst).unwrap(); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let err = build.install_binary("so").await.unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("symlink")); + assert_eq!(std::fs::read_link(&dst).unwrap(), target); + assert_eq!(tokio::fs::read(&target).await.unwrap(), b"old"); + } + + #[tokio::test] + async fn install_directory_destination_errors_even_with_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let src = grammar_dir.join("rust.so"); + let dst = out_dir.join("rust.so"); + tokio::fs::write(&src, b"new").await.unwrap(); + tokio::fs::create_dir(&dst).await.unwrap(); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; + let err = build.install_binary("so").await.unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("directory")); + assert!(dst.is_dir()); + } + #[tokio::test] async fn test_per_grammar_cache_key_format() { // Test that cache keys follow the "language/grammar" format From 2435c7efaa1ac0d96aa5cb2f9d04b64f5c8e5bd8 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 00:58:22 +0200 Subject: [PATCH 28/88] config: show: drop git-column for a clone algo --- src/columns.rs | 401 +++++++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 17 +-- src/git.rs | 27 ---- src/lib.rs | 1 + 4 files changed, 406 insertions(+), 40 deletions(-) create mode 100644 src/columns.rs diff --git a/src/columns.rs b/src/columns.rs new file mode 100644 index 0000000..ad5dca8 --- /dev/null +++ b/src/columns.rs @@ -0,0 +1,401 @@ +/// Layout direction for [`format`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + /// Fill down each column before moving right. + Column, + /// Fill across each row before moving down. + Row, + /// Print one item per line. + Plain, +} + +/// Options controlling [`format`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Options<'a> { + /// Layout direction. + pub layout: Layout, + /// Use per-column widths and try to reduce row count, like `git column --mode=...,dense`. + pub dense: bool, + /// Maximum output width, including indentation. + pub width: usize, + /// Minimum spaces between columns. + pub padding: usize, + /// Prefix printed before each output line. + pub indent: &'a str, + /// Suffix printed after each output line. + pub line_ending: &'a str, +} + +impl<'a> Options<'a> { + /// Options matching `git column --mode=always --indent= --width=`. + #[must_use] + pub const fn git(indent: &'a str, width: usize) -> Self { + Self { + layout: Layout::Column, + dense: false, + width, + padding: 1, + indent, + line_ending: "\n", + } + } +} + +/// Format a list of cells using the same row/column rules as `git column`. +/// +/// This intentionally mirrors Git's small column formatter instead of shelling +/// out to `git column`: non-dense mode uses a single global cell width, while +/// dense mode shrinks the row count using per-column widths. +#[must_use] +pub fn format>(items: &[S], options: Options<'_>) -> String { + if items.is_empty() { + return String::new(); + } + + if options.layout == Layout::Plain { + return format_plain(items, options); + } + + let item_widths = items + .iter() + .map(|item| display_width(item.as_ref())) + .collect::>(); + let max_item_width = item_widths.iter().copied().max().unwrap_or(0); + let initial_width = max_item_width + options.padding; + let indent_width = display_width(options.indent); + let available_width = options.width.saturating_sub(indent_width); + + let mut cols = available_width + .checked_div(initial_width) + .unwrap_or(items.len()) + .max(1) + .min(items.len()); + let mut rows = items.len().div_ceil(cols); + let mut column_widths = None; + + if options.dense { + let dense = shrink_columns(&item_widths, options, rows, cols); + rows = dense.rows; + cols = dense.cols; + column_widths = Some(dense.widths); + } + + format_table( + items, + &item_widths, + options, + rows, + cols, + initial_width, + column_widths.as_deref(), + ) +} + +/// Format with options matching the current `git column` call site. +#[must_use] +pub fn format_git>(items: &[S], indent: &str, width: usize) -> String { + format(items, Options::git(indent, width)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DenseLayout { + rows: usize, + cols: usize, + widths: Vec, +} + +fn format_plain>(items: &[S], options: Options<'_>) -> String { + let mut output = String::new(); + + for item in items { + output.push_str(options.indent); + output.push_str(item.as_ref()); + output.push_str(options.line_ending); + } + + output +} + +fn format_table>( + items: &[S], + item_widths: &[usize], + options: Options<'_>, + rows: usize, + cols: usize, + initial_width: usize, + column_widths: Option<&[usize]>, +) -> String { + let mut output = String::new(); + + for y in 0..rows { + for x in 0..cols { + let item_index = linear_index(options.layout, rows, cols, x, y); + if item_index >= items.len() { + break; + } + + if x == 0 { + output.push_str(options.indent); + } + + output.push_str(items[item_index].as_ref()); + + if is_last_cell_in_row(options.layout, item_index, items.len(), rows, cols, x) { + output.push_str(options.line_ending); + } else { + let target_width = column_widths.map_or(initial_width, |widths| { + widths.get(x).copied().unwrap_or(0) + options.padding + }); + push_spaces( + &mut output, + target_width.saturating_sub(item_widths[item_index]), + ); + } + } + } + + output +} + +fn shrink_columns( + item_widths: &[usize], + options: Options<'_>, + mut rows: usize, + mut cols: usize, +) -> DenseLayout { + let indent_width = display_width(options.indent); + + while rows > 1 { + let previous_rows = rows; + let previous_cols = cols; + + rows -= 1; + cols = item_widths + .len() + .div_ceil(rows) + .max(1) + .min(item_widths.len()); + + let candidate_widths = compute_column_widths(item_widths, options.layout, rows, cols); + let total_width = indent_width + + candidate_widths.iter().sum::() + + options.padding.saturating_mul(cols); + + if total_width > options.width { + rows = previous_rows; + cols = previous_cols; + break; + } + } + + DenseLayout { + rows, + cols, + widths: compute_column_widths(item_widths, options.layout, rows, cols), + } +} + +fn compute_column_widths( + item_widths: &[usize], + layout: Layout, + rows: usize, + cols: usize, +) -> Vec { + let mut widths = vec![0; cols]; + + for (x, column_width) in widths.iter_mut().enumerate() { + for y in 0..rows { + let item_index = linear_index(layout, rows, cols, x, y); + if let Some(width) = item_widths.get(item_index) { + *column_width = (*column_width).max(*width); + } + } + } + + widths +} + +const fn linear_index(layout: Layout, rows: usize, cols: usize, x: usize, y: usize) -> usize { + match layout { + Layout::Column => x * rows + y, + Layout::Row => y * cols + x, + Layout::Plain => unreachable!(), + } +} + +const fn is_last_cell_in_row( + layout: Layout, + item_index: usize, + item_count: usize, + rows: usize, + cols: usize, + x: usize, +) -> bool { + match layout { + Layout::Column => item_index + rows >= item_count, + Layout::Row => x == cols - 1 || item_index == item_count - 1, + Layout::Plain => unreachable!(), + } +} + +fn display_width(value: &str) -> usize { + console::measure_text_width(value) +} + +fn push_spaces(output: &mut String, count: usize) { + output.extend(std::iter::repeat_n(' ', count)); +} + +#[cfg(test)] +mod tests { + use super::*; + + const ITEMS: &[&str] = &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", + ]; + + const fn options(layout: Layout, width: usize) -> Options<'static> { + Options { + layout, + dense: false, + width, + padding: 1, + indent: "", + line_ending: "\n", + } + } + + #[test] + fn empty_list_is_empty() { + assert_eq!(format::<&str>(&[], Options::git(" ", 80)), ""); + } + + #[test] + fn plain_layout_prints_one_indented_item_per_line() { + let opts = Options { + layout: Layout::Plain, + dense: false, + width: 80, + padding: 1, + indent: "Z", + line_ending: "\n", + }; + + assert_eq!( + format(ITEMS, opts), + "Zone\nZtwo\nZthree\nZfour\nZfive\nZsix\nZseven\nZeight\nZnine\nZten\nZeleven\n" + ); + } + + #[test] + fn column_layout_fits_eighty_columns_like_git() { + assert_eq!( + format(ITEMS, options(Layout::Column, 80)), + "one two three four five six seven eight nine ten eleven\n" + ); + } + + #[test] + fn column_layout_width_one_falls_back_to_one_per_line() { + assert_eq!( + format(ITEMS, options(Layout::Column, 1)), + "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\n" + ); + } + + #[test] + fn column_layout_width_twenty_matches_git() { + assert_eq!( + format(ITEMS, options(Layout::Column, 20)), + "one seven\ntwo eight\nthree nine\nfour ten\nfive eleven\nsix\n" + ); + } + + #[test] + fn column_layout_width_twenty_padding_two_matches_git() { + let opts = Options { + padding: 2, + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one seven\ntwo eight\nthree nine\nfour ten\nfive eleven\nsix\n" + ); + } + + #[test] + fn column_layout_width_twenty_indented_matches_git() { + let opts = Options { + indent: " ", + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + " one seven\n two eight\n three nine\n four ten\n five eleven\n six\n" + ); + } + + #[test] + fn dense_column_layout_width_twenty_matches_git() { + let opts = Options { + dense: true, + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one five nine\ntwo six ten\nthree seven eleven\nfour eight\n" + ); + } + + #[test] + fn row_layout_width_twenty_matches_git() { + assert_eq!( + format(ITEMS, options(Layout::Row, 20)), + "one two\nthree four\nfive six\nseven eight\nnine ten\neleven\n" + ); + } + + #[test] + fn dense_row_layout_width_twenty_matches_git() { + let opts = Options { + dense: true, + ..options(Layout::Row, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one two three\nfour five six\nseven eight nine\nten eleven\n" + ); + } + + #[test] + fn zero_padding_keeps_adjacent_columns_adjacent() { + let opts = Options { + padding: 0, + ..options(Layout::Column, 10) + }; + + assert_eq!(format(&["a", "b"], opts), "ab\n"); + } + + #[test] + fn long_items_are_not_truncated() { + assert_eq!( + format(&["abcdef", "g"], options(Layout::Column, 3)), + "abcdef\ng\n" + ); + } + + #[test] + fn format_git_matches_current_call_with_actual_cells() { + let languages = ["rust", "ruby", "json", "typescript", "python"]; + + assert_eq!( + format_git(&languages, " ", 80), + " rust ruby json typescript python\n" + ); + } +} diff --git a/src/config.rs b/src/config.rs index 8305a84..956464c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,8 +10,9 @@ use tracing::debug; use crate::{ app::App, args::{BuildCommand, ConfigCommand}, + columns, error::TsdlError, - git, TsdlResult, + TsdlResult, }; pub fn current(config: &Path, command: Option<&BuildCommand>) -> TsdlResult { @@ -69,18 +70,8 @@ pub fn show(command: &BuildCommand) -> TsdlResult<()> { if let Some(langs) = &command.languages { println!("Building the following languages:"); println!(); - println!( - "{}", - String::from_utf8( - git::column(&langs.join(" "), " ", 80) - .map_err(|e| TsdlError::context("Printing requested languages", e))? - .stdout - ) - .map_err(|e| TsdlError::context( - "Converting column-formatted languages to a string for printing", - e - ))? - ); + print!("{}", columns::format_git(langs, " ", 80)); + println!(); } else { println!("Building all languages."); println!(); diff --git a/src/git.rs b/src/git.rs index a623d13..a65f004 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,9 +1,7 @@ use std::{ ffi::OsStr, fmt, - io::Write, path::{Component, Path, PathBuf}, - process::{Output, Stdio}, }; use serde::{Deserialize, Serialize}; @@ -136,31 +134,6 @@ pub async fn clone_fast_with_force( Ok(()) } -pub fn column(input: &str, indent: &str, width: usize) -> TsdlResult { - let mut child = std::process::Command::new("git") - .arg("column") - .arg("--mode=always") - .arg(format!("--indent={indent}")) - .arg(format!("--width={width}")) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn()?; - - let Some(mut stdin) = child.stdin.take() else { - return child - .wait_with_output() - .map_err(|e| TsdlError::context("git column did not finish normally", e)); - }; - - stdin - .write_all(input.as_bytes()) - .map_err(|e| TsdlError::context("Failed to write to git column stdin", e))?; - - child - .wait_with_output() - .map_err(|e| TsdlError::context("git column did not finish normally", e)) -} - async fn fetch_and_checkout(cwd: &Path, git_ref: &str) -> TsdlResult<()> { Command::new("git") .env("GIT_TERMINAL_PROMPT", "0") diff --git a/src/lib.rs b/src/lib.rs index de18b5a..98d26f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,7 @@ pub mod app; pub mod args; pub mod build; pub mod cache; +pub mod columns; pub mod config; pub mod consts; pub mod display; From cc0f35240852cd2035d1b5afcf7be7fc0bd89e0a Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 01:09:40 +0200 Subject: [PATCH 29/88] display: sort languages --- src/build.rs | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/build.rs b/src/build.rs index dc6e781..d958635 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, HashSet}, + collections::{BTreeMap, BTreeSet}, fs, path::PathBuf, sync::Arc, @@ -250,8 +250,8 @@ fn unique_languages(app: &App) -> Vec> { .unwrap_or_default(), }; - let unique = final_languages.into_iter().collect::>(); - let mut results = Vec::new(); + let unique = final_languages.into_iter().collect::>(); + let mut results = Vec::with_capacity(unique.len()); for language in unique { let (build_script, git_ref, url) = get_language_coords(&language, defined_parsers); @@ -294,3 +294,39 @@ fn unique_languages(app: &App) -> Vec> { results } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{args::BuildCommand, display::Mode}; + + fn app_with_languages(languages: &[&str]) -> App { + let mut command = BuildCommand::default(); + command.languages = Some( + languages + .iter() + .map(|language| (*language).to_string()) + .collect(), + ); + + App { + command, + config_path: PathBuf::from("parsers.toml"), + log_path: PathBuf::from("tmp/log"), + progress_mode: Mode::Plain, + verbose: Default::default(), + } + } + + #[test] + fn unique_languages_sorts_and_deduplicates_requested_languages() { + let app = app_with_languages(&["rust", "json", "ruby", "json", "rust"]); + + let languages = unique_languages(&app) + .into_iter() + .map(|language| language.unwrap().name.to_string()) + .collect::>(); + + assert_eq!(languages, vec!["json", "ruby", "rust"]); + } +} From 453da90ab667e896b1dd30d381ba33cf9676f586 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 01:35:02 +0200 Subject: [PATCH 30/88] style: remove dead code and useless Parser Error --- Cargo.lock | 24 -------- Cargo.toml | 2 - src/build.rs | 2 - src/error.rs | 107 +++++++---------------------------- src/main.rs | 2 +- src/parser.rs | 9 --- src/walk.rs | 151 ++------------------------------------------------ 7 files changed, 26 insertions(+), 271 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b36453a..8703b28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,28 +138,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "atomic" version = "0.6.1" @@ -3891,7 +3869,6 @@ dependencies = [ "assert_cmd", "assert_fs", "async-compression", - "async-stream", "atty", "better-panic", "cargo_metadata", @@ -3907,7 +3884,6 @@ dependencies = [ "fs2", "futures", "human-panic", - "ignore", "indoc", "libc", "log", diff --git a/Cargo.toml b/Cargo.toml index 74f99cf..8f52df0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ repo = "https://github.com/tree-sitter/tree-sitter" [dependencies] async-compression = { version = "0.4", features = ["tokio", "gzip"] } -async-stream = "0.3" atty = "0.2" better-panic = "0.3" clap = { version = "4.6", features = ["cargo", "derive", "env"] } @@ -57,7 +56,6 @@ fs2 = "0.4" futures = "0.3" libc = "0.2" human-panic = "2.0" -ignore = "0.4" ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color", "macros"] } log = "0.4" num_cpus = "1.17" diff --git a/src/build.rs b/src/build.rs index d958635..d93eb98 100644 --- a/src/build.rs +++ b/src/build.rs @@ -44,7 +44,6 @@ pub struct OutputConfig { #[derive(Debug, Clone, PartialEq)] pub struct BuildContext { - pub cache_hit: bool, pub ignore_cache: bool, pub overwrite_output: bool, } @@ -258,7 +257,6 @@ fn unique_languages(app: &App) -> Vec> { let result = match url { Ok(repo) => Ok(LanguageBuild::new( BuildContext { - cache_hit: false, ignore_cache: app.command.force || app.command.fresh, overwrite_output: app.command.force, }, diff --git a/src/error.rs b/src/error.rs index 629e0dc..2dc9565 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,14 +6,6 @@ use derive_more::derive::Display; use crate::shutdown::ShutdownSignal; -/// Macro for creating Step errors with common patterns -#[macro_export] -macro_rules! step_error { - ($name:expr, $kind:expr, $source:expr) => { - error::Step::new($name.to_string(), $kind, $source) - }; -} - /// Represents a single layer in the context chain #[derive(Debug)] pub struct ContextKind { @@ -167,46 +159,6 @@ impl std::error::Error for Language { } } -#[derive(Debug)] -pub struct Parser { - pub related: Vec, -} - -impl fmt::Display for Parser { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) - } -} - -// TODO: review formatting; duplicates? -impl Parser { - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!(w, "{prefix}Could not build all parsers.")?; - - for err in &self.related { - write!(w, "\n\n{}", err.format_indent(indent + 2))?; - } - - Ok(()) - } - - /// Format with indentation - /// - /// # Panics - /// - /// This function will panic if writing to the string fails, which should never happen - /// since we're writing to a String which doesn't fail. - #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - self.format(&mut s, indent).unwrap(); - s - } -} - -impl std::error::Error for Parser {} - #[derive(Debug)] pub struct Step { pub name: Arc, @@ -285,6 +237,22 @@ fn format_languages_inner(w: &mut impl fmt::Write, langs: &[Language]) -> fmt::R Ok(()) } +fn format_build_errors( + w: &mut impl fmt::Write, + errors: &[TsdlError], + indent: usize, +) -> fmt::Result { + let prefix = " ".repeat(indent); + write!(w, "{prefix}Could not build all parsers.")?; + + for error in errors { + write!(w, "\n\n")?; + error.format(w, indent + 2)?; + } + + Ok(()) +} + /// Main error type for tsdl operations #[derive(Debug)] pub enum TsdlError { @@ -315,9 +283,6 @@ pub enum TsdlError { /// Individual language failed Language(Language), - /// Parser building failed - Parser(Parser), - /// Specific step failed Step(Step), } @@ -325,13 +290,7 @@ pub enum TsdlError { impl fmt::Display for TsdlError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - TsdlError::Build(errs) => { - write!(f, "Could not build all parsers.")?; - for e in errs { - write!(f, "\n\n{}", e.format_indent(2))?; - } - Ok(()) - } + TsdlError::Build(errs) => format_build_errors(f, errs, 0), TsdlError::Command(e) => write!(f, "{e}"), TsdlError::Config(msg) => write!(f, "Configuration error: {msg}"), TsdlError::Context(kind) => write!(f, "{kind}"), @@ -340,7 +299,6 @@ impl fmt::Display for TsdlError { TsdlError::Language(e) => write!(f, "{e}"), TsdlError::LanguageCollection(e) => write!(f, "{e}"), TsdlError::Message(msg) => write!(f, "{msg}"), - TsdlError::Parser(e) => write!(f, "{e}"), TsdlError::Step(e) => write!(f, "{e}"), } } @@ -358,7 +316,6 @@ impl std::error::Error for TsdlError { TsdlError::Io(e) => Some(e), TsdlError::Language(e) => Some(e), TsdlError::LanguageCollection(e) => Some(e), - TsdlError::Parser(e) => Some(e), TsdlError::Step(e) => Some(e), } } @@ -383,12 +340,6 @@ impl From for TsdlError { } } -impl From for TsdlError { - fn from(e: Parser) -> Self { - TsdlError::Parser(e) - } -} - impl From for TsdlError { fn from(e: Step) -> Self { TsdlError::Step(e) @@ -493,7 +444,6 @@ impl TsdlError { TsdlError::Message(message.into()) } - /// Format the error with indentation support /// Format the error with indentation support /// /// # Panics @@ -510,15 +460,7 @@ impl TsdlError { fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { let prefix = " ".repeat(indent); match self { - TsdlError::Build(errs) => { - for (i, e) in errs.iter().enumerate() { - e.format(w, indent)?; - if i < errs.len() - 1 { - writeln!(w)?; - } - } - Ok(()) - } + TsdlError::Build(errs) => format_build_errors(w, errs, indent), TsdlError::Command(e) => e.format(w, indent), TsdlError::Config(msg) => write!(w, "{prefix}Configuration error: {msg}"), TsdlError::Context(kind) => { @@ -527,7 +469,7 @@ impl TsdlError { "{}{}\n{}", prefix, kind.message, - TsdlError::format_context_error(&kind.error, indent + 2) + kind.error.format_indent(indent + 2) ) } TsdlError::Io(e) => write!(w, "{prefix}IO error: {e}"), @@ -535,14 +477,9 @@ impl TsdlError { TsdlError::Language(e) => e.format(w, indent), TsdlError::LanguageCollection(e) => write!(w, "{prefix}{e}"), TsdlError::Message(msg) => write!(w, "{prefix}{msg}"), - TsdlError::Parser(e) => e.format(w, indent), TsdlError::Step(e) => e.format(w, indent), } } - - fn format_context_error(err: &TsdlError, indent: usize) -> String { - err.format_indent(indent) - } } #[cfg(test)] @@ -569,11 +506,7 @@ mod tests { source: Box::new(command_error.into()), }; - let parser_error = Parser { - related: vec![TsdlError::Step(step_error)], - }; - - let tsdl_error = TsdlError::Parser(parser_error); + let tsdl_error = TsdlError::Build(vec![TsdlError::Step(step_error)]); let formatted = tsdl_error.format_indent(0); let expected = r"Could not build all parsers. diff --git a/src/main.rs b/src/main.rs index b9b7b21..56b8d3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use clap::Parser; use console::style; use tracing::{error, info}; -use tsdl::{app::App, args, logging, TsdlResult}; +use tsdl::{TsdlResult, app::App, args, error::TsdlError, logging}; fn main() -> ExitCode { set_panic_hook(); diff --git a/src/parser.rs b/src/parser.rs index 001c67a..fe9e064 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -24,16 +24,8 @@ use crate::{ TsdlResult, }; -pub const NUM_STEPS: usize = 3; pub const WASM_EXTENSION: &str = "wasm"; -/// Result message from a grammar build -#[derive(Debug, Clone)] -pub enum GrammarMessage { - Completed(Update), - Failed(String), -} - /// A grammar ready to be built, combining definition and cache state #[derive(Clone, Debug)] pub struct GrammarBuild { @@ -590,7 +582,6 @@ mod tests { .await; let build = GrammarBuild { context: BuildContext { - cache_hit: false, ignore_cache: false, overwrite_output, }, diff --git a/src/walk.rs b/src/walk.rs index 7f71b8c..c1a42a1 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -1,152 +1,11 @@ -use async_stream::try_stream; -use futures::{Stream, StreamExt}; -use ignore::{ - gitignore::{Gitignore, GitignoreBuilder}, - overrides::Override, - types::Types, -}; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::fs; +use std::{path::PathBuf, sync::Arc}; -use crate::{cache, shutdown}; - -/// Holds the immutable rules for the traversal. -struct FilterContext { - types: Types, - overrides: Override, -} - -impl FilterContext { - fn new(root: impl AsRef) -> Self { - use ignore::{overrides::OverrideBuilder, types::TypesBuilder}; - - let mut types_builder = TypesBuilder::new(); - types_builder.add_def("js:*.js").unwrap(); - let types = types_builder.select("js").build().unwrap(); - - let mut overrides_builder = OverrideBuilder::new(root); - overrides_builder.case_insensitive(true).unwrap(); - overrides_builder - .add("!(.github|bindings|doc|docs|examples|queries|script|scripts|test|tests)/**") - .unwrap(); - let overrides = overrides_builder.build().unwrap(); - - Self { types, overrides } - } - - fn is_ignored(&self, path: &Path, is_dir: bool, gitignore: &Gitignore) -> bool { - if gitignore.matched(path, is_dir).is_ignore() - && !self.overrides.matched(path, is_dir).is_whitelist() - { - return true; - } - false - } -} - -/// Recursive async generator -fn scan_directory( - dir: PathBuf, - ctx: Arc, - parent_ignore: Arc, -) -> impl Stream> { - try_stream! { - // 1. Check for a local .gitignore in this folder - // If it exists, we must create a new matcher for this scope. - // If not, we reuse the parent's matcher (cheap pointer copy). - let local_ignore_path = dir.join(".gitignore"); - let active_ignore = if fs::try_exists(&local_ignore_path).await.unwrap_or(false) { - let mut builder = GitignoreBuilder::new(&dir); - builder.add(local_ignore_path); - // Note: In a production 'ignore' replacement, you would chain - // the parent_ignore here. For simplicity, we just build local. - Arc::new(builder.build().unwrap()) - } else { - parent_ignore - }; - - // 2. Open the directory stream - let mut read_dir = fs::read_dir(&dir).await?; - - // 3. Iterate over entries - while let Some(entry) = read_dir.next_entry().await? { - let path = entry.path(); - let metadata = entry.metadata().await?; - let is_dir = metadata.is_dir(); - let is_file = metadata.is_file(); - - // 4. Check Filters - if ctx.is_ignored(&path, is_dir, &active_ignore) { - continue; - } - - if is_dir { - // RECURSION: - // We recursively call this function and yield results from the sub-stream - let mut sub_stream = Box::pin(scan_directory( - path, - ctx.clone(), - active_ignore.clone() - )); - - while let Some(result) = sub_stream.next().await { - yield result?; - } - } else if is_file - && is_grammar_file(&path, &ctx.types) { - yield path; - } - } - } -} - -/// Check if filename is "grammar.js" AND the path is not ignored by types -fn is_grammar_file(path: &Path, types: &Types) -> bool { - path.file_name() == Some("grammar.js".as_ref()) && !types.matched(path, false).is_ignore() -} - -/// Collect grammar.js paths and compute their hashes in a single stream. -/// Returns a stream of (path, hash) tuples. -/// -/// # Panics -/// -/// -pub fn collect_grammar_paths_with_hash( - root: PathBuf, -) -> impl Stream> { - let ctx = Arc::new(FilterContext::new(root.clone())); - - let mut builder = GitignoreBuilder::new(&root); - builder.add(root.join(".gitignore")); - let root_ignore = Arc::new( - builder - .build() - .unwrap_or_else(|_| panic!("gitignore builder failed")), - ); - - try_stream! { - let mut stream = Box::pin(scan_directory(root, ctx, root_ignore)); - while let Some(path_result) = stream.next().await { - let path = path_result?; - let hash = cache::hash_file(&path).await.map_err(|e| { - io::Error::other(format!("Failed to hash {}: {}", path.display(), e)) - })?; - yield (path, hash); - } - } -} +use crate::{cache, git, shutdown, TsdlResult}; /// Collect grammar.js paths via git ls-files and compute their hashes. -/// Uses git for file enumeration (truly async, avoids blocking thread pool). -pub async fn collect_grammar_paths( - root: Arc, -) -> crate::TsdlResult> { - use crate::git; - - let files = git::list_grammar_files(&root).await?; - let mut results = Vec::new(); +pub async fn collect_grammar_paths(root: Arc) -> TsdlResult> { + let files = git::list_grammar_files(root.as_ref()).await?; + let mut results = Vec::with_capacity(files.len()); for file in files { shutdown::check()?; From 0757c36f26bdade5bce722703e959ed59aeed4ed Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 02:17:45 +0200 Subject: [PATCH 31/88] cache: cebtralize and give more details --- src/actors/cache.rs | 12 +- src/cache.rs | 484 +++++++++++++++++++++++++++++++++----------- src/parser.rs | 25 +-- 3 files changed, 384 insertions(+), 137 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 3e63ebd..3509be2 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -5,7 +5,7 @@ use tokio::sync::{mpsc, oneshot}; use crate::{ actors::{Addr, Response}, build::BuildSpec, - cache::{Db, Entry, Update}, + cache::{CacheDecision, CacheMissReason, Db, Entry, Update}, TsdlResult, }; @@ -25,7 +25,7 @@ pub enum CacheMessage { hash: Arc, name: Arc, spec: Arc, - tx: oneshot::Sender, + tx: oneshot::Sender, }, /// Update a cache entry Update { entry: Entry, name: Arc }, @@ -91,7 +91,7 @@ impl CacheAddr { name: S, hash: S, spec: Arc, - ) -> bool { + ) -> CacheDecision { self.request(|tx| CacheMessage::NeedsRebuild { name: name.into(), hash: hash.into(), @@ -138,7 +138,11 @@ impl CacheActor { hash: &hash, }, } - .send(self.db.needs_rebuild(&name, &hash, &spec)); + .send(if self.force { + CacheDecision::miss(CacheMissReason::CacheIgnored) + } else { + self.db.rebuild_decision(&name, &hash, &spec) + }); } CacheMessage::Update { entry, name } => { diff --git a/src/cache.rs b/src/cache.rs index 2d2d613..4d7c32e 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,5 +1,6 @@ use std::{ collections::BTreeMap, + fmt, fmt::Write, fs, path::{Path, PathBuf}, @@ -11,7 +12,14 @@ use sha1::{Digest, Sha1}; use tokio::io::{AsyncReadExt, ReadBuf}; use tracing::debug; -use crate::{build::BuildSpec, consts::TSDL_CACHE_FILE, error::TsdlError, TsdlResult}; +use crate::{ + args::{Target, TreeSitter}, + build::BuildSpec, + consts::TSDL_CACHE_FILE, + error::TsdlError, + git::GitRef, + TsdlResult, +}; /// The build cache stored in `build-dir/TSDL_CACHE_FILE` #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -29,6 +37,249 @@ pub struct Entry { pub spec: Arc, } +/// A cache lookup result for a requested parser build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CacheDecision { + Hit, + Miss(CacheMiss), +} + +/// Details explaining why a cache entry cannot satisfy a requested parser build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheMiss { + pub reasons: Vec, +} + +/// One reason a cached parser build cannot be reused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CacheMissReason { + MissingEntry, + CacheIgnored, + HashChanged { + cached: Arc, + current: Arc, + }, + RepoChanged { + cached: String, + current: String, + }, + GitRefChanged { + cached: GitRef, + current: GitRef, + }, + TreeSitterChanged { + cached: TreeSitter, + current: TreeSitter, + }, + BuildScriptChanged, + PrefixChanged { + cached: String, + current: String, + }, + TargetChanged { + cached: Target, + current: Target, + }, +} + +impl CacheDecision { + #[must_use] + pub fn miss(reason: CacheMissReason) -> Self { + Self::Miss(CacheMiss { + reasons: vec![reason], + }) + } + + #[must_use] + pub fn from_reasons(reasons: Vec) -> Self { + if reasons.is_empty() { + Self::Hit + } else { + Self::Miss(CacheMiss { reasons }) + } + } + + #[must_use] + pub fn is_hit(&self) -> bool { + matches!(self, Self::Hit) + } + + #[must_use] + pub fn needs_rebuild(&self) -> bool { + !self.is_hit() + } + + #[must_use] + pub fn short_message(&self) -> String { + match self { + Self::Hit => "cache hit".to_string(), + Self::Miss(miss) => miss.short_message(), + } + } +} + +impl fmt::Display for CacheDecision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CacheDecision::Hit => write!(f, "cache hit"), + CacheDecision::Miss(miss) => write!(f, "cache miss: {miss}"), + } + } +} + +impl CacheMiss { + #[must_use] + pub fn short_message(&self) -> String { + match self.reasons.as_slice() { + [] => "cache changed".to_string(), + [reason] => reason.short_message().to_string(), + reasons => { + let labels = reasons + .iter() + .map(CacheMissReason::short_label) + .collect::>() + .join(", "); + format!("cache changed: {labels}") + } + } + } +} + +impl fmt::Display for CacheMiss { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, reason) in self.reasons.iter().enumerate() { + if index > 0 { + write!(f, "; ")?; + } + write!(f, "{reason}")?; + } + Ok(()) + } +} + +impl CacheMissReason { + #[must_use] + pub fn short_label(&self) -> &'static str { + match self { + Self::MissingEntry => "missing", + Self::CacheIgnored => "ignored", + Self::HashChanged { .. } => "hash", + Self::RepoChanged { .. } => "repo", + Self::GitRefChanged { .. } => "ref", + Self::TreeSitterChanged { .. } => "tree-sitter", + Self::BuildScriptChanged => "script", + Self::PrefixChanged { .. } => "prefix", + Self::TargetChanged { .. } => "target", + } + } + + #[must_use] + pub fn short_message(&self) -> &'static str { + match self { + Self::MissingEntry => "not cached", + Self::CacheIgnored => "cache ignored", + Self::HashChanged { .. } => "grammar changed", + Self::RepoChanged { .. } => "repo changed", + Self::GitRefChanged { .. } => "git ref changed", + Self::TreeSitterChanged { .. } => "tree-sitter changed", + Self::BuildScriptChanged => "build script changed", + Self::PrefixChanged { .. } => "prefix changed", + Self::TargetChanged { .. } => "target changed", + } + } +} + +impl fmt::Display for CacheMissReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingEntry => write!(f, "missing cache entry"), + Self::CacheIgnored => write!(f, "cache ignored"), + Self::HashChanged { cached, current } => { + write!(f, "grammar hash changed cached={cached} current={current}") + } + Self::RepoChanged { cached, current } => { + write!(f, "repo changed cached={cached} current={current}") + } + Self::GitRefChanged { cached, current } => { + write!(f, "git ref changed cached={cached} current={current}") + } + Self::TreeSitterChanged { cached, current } => write!( + f, + "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", + cached.version, + cached.repo, + cached.platform, + current.version, + current.repo, + current.platform + ), + Self::BuildScriptChanged => write!(f, "build script changed"), + Self::PrefixChanged { cached, current } => { + write!(f, "prefix changed cached={cached:?} current={current:?}") + } + Self::TargetChanged { cached, current } => { + write!(f, "target changed cached={cached:?} current={current:?}") + } + } + } +} + +impl Entry { + #[must_use] + pub fn rebuild_decision(&self, hash: &str, spec: &BuildSpec) -> CacheDecision { + let mut reasons = Vec::new(); + let cached = self.spec.as_ref(); + + if self.hash.as_ref() != hash { + reasons.push(CacheMissReason::HashChanged { + cached: self.hash.clone(), + current: Arc::from(hash), + }); + } + + if cached.repo != spec.repo { + reasons.push(CacheMissReason::RepoChanged { + cached: cached.repo.to_string(), + current: spec.repo.to_string(), + }); + } + + if cached.git_ref != spec.git_ref { + reasons.push(CacheMissReason::GitRefChanged { + cached: cached.git_ref.clone(), + current: spec.git_ref.clone(), + }); + } + + if cached.tree_sitter != spec.tree_sitter { + reasons.push(CacheMissReason::TreeSitterChanged { + cached: cached.tree_sitter.clone(), + current: spec.tree_sitter.clone(), + }); + } + + if cached.build_script != spec.build_script { + reasons.push(CacheMissReason::BuildScriptChanged); + } + + if cached.prefix != spec.prefix { + reasons.push(CacheMissReason::PrefixChanged { + cached: cached.prefix.clone(), + current: spec.prefix.clone(), + }); + } + + if cached.target != spec.target { + reasons.push(CacheMissReason::TargetChanged { + cached: cached.target, + current: spec.target, + }); + } + + CacheDecision::from_reasons(reasons) + } +} + /// Represents a "Delta" to be applied to the cache after a successful build #[derive(Debug, Clone)] pub struct Update { @@ -82,31 +333,22 @@ impl Db { .map_err(|e| TsdlError::context(format!("Parsing cache file at {}", file.display()), e)) } - /// Check if a parser needs rebuilding by comparing grammar hash and build definition - pub fn needs_rebuild(&self, name: &str, hash: &str, spec: &BuildSpec) -> bool { + /// Explain whether a parser cache entry can satisfy the requested build. + pub fn rebuild_decision(&self, name: &str, hash: &str, spec: &BuildSpec) -> CacheDecision { // TODO: hash and name are plain str, I'd like strong types here. - match self.get(name) { - None => { - debug!("No cache entry for {}, rebuild needed", name); - true - } - Some(entry) => { - let hash_eq = entry.hash.as_ref() == hash; - let spec_eq = entry.spec.as_ref() == spec; - let needs_rebuild = !(hash_eq && spec_eq); - - if needs_rebuild { - debug!( - "Cache mismatch for {}: hash={} (cached={}), config_changed=true", - name, hash, entry.hash - ); - } else { - debug!("Cache hit for {}, no rebuild needed", name); - } - - needs_rebuild - } - } + let decision = match self.get(name) { + None => CacheDecision::miss(CacheMissReason::MissingEntry), + Some(entry) => entry.rebuild_decision(hash, spec), + }; + + debug!("Cache decision for {name}: {decision}"); + decision + } + + /// Check if a parser needs rebuilding by comparing grammar hash and build definition. + #[must_use] + pub fn needs_rebuild(&self, name: &str, hash: &str, spec: &BuildSpec) -> bool { + self.rebuild_decision(name, hash, spec).needs_rebuild() } /// Save the cache to disk @@ -161,132 +403,132 @@ pub async fn hash_file(path: &Path) -> TsdlResult { #[cfg(test)] mod tests { use super::*; - use crate::args::{Target, TreeSitter}; - use crate::git::GitRef; - #[test] - fn test_needs_rebuild_no_entry() { - let cache = Db::default(); - let test_definition = BuildSpec { + fn test_spec() -> BuildSpec { + BuildSpec { build_script: None, git_ref: GitRef::from("master"), repo: "https://github.com/example/parser".parse().unwrap(), tree_sitter: TreeSitter::default(), prefix: String::new(), target: Target::Native, - }; - assert!(cache.needs_rebuild("test-parser", "abc123", &test_definition)); + } } - #[test] - fn test_needs_rebuild_sha1_mismatch() { + fn cache_with_entry(hash: &str, spec: BuildSpec) -> Db { let mut cache = Db::default(); - let spec = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::All, - }; cache.set( "test-parser".to_string(), Entry { - hash: "abc123".into(), - spec: spec.into(), + hash: hash.into(), + spec: Arc::new(spec), }, ); + cache + } - let current_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - assert!(cache.needs_rebuild("test-parser", "def456", ¤t_definition)); + fn assert_miss(decision: CacheDecision, expected: &[CacheMissReason]) { + match decision { + CacheDecision::Hit => panic!("expected cache miss"), + CacheDecision::Miss(miss) => assert_eq!(miss.reasons, expected), + } } #[test] - fn test_needs_rebuild_git_ref_mismatch() { - let mut cache = Db::default(); - let spec = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::All, - }; - cache.set( - "test-parser".to_string(), - Entry { - hash: "abc123".into(), - spec: spec.into(), - }, + fn test_rebuild_decision_no_entry() { + let cache = Db::default(); + let spec = test_spec(); + + assert_miss( + cache.rebuild_decision("test-parser", "abc123", &spec), + &[CacheMissReason::MissingEntry], ); + } - let current_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("v1.0.0"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - assert!(cache.needs_rebuild("test-parser", "abc123", ¤t_definition)); + #[test] + fn test_rebuild_decision_hash_mismatch() { + let spec = test_spec(); + let cache = cache_with_entry("abc123", spec.clone()); + + assert_miss( + cache.rebuild_decision("test-parser", "def456", &spec), + &[CacheMissReason::HashChanged { + cached: "abc123".into(), + current: "def456".into(), + }], + ); } #[test] - fn test_needs_rebuild_target_not_covered() { - let mut cache = Db::default(); - let spec = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - cache.set( - "test-parser".to_string(), - Entry { - hash: "abc123".into(), - spec: spec.into(), - }, + fn test_rebuild_decision_git_ref_mismatch() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.git_ref = GitRef::from("v1.0.0"); + let cache = cache_with_entry("abc123", cached); + + assert_miss( + cache.rebuild_decision("test-parser", "abc123", &requested), + &[CacheMissReason::GitRefChanged { + cached: GitRef::from("master"), + current: GitRef::from("v1.0.0"), + }], ); + } - let current_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Wasm, - }; - assert!(cache.needs_rebuild("test-parser", "abc123", ¤t_definition)); + #[test] + fn test_rebuild_decision_target_changed() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.target = Target::Wasm; + let cache = cache_with_entry("abc123", cached); + + assert_miss( + cache.rebuild_decision("test-parser", "abc123", &requested), + &[CacheMissReason::TargetChanged { + cached: Target::Native, + current: Target::Wasm, + }], + ); } #[test] - fn test_needs_rebuild_cache_hit_exact() { - let mut cache = Db::default(); - let test_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - cache.set( - "test-parser".to_string(), - Entry { - hash: "abc123".into(), - spec: Arc::new(test_definition.clone()), - }, + fn test_rebuild_decision_cache_hit_exact() { + let spec = test_spec(); + let cache = cache_with_entry("abc123", spec.clone()); + + assert_eq!( + cache.rebuild_decision("test-parser", "abc123", &spec), + CacheDecision::Hit ); + assert!(!cache.needs_rebuild("test-parser", "abc123", &spec)); + } - assert!(!cache.needs_rebuild("test-parser", "abc123", &test_definition)); + #[test] + fn test_rebuild_decision_collects_multiple_reasons() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.build_script = Some("make".to_string()); + requested.prefix = "custom-".to_string(); + requested.target = Target::All; + let cache = cache_with_entry("abc123", cached); + + assert_miss( + cache.rebuild_decision("test-parser", "def456", &requested), + &[ + CacheMissReason::HashChanged { + cached: "abc123".into(), + current: "def456".into(), + }, + CacheMissReason::BuildScriptChanged, + CacheMissReason::PrefixChanged { + cached: String::new(), + current: "custom-".to_string(), + }, + CacheMissReason::TargetChanged { + cached: Target::Native, + current: Target::All, + }, + ], + ); } } diff --git a/src/parser.rs b/src/parser.rs index fe9e064..73c6ea6 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -15,7 +15,7 @@ use tracing::{debug, warn}; use crate::{ actors::ProgressAddr, build::{BuildContext, BuildSpec, OutputConfig}, - cache::{self, Entry, Update}, + cache::{self, CacheDecision, CacheMissReason, Entry, Update}, error::{self, TsdlError}, git::clone_fast, sh::{Exec, Script}, @@ -56,9 +56,10 @@ impl GrammarBuild { let key = format!("{}/{}", self.language, self.name); // Check cache: if cached and definitions match, skip build but still install - let hit = !self.context.ignore_cache && !self.needs_rebuild(&key); + let cache_decision = self.cache_decision(); + debug!("[grammar:cache] {key}: {cache_decision}"); - if hit { + if cache_decision.is_hit() { self.progress.set_outcome_cached().await; // Install the binary from the build directory if let Err(e) = self.install().await { @@ -71,6 +72,7 @@ impl GrammarBuild { } self.progress.set_outcome_built().await; + self.progress.msg(cache_decision.short_message()); // Use the grammar directory path provided if !self.dir.exists() { @@ -386,16 +388,15 @@ impl GrammarBuild { )) } - /// Check if this grammar needs rebuilding based on cache - fn needs_rebuild(&self, _cache_key: &str) -> bool { + /// Explain whether this grammar's cache entry can satisfy the requested build. + fn cache_decision(&self) -> CacheDecision { + if self.context.ignore_cache { + return CacheDecision::miss(CacheMissReason::CacheIgnored); + } + match &self.entry { - None => true, // No cache entry - rebuild needed - Some(entry) => { - // Check if hash or definition changed - let hash_eq = entry.hash == self.hash; - let def_eq = entry.spec == self.spec; - !(hash_eq && def_eq) - } + None => CacheDecision::miss(CacheMissReason::MissingEntry), + Some(entry) => entry.rebuild_decision(&self.hash, &self.spec), } } From 02ffd4dca755601366547a4cb1eeca0776255c9e Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 02:27:20 +0200 Subject: [PATCH 32/88] args: --jobs is a NonZeroUsize --- CHANGELOG.md | 1 + src/actors/mod.rs | 10 +++++----- src/args.rs | 14 +++++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb0b107..4d7f839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ informative and more coherent. - **install**: Avoid replacing existing output files unless they are already the same file, have identical contents, or `--force` is used; replacements are now installed through visible temporary hardlinks. +- `--jobs` is a strictly positive numbet now. ## [2.0.0] - 2026-02-20 diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 632e7df..50b7943 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -1,7 +1,7 @@ mod cache; mod display; -use std::{path::PathBuf, sync::Arc}; +use std::{num::NonZeroUsize, path::PathBuf, sync::Arc}; pub use cache::{CacheActor, CacheAddr}; pub use display::{DisplayActor, DisplayAddr, DisplayMessage, ProgressAddr}; @@ -69,7 +69,7 @@ pub async fn run( build_dir: &PathBuf, cache: CacheAddr, display: DisplayAddr, - jobs: usize, + jobs: NonZeroUsize, languages: Vec, tree_sitter: &TreeSitter, ) -> TsdlResult<()> { @@ -116,7 +116,7 @@ async fn run_inner( build_dir: &PathBuf, cache: CacheAddr, display: DisplayAddr, - jobs: usize, + jobs: NonZeroUsize, languages: Vec, tree_sitter: &TreeSitter, ) -> TsdlResult<()> { @@ -135,7 +135,7 @@ async fn run_inner( } }) // Run up to `concurrency` discovery tasks at once - .buffer_unordered(jobs) + .buffer_unordered(jobs.into()) // 3. Flattening & Error Propagation // Turn the stream of "Lists of Grammars" into a flat stream of "Individual Grammars" // If discovery failed, pass the error through as an Item @@ -156,7 +156,7 @@ async fn run_inner( } }) // Run up to `concurrency` build tasks at once - .buffer_unordered(jobs) + .buffer_unordered(jobs.into()) // 5. Sink: Accumulator // Fold the stream into the final error vector. // This implicitly waits for ALL tasks to finish. diff --git a/src/args.rs b/src/args.rs index f5b012a..1a9023f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, fmt, path::PathBuf}; +use std::{collections::BTreeMap, fmt, num::NonZeroUsize, path::PathBuf}; use clap::{ builder::styling::{AnsiColor, Color, Style}, @@ -199,9 +199,9 @@ pub struct BuildCommand { pub languages: Option>, /// Number of threads; defaults to the number of available CPUs. - #[arg(short, long, env = "TSDL_NCPUS", default_value_t = num_cpus::get())] - #[serde(default)] - pub jobs: usize, + #[arg(short, long, env = "TSDL_NCPUS", default_value_t = NonZeroUsize::new(num_cpus::get()).unwrap())] + #[serde(default = "default_jobs")] + pub jobs: NonZeroUsize, /// Output Directory. #[arg(short, long, env = "TSDL_OUT_DIR", default_value = TSDL_OUT_DIR)] @@ -243,7 +243,7 @@ impl Default for BuildCommand { force: TSDL_FORCE, fresh: TSDL_FRESH, languages: None, - jobs: num_cpus::get(), + jobs: default_jobs(), out_dir: PathBuf::from(TSDL_OUT_DIR), parsers: None, prefix: String::from(TSDL_PREFIX), @@ -255,6 +255,10 @@ impl Default for BuildCommand { } } +fn default_jobs() -> NonZeroUsize { + NonZeroUsize::new(num_cpus::get()).unwrap_or(NonZeroUsize::MIN) +} + #[derive(Clone, Debug, Deserialize, Diff, Serialize, PartialEq, Eq)] #[diff(attr( #[derive(Debug, PartialEq)] From f9785792228b61483127eca8b53450b394943c68 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 03:29:17 +0200 Subject: [PATCH 33/88] build: manage the artifacts to improve discovery --- CHANGELOG.md | 5 + src/actors/cache.rs | 81 ++++++++++- src/actors/mod.rs | 12 +- src/build.rs | 20 +-- src/cache.rs | 25 ++++ src/parser.rs | 322 ++++++++++++++++++++++++++++++++++---------- tests/cmd/cache.rs | 7 +- 7 files changed, 384 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7f839..c27a747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - `tsdl selfupdate patch` / `minor` / `major` filters compatible releases. - `tsdl selfupdate 2.5.0` installs an exact version with downgrade confirmation. +### Performance + +- Artifact (wasm, dylib, so) discovery is now exact except for custom `script` + builds. + ### UI/UX The display backend moved to ratatui. The information displayed are more diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 3509be2..93e5617 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -1,6 +1,9 @@ -use std::sync::Arc; +use std::{io, path::PathBuf, sync::Arc}; -use tokio::sync::{mpsc, oneshot}; +use tokio::{ + fs, + sync::{mpsc, oneshot}, +}; use crate::{ actors::{Addr, Response}, @@ -25,6 +28,7 @@ pub enum CacheMessage { hash: Arc, name: Arc, spec: Arc, + artifacts: Vec, tx: oneshot::Sender, }, /// Update a cache entry @@ -91,11 +95,13 @@ impl CacheAddr { name: S, hash: S, spec: Arc, + artifacts: Vec, ) -> CacheDecision { self.request(|tx| CacheMessage::NeedsRebuild { name: name.into(), hash: hash.into(), spec, + artifacts, tx, }) .await @@ -121,6 +127,26 @@ pub struct CacheActor { rx: mpsc::Receiver, } +async fn verify_artifacts(artifacts: Vec) -> CacheDecision { + let mut reasons = Vec::new(); + + for path in artifacts { + match fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() => {} + Ok(_) => reasons.push(CacheMissReason::ArtifactNotFile { path }), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + reasons.push(CacheMissReason::ArtifactMissing { path }); + } + Err(err) => reasons.push(CacheMissReason::ArtifactInaccessible { + path, + error: err.to_string(), + }), + } + } + + CacheDecision::from_reasons(reasons) +} + impl CacheActor { async fn run(mut self) { while let Some(msg) = self.rx.recv().await { @@ -129,8 +155,20 @@ impl CacheActor { hash, name, spec, + artifacts, tx, } => { + let decision = if self.force { + CacheDecision::miss(CacheMissReason::CacheIgnored) + } else { + let decision = self.db.rebuild_decision(&name, &hash, &spec); + if decision.is_hit() { + verify_artifacts(artifacts).await + } else { + decision + } + }; + Response { tx, kind: ResponseKind::NeedsRebuild { @@ -138,11 +176,7 @@ impl CacheActor { hash: &hash, }, } - .send(if self.force { - CacheDecision::miss(CacheMissReason::CacheIgnored) - } else { - self.db.rebuild_decision(&name, &hash, &spec) - }); + .send(decision); } CacheMessage::Update { entry, name } => { @@ -194,3 +228,36 @@ impl CacheActor { CacheAddr::new(tx) } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn verify_artifacts_hits_when_all_paths_are_files() { + let temp = TempDir::new().unwrap(); + let artifact = temp.path().join("parser.so"); + fs::write(&artifact, b"parser").await.unwrap(); + + assert_eq!(verify_artifacts(vec![artifact]).await, CacheDecision::Hit); + } + + #[tokio::test] + async fn verify_artifacts_reports_missing_and_non_file_paths() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("missing.so"); + let directory = temp.path().join("parser.so"); + fs::create_dir(&directory).await.unwrap(); + + assert_eq!( + verify_artifacts(vec![missing.clone(), directory.clone()]).await, + CacheDecision::Miss(crate::cache::CacheMiss { + reasons: vec![ + CacheMissReason::ArtifactMissing { path: missing }, + CacheMissReason::ArtifactNotFile { path: directory }, + ], + }) + ); + } +} diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 50b7943..458f70f 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -244,8 +244,16 @@ async fn discover_grammars( shutdown::check()?; let key = format!("{}/{}", language.name, name); - let entry = cache.get(key).await; let name_arc: std::sync::Arc = name.into(); + let artifacts = GrammarBuild::required_artifacts_for( + &language.output.build_dir, + &ts_cli, + &language.spec, + &name_arc, + )?; + let cache_decision = cache + .needs_rebuild(key, hash.clone(), language.spec.clone(), artifacts) + .await; let progress = display .add_grammar( @@ -258,8 +266,8 @@ async fn discover_grammars( builds.push(GrammarBuild { context: language.context.clone(), + cache_decision, dir: dir.into(), - entry, hash: hash.into(), language: language.name.clone(), name: name_arc, diff --git a/src/build.rs b/src/build.rs index d93eb98..f412468 100644 --- a/src/build.rs +++ b/src/build.rs @@ -44,7 +44,6 @@ pub struct OutputConfig { #[derive(Debug, Clone, PartialEq)] pub struct BuildContext { - pub ignore_cache: bool, pub overwrite_output: bool, } @@ -257,7 +256,6 @@ fn unique_languages(app: &App) -> Vec> { let result = match url { Ok(repo) => Ok(LanguageBuild::new( BuildContext { - ignore_cache: app.command.force || app.command.fresh, overwrite_output: app.command.force, }, Arc::new(BuildSpec { @@ -299,20 +297,22 @@ mod tests { use crate::{args::BuildCommand, display::Mode}; fn app_with_languages(languages: &[&str]) -> App { - let mut command = BuildCommand::default(); - command.languages = Some( - languages - .iter() - .map(|language| (*language).to_string()) - .collect(), - ); + let command = BuildCommand { + languages: Some( + languages + .iter() + .map(|language| (*language).to_string()) + .collect(), + ), + ..BuildCommand::default() + }; App { command, config_path: PathBuf::from("parsers.toml"), log_path: PathBuf::from("tmp/log"), progress_mode: Mode::Plain, - verbose: Default::default(), + verbose: clap_verbosity_flag::Verbosity::default(), } } diff --git a/src/cache.rs b/src/cache.rs index 4d7c32e..d1462b3 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -80,6 +80,16 @@ pub enum CacheMissReason { cached: Target, current: Target, }, + ArtifactMissing { + path: PathBuf, + }, + ArtifactNotFile { + path: PathBuf, + }, + ArtifactInaccessible { + path: PathBuf, + error: String, + }, } impl CacheDecision { @@ -170,6 +180,9 @@ impl CacheMissReason { Self::BuildScriptChanged => "script", Self::PrefixChanged { .. } => "prefix", Self::TargetChanged { .. } => "target", + Self::ArtifactMissing { .. } + | Self::ArtifactNotFile { .. } + | Self::ArtifactInaccessible { .. } => "artifact", } } @@ -185,6 +198,9 @@ impl CacheMissReason { Self::BuildScriptChanged => "build script changed", Self::PrefixChanged { .. } => "prefix changed", Self::TargetChanged { .. } => "target changed", + Self::ArtifactMissing { .. } => "artifact missing", + Self::ArtifactNotFile { .. } => "artifact invalid", + Self::ArtifactInaccessible { .. } => "artifact inaccessible", } } } @@ -220,6 +236,15 @@ impl fmt::Display for CacheMissReason { Self::TargetChanged { cached, current } => { write!(f, "target changed cached={cached:?} current={current:?}") } + Self::ArtifactMissing { path } => { + write!(f, "artifact missing path={}", path.display()) + } + Self::ArtifactNotFile { path } => { + write!(f, "artifact is not a regular file path={}", path.display()) + } + Self::ArtifactInaccessible { path, error } => { + write!(f, "artifact inaccessible path={} error={error}", path.display()) + } } } } diff --git a/src/parser.rs b/src/parser.rs index 73c6ea6..d9b17d1 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -15,7 +15,7 @@ use tracing::{debug, warn}; use crate::{ actors::ProgressAddr, build::{BuildContext, BuildSpec, OutputConfig}, - cache::{self, CacheDecision, CacheMissReason, Entry, Update}, + cache::{self, CacheDecision, Entry, Update}, error::{self, TsdlError}, git::clone_fast, sh::{Exec, Script}, @@ -30,8 +30,8 @@ pub const WASM_EXTENSION: &str = "wasm"; #[derive(Clone, Debug)] pub struct GrammarBuild { pub context: BuildContext, + pub cache_decision: CacheDecision, pub dir: Arc, - pub entry: Option, pub hash: Arc, pub language: Arc, // Required for error reporting and cache keys; set from parent LanguageBuild pub name: Arc, @@ -55,11 +55,11 @@ impl GrammarBuild { self.progress.step("checking cache"); let key = format!("{}/{}", self.language, self.name); - // Check cache: if cached and definitions match, skip build but still install - let cache_decision = self.cache_decision(); - debug!("[grammar:cache] {key}: {cache_decision}"); + // Cache decisions are computed centrally by the cache actor, including + // verification that all required managed artifacts exist. + debug!("[grammar:cache] {key}: {}", self.cache_decision); - if cache_decision.is_hit() { + if self.cache_decision.is_hit() { self.progress.set_outcome_cached().await; // Install the binary from the build directory if let Err(e) = self.install().await { @@ -72,7 +72,7 @@ impl GrammarBuild { } self.progress.set_outcome_built().await; - self.progress.msg(cache_decision.short_message()); + self.progress.msg(self.cache_decision.short_message()); // Use the grammar directory path provided if !self.dir.exists() { @@ -108,11 +108,7 @@ impl GrammarBuild { Ok(Some(update)) } - fn build_command(&self, ext: &str, output_name: &str) -> Command { - if let Some(script) = &self.spec.build_script { - return Command::from_str(script); - } - + fn builtin_build_command(&self, ext: &str, output_path: &Path) -> Command { let mut cmd = Command::new(self.ts_cli.as_os_str()); cmd.arg("build"); @@ -120,7 +116,7 @@ impl GrammarBuild { cmd.arg("--wasm"); } - cmd.args(["--output", output_name]); + cmd.arg("--output").arg(output_path); cmd } @@ -147,7 +143,7 @@ impl GrammarBuild { Ok(()) } - async fn build_target(&self, ext: &str) -> TsdlResult<()> { + async fn build_target(&self, ext: &str) -> TsdlResult { shutdown::test_delay().await; shutdown::check()?; debug!( @@ -155,23 +151,49 @@ impl GrammarBuild { self.language, self.name ); - let output_name = self.parser_name_and_ext(ext); - let mut cmd = self.build_command(ext, &output_name); + if let Some(script) = &self.spec.build_script { + return self.build_custom_target(ext, script).await; + } + + self.build_builtin_target(ext).await + } + + async fn build_builtin_target(&self, ext: &str) -> TsdlResult { + let artifact = self.artifact_path(ext)?; + ensure_parent_dir(&artifact).await?; + let mut cmd = self.builtin_build_command(ext, &artifact); cmd.current_dir(self.dir.as_ref()) .exec() .await - .map_err(|err| { - error::TsdlError::Step(error::Step::new( - self.language.clone(), - error::ParserOp::Build { - dir: self.dir.to_path_buf(), - }, - err, - )) - })?; + .map_err(|err| self.build_step_error(err))?; - Ok(()) + verify_artifact(&artifact).await?; + Ok(artifact) + } + + async fn build_custom_target(&self, ext: &str, script: &str) -> TsdlResult { + let mut cmd = Command::from_str(script); + cmd.current_dir(self.dir.as_ref()) + .exec() + .await + .map_err(|err| self.build_step_error(err))?; + + let discovered = self.brute_force_discover(ext).await?; + let artifact = self.artifact_path(ext)?; + self.stage_artifact(&discovered, &artifact).await?; + verify_artifact(&artifact).await?; + Ok(artifact) + } + + fn build_step_error(&self, err: TsdlError) -> TsdlError { + error::TsdlError::Step(error::Step::new( + self.language.clone(), + error::ParserOp::Build { + dir: self.dir.to_path_buf(), + }, + err, + )) } async fn build_targets(&self) -> TsdlResult<()> { @@ -199,7 +221,7 @@ impl GrammarBuild { }) } - async fn find_parser_binary(&self, ext: &str) -> TsdlResult { + async fn brute_force_discover(&self, ext: &str) -> TsdlResult { let expected_name = self.parser_name_and_ext(ext); let mut files = fs::read_dir(self.dir.as_ref()).await.map_err(|e| { TsdlError::context( @@ -211,8 +233,26 @@ impl GrammarBuild { let mut exact_match = None; let mut candidates = Vec::new(); - while let Ok(Some(entry)) = files.next_entry().await { - if !entry.file_type().await.unwrap().is_file() { + loop { + let Some(entry) = files.next_entry().await.map_err(|e| { + TsdlError::context( + format!("Failed to read directory entry in {}", self.dir.display()), + e, + ) + })? + else { + break; + }; + + let path = entry.path(); + let file_type = entry.file_type().await.map_err(|e| { + TsdlError::context( + format!("Failed to read file type for {}", path.display()), + e, + ) + })?; + + if !file_type.is_file() { continue; } @@ -220,12 +260,12 @@ impl GrammarBuild { let name = file_name.to_string_lossy(); if name == expected_name { - exact_match = Some(self.dir.join(&file_name)); + exact_match = Some(path); break; } if Path::new(&file_name).extension().and_then(|e| e.to_str()) == Some(ext) { - candidates.push(self.dir.join(&file_name)); + candidates.push(path); } } @@ -269,7 +309,7 @@ impl GrammarBuild { } async fn install_binary(&self, ext: &str) -> TsdlResult<()> { - let src = self.find_parser_binary(ext).await?; + let src = self.artifact_path(ext)?; let dst = self.output.out_dir.join(self.parser_name_and_ext(ext)); let src_metadata = fs::metadata(&src) .await @@ -352,6 +392,75 @@ impl GrammarBuild { Ok(()) } + async fn stage_artifact(&self, src: &Path, dst: &Path) -> TsdlResult<()> { + ensure_parent_dir(dst).await?; + let src_metadata = fs::metadata(src) + .await + .map_err(|e| TsdlError::context(format!("Reading {}", src.display()), e))?; + + if !src_metadata.is_file() { + return Err(TsdlError::message(format!( + "Discovered parser artifact is not a regular file: {}", + src.display() + ))); + } + + match fs::metadata(dst).await { + Ok(dst_metadata) if same_file_identity(&src_metadata, &dst_metadata) => return Ok(()), + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(TsdlError::context( + format!("Reading {}", dst.display()), + err, + )); + } + } + + self.replace_with_hardlink(src, dst).await + } + + pub fn required_artifacts_for( + build_dir: &Path, + ts_cli: &Path, + spec: &BuildSpec, + grammar_name: &str, + ) -> TsdlResult> { + let mut artifacts = Vec::new(); + + if spec.target.native() { + artifacts.push(artifact_path_for( + build_dir, + ts_cli, + spec, + grammar_name, + DLL_EXTENSION, + )?); + } + + if spec.target.wasm() { + artifacts.push(artifact_path_for( + build_dir, + ts_cli, + spec, + grammar_name, + WASM_EXTENSION, + )?); + } + + Ok(artifacts) + } + + fn artifact_path(&self, ext: &str) -> TsdlResult { + artifact_path_for( + &self.output.build_dir, + &self.ts_cli, + &self.spec, + &self.name, + ext, + ) + } + async fn replace_with_hardlink(&self, src: &Path, dst: &Path) -> TsdlResult<()> { let tmp = temp_install_path(dst)?; self.create_hardlink(src, &tmp).await?; @@ -388,18 +497,6 @@ impl GrammarBuild { )) } - /// Explain whether this grammar's cache entry can satisfy the requested build. - fn cache_decision(&self) -> CacheDecision { - if self.context.ignore_cache { - return CacheDecision::miss(CacheMissReason::CacheIgnored); - } - - match &self.entry { - None => CacheDecision::miss(CacheMissReason::MissingEntry), - Some(entry) => entry.rebuild_decision(&self.hash, &self.spec), - } - } - fn parser_name_and_ext(&self, ext: &str) -> String { format!("{}{}.{}", self.spec.prefix, self.name, ext) } @@ -466,6 +563,83 @@ impl LanguageBuild { } } +fn parser_name_and_ext(prefix: &str, grammar_name: &str, ext: &str) -> String { + format!("{prefix}{grammar_name}.{ext}") +} + +fn artifact_path_for( + build_dir: &Path, + ts_cli: &Path, + spec: &BuildSpec, + grammar_name: &str, + ext: &str, +) -> TsdlResult { + Ok(build_dir + .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) + .join(parser_name_and_ext(&spec.prefix, grammar_name, ext))) +} + +fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> TsdlResult { + let file_name = ts_cli + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + TsdlError::message(format!( + "Could not derive artifact id from tree-sitter CLI path {}", + ts_cli.display() + )) + })?; + let id = file_name.strip_prefix("tree-sitter-").unwrap_or(file_name); + Ok(format!("tsdl-{}", sanitize_path_component(id))) +} + +fn sanitize_path_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '-' + } + }) + .collect::(); + + if sanitized.is_empty() { + "unknown".to_string() + } else { + sanitized + } +} + +async fn ensure_parent_dir(path: &Path) -> TsdlResult<()> { + let parent = path.parent().ok_or_else(|| { + TsdlError::message(format!( + "Could not determine parent directory for {}", + path.display() + )) + })?; + + fs::create_dir_all(parent) + .await + .map_err(|e| TsdlError::context(format!("Creating {}", parent.display()), e)) +} + +async fn verify_artifact(path: &Path) -> TsdlResult<()> { + let metadata = fs::metadata(path) + .await + .map_err(|e| TsdlError::context(format!("Reading built artifact {}", path.display()), e))?; + + if metadata.is_file() { + Ok(()) + } else { + Err(TsdlError::message(format!( + "Built artifact is not a regular file: {}", + path.display() + ))) + } +} + fn same_file_identity(a: &Metadata, b: &Metadata) -> bool { a.dev() == b.dev() && a.ino() == b.ino() } @@ -582,12 +756,9 @@ mod tests { .add_grammar(GitRef::from("HEAD"), "rust", "rust", 1) .await; let build = GrammarBuild { - context: BuildContext { - ignore_cache: false, - overwrite_output, - }, + context: BuildContext { overwrite_output }, + cache_decision: CacheDecision::miss(cache::CacheMissReason::MissingEntry), dir: grammar_dir.clone().into(), - entry: None, hash: "test".into(), language: "rust".into(), name: "rust".into(), @@ -604,12 +775,21 @@ mod tests { target: Target::Native, tree_sitter: TreeSitter::default(), }), - ts_cli: Arc::new(PathBuf::from("tree-sitter")), + ts_cli: Arc::new(PathBuf::from("tree-sitter-macos-arm64-v0.26.5")), }; (build, display) } + async fn write_artifact(build: &GrammarBuild, ext: &str, contents: &[u8]) -> PathBuf { + let path = build.artifact_path(ext).unwrap(); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, contents).await.unwrap(); + path + } + fn same_identity(a: &Path, b: &Path) -> bool { let a = std::fs::metadata(a).unwrap(); let b = std::fs::metadata(b).unwrap(); @@ -653,6 +833,17 @@ mod tests { assert_eq!(not_stripped, "custom-parser"); } + #[test] + fn test_artifact_dir_name_from_tree_sitter_cli() { + assert_eq!( + artifact_dir_name_from_tree_sitter_cli(Path::new( + "/tmp/tsdl/tree-sitter-macos-arm64-v0.26.5" + )) + .unwrap(), + "tsdl-macos-arm64-v0.26.5" + ); + } + #[test] fn test_parser_name_and_ext() { let name = parser_name_and_ext("typescript", "", "so"); @@ -676,11 +867,10 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let src = grammar_dir.join("rust.so"); let dst = out_dir.join("rust.so"); - tokio::fs::write(&src, b"parser").await.unwrap(); let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, "so", b"parser").await; build.install_binary("so").await.unwrap(); display.shutdown(false).await; @@ -695,13 +885,13 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let src = grammar_dir.join("rust.so"); let dst = out_dir.join("rust.so"); - tokio::fs::write(&src, b"parser").await.unwrap(); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, "so", b"parser").await; tokio::fs::write(&dst, b"parser").await.unwrap(); assert!(!same_identity(&src, &dst)); - let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; build.install_binary("so").await.unwrap(); display.shutdown(false).await; @@ -716,12 +906,11 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let src = grammar_dir.join("rust.so"); let dst = out_dir.join("rust.so"); - tokio::fs::write(&src, b"new").await.unwrap(); - tokio::fs::write(&dst, b"old").await.unwrap(); let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, "so", b"new").await; + tokio::fs::write(&dst, b"old").await.unwrap(); let err = build.install_binary("so").await.unwrap_err(); display.shutdown(false).await; @@ -738,12 +927,11 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let src = grammar_dir.join("rust.so"); let dst = out_dir.join("rust.so"); - tokio::fs::write(&src, b"new").await.unwrap(); - tokio::fs::write(&dst, b"old").await.unwrap(); let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; + let src = write_artifact(&build, "so", b"new").await; + tokio::fs::write(&dst, b"old").await.unwrap(); build.install_binary("so").await.unwrap(); display.shutdown(false).await; @@ -759,14 +947,13 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let src = grammar_dir.join("rust.so"); let dst = out_dir.join("rust.so"); let target = out_dir.join("target.so"); - tokio::fs::write(&src, b"new").await.unwrap(); - tokio::fs::write(&target, b"old").await.unwrap(); - symlink(&target, &dst).unwrap(); let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let _src = write_artifact(&build, "so", b"new").await; + tokio::fs::write(&target, b"old").await.unwrap(); + symlink(&target, &dst).unwrap(); let err = build.install_binary("so").await.unwrap_err(); display.shutdown(false).await; @@ -783,12 +970,11 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let src = grammar_dir.join("rust.so"); let dst = out_dir.join("rust.so"); - tokio::fs::write(&src, b"new").await.unwrap(); - tokio::fs::create_dir(&dst).await.unwrap(); let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; + let _src = write_artifact(&build, "so", b"new").await; + tokio::fs::create_dir(&dst).await.unwrap(); let err = build.install_binary("so").await.unwrap_err(); display.shutdown(false).await; diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index 0639699..c253e36 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -5,7 +5,9 @@ use assert_fs::prelude::*; use predicates::{self as p, prelude::*}; use rstest::*; -use tsdl::consts::{TSDL_BUILD_DIR, TSDL_OUT_DIR, TSDL_PREFIX}; +use tsdl::consts::{ + TREE_SITTER_PLATFORM, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_OUT_DIR, TSDL_PREFIX, +}; use crate::cmd::Sandbox; @@ -153,6 +155,9 @@ fn force_flag_reinstalls_hardlink() { .tmp .child(TSDL_BUILD_DIR) .child("tree-sitter-json") + .child(format!( + "tsdl-{TREE_SITTER_PLATFORM}-v{TREE_SITTER_VERSION}" + )) .child(format!("libtree-sitter-json.{DLL_EXTENSION}")); let first_inode_out = binary.metadata().unwrap().ino(); From 1a696ebce139cbca40a613ec88c89206d0163161 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 11:10:02 +0200 Subject: [PATCH 34/88] args: implement proper cli args > env vars > config > defaults --- Cargo.lock | 229 +------------------ Cargo.toml | 2 - TODO.md | 5 - src/app.rs | 52 +++-- src/args.rs | 280 ++++++++++++------------ src/build.rs | 9 +- src/cache.rs | 3 +- src/config.rs | 555 ++++++++++++++++++++++++++++++++++++++++++++--- src/error.rs | 6 - src/logging.rs | 52 ++--- src/main.rs | 28 +-- tests/cmd/log.rs | 1 - tests/cmd/mod.rs | 16 +- tests/config.rs | 259 +++++++++++++++++++--- 14 files changed, 987 insertions(+), 510 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8703b28..f561775 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,28 +824,6 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" -[[package]] -name = "diff-struct" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79aac083112b31f7cb768b24b893dc0c34c296a4b06b250c407bfd495e42075c" -dependencies = [ - "diff_derive", - "num", - "serde", -] - -[[package]] -name = "diff_derive" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe165e7ead196bbbf44c7ce11a7a21157b5c002ce46d7098ff9c556784a4912d" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "difflib" version = "0.4.0" @@ -1014,20 +992,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "figment" -version = "0.10.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" -dependencies = [ - "atomic", - "pear", - "serde", - "toml 0.8.23", - "uncased", - "version_check", -] - [[package]] name = "filedescriptor" version = "0.8.3" @@ -1421,7 +1385,7 @@ dependencies = [ "serde", "serde_derive", "sysinfo 0.38.4", - "toml 1.1.4+spec-1.1.0", + "toml", "uuid", ] @@ -1650,12 +1614,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - [[package]] name = "instability" version = "0.3.13" @@ -1964,39 +1922,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.2" @@ -2014,36 +1939,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2221,29 +2116,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.119", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2433,7 +2305,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit", ] [[package]] @@ -2445,19 +2317,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "version_check", - "yansi", -] - [[package]] name = "quick-xml" version = "0.38.4" @@ -3075,15 +2934,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -3638,18 +3488,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -3658,20 +3496,11 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.4", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "winnow", ] [[package]] @@ -3683,20 +3512,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -3704,9 +3519,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.4", + "winnow", ] [[package]] @@ -3715,15 +3530,9 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.4", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" @@ -3878,9 +3687,7 @@ dependencies = [ "const-str", "crossterm", "derive_more", - "diff-struct", "enum_dispatch", - "figment", "fs2", "futures", "human-panic", @@ -3901,7 +3708,7 @@ dependencies = [ "sysinfo 0.39.6", "tempfile", "tokio", - "toml 1.1.4+spec-1.1.0", + "toml", "tracing", "tracing-appender", "tracing-error", @@ -3922,15 +3729,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - [[package]] name = "unicode-ident" version = "1.0.24" @@ -4522,15 +4320,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 8f52df0..bee88d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,9 +49,7 @@ clap-verbosity-flag = "3.0" console = "0.16" crossterm = "0.29" derive_more = { version = "2", features = ["as_ref", "deref", "display"] } -diff-struct = "0.5" enum_dispatch = "0.3" -figment = { version = "0.10", features = ["toml", "env"] } fs2 = "0.4" futures = "0.3" libc = "0.2" diff --git a/TODO.md b/TODO.md index 04cdf43..cc22b7e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,10 +1,5 @@ # TODO -## Configuration - -- [ ] Investigate a figment replacement / custom impl to merge different configuration - sources. - ## Tests - [ ] changing log file destination from command line, apparently it's not working. diff --git a/src/app.rs b/src/app.rs index f66aeea..70652a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,30 +2,48 @@ use std::path::PathBuf; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use crate::{args::Args, args::BuildCommand, config, display, TsdlResult}; +use crate::{ + args::{BuildCommand, Command}, + config, display, logging, + TsdlResult, +}; -/// Application containing all resolved configuration and state. +/// Resolved application state, ready to run. pub struct App { + pub subcommand: Command, pub command: BuildCommand, + pub provenance: config::BuildProvenance, pub config_path: PathBuf, pub log_path: PathBuf, pub progress_mode: display::Mode, pub verbose: Verbosity, + pub _logging: Option, } -impl App { - /// Create application from CLI arguments. - /// This resolves and merges all configuration sources (CLI, config file, defaults). - pub fn new(args: &Args, log_path: PathBuf) -> TsdlResult { - let command = config::current(&args.config, args.command.as_build())?; - let progress_mode = display::mode_from_args(&args.progress, &args.verbose); - - Ok(Self { - command, - log_path, - progress_mode, - config_path: args.config.clone(), - verbose: args.verbose, - }) - } +pub fn setup() -> TsdlResult { + let (args, matches) = config::parse_with_matches(); + let build_matches = crate::args::build_matches(&matches); + + let (command, provenance) = + config::current_with_provenance(&args.config, build_matches)?; + + let (log_path, _logging) = logging::init( + args.log.clone(), + args.log_color, + args.verbose, + &command.build_dir, + )?; + + let progress_mode = display::mode_from_args(&args.progress, &args.verbose); + + Ok(App { + subcommand: args.command, + command, + provenance, + config_path: args.config, + log_path, + progress_mode, + verbose: args.verbose, + _logging: Some(_logging), + }) } diff --git a/src/args.rs b/src/args.rs index 1a9023f..6e5eb90 100644 --- a/src/args.rs +++ b/src/args.rs @@ -2,10 +2,9 @@ use std::{collections::BTreeMap, fmt, num::NonZeroUsize, path::PathBuf}; use clap::{ builder::styling::{AnsiColor, Color, Style}, - crate_authors, + crate_authors, ArgMatches, }; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use diff::Diff; use serde::{Deserialize, Serialize}; use crate::consts::{ @@ -15,8 +14,7 @@ use crate::consts::{ const TSDL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/tsdl.version")); -/// Command-line arguments. -#[derive(Clone, Debug, Deserialize, clap::Parser, Serialize)] +#[derive(Clone, Debug, clap::Parser)] #[command(author = crate_authors!("\n"), version = TSDL_VERSION, about, styles=get_styles(), allow_external_subcommands = true)] #[command(help_template( "{before-help}{name} {version} @@ -47,9 +45,6 @@ pub struct Args { pub progress: ProgressStyle, /// Verbosity level: -v, -vv, or -q, -qq. - // clap_verbosity_flag, as of now, refuses to add a serialization feature, so this will not be part of the config file. - // It's global by default, so we don't need to specify it. - #[serde(skip_serializing, skip_deserializing)] #[command(flatten)] pub verbose: Verbosity, } @@ -68,15 +63,13 @@ pub enum ProgressStyle { Plain, } -#[allow(clippy::large_enum_variant)] -#[derive(clap::Subcommand, Clone, Debug, Deserialize, Serialize)] +#[derive(clap::Subcommand, Clone, Debug)] pub enum Command { /// Build one or many parsers. #[command(visible_alias = "b")] - Build(BuildCommand), + Build, /// Configuration helpers. - #[serde(skip_serializing, skip_deserializing)] #[command(visible_alias = "c")] Config { #[command(subcommand)] @@ -84,7 +77,6 @@ pub enum Command { }, /// Update tsdl to the latest compatible version. - #[serde(skip_serializing, skip_deserializing)] #[command(visible_alias = "u")] Selfupdate { /// Skip the downgrade confirmation prompt. @@ -99,30 +91,34 @@ pub enum Command { impl Command { #[must_use] - pub fn as_build(&self) -> Option<&BuildCommand> { - if let Command::Build(build) = self { - Some(build) - } else { - None - } + pub const fn is_build(&self) -> bool { + matches!(self, Command::Build) } +} - #[must_use] - pub fn as_config(&self) -> Option<&ConfigCommand> { - if let Command::Config { command } = self { - Some(command) - } else { - None +#[derive(Clone, Copy, Debug, Default)] +pub enum VersionBump { + /// Update to the latest version, including breaking changes. + Major, + /// Update to the latest minor or patch version (same MAJOR). + #[default] + Minor, + /// Update to the latest patch version (same MAJOR.MINOR). + Patch, +} + +impl fmt::Display for VersionBump { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Major => write!(f, "major"), + Self::Minor => write!(f, "minor"), + Self::Patch => write!(f, "patch"), } } } -#[derive(clap::ValueEnum, Clone, Copy, Debug, Deserialize, Diff, PartialEq, Eq, Serialize)] -#[diff(attr( - #[derive(Debug, PartialEq)] -))] +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] -#[derive(Default)] pub enum Target { #[default] Native, @@ -148,91 +144,126 @@ impl Target { pub fn wasm(&self) -> bool { matches!(self, Self::All | Self::Wasm) } + + pub fn to_lowercase(&self) -> &'static str { + match self { + Target::Native => "native", + Target::Wasm => "wasm", + Target::All => "all", + } + } } -#[derive(Clone, Copy, Debug, Default)] -pub enum VersionBump { - /// Update to the latest version, including breaking changes. - Major, - /// Update to the latest minor or patch version (same MAJOR). - #[default] - Minor, - /// Update to the latest patch version (same MAJOR.MINOR). - Patch, +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +#[serde(rename_all = "kebab-case")] +pub enum ParserConfig { + Full { + #[serde(alias = "cmd", alias = "script")] + build_script: Option, + + from: Option, + + #[serde(rename = "ref")] + git_ref: String, + }, + Ref(String), } -impl fmt::Display for VersionBump { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Major => write!(f, "major"), - Self::Minor => write!(f, "minor"), - Self::Patch => write!(f, "patch"), +/// Fully optional overrides for [`TreeSitter`]. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct OptionalTreeSitter { + #[serde(default, alias = "git-ref", alias = "ref")] + pub version: Option, + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub repo: Option, +} + +/// Fully optional overrides for every build configuration field. +/// +/// Every field is `Option` so that config sources (TOML file, +/// environment variables, CLI flags) can be layered: missing keys +/// remain `None` while explicitly-set fields are `Some(...)`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct OptionalBuildCommand { + #[serde(default)] + pub build_dir: Option, + #[serde(default)] + pub force: Option, + #[serde(default)] + pub fresh: Option, + #[serde(default, alias = "out", rename = "out-dir")] + pub out_dir: Option, + #[serde(default)] + pub jobs: Option, + #[serde(default)] + pub prefix: Option, + #[serde(default)] + pub show_config: Option, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub tree_sitter: OptionalTreeSitter, + #[serde(default)] + pub unlock_timeout: Option, + #[serde(default)] + pub parsers: Option>, + #[serde(skip_deserializing)] + pub languages: Option>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TreeSitter { + #[serde( + default = "default_tree_sitter_version", + alias = "git-ref", + alias = "ref" + )] + pub version: String, + + #[serde(default = "default_tree_sitter_platform")] + pub platform: String, + + #[serde(default = "default_tree_sitter_repo")] + pub repo: String, +} + +impl Default for TreeSitter { + fn default() -> Self { + Self { + version: TREE_SITTER_VERSION.to_string(), + platform: TREE_SITTER_PLATFORM.to_string(), + repo: TREE_SITTER_REPO.to_string(), } } } -#[allow(clippy::struct_excessive_bools)] -#[derive(clap::Args, Clone, Debug, Deserialize, Diff, PartialEq, Eq, Serialize)] -#[diff(attr( - #[derive(Debug, PartialEq)] -))] + // #[allow(clippy::struct_excessive_bools)] + // #[derive(clap::Args, Clone, Debug, Deserialize, Diff, PartialEq, Eq, Serialize)] + // #[diff(attr( + // #[derive(Debug, PartialEq)] + // ))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct BuildCommand { - /// Build Directory. - #[serde(default)] - #[arg(short, long, env = "TSDL_BUILD_DIR", default_value = TSDL_BUILD_DIR)] pub build_dir: PathBuf, - - /// Force clone the repository and rebuild, bypassing cache checks. Overwrites existing binaries. - #[arg(long, default_value_t = false)] - #[serde(default)] pub force: bool, - - /// Clears the `build-dir` and starts a fresh build. - #[arg(short, long, default_value_t = TSDL_FRESH)] - #[serde(default)] pub fresh: bool, - - /// Parsers to compile. - #[serde(skip_serializing, skip_deserializing)] - #[arg(verbatim_doc_comment)] + #[serde(skip_serializing)] pub languages: Option>, - - /// Number of threads; defaults to the number of available CPUs. - #[arg(short, long, env = "TSDL_NCPUS", default_value_t = NonZeroUsize::new(num_cpus::get()).unwrap())] - #[serde(default = "default_jobs")] pub jobs: NonZeroUsize, - - /// Output Directory. - #[arg(short, long, env = "TSDL_OUT_DIR", default_value = TSDL_OUT_DIR)] - #[serde(default)] + #[serde(rename = "out-dir", alias = "out")] pub out_dir: PathBuf, - - /// Configured Parsers. - #[clap(skip)] pub parsers: Option>, - - /// Prefix parser names. - #[arg(short, long, env = "TSDL_PREFIX", default_value = TSDL_PREFIX)] - #[serde(default)] pub prefix: String, - - /// Show Config. - #[arg(long, default_value_t = TSDL_SHOW_CONFIG)] - #[serde(default)] pub show_config: bool, - - /// Build target. - #[arg(short, long, value_enum, default_value_t = Target::default())] pub target: Target, - - #[command(flatten)] - #[serde(default)] pub tree_sitter: TreeSitter, - - /// Seconds to wait after terminating a lock owner for the build lock to be released. - #[arg(long, env = "TSDL_UNLOCK_TIMEOUT", default_value_t = TSDL_UNLOCK_TIMEOUT, value_parser = clap::value_parser!(u64).range(1..))] - #[serde(default)] pub unlock_timeout: u64, } @@ -255,57 +286,21 @@ impl Default for BuildCommand { } } -fn default_jobs() -> NonZeroUsize { +#[must_use] +pub fn default_jobs() -> NonZeroUsize { NonZeroUsize::new(num_cpus::get()).unwrap_or(NonZeroUsize::MIN) } -#[derive(Clone, Debug, Deserialize, Diff, Serialize, PartialEq, Eq)] -#[diff(attr( - #[derive(Debug, PartialEq)] -))] -#[serde(untagged)] -#[serde(rename_all = "kebab-case")] -pub enum ParserConfig { - Full { - #[serde(alias = "cmd", alias = "script")] - build_script: Option, - - #[diff(attr(#[derive(Debug, PartialEq)]))] - from: Option, - - #[serde(rename = "ref")] - #[diff(attr(#[derive(Debug, PartialEq)]))] - git_ref: String, - }, - Ref(String), +fn default_tree_sitter_version() -> String { + TREE_SITTER_VERSION.to_string() } -#[derive(clap::Args, Clone, Debug, Diff, Deserialize, PartialEq, Eq, Serialize)] -#[diff(attr( - #[derive(Debug, PartialEq)] -))] -pub struct TreeSitter { - /// Tree-sitter version. - #[arg(short = 'V', long = "tree-sitter-version", default_value = TREE_SITTER_VERSION)] - pub version: String, - - /// Tree-sitter platform to build. Change at your own risk. - #[clap(long = "tree-sitter-platform", default_value = TREE_SITTER_PLATFORM)] - pub platform: String, - - /// Tree-sitter repo. - #[arg(short = 'R', long = "tree-sitter-repo", default_value = TREE_SITTER_REPO)] - pub repo: String, +fn default_tree_sitter_platform() -> String { + TREE_SITTER_PLATFORM.to_string() } -impl Default for TreeSitter { - fn default() -> Self { - Self { - version: TREE_SITTER_VERSION.to_string(), - platform: TREE_SITTER_PLATFORM.to_string(), - repo: TREE_SITTER_REPO.to_string(), - } - } +fn default_tree_sitter_repo() -> String { + TREE_SITTER_REPO.to_string() } #[derive(clap::Subcommand, Clone, Debug, Default)] @@ -321,6 +316,17 @@ impl fmt::Display for ConfigCommand { } } +#[must_use] +pub fn build_matches(matches: &ArgMatches) -> Option<&ArgMatches> { + matches.subcommand().and_then(|(name, sub)| { + if matches!(name, "build" | "b") { + Some(sub) + } else { + None + } + }) +} + #[must_use] const fn get_styles() -> clap::builder::Styles { clap::builder::Styles::styled() diff --git a/src/build.rs b/src/build.rs index f412468..9f45838 100644 --- a/src/build.rs +++ b/src/build.rs @@ -47,7 +47,7 @@ pub struct BuildContext { pub overwrite_output: bool, } -pub fn run(app: &mut App) -> TsdlResult<()> { +pub fn run(app: &App) -> TsdlResult<()> { if app.command.show_config { crate::config::show(&app.command)?; } @@ -123,7 +123,7 @@ fn handle_locked_by( lock.wait_for_release(owner, unlock_timeout) } -fn clear(app: &mut App, guard: &LockGuard) -> TsdlResult<()> { +fn clear(app: &App, guard: &LockGuard) -> TsdlResult<()> { if app.command.fresh && app.command.build_dir.exists() { guard.clear_directory(std::slice::from_ref(&app.log_path))?; } @@ -294,7 +294,7 @@ fn unique_languages(app: &App) -> Vec> { #[cfg(test)] mod tests { use super::*; - use crate::{args::BuildCommand, display::Mode}; + use crate::{args::BuildCommand, config::BuildProvenance, display::Mode}; fn app_with_languages(languages: &[&str]) -> App { let command = BuildCommand { @@ -308,11 +308,14 @@ mod tests { }; App { + subcommand: crate::args::Command::Build, command, config_path: PathBuf::from("parsers.toml"), log_path: PathBuf::from("tmp/log"), progress_mode: Mode::Plain, + provenance: BuildProvenance::default(), verbose: clap_verbosity_flag::Verbosity::default(), + _logging: None, } } diff --git a/src/cache.rs b/src/cache.rs index d1462b3..b76808e 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,7 +1,6 @@ use std::{ collections::BTreeMap, - fmt, - fmt::Write, + fmt::{self, Write as _}, fs, path::{Path, PathBuf}, sync::Arc, diff --git a/src/config.rs b/src/config.rs index 956464c..ca437e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,59 +1,510 @@ -use std::path::Path; +use std::{ + ffi::OsString, + fs, + path::{Path, PathBuf}, +}; -use diff::Diff; -use figment::{ - providers::{Format, Serialized, Toml}, - Figment, +use clap::{ + parser::ValueSource, + value_parser, Arg, ArgAction, ArgMatches, CommandFactory, FromArgMatches, }; +use serde::Serialize; use tracing::debug; use crate::{ - app::App, - args::{BuildCommand, ConfigCommand}, + args::{ + Args, BuildCommand, ConfigCommand, OptionalBuildCommand, Target, + }, columns, error::TsdlError, TsdlResult, }; -pub fn current(config: &Path, command: Option<&BuildCommand>) -> TsdlResult { - let from_default = BuildCommand::default(); - let mut from_file: BuildCommand = Figment::new() - .merge(Serialized::defaults(from_default.clone())) - .merge(Toml::file(config)) - .extract() - .map_err(|e| TsdlError::context("Merging default and config file", e))?; - match command { - Some(from_command) => { - debug!("Merging cli args + config files"); - let diff = from_default.diff(from_command); - debug!("diff default command = {:?}", diff); - from_file.apply(&diff); +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ConfigSource { + #[default] + BuiltInDefault, + ConfigFile, + Environment, + CommandLine, +} + +impl ConfigSource { + fn from_value_source(source: ValueSource) -> Option { + match source { + ValueSource::CommandLine => Some(Self::CommandLine), + ValueSource::EnvVariable => Some(Self::Environment), + _ => None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TreeSitterProvenance { + pub version: ConfigSource, + pub platform: ConfigSource, + pub repo: ConfigSource, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct BuildProvenance { + pub build_dir: ConfigSource, + pub force: ConfigSource, + pub fresh: ConfigSource, + pub languages: ConfigSource, + pub jobs: ConfigSource, + pub out_dir: ConfigSource, + pub parsers: ConfigSource, + pub prefix: ConfigSource, + pub show_config: ConfigSource, + pub target: ConfigSource, + pub tree_sitter: TreeSitterProvenance, + pub unlock_timeout: ConfigSource, +} + +pub fn current(config: &Path, matches: Option<&ArgMatches>) -> TsdlResult { + let (cmd, _provenance) = current_with_provenance(config, matches)?; + Ok(cmd) +} + +pub fn current_with_provenance( + config: &Path, + matches: Option<&ArgMatches>, +) -> TsdlResult<(BuildCommand, BuildProvenance)> { + let defaults = BuildCommand::default(); + let file_overrides = read_file_overrides(config)?; + let file_provenance = file_provenance_from(&file_overrides); + + let (cli_overrides, cli_provenance) = if let Some(matches) = matches { + extract_overrides(matches, &defaults) + } else { + (OptionalBuildCommand::default(), BuildProvenance::default()) + }; + + let command = merge(defaults, file_overrides, cli_overrides); + let provenance = merge_provenance(file_provenance, cli_provenance); + + debug!(?provenance, ?command, "Resolved build configuration"); + + Ok((command, provenance)) +} + +fn merge( + defaults: BuildCommand, + file: OptionalBuildCommand, + cli: OptionalBuildCommand, +) -> BuildCommand { + let mut cmd = defaults; + apply_opt(&mut cmd.build_dir, file.build_dir); + apply_opt(&mut cmd.build_dir, cli.build_dir); + apply_opt(&mut cmd.force, file.force); + apply_opt(&mut cmd.force, cli.force); + apply_opt(&mut cmd.fresh, file.fresh); + apply_opt(&mut cmd.fresh, cli.fresh); + apply_opt(&mut cmd.jobs, file.jobs); + apply_opt(&mut cmd.jobs, cli.jobs); + apply_opt(&mut cmd.out_dir, file.out_dir); + apply_opt(&mut cmd.out_dir, cli.out_dir); + if let Some(parsers) = file.parsers { + cmd.parsers = Some(parsers); + } + apply_opt(&mut cmd.prefix, file.prefix); + apply_opt(&mut cmd.prefix, cli.prefix); + apply_opt(&mut cmd.show_config, file.show_config); + apply_opt(&mut cmd.show_config, cli.show_config); + apply_opt(&mut cmd.target, file.target); + apply_opt(&mut cmd.target, cli.target); + apply_opt(&mut cmd.unlock_timeout, file.unlock_timeout); + apply_opt(&mut cmd.unlock_timeout, cli.unlock_timeout); + + apply_opt(&mut cmd.tree_sitter.version, file.tree_sitter.version); + apply_opt(&mut cmd.tree_sitter.version, cli.tree_sitter.version); + apply_opt(&mut cmd.tree_sitter.platform, file.tree_sitter.platform); + apply_opt(&mut cmd.tree_sitter.platform, cli.tree_sitter.platform); + apply_opt(&mut cmd.tree_sitter.repo, file.tree_sitter.repo); + apply_opt(&mut cmd.tree_sitter.repo, cli.tree_sitter.repo); + + if let Some(langs) = cli.languages { + cmd.languages = Some(langs); + } + + cmd +} + +fn apply_opt(field: &mut T, value: Option) { + if let Some(v) = value { + *field = v; + } +} + +fn read_file_overrides(config: &Path) -> TsdlResult { + if !config.exists() { + return Ok(OptionalBuildCommand::default()); + } + + let contents = fs::read_to_string(config) + .map_err(|e| TsdlError::context(format!("Reading config file {}", config.display()), e))?; + + if contents.trim().is_empty() { + return Ok(OptionalBuildCommand::default()); + } + + toml::from_str(&contents) + .map_err(|e| TsdlError::context(format!("Parsing config file {}", config.display()), e)) +} + +fn file_provenance_from(overrides: &OptionalBuildCommand) -> BuildProvenance { + let mut p = BuildProvenance::default(); + if overrides.build_dir.is_some() { + p.build_dir = ConfigSource::ConfigFile; + } + if overrides.force.is_some() { + p.force = ConfigSource::ConfigFile; + } + if overrides.fresh.is_some() { + p.fresh = ConfigSource::ConfigFile; + } + if overrides.jobs.is_some() { + p.jobs = ConfigSource::ConfigFile; + } + if overrides.out_dir.is_some() { + p.out_dir = ConfigSource::ConfigFile; + } + if overrides.parsers.is_some() { + p.parsers = ConfigSource::ConfigFile; + } + if overrides.prefix.is_some() { + p.prefix = ConfigSource::ConfigFile; + } + if overrides.show_config.is_some() { + p.show_config = ConfigSource::ConfigFile; + } + if overrides.target.is_some() { + p.target = ConfigSource::ConfigFile; + } + if overrides.unlock_timeout.is_some() { + p.unlock_timeout = ConfigSource::ConfigFile; + } + let ts = &overrides.tree_sitter; + if ts.version.is_some() { + p.tree_sitter.version = ConfigSource::ConfigFile; + } + if ts.platform.is_some() { + p.tree_sitter.platform = ConfigSource::ConfigFile; + } + if ts.repo.is_some() { + p.tree_sitter.repo = ConfigSource::ConfigFile; + } + p +} + +fn merge_provenance(file: BuildProvenance, cli: BuildProvenance) -> BuildProvenance { + BuildProvenance { + build_dir: merge_source(file.build_dir, cli.build_dir), + force: merge_source(file.force, cli.force), + fresh: merge_source(file.fresh, cli.fresh), + languages: merge_source(file.languages, cli.languages), + jobs: merge_source(file.jobs, cli.jobs), + out_dir: merge_source(file.out_dir, cli.out_dir), + parsers: merge_source(file.parsers, cli.parsers), + prefix: merge_source(file.prefix, cli.prefix), + show_config: merge_source(file.show_config, cli.show_config), + target: merge_source(file.target, cli.target), + tree_sitter: TreeSitterProvenance { + version: merge_source(file.tree_sitter.version, cli.tree_sitter.version), + platform: merge_source(file.tree_sitter.platform, cli.tree_sitter.platform), + repo: merge_source(file.tree_sitter.repo, cli.tree_sitter.repo), + }, + unlock_timeout: merge_source(file.unlock_timeout, cli.unlock_timeout), + } +} + +fn merge_source(file: ConfigSource, cli: ConfigSource) -> ConfigSource { + if cli != ConfigSource::default() { + cli + } else { + file + } +} + +pub fn build_cli(defaults: &BuildCommand) -> Vec { + let jobs_default = defaults.jobs.to_string(); + let ut_default = defaults.unlock_timeout.to_string(); + + vec![ + Arg::new("build-dir") + .long("build-dir") + .short('b') + .env("TSDL_BUILD_DIR") + .value_parser(value_parser!(PathBuf)) + .help(format!("Build Directory [default: {}]", defaults.build_dir.display())), + Arg::new("force") + .long("force") + .env("TSDL_FORCE") + .num_args(0..=1) + .require_equals(true) + .default_missing_value("true") + .value_parser(value_parser!(bool)) + .help("Force clone the repository and rebuild, bypassing cache checks"), + Arg::new("no-force") + .long("no-force") + .action(ArgAction::SetTrue) + .help("Disable --force, overriding config files or environment variables"), + Arg::new("fresh") + .long("fresh") + .short('f') + .env("TSDL_FRESH") + .num_args(0..=1) + .require_equals(true) + .default_missing_value("true") + .value_parser(value_parser!(bool)) + .help("Clears the build-dir and starts a fresh build"), + Arg::new("no-fresh") + .long("no-fresh") + .action(ArgAction::SetTrue) + .help("Disable --fresh, overriding config files or environment variables"), + Arg::new("languages") + .num_args(0..) + .value_parser(value_parser!(String)) + .help("Parsers to compile"), + Arg::new("jobs") + .long("jobs") + .short('j') + .env("TSDL_NCPUS") + .value_parser(value_parser!(usize)) + .help(format!("Number of threads [default: {jobs_default}]")), + Arg::new("out-dir") + .long("out-dir") + .short('o') + .env("TSDL_OUT_DIR") + .value_parser(value_parser!(PathBuf)) + .help(format!("Output Directory [default: {}]", defaults.out_dir.display())), + Arg::new("prefix") + .long("prefix") + .short('p') + .env("TSDL_PREFIX") + .value_parser(value_parser!(String)) + .help(format!("Prefix parser names [default: {}]", defaults.prefix)), + Arg::new("show-config") + .long("show-config") + .env("TSDL_SHOW_CONFIG") + .num_args(0..=1) + .require_equals(true) + .default_missing_value("true") + .value_parser(value_parser!(bool)) + .help("Show Config"), + Arg::new("no-show-config") + .long("no-show-config") + .action(ArgAction::SetTrue) + .help("Disable --show-config, overriding config files or environment variables"), + Arg::new("target") + .long("target") + .short('t') + .env("TSDL_TARGET") + .value_parser([ + clap::builder::PossibleValue::new("native"), + clap::builder::PossibleValue::new("wasm"), + clap::builder::PossibleValue::new("all"), + ]) + .help(format!("Build target [default: {}]", defaults.target.to_lowercase())), + Arg::new("tree-sitter-version") + .long("tree-sitter-version") + .short('V') + .env("TSDL_TREE_SITTER_VERSION") + .value_parser(value_parser!(String)) + .help(format!( + "Tree-sitter version [default: {}]", + defaults.tree_sitter.version + )), + Arg::new("tree-sitter-platform") + .long("tree-sitter-platform") + .env("TSDL_TREE_SITTER_PLATFORM") + .value_parser(value_parser!(String)) + .help(format!( + "Tree-sitter platform to build [default: {}]", + defaults.tree_sitter.platform + )), + Arg::new("tree-sitter-repo") + .long("tree-sitter-repo") + .short('R') + .env("TSDL_TREE_SITTER_REPO") + .value_parser(value_parser!(String)) + .help(format!( + "Tree-sitter repo [default: {}]", + defaults.tree_sitter.repo + )), + Arg::new("unlock-timeout") + .long("unlock-timeout") + .env("TSDL_UNLOCK_TIMEOUT") + .value_parser(value_parser!(u64).range(1..)) + .help(format!("Seconds to wait after terminating a lock owner [default: {ut_default}]")), + ] +} + +pub fn extract_overrides( + matches: &ArgMatches, + _defaults: &BuildCommand, +) -> (OptionalBuildCommand, BuildProvenance) { + let mut o = OptionalBuildCommand::default(); + let mut p = BuildProvenance::default(); + + extract_simple(matches, "build-dir", &mut o.build_dir, &mut p.build_dir); + extract_bool( + matches, "force", "no-force", &mut o.force, &mut p.force, + ); + extract_bool( + matches, "fresh", "no-fresh", &mut o.fresh, &mut p.fresh, + ); + + if let Some(source) = source_for(matches, "languages") { + let vals: Vec = matches + .get_many::("languages") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + if !vals.is_empty() { + o.languages = Some(vals); + p.languages = source; } - None => { - debug!("Skipping cli args + config file merger."); + } + + extract_simple_usize(matches, "jobs", &mut o.jobs, &mut p.jobs); + extract_simple(matches, "out-dir", &mut o.out_dir, &mut p.out_dir); + extract_simple(matches, "prefix", &mut o.prefix, &mut p.prefix); + + extract_bool( + matches, + "show-config", + "no-show-config", + &mut o.show_config, + &mut p.show_config, + ); + + extract_target(matches, "target", &mut o.target, &mut p.target); + + extract_simple(matches, "tree-sitter-version", &mut o.tree_sitter.version, &mut p.tree_sitter.version); + extract_simple(matches, "tree-sitter-platform", &mut o.tree_sitter.platform, &mut p.tree_sitter.platform); + extract_simple(matches, "tree-sitter-repo", &mut o.tree_sitter.repo, &mut p.tree_sitter.repo); + + extract_simple_u64(matches, "unlock-timeout", &mut o.unlock_timeout, &mut p.unlock_timeout); + + (o, p) +} + +fn extract_simple( + matches: &ArgMatches, + id: &str, + field: &mut Option, + provenance: &mut ConfigSource, +) { + if let Some(source) = source_for(matches, id) { + if let Some(val) = matches.get_one::(id) { + *field = Some(val.clone()); + *provenance = source; } } - debug!("from_both = {:?}", from_file); - // Figment is screwing with me, and it's overriding config coming - // from Env::prefixed("TSDL_"). - // The scary thing is that I might have to write my own config - // joiner, where I need to track provenance of the config, and also - // whether it was explicitly set or taken from default … Figment - // has many features I don't care about. - Ok(from_file) +} + +fn extract_simple_usize( + matches: &ArgMatches, + id: &str, + field: &mut Option, + provenance: &mut ConfigSource, +) { + if let Some(source) = source_for(matches, id) { + if let Some(&n) = matches.get_one::(id) { + if let Some(nz) = std::num::NonZeroUsize::new(n) { + *field = Some(nz); + *provenance = source; + } + } + } +} + +fn extract_simple_u64( + matches: &ArgMatches, + id: &str, + field: &mut Option, + provenance: &mut ConfigSource, +) { + if let Some(source) = source_for(matches, id) { + if let Some(&val) = matches.get_one::(id) { + *field = Some(val); + *provenance = source; + } + } +} + +fn extract_target( + matches: &ArgMatches, + id: &str, + field: &mut Option, + provenance: &mut ConfigSource, +) { + if let Some(source) = source_for(matches, id) { + if let Some(raw) = matches.get_one::(id) { + match raw.to_lowercase().as_str() { + "native" => { + *field = Some(Target::Native); + *provenance = source; + } + "wasm" => { + *field = Some(Target::Wasm); + *provenance = source; + } + "all" => { + *field = Some(Target::All); + *provenance = source; + } + _ => {} + } + } + } +} + +fn extract_bool( + matches: &ArgMatches, + positive_id: &str, + negative_id: &str, + field: &mut Option, + provenance: &mut ConfigSource, +) { + let pos_source = source_for(matches, positive_id); + let neg_source = source_for(matches, negative_id); + + if matches!(neg_source, Some(ConfigSource::CommandLine)) { + *field = Some(false); + *provenance = ConfigSource::CommandLine; + return; + } + + if let Some(source) = pos_source { + if let Some(&val) = matches.get_one::(positive_id) { + *field = Some(val); + *provenance = source; + } + } +} + +fn source_for(matches: &ArgMatches, id: &str) -> Option { + matches + .value_source(id) + .and_then(ConfigSource::from_value_source) } pub fn print_indent(s: &str, indent: &str) { s.lines().for_each(|line| println!("{indent}{line}")); } -pub fn run(app: &App, command: &ConfigCommand) -> TsdlResult<()> { +pub fn run(config_path: &Path, command: &ConfigCommand) -> TsdlResult<()> { match command { ConfigCommand::Current => { - let config: BuildCommand = current(&app.config_path, None)?; + let cmd: BuildCommand = current(config_path, None)?; println!( "{}", - toml::to_string(&config) + toml::to_string(&cmd) .map_err(|e| { TsdlError::context("Generating default TOML config", e) })? ); } @@ -85,3 +536,41 @@ pub fn show(command: &BuildCommand) -> TsdlResult<()> { println!(); Ok(()) } + +pub fn parse_with_matches() -> (Args, ArgMatches) { + let defaults = BuildCommand::default(); + let build_args = build_cli(&defaults); + let mut cmd = Args::command(); + if let Some(build_sub) = cmd.find_subcommand_mut("build") { + let mut new_sub = build_sub.clone(); + for arg in build_args { + new_sub = new_sub.arg(arg); + } + *build_sub = new_sub; + } + let matches = cmd.get_matches(); + let args = Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); + (args, matches) +} + +pub fn try_parse_from_with_matches( + itr: I, +) -> Result<(Args, ArgMatches), clap::Error> +where + I: IntoIterator, + T: Into + Clone, +{ + let defaults = BuildCommand::default(); + let build_args = build_cli(&defaults); + let mut cmd = Args::command(); + if let Some(build_sub) = cmd.find_subcommand_mut("build") { + let mut new_sub = build_sub.clone(); + for arg in build_args { + new_sub = new_sub.arg(arg); + } + *build_sub = new_sub; + } + let matches = cmd.try_get_matches_from(itr)?; + let args = Args::from_arg_matches(&matches)?; + Ok((args, matches)) +} diff --git a/src/error.rs b/src/error.rs index 2dc9565..7c98544 100644 --- a/src/error.rs +++ b/src/error.rs @@ -388,12 +388,6 @@ impl From for TsdlError { } } -impl From for TsdlError { - fn from(e: figment::Error) -> Self { - TsdlError::Message(format!("Configuration error: {e}")) - } -} - impl From for TsdlError { fn from(e: semver::Error) -> Self { TsdlError::Message(format!("Semver error: {e}")) diff --git a/src/logging.rs b/src/logging.rs index 25ae218..17b9b7b 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -11,37 +11,36 @@ use tracing_subscriber::{layer::SubscriberExt, Layer}; use crate::{ absolute_normalize, - args::{Args, LogColor}, - config::current, - consts::{TSDL_BUILD_DIR, TSDL_CACHE_FILE, TSDL_LOCK_FILE, TSDL_LOG_FILE}, + args::LogColor, + consts::{TSDL_CACHE_FILE, TSDL_LOCK_FILE, TSDL_LOG_FILE}, error::TsdlError, TsdlResult, }; -pub struct Logging { - pub path: PathBuf, - _guard: WorkerGuard, -} +#[allow(dead_code)] +pub struct Guard(WorkerGuard); -pub fn init(args: &Args) -> TsdlResult { - let color = match args.log_color { +pub fn init( + log: Option, + log_color: LogColor, + verbose: clap_verbosity_flag::Verbosity, + build_dir: &Path, +) -> TsdlResult<(PathBuf, Guard)> { + let color = match log_color { LogColor::Auto => atty::is(atty::Stream::Stdout), LogColor::No => false, LogColor::Yes => true, }; console::set_colors_enabled(color); - let filter = args.verbose.log_level_filter().as_trace(); - let path = resolve_log_path(args)?; + let filter = verbose.log_level_filter().as_trace(); + let path = resolve_log_path(log.as_ref(), build_dir)?; let file = open_log_file(&path)?; - let guard = init_tracing(file, color, filter); - Ok(Logging { - path, - _guard: guard, - }) + let (writer, guard) = tracing_appender::non_blocking(file); + init_tracing(writer, color, filter); + Ok((path, Guard(guard))) } -fn init_tracing(file: File, color: bool, filter: LevelFilter) -> WorkerGuard { - let (writer, guard) = tracing_appender::non_blocking(file); +fn init_tracing(writer: tracing_appender::non_blocking::NonBlocking, color: bool, filter: LevelFilter) { let stdout_layer = tracing_subscriber::fmt::layer() .compact() .with_ansi(color) @@ -72,20 +71,13 @@ fn init_tracing(file: File, color: bool, filter: LevelFilter) -> WorkerGuard { let subscriber = tracing_subscriber::registry().with(file_layer); tracing::subscriber::set_global_default(subscriber).unwrap(); } - guard } -fn resolve_log_path(args: &Args) -> TsdlResult { - let command = current(&args.config, args.command.as_build()).ok(); - let build_dir = command - .as_ref() - .map_or_else(|| PathBuf::from(TSDL_BUILD_DIR), |c| c.build_dir.clone()); - let log = args - .log - .as_ref() - .map_or_else(|| build_dir.join(TSDL_LOG_FILE), std::clone::Clone::clone); - - validate_log_path(&build_dir, &log) +fn resolve_log_path(log: Option<&PathBuf>, build_dir: &Path) -> TsdlResult { + let log = log + .map_or_else(|| build_dir.join(TSDL_LOG_FILE), Clone::clone); + + validate_log_path(build_dir, &log) } fn validate_log_path(build_dir: &Path, log: &Path) -> TsdlResult { diff --git a/src/main.rs b/src/main.rs index 56b8d3b..f869f9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,22 @@ use std::{process::ExitCode, time::Instant}; -use clap::Parser; use console::style; use tracing::{error, info}; -use tsdl::{TsdlResult, app::App, args, error::TsdlError, logging}; +use tsdl::{TsdlResult, app, args, error::TsdlError}; fn main() -> ExitCode { set_panic_hook(); - let args = args::Args::parse(); - - let logging = match logging::init(&args) { - Ok(logging) => logging, + let app = match app::setup() { + Ok(app) => app, Err(e) => { - eprintln!("Could not initialize logging: {e}"); + eprintln!("{e}"); return ExitCode::FAILURE; } }; info!("Starting"); - match App::new(&args, logging.path.clone()).and_then(|mut app| run(&mut app, &args)) { + match run(app) { Err(TsdlError::Interrupted(signal)) => ExitCode::from(signal.shell_exit_code()), Err(e) => { eprintln!("{e}"); @@ -29,10 +26,10 @@ fn main() -> ExitCode { } } -fn run(app: &mut App, args: &args::Args) -> TsdlResult<()> { - match &args.command { - args::Command::Build(_) => { - let (result, duration) = time(|| tsdl::build::run(app)); +fn run(app: app::App) -> TsdlResult<()> { + match app.subcommand { + tsdl::args::Command::Build => { + let (result, duration) = time(|| tsdl::build::run(&app)); match &result { Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), Err(TsdlError::Interrupted(signal)) => println!( @@ -43,10 +40,10 @@ fn run(app: &mut App, args: &args::Args) -> TsdlResult<()> { } result } - args::Command::Config { command } => tsdl::config::run(app, command), - args::Command::Selfupdate{ force, target} => tsdl::selfupdate::run(*force, target), + args::Command::Config { command } => tsdl::config::run(&app.config_path, &command), + args::Command::Selfupdate{ force, target} => tsdl::selfupdate::run(force, target.as_str()), } -} + } pub fn set_panic_hook() { std::panic::set_hook(Box::new(move |info| { @@ -56,7 +53,6 @@ pub fn set_panic_hook() { let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) .authors(env!("CARGO_PKG_AUTHORS").replace(':', ", ")) .homepage(env!("CARGO_PKG_HOMEPAGE")); - let file_path = handle_dump(&meta, info); print_msg(file_path, &meta) .expect("human-panic: printing error message to console failed"); diff --git a/tests/cmd/log.rs b/tests/cmd/log.rs index 2a83b3c..ce175a0 100644 --- a/tests/cmd/log.rs +++ b/tests/cmd/log.rs @@ -59,7 +59,6 @@ fn build_rejects_log_paths_that_conflict_with_fresh_cleanup( .cmd .assert() .failure() - .stderr(p::str::contains("Could not initialize logging")) .stderr(p::str::contains(expected)); } diff --git a/tests/cmd/mod.rs b/tests/cmd/mod.rs index 0fffa5c..93a95e1 100644 --- a/tests/cmd/mod.rs +++ b/tests/cmd/mod.rs @@ -11,12 +11,8 @@ use std::{env, fs, path::Path}; use assert_cmd::{cargo::cargo_bin_cmd, Command}; use assert_fs::TempDir; -use figment::{ - providers::{Format, Serialized, Toml}, - Figment, -}; -use tsdl::{args::BuildCommand, consts::TSDL_CONFIG_FILE}; +use tsdl::{args::BuildCommand, config as tsdl_config, consts::TSDL_CONFIG_FILE}; pub struct Sandbox { pub build: BuildCommand, @@ -40,13 +36,9 @@ impl Sandbox { self.config_at(config, &self.tmp.path().join(TSDL_CONFIG_FILE)) } - pub fn config_at(&mut self, config: &str, dst: &Path) -> &mut Self { - self.build = Figment::new() - .merge(Serialized::defaults(BuildCommand::default())) - .merge(Toml::string(config)) - .extract() - .unwrap(); - fs::write(dst, config).unwrap(); + pub fn config_at(&mut self, config_contents: &str, dst: &Path) -> &mut Self { + fs::write(dst, config_contents).unwrap(); + self.build = tsdl_config::current(dst, None).unwrap(); self } diff --git a/tests/config.rs b/tests/config.rs index 8998813..f1941b5 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,25 +1,70 @@ +use std::{env, ffi::OsString, sync::Mutex}; + use anyhow::Result; use assert_fs::prelude::*; use indoc::{formatdoc, indoc}; #[cfg(test)] use pretty_assertions::{assert_eq, assert_ne}; +use std::path::PathBuf; + use tsdl::{ - args::BuildCommand, - config, + args::{self, BuildCommand, Target}, + config::{self, ConfigSource}, consts::{ TREE_SITTER_PLATFORM, TREE_SITTER_REPO, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_FRESH, - TSDL_OUT_DIR, TSDL_SHOW_CONFIG, + TSDL_OUT_DIR, TSDL_PREFIX, TSDL_SHOW_CONFIG, }, }; +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var_os(key); + env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } +} + +fn current_with_cli(config: &assert_fs::fixture::ChildPath, argv: &[&str]) -> BuildCommand { + let _lock = ENV_LOCK.lock().unwrap(); + let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); + let build_matches = args::build_matches(&matches); + config::current(config.path(), build_matches).unwrap() +} + +fn current_with_cli_provenance( + config: &assert_fs::fixture::ChildPath, + argv: &[&str], +) -> (BuildCommand, config::BuildProvenance) { + let _lock = ENV_LOCK.lock().unwrap(); + let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); + let build_matches = args::build_matches(&matches); + config::current_with_provenance(config.path(), build_matches).unwrap() +} + #[test] fn current_from_generated_default() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); let def = BuildCommand::default(); generated.write_str(&toml::to_string(&def)?)?; - assert_eq!(def, config::current(&generated, None).unwrap()); + assert_eq!(def, config::current(generated.path(), None).unwrap()); Ok(()) } @@ -29,34 +74,43 @@ fn current_from_empty() -> Result<()> { let generated = temp.child("generated.toml"); let def = BuildCommand::default(); generated.touch()?; - assert_eq!(def, config::current(&generated, None).unwrap()); + assert_eq!(def, config::current(generated.path(), None).unwrap()); Ok(()) } #[test] -fn current_preserve_languages() -> Result<()> { +fn current_preserves_cli_languages() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); - let mut def = BuildCommand::default(); generated.touch()?; - def.languages = None; - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); - def.languages = Some(vec![]); - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); - def.languages = Some(vec!["rust".to_string()]); - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); - def.languages = Some(vec!["rust".to_string(), "ruby".to_string()]); - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); + + let def = BuildCommand::default(); + assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); + + let mut expected = BuildCommand { + languages: Some(vec!["rust".to_string()]), + ..BuildCommand::default() + }; + assert_eq!( + expected, + current_with_cli(&generated, &["tsdl", "build", "rust"]) + ); + + expected.languages = Some(vec!["rust".to_string(), "ruby".to_string()]); + assert_eq!( + expected, + current_with_cli(&generated, &["tsdl", "build", "rust", "ruby"]) + ); Ok(()) } #[test] fn current_default_is_default() -> Result<()> { - let config = formatdoc! { + let config_contents = formatdoc! { r#" build-dir = "{}" fresh = {} - out = "{}" + out-dir = "{}" show-config = {} [tree-sitter] @@ -75,19 +129,19 @@ fn current_default_is_default() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); let def = BuildCommand::default(); - generated.write_str(&config)?; - assert_eq!(def, config::current(&generated, None).unwrap()); - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); + generated.write_str(&config_contents)?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); Ok(()) } #[test] fn current_overrides_default() -> Result<()> { - let config = indoc! { + let config_contents = indoc! { r#" build-dir = "/root" fresh = true - out = "tree-sitter-parsers" + out-dir = "tree-sitter-parsers" show-config = true [tree-sitter] @@ -99,9 +153,162 @@ fn current_overrides_default() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); let def = BuildCommand::default(); - generated.write_str(config)?; - generated.assert(config); - assert_ne!(def, config::current(&generated, None).unwrap()); - assert_ne!(def, config::current(&generated, Some(&def)).unwrap()); + generated.write_str(config_contents)?; + generated.assert(config_contents); + assert_ne!(def, config::current(generated.path(), None).unwrap()); + assert_ne!(def, current_with_cli(&generated, &["tsdl", "build"])); Ok(()) } + +#[test] +fn cli_can_override_config_to_builtin_default_value() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("target = \"wasm\"\n")?; + + let resolved = current_with_cli(&generated, &["tsdl", "build", "--target", "native"]); + + assert_eq!(resolved.target, Target::Native); + Ok(()) +} + +#[test] +fn cli_can_override_tree_sitter_version_to_builtin_default_value() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("[tree-sitter]\nversion = \"0.25.0\"\n")?; + + let resolved = current_with_cli( + &generated, + &[ + "tsdl", + "build", + "--tree-sitter-version", + TREE_SITTER_VERSION, + ], + ); + + assert_eq!(resolved.tree_sitter.version, TREE_SITTER_VERSION); + Ok(()) +} + +#[test] +fn negative_boolean_flags_override_config_file() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("force = true\nfresh = true\nshow-config = true\n")?; + + let resolved = current_with_cli( + &generated, + &[ + "tsdl", + "build", + "--no-force", + "--no-fresh", + "--no-show-config", + ], + ); + + assert!(!resolved.force); + assert!(!resolved.fresh); + assert!(!resolved.show_config); + Ok(()) +} + +#[test] +fn negative_boolean_flag_overrides_positive() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; + + let (cmd, prov) = current_with_cli_provenance( + &generated, + &["tsdl", "build", "--force=true", "--no-force"], + ); + + assert!(!cmd.force); + assert_eq!(prov.force, ConfigSource::CommandLine); + Ok(()) +} + +#[test] +fn env_can_override_config_to_builtin_default_value() -> Result<()> { + let _lock = ENV_LOCK.lock().unwrap(); + let _prefix = EnvVarGuard::set("TSDL_PREFIX", TSDL_PREFIX); + + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("prefix = \"\"\n")?; + + let (_parsed_args, matches) = + config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = + config::current_with_provenance(generated.path(), build_matches)?; + + assert_eq!(cmd.prefix, TSDL_PREFIX); + assert_eq!(prov.prefix, ConfigSource::Environment); + Ok(()) +} + +#[test] +fn boolean_env_can_override_config_file() -> Result<()> { + let _lock = ENV_LOCK.lock().unwrap(); + let _force = EnvVarGuard::set("TSDL_FORCE", "false"); + + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("force = true\n")?; + + let (_parsed_args, matches) = + config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = + config::current_with_provenance(generated.path(), build_matches)?; + + assert!(!cmd.force); + assert_eq!(prov.force, ConfigSource::Environment); + Ok(()) +} + +#[test] +fn cli_has_precedence_over_env() -> Result<()> { + let _lock = ENV_LOCK.lock().unwrap(); + let _target = EnvVarGuard::set("TSDL_TARGET", "wasm"); + + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; + + let (_parsed_args, matches) = + config::try_parse_from_with_matches(["tsdl", "build", "--target", "native"]) + .unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = + config::current_with_provenance(generated.path(), build_matches)?; + + assert_eq!(cmd.target, Target::Native); + assert_eq!(prov.target, ConfigSource::CommandLine); + Ok(()) +} + +#[test] +fn cli_explicit_default_value_overrides_config_file() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("build-dir = \"/custom\"\n")?; + + let (cmd, prov) = current_with_cli_provenance( + &generated, + &["tsdl", "build", "--build-dir", "tmp"], + ); + + assert_eq!(cmd.build_dir, PathBuf::from("tmp")); + assert_eq!( + prov.build_dir, + ConfigSource::CommandLine + ); + Ok(()) +} + + From 052b55cb0f3315cda310a4a53f92eba5e67c731a Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 16:26:22 +0200 Subject: [PATCH 35/88] cargo: remove unused deps --- Cargo.lock | 32 ++++---------------------------- Cargo.toml | 7 +------ 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f561775..6269e15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,9 +360,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" dependencies = [ "find-msvc-tools", "jobserver", @@ -933,18 +933,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum_dispatch" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1015,9 +1003,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "finl_unicode" @@ -3629,16 +3617,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -3687,7 +3665,6 @@ dependencies = [ "const-str", "crossterm", "derive_more", - "enum_dispatch", "fs2", "futures", "human-panic", @@ -3711,7 +3688,6 @@ dependencies = [ "toml", "tracing", "tracing-appender", - "tracing-error", "tracing-log", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index bee88d7..a81dec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,6 @@ clap-verbosity-flag = "3.0" console = "0.16" crossterm = "0.29" derive_more = { version = "2", features = ["as_ref", "deref", "display"] } -enum_dispatch = "0.3" fs2 = "0.4" futures = "0.3" libc = "0.2" @@ -57,10 +56,7 @@ human-panic = "2.0" ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color", "macros"] } log = "0.4" num_cpus = "1.17" -reqwest = { version = "0.13", default-features = false, features = [ - "http2", - "rustls", -] } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } sha1 = "0.11" self_update = { version = "0.44", default-features = false, features = [ "compression-flate2", @@ -82,7 +78,6 @@ tokio = { version = "1", features = [ toml = "1.1" tracing = "0.1" tracing-appender = "0.2" -tracing-error = "0.2" tracing-log = "0.2" tracing-subscriber = "0.3" url = { version = "2.5", features = ["serde"] } From ba7eb979611071c6fc613c402445b9e298756e8f Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 25 May 2026 16:31:14 +0200 Subject: [PATCH 36/88] cache: use tokio fs instead of std Doesn't chage much, it's the principle. --- src/actors/cache.rs | 2 +- src/cache.rs | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 93e5617..d641bbd 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -188,7 +188,7 @@ impl CacheActor { tx, kind: ResponseKind::SaveComplete, } - .send(self.db.save()); + .send(self.db.save().await); } CacheMessage::NeedsClone { language, spec, tx } => { diff --git a/src/cache.rs b/src/cache.rs index b76808e..bebc100 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,7 +1,6 @@ use std::{ collections::BTreeMap, fmt::{self, Write as _}, - fs, path::{Path, PathBuf}, sync::Arc, }; @@ -20,7 +19,7 @@ use crate::{ TsdlResult, }; -/// The build cache stored in `build-dir/TSDL_CACHE_FILE` +/// The build cache stored in `[build-dir]/[TSDL_CACHE_FILE]` #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { pub parsers: BTreeMap, @@ -318,10 +317,10 @@ impl Db { } /// Delete the cache file from disk - pub fn delete(build_dir: &Path) -> TsdlResult<()> { + pub async fn delete(build_dir: &Path) -> TsdlResult<()> { let file = build_dir.join(TSDL_CACHE_FILE); - if file.exists() { - fs::remove_file(&file).map_err(|e| { + if tokio::fs::metadata(&file).await.is_ok() { + tokio::fs::remove_file(&file).await.map_err(|e| { TsdlError::context(format!("Deleting cache file at {}", file.display()), e) })?; debug!("Cache file deleted"); @@ -349,7 +348,7 @@ impl Db { }); } - let contents = fs::read_to_string(&file).map_err(|e| { + let contents = std::fs::read_to_string(&file).map_err(|e| { TsdlError::context(format!("Reading cache file at {}", file.display()), e) })?; @@ -376,11 +375,11 @@ impl Db { } /// Save the cache to disk - pub fn save(&self) -> TsdlResult<()> { + pub async fn save(&self) -> TsdlResult<()> { let contents = toml::to_string_pretty(self) .map_err(|e| TsdlError::context("Serializing cache to TOML", e))?; - fs::write(&self.file, contents).map_err(|e| { + tokio::fs::write(&self.file, contents).await.map_err(|e| { TsdlError::context(format!("Writing cache file to {}", self.file.display()), e) })?; From a1d7a8b479637dc01c55b914a927096b2d4f532f Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Tue, 26 May 2026 10:30:49 +0200 Subject: [PATCH 37/88] git: refactor git ref usage w/ a newtype --- CHANGELOG.md | 5 + Cargo.toml | 2 +- src/actors/display.rs | 73 +++---- src/actors/mod.rs | 18 +- src/build.rs | 47 ++--- src/cache.rs | 17 +- src/display.rs | 4 +- src/git.rs | 463 +++++++++++++++++++++++++++++++++++++----- src/parser.rs | 6 +- src/tree_sitter.rs | 66 +++--- 10 files changed, 529 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c27a747..25aaa83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ The display backend moved to ratatui. The information displayed are more informative and more coherent. +### Changed + +- **git**: Strongly type git refs with validated `GitRef`, `GitSha`, and + `ResolvedRef` values. + ### Bug Fixes - **lock**: Use OS-level build locks to avoid concurrent lock acquisition races, diff --git a/Cargo.toml b/Cargo.toml index a81dec8..5d2ffdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ clap = { version = "4.6", features = ["cargo", "derive", "env"] } clap-verbosity-flag = "3.0" console = "0.16" crossterm = "0.29" -derive_more = { version = "2", features = ["as_ref", "deref", "display"] } +derive_more = { version = "2", features = ["display"] } fs2 = "0.4" futures = "0.3" libc = "0.2" diff --git a/src/actors/display.rs b/src/actors/display.rs index e9bd7ca..ae468fe 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -634,7 +634,7 @@ impl DisplayActor { let _ = tx.send(addr); } DisplayMessage::RegisterReference { git_ref, name } => { - self.print_plain_ref(&name, &git_ref.to_string()); + self.print_plain_ref(&name, git_ref.short()); } DisplayMessage::Update { id, kind, msg } => { self.apply_update(id, kind, msg); @@ -1244,18 +1244,18 @@ mod tests { fn summary_counts_include_repo_only_rows() { let mut actor = actor(); - let cached = actor.register_repo("tree-sitter-cli".into(), GitRef::from("HEAD"), 2); + let cached = actor.register_repo("tree-sitter-cli".into(), GitRef::head(), 2); actor.apply_update(cached.id, UpdateKind::SetOutcomeCached, Arc::from("")); actor.apply_update(cached.id, UpdateKind::Fin, Arc::from("done")); - let built = actor.register_repo("standalone".into(), GitRef::from("HEAD"), 1); + let built = actor.register_repo("standalone".into(), GitRef::head(), 1); actor.apply_update(built.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); actor.apply_update(built.id, UpdateKind::Fin, Arc::from("done")); - let active = actor.register_repo("active".into(), GitRef::from("HEAD"), 1); + let active = actor.register_repo("active".into(), GitRef::head(), 1); actor.apply_update(active.id, UpdateKind::Step, Arc::from("working")); - let failed = actor.register_repo("failed".into(), GitRef::from("HEAD"), 1); + let failed = actor.register_repo("failed".into(), GitRef::head(), 1); actor.apply_update(failed.id, UpdateKind::Err, Arc::from("failed")); assert_eq!(actor.state.summary_counts(), (1, 1, 1, 1, 0)); @@ -1265,11 +1265,11 @@ mod tests { fn summary_counts_do_not_double_count_parent_repos_with_grammars() { let mut actor = actor(); - let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 2); + let repo = actor.register_repo("json".into(), GitRef::head(), 2); actor.apply_update(repo.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); - let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::from("HEAD"), 4); + let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::head(), 4); actor.apply_update(grammar.id, UpdateKind::SetOutcomeCached, Arc::from("")); actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); @@ -1280,8 +1280,8 @@ mod tests { fn cancelled_grammar_is_terminal_and_updates_parent_repo() { let mut actor = actor(); - let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 2); - let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::from("HEAD"), 4); + let repo = actor.register_repo("json".into(), GitRef::head(), 2); + let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::head(), 4); actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); @@ -1305,15 +1305,10 @@ mod tests { fn cancelled_child_does_not_cancel_parent_while_sibling_is_active() { let mut actor = actor(); - let repo = actor.register_repo("typescript".into(), GitRef::from("HEAD"), 2); - let cancelled = actor.register_grammar( - "typescript".into(), - "typescript".into(), - GitRef::from("HEAD"), - 4, - ); - let active = - actor.register_grammar("typescript".into(), "tsx".into(), GitRef::from("HEAD"), 4); + let repo = actor.register_repo("typescript".into(), GitRef::head(), 2); + let cancelled = + actor.register_grammar("typescript".into(), "typescript".into(), GitRef::head(), 4); + let active = actor.register_grammar("typescript".into(), "tsx".into(), GitRef::head(), 4); actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); @@ -1336,15 +1331,11 @@ mod tests { fn failed_child_wins_over_cancelled_child_when_parent_terminal() { let mut actor = actor(); - let repo = actor.register_repo("typescript".into(), GitRef::from("HEAD"), 2); - let failing = actor.register_grammar( - "typescript".into(), - "typescript".into(), - GitRef::from("HEAD"), - 4, - ); + let repo = actor.register_repo("typescript".into(), GitRef::head(), 2); + let failing = + actor.register_grammar("typescript".into(), "typescript".into(), GitRef::head(), 4); let cancelled = - actor.register_grammar("typescript".into(), "tsx".into(), GitRef::from("HEAD"), 4); + actor.register_grammar("typescript".into(), "tsx".into(), GitRef::head(), 4); actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); @@ -1363,7 +1354,7 @@ mod tests { #[should_panic(expected = "finish update received before cached/built path was set")] fn finish_without_cached_or_built_path_panics() { let mut actor = actor(); - let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 2); + let repo = actor.register_repo("json".into(), GitRef::head(), 2); actor.apply_update(repo.id, UpdateKind::Step, Arc::from("scanning")); actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); @@ -1373,9 +1364,8 @@ mod tests { fn interrupted_shutdown_cancels_live_rows() { let mut actor = actor(); - let repo = actor.register_repo("typescript".into(), GitRef::from("HEAD"), 2); - let grammar = - actor.register_grammar("typescript".into(), "tsx".into(), GitRef::from("HEAD"), 4); + let repo = actor.register_repo("typescript".into(), GitRef::head(), 2); + let grammar = actor.register_grammar("typescript".into(), "tsx".into(), GitRef::head(), 4); actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); actor.cancel_live_rows(); @@ -1395,7 +1385,7 @@ mod tests { fn terminal_direct_updates_are_absorbing() { let mut actor = actor(); - let repo = actor.register_repo("json".into(), GitRef::from("HEAD"), 4); + let repo = actor.register_repo("json".into(), GitRef::head(), 4); actor.apply_update(repo.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(repo.id, UpdateKind::Cancel, Arc::from("cancelled")); actor.apply_update(repo.id, UpdateKind::Cached, Arc::from("done")); @@ -1405,7 +1395,7 @@ mod tests { assert_eq!(entry.state, ItemState::Cancelled); assert_eq!(entry.msg.as_ref(), "cancelled"); - let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::from("HEAD"), 4); + let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::head(), 4); actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); @@ -1420,15 +1410,10 @@ mod tests { fn parent_repo_stays_active_while_any_child_is_active() { let mut actor = actor(); - let repo = actor.register_repo("typescript".into(), GitRef::from("HEAD"), 2); - let failing = actor.register_grammar( - "typescript".into(), - "typescript".into(), - GitRef::from("HEAD"), - 4, - ); - let pending = - actor.register_grammar("typescript".into(), "tsx".into(), GitRef::from("HEAD"), 4); + let repo = actor.register_repo("typescript".into(), GitRef::head(), 2); + let failing = + actor.register_grammar("typescript".into(), "typescript".into(), GitRef::head(), 4); + let pending = actor.register_grammar("typescript".into(), "tsx".into(), GitRef::head(), 4); actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); @@ -1450,7 +1435,7 @@ mod tests { #[test] fn materialize_recomputes_message_cells_when_terminal_width_changes() { let mut actor = actor(); - let progress = actor.register_repo("json".into(), GitRef::from("HEAD"), 1); + let progress = actor.register_repo("json".into(), GitRef::head(), 1); let message = "abcdefghijklmnop"; actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); @@ -1468,7 +1453,7 @@ mod tests { #[test] fn materialize_recomputes_message_cells_when_layout_width_changes() { let mut actor = actor(); - let progress = actor.register_repo("json".into(), GitRef::from("HEAD"), 1); + let progress = actor.register_repo("json".into(), GitRef::head(), 1); let message = "abcdefghijklmnop"; actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); @@ -1477,7 +1462,7 @@ mod tests { let initial_row = line_text(&initial_lines[0]); assert!(initial_row.contains(message)); - actor.register_repo("zzzzzzzzzzzzzzzzzzzz".into(), GitRef::from("HEAD"), 1); + actor.register_repo("zzzzzzzzzzzzzzzzzzzz".into(), GitRef::head(), 1); let updated_lines = actor.materialize(42); let updated_row = updated_lines diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 458f70f..5d18dcf 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -73,12 +73,18 @@ pub async fn run( languages: Vec, tree_sitter: &TreeSitter, ) -> TsdlResult<()> { - display - .reference( - tree_sitter::display_tree_sitter_ref(&tree_sitter.version), - "tree-sitter-cli", - ) - .await; + let tree_sitter_ref = match tree_sitter::display_tree_sitter_ref(&tree_sitter.version) { + Ok(git_ref) => git_ref, + Err(err) => { + display.shutdown(false).await; + return Err(TsdlError::context( + format!("Parsing tree-sitter git ref {:?}", tree_sitter.version), + err, + )); + } + }; + + display.reference(tree_sitter_ref, "tree-sitter-cli").await; for language in &languages { display .reference(language.spec.git_ref.clone(), language.name.clone()) diff --git a/src/build.rs b/src/build.rs index 9f45838..eea4023 100644 --- a/src/build.rs +++ b/src/build.rs @@ -155,30 +155,40 @@ fn default_repo(language: &str) -> TsdlResult { fn get_language_coords( language: &str, defined_parsers: Option<&BTreeMap>, -) -> (Option, GitRef, TsdlResult) { +) -> TsdlResult<(Option, GitRef, Url)> { let config = defined_parsers.and_then(|parsers| parsers.get(language)); match config { - Some(ParserConfig::Ref(git_ref)) => { - (None, resolve_git_ref(git_ref), default_repo(language)) - } + Some(ParserConfig::Ref(git_ref)) => Ok(( + None, + GitRef::from_version_or_ref(git_ref).map_err(|e| { + TsdlError::context(format!("Parsing git ref {git_ref:?} for {language}"), e) + })?, + default_repo(language)?, + )), Some(ParserConfig::Full { build_script, git_ref, from, }) => { - let url_result = match from { + let repo = match from { Some(url_str) => Url::parse(url_str).map_err(|e| { TsdlError::context(format!("Parsing {url_str} for {language}"), e) - }), - None => default_repo(language), + })?, + None => default_repo(language)?, }; - (build_script.clone(), resolve_git_ref(git_ref), url_result) + Ok(( + build_script.clone(), + GitRef::from_version_or_ref(git_ref).map_err(|e| { + TsdlError::context(format!("Parsing git ref {git_ref:?} for {language}"), e) + })?, + repo, + )) } - None => (None, GitRef::from("HEAD"), default_repo(language)), + None => Ok((None, GitRef::head(), default_repo(language)?)), } } @@ -223,20 +233,6 @@ fn ignite(app: &App) -> TsdlResult<()> { result } -fn resolve_git_ref(git_ref: &str) -> GitRef { - let is_sha1 = git_ref.len() == 40 && git_ref.chars().all(|c| c.is_ascii_hexdigit()); - - if is_sha1 || git_ref.starts_with('v') { - return GitRef::from(git_ref); - } - - if git_ref.split('.').all(|part| part.parse::().is_ok()) { - GitRef::from(format!("v{git_ref}")) - } else { - GitRef::from(git_ref) - } -} - fn unique_languages(app: &App) -> Vec> { let requested_languages = &app.command.languages; let defined_parsers = app.command.parsers.as_ref(); @@ -252,9 +248,8 @@ fn unique_languages(app: &App) -> Vec> { let mut results = Vec::with_capacity(unique.len()); for language in unique { - let (build_script, git_ref, url) = get_language_coords(&language, defined_parsers); - let result = match url { - Ok(repo) => Ok(LanguageBuild::new( + let result = match get_language_coords(&language, defined_parsers) { + Ok((build_script, git_ref, repo)) => Ok(LanguageBuild::new( BuildContext { overwrite_output: app.command.force, }, diff --git a/src/cache.rs b/src/cache.rs index bebc100..8e41c9c 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -214,9 +214,12 @@ impl fmt::Display for CacheMissReason { Self::RepoChanged { cached, current } => { write!(f, "repo changed cached={cached} current={current}") } - Self::GitRefChanged { cached, current } => { - write!(f, "git ref changed cached={cached} current={current}") - } + Self::GitRefChanged { cached, current } => write!( + f, + "git ref changed cached={} current={}", + cached.as_str(), + current.as_str() + ), Self::TreeSitterChanged { cached, current } => write!( f, "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", @@ -430,7 +433,7 @@ mod tests { fn test_spec() -> BuildSpec { BuildSpec { build_script: None, - git_ref: GitRef::from("master"), + git_ref: GitRef::new("master").unwrap(), repo: "https://github.com/example/parser".parse().unwrap(), tree_sitter: TreeSitter::default(), prefix: String::new(), @@ -486,14 +489,14 @@ mod tests { fn test_rebuild_decision_git_ref_mismatch() { let cached = test_spec(); let mut requested = cached.clone(); - requested.git_ref = GitRef::from("v1.0.0"); + requested.git_ref = GitRef::new("v1.0.0").unwrap(); let cache = cache_with_entry("abc123", cached); assert_miss( cache.rebuild_decision("test-parser", "abc123", &requested), &[CacheMissReason::GitRefChanged { - cached: GitRef::from("master"), - current: GitRef::from("v1.0.0"), + cached: GitRef::new("master").unwrap(), + current: GitRef::new("v1.0.0").unwrap(), }], ); } diff --git a/src/display.rs b/src/display.rs index ac5021e..4e5c1c8 100644 --- a/src/display.rs +++ b/src/display.rs @@ -415,7 +415,7 @@ pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> S } pub(crate) fn compute_ref_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { - let ref_str = info.git_ref().to_string(); + let ref_str = info.git_ref().short(); let padded = format!("{: 0 { format!("[{}/{}]", info.step().min(info.total()), info.total()) } else { diff --git a/src/git.rs b/src/git.rs index a65f004..8ed5543 100644 --- a/src/git.rs +++ b/src/git.rs @@ -2,87 +2,347 @@ use std::{ ffi::OsStr, fmt, path::{Component, Path, PathBuf}, + sync::Arc, }; -use serde::{Deserialize, Serialize}; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use tokio::{fs, process::Command}; use crate::{error::TsdlError, sh::Exec, TsdlResult}; -use derive_more::{AsRef, Deref}; -use std::sync::Arc; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GitRefParseError { + EmptyRef, + InvalidRefCharacter { index: usize, character: char }, + InvalidRefSyntax { reason: &'static str }, + InvalidShaLength { actual: usize }, + InvalidShaHex { index: usize, character: char }, +} + +impl fmt::Display for GitRefParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyRef => write!(f, "git ref cannot be empty"), + Self::InvalidRefCharacter { index, character } => write!( + f, + "git ref contains invalid character at byte {index}: {character:?}" + ), + Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), + Self::InvalidShaLength { actual } => { + write!(f, "git SHA must be exactly 40 hex characters, got {actual}") + } + Self::InvalidShaHex { index, character } => write!( + f, + "git SHA contains non-hex character at byte {index}: {character:?}" + ), + } + } +} + +impl std::error::Error for GitRefParseError {} + +impl From for TsdlError { + fn from(error: GitRefParseError) -> Self { + TsdlError::message(error.to_string()) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct GitRef(Arc); + +impl GitRef { + /// Create a validated git ref. + pub fn new(value: impl Into>) -> Result { + let value = value.into(); + validate_git_ref(&value)?; + Ok(Self(value)) + } + + /// The default ref used for unpinned parser builds. + #[must_use] + pub fn head() -> Self { + Self(Arc::from("HEAD")) + } -#[derive(AsRef, Clone, Deref, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitRef(pub Arc); + /// Create a validated git ref from user input, normalizing dotted numeric + /// versions such as `0.21.0` to the conventional git tag `v0.21.0`. + pub fn from_version_or_ref(value: &str) -> Result { + if GitSha::is_full_sha(value) || value.starts_with('v') { + return Self::new(value); + } + + if is_dotted_numeric_version(value) { + Self::new(format!("v{value}")) + } else { + Self::new(value) + } + } -impl From for GitRef { - fn from(s: String) -> Self { - Self(s.into()) + /// Get the exact git ref string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get a human-oriented representation, shortening full commit SHAs. + #[must_use] + pub fn short(&self) -> &str { + if GitSha::is_full_sha(self.as_str()) { + &self.0[..7] + } else { + &self.0 + } + } + + #[must_use] + pub fn is_exact_sha(&self) -> bool { + GitSha::is_full_sha(self.as_str()) } } -impl From<&str> for GitRef { - fn from(s: &str) -> Self { - Self(s.into()) +impl AsRef for GitRef { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl TryFrom<&str> for GitRef { + type Error = GitRefParseError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl TryFrom for GitRef { + type Error = GitRefParseError; + + fn try_from(value: String) -> Result { + Self::new(value) } } impl std::str::FromStr for GitRef { - type Err = std::convert::Infallible; + type Err = GitRefParseError; - fn from_str(s: &str) -> Result { - Ok(Self(s.into())) + fn from_str(value: &str) -> Result { + Self::new(value) } } -impl GitRef { - /// Create a new `GitRef` from a string slice - #[must_use] - pub fn new(s: &str) -> Self { - Self(s.into()) +impl fmt::Display for GitRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) + } +} + +impl Serialize for GitRef { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for GitRef { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct GitSha(Arc); + +impl GitSha { + /// Create a validated full 40-character git SHA-1. + pub fn new(value: impl Into>) -> Result { + let value = value.into(); + validate_git_sha(&value)?; + Ok(Self(value)) } - /// Get as string slice #[must_use] pub fn as_str(&self) -> &str { &self.0 } + + #[must_use] + pub fn short(&self) -> &str { + &self.0[..7] + } + + #[must_use] + pub fn is_full_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) + } +} + +impl AsRef for GitSha { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl TryFrom<&str> for GitSha { + type Error = GitRefParseError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl TryFrom for GitSha { + type Error = GitRefParseError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl std::str::FromStr for GitSha { + type Err = GitRefParseError; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl fmt::Display for GitSha { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) + } +} + +impl Serialize for GitSha { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for GitSha { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} + +impl From for GitRef { + fn from(sha: GitSha) -> Self { + Self(sha.0) + } } #[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub enum Tag { - Exact { label: String, sha1: GitRef }, +pub enum ResolvedRef { + Tag { label: String, sha: GitSha }, Ref(GitRef), } -impl Tag { - #[must_use] - pub fn git_ref(&self) -> &GitRef { +impl fmt::Display for ResolvedRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Tag::Exact { sha1, .. } => sha1, - Tag::Ref(r) => r, + Self::Tag { label, .. } => write!(f, "{label}"), + Self::Ref(git_ref) => write!(f, "{git_ref}"), } } } -impl fmt::Display for GitRef { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let git_ref = if self.0.len() == 40 && self.0.chars().all(|c| c.is_ascii_hexdigit()) { - &self.0[..7] - } else { - &self.0 - }; - write!(f, "{git_ref}") +fn validate_git_ref(value: &str) -> Result<(), GitRefParseError> { + if value.is_empty() { + return Err(GitRefParseError::EmptyRef); } -} -impl fmt::Display for Tag { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Tag::Exact { label, .. } => write!(f, "{label}"), - Tag::Ref(ref_) => write!(f, "{ref_}"), + if value == "@" { + return Err(GitRefParseError::InvalidRefSyntax { + reason: "single @ is not a ref", + }); + } + + if value.starts_with('/') || value.ends_with('/') { + return Err(GitRefParseError::InvalidRefSyntax { + reason: "refs cannot start or end with /", + }); + } + + if value.ends_with('.') { + return Err(GitRefParseError::InvalidRefSyntax { + reason: "refs cannot end with .", + }); + } + + if value.contains("..") { + return Err(GitRefParseError::InvalidRefSyntax { + reason: "refs cannot contain ..", + }); + } + + if value.contains("@{") { + return Err(GitRefParseError::InvalidRefSyntax { + reason: "refs cannot contain @{", + }); + } + + if let Some((index, character)) = value.char_indices().find(|(_, c)| { + c.is_ascii_control() + || c.is_ascii_whitespace() + || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\') + }) { + return Err(GitRefParseError::InvalidRefCharacter { index, character }); + } + + for component in value.split('/') { + if component.is_empty() { + return Err(GitRefParseError::InvalidRefSyntax { + reason: "refs cannot contain empty path components", + }); + } + + if component.starts_with('.') { + return Err(GitRefParseError::InvalidRefSyntax { + reason: "ref path components cannot start with .", + }); + } + + if component.strip_suffix(".lock").is_some() { + return Err(GitRefParseError::InvalidRefSyntax { + reason: "ref path components cannot end with .lock", + }); } } + + Ok(()) +} + +fn validate_git_sha(value: &str) -> Result<(), GitRefParseError> { + if value.len() != 40 { + return Err(GitRefParseError::InvalidShaLength { + actual: value.len(), + }); + } + + if let Some((index, character)) = value.char_indices().find(|(_, c)| !c.is_ascii_hexdigit()) { + return Err(GitRefParseError::InvalidShaHex { index, character }); + } + + Ok(()) +} + +fn is_dotted_numeric_version(value: &str) -> bool { + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) } // TODO: get rid of async fs completely. @@ -113,13 +373,13 @@ pub async fn clone(repo: &str, cwd: &Path) -> TsdlResult<()> { Ok(()) } -pub async fn clone_fast(repo: &str, git_ref: &str, cwd: &Path) -> TsdlResult<()> { +pub async fn clone_fast(repo: &str, git_ref: &GitRef, cwd: &Path) -> TsdlResult<()> { clone_fast_with_force(repo, git_ref, cwd, false).await } pub async fn clone_fast_with_force( repo: &str, - git_ref: &str, + git_ref: &GitRef, cwd: &Path, force: bool, ) -> TsdlResult<()> { @@ -134,11 +394,11 @@ pub async fn clone_fast_with_force( Ok(()) } -async fn fetch_and_checkout(cwd: &Path, git_ref: &str) -> TsdlResult<()> { +async fn fetch_and_checkout(cwd: &Path, git_ref: &GitRef) -> TsdlResult<()> { Command::new("git") .env("GIT_TERMINAL_PROMPT", "0") .current_dir(cwd) - .args(["fetch", "origin", "--depth", "1", git_ref]) + .args(["fetch", "origin", "--depth", "1", git_ref.as_str()]) .exec() .await?; Command::new("git") @@ -173,7 +433,7 @@ async fn get_remote_url(cwd: &Path) -> TsdlResult { .map_err(|e| TsdlError::context("remote get-url origin did not return a valid utf-8", e)) } -async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &str) -> TsdlResult<()> { +async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &GitRef) -> TsdlResult<()> { clean_anyway(cwd).await?; fs::create_dir_all(cwd).await?; @@ -264,8 +524,8 @@ pub async fn list_grammar_files(cwd: &Path) -> TsdlResult> { Ok(result) } -async fn reset_head_hard(cwd: &Path, git_ref: &str) -> TsdlResult<()> { - if git_ref != get_head_sha1(cwd).await?.trim() { +async fn reset_head_hard(cwd: &Path, git_ref: &GitRef) -> TsdlResult<()> { + if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { Command::new("git") .current_dir(cwd) .args(["reset", "--hard", "HEAD"]) @@ -276,11 +536,11 @@ async fn reset_head_hard(cwd: &Path, git_ref: &str) -> TsdlResult<()> { Ok(()) } -pub async fn tag_for_ref(cwd: &Path, git_ref: &str) -> TsdlResult { +pub async fn tag_for_ref(cwd: &Path, git_ref: &GitRef) -> TsdlResult { // Try to find a tag for this ref let tag = Command::new("git") .current_dir(cwd) - .args(["describe", "--abbrev=0", "--tags", git_ref]) + .args(["describe", "--abbrev=0", "--tags", git_ref.as_str()]) .exec() .await; @@ -293,7 +553,7 @@ pub async fn tag_for_ref(cwd: &Path, git_ref: &str) -> TsdlResult { // No tag found (e.g., ref is a branch), fall back to commit SHA1 let sha1 = Command::new("git") .current_dir(cwd) - .args(["rev-parse", git_ref]) + .args(["rev-parse", git_ref.as_str()]) .exec() .await?; String::from_utf8(sha1.stdout) @@ -301,3 +561,104 @@ pub async fn tag_for_ref(cwd: &Path, git_ref: &str) -> TsdlResult { .map(|s| s.trim().to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + const FULL_SHA: &str = "636801770eea172d140e64b691815ff11f6b556f"; + + #[test] + fn git_ref_rejects_empty_refs() { + assert_eq!(GitRef::new(""), Err(GitRefParseError::EmptyRef)); + } + + #[test] + fn git_ref_rejects_whitespace() { + assert_eq!( + GitRef::new("feature branch"), + Err(GitRefParseError::InvalidRefCharacter { + index: 7, + character: ' ', + }) + ); + } + + #[test] + fn git_ref_rejects_invalid_git_ref_syntax() { + assert_eq!( + GitRef::new("feature..branch"), + Err(GitRefParseError::InvalidRefSyntax { + reason: "refs cannot contain ..", + }) + ); + assert_eq!( + GitRef::new("feature.lock"), + Err(GitRefParseError::InvalidRefSyntax { + reason: "ref path components cannot end with .lock", + }) + ); + assert_eq!( + GitRef::new("refs/heads/main"), + Ok(GitRef::new("refs/heads/main").unwrap()) + ); + } + + #[test] + fn git_ref_normalizes_dotted_numeric_versions() { + assert_eq!( + GitRef::from_version_or_ref("0.21.0").unwrap().as_str(), + "v0.21.0" + ); + assert_eq!( + GitRef::from_version_or_ref("v0.21.0").unwrap().as_str(), + "v0.21.0" + ); + } + + #[test] + fn git_ref_preserves_branches_and_full_shas() { + assert_eq!( + GitRef::from_version_or_ref("master").unwrap().as_str(), + "master" + ); + assert_eq!( + GitRef::from_version_or_ref(FULL_SHA).unwrap().as_str(), + FULL_SHA + ); + } + + #[test] + fn git_ref_display_is_short_but_as_str_is_exact() { + let git_ref = GitRef::new(FULL_SHA).unwrap(); + + assert_eq!(git_ref.as_str(), FULL_SHA); + assert_eq!(git_ref.short(), "6368017"); + assert_eq!(git_ref.to_string(), "6368017"); + } + + #[test] + fn git_sha_requires_full_hex_sha() { + assert_eq!(GitSha::new(FULL_SHA).unwrap().as_str(), FULL_SHA); + assert_eq!(GitSha::new(FULL_SHA).unwrap().short(), "6368017"); + assert_eq!( + GitSha::new("6368017"), + Err(GitRefParseError::InvalidShaLength { actual: 7 }) + ); + assert_eq!( + GitSha::new("636801770eea172d140e64b691815ff11f6b556x"), + Err(GitRefParseError::InvalidShaHex { + index: 39, + character: 'x', + }) + ); + } + + #[test] + fn serde_rejects_invalid_git_refs() { + let err = toml::from_str::>(r#"git_ref = """#) + .unwrap_err(); + + assert!(err.to_string().contains("git ref cannot be empty")); + } +} diff --git a/src/parser.rs b/src/parser.rs index d9b17d1..4865638 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -752,9 +752,7 @@ mod tests { Arc::new(grammar_dir.clone()), Arc::new(out_dir.clone()), ); - let progress = display - .add_grammar(GitRef::from("HEAD"), "rust", "rust", 1) - .await; + let progress = display.add_grammar(GitRef::head(), "rust", "rust", 1).await; let build = GrammarBuild { context: BuildContext { overwrite_output }, cache_decision: CacheDecision::miss(cache::CacheMissReason::MissingEntry), @@ -769,7 +767,7 @@ mod tests { progress, spec: Arc::new(BuildSpec { build_script: None, - git_ref: GitRef::from("HEAD"), + git_ref: GitRef::head(), prefix: String::new(), repo: "https://example.com/tree-sitter-rust".parse().unwrap(), target: Target::Native, diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 73db154..23ff17e 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -2,7 +2,6 @@ use std::borrow::Cow; use std::collections::HashMap; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use std::str::FromStr; use async_compression::tokio::bufread::GzipDecoder; use tokio::{fs, io, process::Command}; @@ -11,11 +10,11 @@ use url::Url; use crate::actors::{DisplayAddr, ProgressAddr}; use crate::args::TreeSitter; -use crate::git::{self, GitRef}; +use crate::git::{self, GitRef, GitSha, ResolvedRef}; +use crate::sh::Exec; use crate::shutdown; use crate::SafeCanonicalize; use crate::{error::TsdlError, TsdlResult}; -use crate::{git::Tag, sh::Exec}; async fn chmod_x(prog: &Path) -> TsdlResult<()> { let metadata = fs::metadata(prog) @@ -33,12 +32,12 @@ async fn cli( handle: &ProgressAddr, platform: &str, repo: &str, - tag: &Tag, + resolved_ref: &ResolvedRef, ) -> TsdlResult { - let tag = match tag { - Tag::Exact { label, .. } => Cow::Borrowed(label), - Tag::Ref(git_ref) => { - handle.msg(format!("resolving exact tag for {tag}")); + let tag = match resolved_ref { + ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), + ResolvedRef::Ref(git_ref) => { + handle.msg(format!("resolving exact tag for {resolved_ref}")); let tree_sitter = PathBuf::new().join(build_dir).join("tree-sitter"); git::clone(repo, &tree_sitter).await?; Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) @@ -90,17 +89,20 @@ async fn download_and_extract(gz: &Path, url: &str, res: &Path) -> TsdlResult<() Ok(()) } -fn find_tag(refs: &HashMap, version: &str) -> Tag { +fn find_tag( + refs: &HashMap, + version: &str, +) -> Result { refs.get_key_value(&format!("v{version}")) .or_else(|| refs.get_key_value(version)) .map_or_else( - || Tag::Ref(GitRef::from_str(version).unwrap()), + || GitRef::from_version_or_ref(version).map(ResolvedRef::Ref), |(k, v)| { trace!("Found! {k} -> {v}"); - Tag::Exact { - sha1: GitRef::from_str(v).unwrap(), + Ok(ResolvedRef::Tag { + sha: GitSha::new(v.as_str())?, label: k.clone(), - } + }) }, ) } @@ -149,7 +151,7 @@ pub async fn prepare( let progress = display .add_language( - display_tree_sitter_ref(&tree_sitter.version), + display_tree_sitter_ref(&tree_sitter.version)?, "tree-sitter-cli", 2, ) @@ -196,23 +198,22 @@ pub async fn prepare( Ok(cli) } -pub(crate) fn display_tree_sitter_ref(version: &str) -> GitRef { - if version.starts_with('v') || !version.split('.').all(|part| part.parse::().is_ok()) { - GitRef::from(version.to_string()) - } else { - GitRef::from(format!("v{version}")) - } +pub(crate) fn display_tree_sitter_ref( + version: &str, +) -> Result { + GitRef::from_version_or_ref(version) } #[allow(clippy::missing_panics_doc)] -pub async fn tag(repo: &str, version: &str) -> TsdlResult { +pub async fn tag(repo: &str, version: &str) -> TsdlResult { let output = Command::new("git") .args(["ls-remote", "--refs", "--tags", repo]) .exec() .await?; let stdout = String::from_utf8_lossy(&output.stdout); let refs = parse_refs(&stdout); - Ok(find_tag(&refs, version)) + find_tag(&refs, version) + .map_err(|e| TsdlError::context(format!("Parsing tree-sitter git ref {version:?}"), e)) } #[cfg(test)] @@ -239,26 +240,29 @@ mod tests { #[test] fn test_find_tag_exact() { let mut refs = HashMap::new(); - refs.insert("v1.0.0".to_string(), "abc123".to_string()); - let tag = find_tag(&refs, "1.0.0"); + refs.insert( + "v1.0.0".to_string(), + "636801770eea172d140e64b691815ff11f6b556f".to_string(), + ); + let tag = find_tag(&refs, "1.0.0").unwrap(); match tag { - Tag::Exact { sha1, label } => { - assert_eq!(sha1.as_str(), "abc123"); + ResolvedRef::Tag { sha, label } => { + assert_eq!(sha.as_str(), "636801770eea172d140e64b691815ff11f6b556f"); assert_eq!(label, "v1.0.0"); } - Tag::Ref(_) => panic!("Expected Tag::Exact"), + ResolvedRef::Ref(_) => panic!("Expected ResolvedRef::Tag"), } } #[test] fn test_find_tag_ref() { let refs = HashMap::new(); - let tag = find_tag(&refs, "1.0.0"); + let tag = find_tag(&refs, "1.0.0").unwrap(); match tag { - Tag::Ref(git_ref) => { - assert_eq!(git_ref.as_str(), "1.0.0"); + ResolvedRef::Ref(git_ref) => { + assert_eq!(git_ref.as_str(), "v1.0.0"); } - Tag::Exact { .. } => panic!("Expected Tag::Ref"), + ResolvedRef::Tag { .. } => panic!("Expected ResolvedRef::Ref"), } } } From 075e75350036d8b249bec4ce703d7335d416dc65 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Wed, 27 May 2026 08:22:24 +0200 Subject: [PATCH 38/88] cache: support moving refs --- CHANGELOG.md | 6 +- docs/cache.md | 97 +++++++++++++++++ src/actors/cache.rs | 12 ++- src/actors/display.rs | 97 ++++++++++------- src/actors/mod.rs | 105 ++++++++++++++---- src/app.rs | 6 +- src/build.rs | 13 ++- src/cache.rs | 241 +++++++++++++++++++++++++++++++++++------- src/config.rs | 71 +++++++++---- src/display.rs | 10 +- src/git.rs | 174 ++++++++++++++++-------------- src/logging.rs | 9 +- src/main.rs | 2 +- src/parser.rs | 165 +++++++++++++++++++++++++++-- src/tree_sitter.rs | 84 ++++++++++----- tests/cmd/cache.rs | 12 ++- tests/config.rs | 37 ++----- 17 files changed, 856 insertions(+), 285 deletions(-) create mode 100644 docs/cache.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 25aaa83..1c9c667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,10 @@ informative and more coherent. ### Changed -- **git**: Strongly type git refs with validated `GitRef`, `GitSha`, and - `ResolvedRef` values. +- **cache**: Parser builds that track moving refs such as `HEAD` or branches now + notice when those refs move and rebuild the affected parsers. Existing cache + entries for moving refs are rebuilt once so TSDL can record the checked-out + commit for future runs. ### Bug Fixes diff --git a/docs/cache.md b/docs/cache.md new file mode 100644 index 0000000..319a6ef --- /dev/null +++ b/docs/cache.md @@ -0,0 +1,97 @@ +# Cache Semantics + +TSDL caches parser builds in `/cache.toml`. The cache is a build +reuse decision, not only a list of produced files. A cache entry is reused only +when the parser source, build inputs, and expected artifacts still match the +current request. + +## Cache Entries + +Each grammar is cached independently under a `language/grammar` key. For +example, the JSON parser is cached as `json/json`, while a repository with +multiple grammars can produce keys such as `typescript/typescript` and +`typescript/tsx`. + +Each entry records: + +- the `grammar.js` content hash; +- the parser repository URL; +- the requested parser git ref; +- the parser source cache identity; +- the resolved tree-sitter CLI release tag, platform, and repository; +- the build script, output prefix, and target; +- the expected build artifacts on disk. + +If any of these values no longer match, the grammar is rebuilt. + +## Parser Git Refs + +Parser refs are first interpreted from the user definition in `parsers.toml` or +from the default unpinned build behavior. + +Stable parser refs are cached by the requested ref only: + +- full 40-character commit SHAs; +- dotted versions such as `0.21.0`, normalized to `v0.21.0`; +- `v` plus dotted versions such as `v0.21.0`; +- explicit tag refs under `refs/tags/...`. + +Moving parser refs are cached by both the requested ref and the resolved commit: + +- `HEAD`; +- branches such as `master`, `main`, or `dev`; +- explicit branch refs under `refs/heads/...`; +- other unqualified names such as `release`, `stable`, or `vnext`. + +The requested ref always remains part of the cache identity. If `main` and +`stable` point to the same commit, they are still different cache identities. + +## Moving Ref Behavior + +Moving refs are resolved before cache lookup. TSDL checks out the requested ref, +reads the resulting `HEAD` commit, and compares that commit with the cached +commit for the same requested ref. + +This means: + +- if a branch or `HEAD` has not moved, the cache can be reused; +- if a branch or `HEAD` has moved, the affected grammars are rebuilt; +- if the requested ref changes, the cache misses even when both refs currently + point to the same commit. + +Tag-like parser refs are not remotely resolved for cache purposes. A tag ref is +treated as stable by name. If a remote tag is moved, TSDL does not detect that +movement from the cache alone; use `--force` or `--fresh` when you need to force +a rebuild. + +## Tree-Sitter CLI Identity + +The tree-sitter CLI version is resolved to the actual release tag that exists in +the configured tree-sitter repository. The cache records that resolved release +tag rather than the raw input string. + +For example, if both `0.26.5` and `v0.26.5` resolve to the same release tag, the +parser cache treats them as the same tree-sitter CLI identity. + +Changing the tree-sitter CLI release tag, platform, or repository invalidates +the affected parser cache entries. + +## Cache Migration + +Older cache files do not contain parser source cache identities. + +Stable parser refs can keep using old cache entries because their cache identity +is still the requested ref. Moving parser refs miss once after the cache format +upgrade because TSDL needs to store the resolved commit for future comparisons. +After that rebuild, the cache entry contains the resolved commit and normal +moving-ref cache behavior applies. + +Old entries may also miss once when their tree-sitter CLI version was stored as +raw input and the current run resolves it to a canonical release tag. + +## Manual Controls + +Use `--force` to ignore cache reuse for the current build. Use `--fresh` to clear +the build directory before building. These options override normal cache reuse +and are useful when remote state was intentionally changed outside the semantics +above. diff --git a/src/actors/cache.rs b/src/actors/cache.rs index d641bbd..d62fcf4 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -4,11 +4,12 @@ use tokio::{ fs, sync::{mpsc, oneshot}, }; +use tracing::info; use crate::{ actors::{Addr, Response}, build::BuildSpec, - cache::{CacheDecision, CacheMissReason, Db, Entry, Update}, + cache::{CacheDecision, CacheMissReason, Db, Entry, Source, Update}, TsdlResult, }; @@ -27,6 +28,7 @@ pub enum CacheMessage { NeedsRebuild { hash: Arc, name: Arc, + source: Source, spec: Arc, artifacts: Vec, tx: oneshot::Sender, @@ -95,11 +97,13 @@ impl CacheAddr { name: S, hash: S, spec: Arc, + source: Source, artifacts: Vec, ) -> CacheDecision { self.request(|tx| CacheMessage::NeedsRebuild { name: name.into(), hash: hash.into(), + source, spec, artifacts, tx, @@ -155,19 +159,23 @@ impl CacheActor { hash, name, spec, + source, artifacts, tx, } => { let decision = if self.force { CacheDecision::miss(CacheMissReason::CacheIgnored) } else { - let decision = self.db.rebuild_decision(&name, &hash, &spec); + let decision = self.db.rebuild_decision(&name, &hash, &spec, &source); if decision.is_hit() { verify_artifacts(artifacts).await } else { decision } }; + if decision.needs_rebuild() { + info!("Cache miss for {name}: {}", decision.short_message()); + } Response { tx, diff --git a/src/actors/display.rs b/src/actors/display.rs index ae468fe..d97a11a 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -16,7 +16,7 @@ use crate::display::{ compute_time_cell, Column, DisplayState, GrammarEntry, GridCache, ItemState, Mode, RepoEntry, RowSpec, SuccessOutcome, }; -use crate::git::GitRef; +use crate::git; // --------------------------------------------------------------------------- // Message types @@ -26,7 +26,7 @@ use crate::git::GitRef; pub enum DisplayMessage { /// Register a repo-level progress line. Returns a `ProgressAddr`. RegisterLanguage { - git_ref: GitRef, + git_ref: git::Ref, name: Arc, num_tasks: usize, tx: oneshot::Sender, @@ -34,7 +34,7 @@ pub enum DisplayMessage { /// Register a grammar-level progress line. Returns a `ProgressAddr`. RegisterGrammar { - git_ref: GitRef, + git_ref: git::Ref, language: Arc, name: Arc, num_tasks: usize, @@ -42,7 +42,7 @@ pub enum DisplayMessage { }, /// Plain-mode reference line. Fancy mode ignores this because refs are rendered per row. - RegisterReference { git_ref: GitRef, name: Arc }, + RegisterReference { git_ref: git::Ref, name: Arc }, /// Update a specific bar. Update { @@ -101,7 +101,7 @@ impl DisplayAddr { pub async fn add_language>>( &self, - git_ref: GitRef, + git_ref: git::Ref, name: S, num_tasks: usize, ) -> ProgressAddr { @@ -116,7 +116,7 @@ impl DisplayAddr { pub async fn add_grammar>>( &self, - git_ref: GitRef, + git_ref: git::Ref, language: S, name: S, num_tasks: usize, @@ -131,7 +131,7 @@ impl DisplayAddr { .await } - pub async fn reference>>(&self, git_ref: GitRef, name: S) { + pub async fn reference>>(&self, git_ref: git::Ref, name: S) { self.fire(DisplayMessage::RegisterReference { git_ref, name: name.into(), @@ -526,7 +526,7 @@ impl DisplayActor { // Invalidate columns whose width changed if new_layout.ref_ != self.grid.layout.ref_ { - self.grid.invalidate_column(Column::GitRef); + self.grid.invalidate_column(Column::Ref); } if new_layout.step != self.grid.layout.step { self.grid.invalidate_column(Column::Step); @@ -556,7 +556,7 @@ impl DisplayActor { compute_time_cell(&info, &layout) }); - let gref = self.grid.cell(item_id, Column::GitRef, is_dirty, || { + let gref = self.grid.cell(item_id, Column::Ref, is_dirty, || { compute_ref_cell(&info, &layout) }); @@ -754,7 +754,12 @@ impl DisplayActor { } } - fn register_repo(&mut self, name: Arc, git_ref: GitRef, num_tasks: usize) -> ProgressAddr { + fn register_repo( + &mut self, + name: Arc, + git_ref: git::Ref, + num_tasks: usize, + ) -> ProgressAddr { let id = self.next_id; self.next_id += 1; @@ -784,7 +789,7 @@ impl DisplayActor { &mut self, language: Arc, name: Arc, - git_ref: GitRef, + git_ref: git::Ref, num_tasks: usize, ) -> ProgressAddr { let id = self.next_id; @@ -1244,18 +1249,18 @@ mod tests { fn summary_counts_include_repo_only_rows() { let mut actor = actor(); - let cached = actor.register_repo("tree-sitter-cli".into(), GitRef::head(), 2); + let cached = actor.register_repo("tree-sitter-cli".into(), git::Ref::head(), 2); actor.apply_update(cached.id, UpdateKind::SetOutcomeCached, Arc::from("")); actor.apply_update(cached.id, UpdateKind::Fin, Arc::from("done")); - let built = actor.register_repo("standalone".into(), GitRef::head(), 1); + let built = actor.register_repo("standalone".into(), git::Ref::head(), 1); actor.apply_update(built.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); actor.apply_update(built.id, UpdateKind::Fin, Arc::from("done")); - let active = actor.register_repo("active".into(), GitRef::head(), 1); + let active = actor.register_repo("active".into(), git::Ref::head(), 1); actor.apply_update(active.id, UpdateKind::Step, Arc::from("working")); - let failed = actor.register_repo("failed".into(), GitRef::head(), 1); + let failed = actor.register_repo("failed".into(), git::Ref::head(), 1); actor.apply_update(failed.id, UpdateKind::Err, Arc::from("failed")); assert_eq!(actor.state.summary_counts(), (1, 1, 1, 1, 0)); @@ -1265,11 +1270,11 @@ mod tests { fn summary_counts_do_not_double_count_parent_repos_with_grammars() { let mut actor = actor(); - let repo = actor.register_repo("json".into(), GitRef::head(), 2); + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); actor.apply_update(repo.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); - let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::head(), 4); + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); actor.apply_update(grammar.id, UpdateKind::SetOutcomeCached, Arc::from("")); actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); @@ -1280,8 +1285,8 @@ mod tests { fn cancelled_grammar_is_terminal_and_updates_parent_repo() { let mut actor = actor(); - let repo = actor.register_repo("json".into(), GitRef::head(), 2); - let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::head(), 4); + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); @@ -1305,10 +1310,14 @@ mod tests { fn cancelled_child_does_not_cancel_parent_while_sibling_is_active() { let mut actor = actor(); - let repo = actor.register_repo("typescript".into(), GitRef::head(), 2); - let cancelled = - actor.register_grammar("typescript".into(), "typescript".into(), GitRef::head(), 4); - let active = actor.register_grammar("typescript".into(), "tsx".into(), GitRef::head(), 4); + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let cancelled = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); + let active = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); @@ -1331,11 +1340,15 @@ mod tests { fn failed_child_wins_over_cancelled_child_when_parent_terminal() { let mut actor = actor(); - let repo = actor.register_repo("typescript".into(), GitRef::head(), 2); - let failing = - actor.register_grammar("typescript".into(), "typescript".into(), GitRef::head(), 4); + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let failing = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); let cancelled = - actor.register_grammar("typescript".into(), "tsx".into(), GitRef::head(), 4); + actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); @@ -1354,7 +1367,7 @@ mod tests { #[should_panic(expected = "finish update received before cached/built path was set")] fn finish_without_cached_or_built_path_panics() { let mut actor = actor(); - let repo = actor.register_repo("json".into(), GitRef::head(), 2); + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); actor.apply_update(repo.id, UpdateKind::Step, Arc::from("scanning")); actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); @@ -1364,8 +1377,9 @@ mod tests { fn interrupted_shutdown_cancels_live_rows() { let mut actor = actor(); - let repo = actor.register_repo("typescript".into(), GitRef::head(), 2); - let grammar = actor.register_grammar("typescript".into(), "tsx".into(), GitRef::head(), 4); + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let grammar = + actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); actor.cancel_live_rows(); @@ -1385,7 +1399,7 @@ mod tests { fn terminal_direct_updates_are_absorbing() { let mut actor = actor(); - let repo = actor.register_repo("json".into(), GitRef::head(), 4); + let repo = actor.register_repo("json".into(), git::Ref::head(), 4); actor.apply_update(repo.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(repo.id, UpdateKind::Cancel, Arc::from("cancelled")); actor.apply_update(repo.id, UpdateKind::Cached, Arc::from("done")); @@ -1395,7 +1409,7 @@ mod tests { assert_eq!(entry.state, ItemState::Cancelled); assert_eq!(entry.msg.as_ref(), "cancelled"); - let grammar = actor.register_grammar("json".into(), "json".into(), GitRef::head(), 4); + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); @@ -1410,10 +1424,15 @@ mod tests { fn parent_repo_stays_active_while_any_child_is_active() { let mut actor = actor(); - let repo = actor.register_repo("typescript".into(), GitRef::head(), 2); - let failing = - actor.register_grammar("typescript".into(), "typescript".into(), GitRef::head(), 4); - let pending = actor.register_grammar("typescript".into(), "tsx".into(), GitRef::head(), 4); + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let failing = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); + let pending = + actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); @@ -1435,7 +1454,7 @@ mod tests { #[test] fn materialize_recomputes_message_cells_when_terminal_width_changes() { let mut actor = actor(); - let progress = actor.register_repo("json".into(), GitRef::head(), 1); + let progress = actor.register_repo("json".into(), git::Ref::head(), 1); let message = "abcdefghijklmnop"; actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); @@ -1453,7 +1472,7 @@ mod tests { #[test] fn materialize_recomputes_message_cells_when_layout_width_changes() { let mut actor = actor(); - let progress = actor.register_repo("json".into(), GitRef::head(), 1); + let progress = actor.register_repo("json".into(), git::Ref::head(), 1); let message = "abcdefghijklmnop"; actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); @@ -1462,7 +1481,7 @@ mod tests { let initial_row = line_text(&initial_lines[0]); assert!(initial_row.contains(message)); - actor.register_repo("zzzzzzzzzzzzzzzzzzzz".into(), GitRef::head(), 1); + actor.register_repo("zzzzzzzzzzzzzzzzzzzz".into(), git::Ref::head(), 1); let updated_lines = actor.materialize(42); let updated_row = updated_lines diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 5d18dcf..04bbf09 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -12,6 +12,7 @@ use tracing::{debug, info}; use crate::{ args::TreeSitter, + cache::Source, error::TsdlError, parser::{GrammarBuild, LanguageBuild}, shutdown, tree_sitter, TsdlResult, @@ -87,7 +88,10 @@ pub async fn run( display.reference(tree_sitter_ref, "tree-sitter-cli").await; for language in &languages { display - .reference(language.spec.git_ref.clone(), language.name.clone()) + .reference( + language.spec.git_ref.requested().clone(), + language.name.clone(), + ) .await; } @@ -126,7 +130,12 @@ async fn run_inner( languages: Vec, tree_sitter: &TreeSitter, ) -> TsdlResult<()> { - let ts_cli = Arc::new(tree_sitter::prepare(build_dir, display.clone(), tree_sitter).await?); + let prepared = tree_sitter::prepare(build_dir, display.clone(), tree_sitter).await?; + let ts_cli = Arc::new(prepared.path); + let languages = languages + .into_iter() + .map(|language| language.with_tree_sitter(prepared.tree_sitter.clone())) + .collect::>(); let mut errors : Vec = // 1. Source: Create a stream from the input list @@ -207,27 +216,14 @@ async fn discover_grammars( debug!("[discover] lang={}", language.name); let progress = display - .add_language(language.spec.git_ref.clone(), language.name.clone(), 2) + .add_language( + language.spec.git_ref.requested().clone(), + language.name.clone(), + 2, + ) .await; - let needs_clone = cache - .needs_clone(language.name.clone(), language.spec.clone()) - .await; - - if needs_clone { - progress.set_outcome_built().await; - progress.step("cloning"); - if let Err(e) = language.clone().await { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("clone failed").await; - } - return Err(e); - } - } else { - progress.set_outcome_cached().await; - } + let source = resolve_source(&cache, &language, &progress).await?; progress.step("scanning"); let grammars = match language.discover_grammars().await { @@ -258,12 +254,18 @@ async fn discover_grammars( &name_arc, )?; let cache_decision = cache - .needs_rebuild(key, hash.clone(), language.spec.clone(), artifacts) + .needs_rebuild( + key, + hash.clone(), + language.spec.clone(), + source.clone(), + artifacts, + ) .await; let progress = display .add_grammar( - language.spec.git_ref.clone(), + language.spec.git_ref.requested().clone(), language.name.clone(), name_arc.clone(), 4, @@ -279,6 +281,7 @@ async fn discover_grammars( name: name_arc, output: language.output.clone(), progress, + source: source.clone(), spec: language.spec.clone(), ts_cli: ts_cli.clone(), }); @@ -286,3 +289,59 @@ async fn discover_grammars( Ok(builds) } + +async fn resolve_source( + cache: &CacheAddr, + language: &LanguageBuild, + progress: &ProgressAddr, +) -> TsdlResult { + if language.spec.git_ref.is_moving() { + info!( + "Resolving moving parser git ref for {}: {}", + language.name, + language.spec.git_ref.requested().as_str() + ); + progress.set_outcome_built().await; + progress.step("cloning"); + match language.checkout().await { + Ok(commit) => { + info!( + "Resolved parser {} git ref {} to commit {}", + language.name, + language.spec.git_ref.requested().as_str(), + commit.as_str() + ); + Ok(language.spec.git_ref.moving_source(commit)) + } + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("clone failed").await; + } + Err(e) + } + } + } else { + let needs_clone = cache + .needs_clone(language.name.clone(), language.spec.clone()) + .await; + + if needs_clone { + progress.set_outcome_built().await; + progress.step("cloning"); + if let Err(e) = language.checkout().await { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("clone failed").await; + } + return Err(e); + } + } else { + progress.set_outcome_cached().await; + } + + Ok(language.spec.git_ref.stable_source()) + } +} diff --git a/src/app.rs b/src/app.rs index 70652a6..a932632 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,8 +4,7 @@ use clap_verbosity_flag::{InfoLevel, Verbosity}; use crate::{ args::{BuildCommand, Command}, - config, display, logging, - TsdlResult, + config, display, logging, TsdlResult, }; /// Resolved application state, ready to run. @@ -24,8 +23,7 @@ pub fn setup() -> TsdlResult { let (args, matches) = config::parse_with_matches(); let build_matches = crate::args::build_matches(&matches); - let (command, provenance) = - config::current_with_provenance(&args.config, build_matches)?; + let (command, provenance) = config::current_with_provenance(&args.config, build_matches)?; let (log_path, _logging) = logging::init( args.log.clone(), diff --git a/src/build.rs b/src/build.rs index eea4023..fca27d4 100644 --- a/src/build.rs +++ b/src/build.rs @@ -18,9 +18,8 @@ use crate::{ consts::TSDL_FROM, error::{self, TsdlError}, format_duration, - git::GitRef, lock::{Lock, LockGuard, LockOwner, LockStatus, LockTakeoverError}, - parser::LanguageBuild, + parser::{self, LanguageBuild}, prompt_user, shutdown::{self, Shutdown}, SafeCanonicalize, TsdlResult, @@ -29,7 +28,7 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct BuildSpec { pub build_script: Option, - pub git_ref: GitRef, + pub git_ref: parser::Ref, pub prefix: String, pub repo: Url, pub target: Target, @@ -155,13 +154,13 @@ fn default_repo(language: &str) -> TsdlResult { fn get_language_coords( language: &str, defined_parsers: Option<&BTreeMap>, -) -> TsdlResult<(Option, GitRef, Url)> { +) -> TsdlResult<(Option, parser::Ref, Url)> { let config = defined_parsers.and_then(|parsers| parsers.get(language)); match config { Some(ParserConfig::Ref(git_ref)) => Ok(( None, - GitRef::from_version_or_ref(git_ref).map_err(|e| { + parser::Ref::parse(git_ref).map_err(|e| { TsdlError::context(format!("Parsing git ref {git_ref:?} for {language}"), e) })?, default_repo(language)?, @@ -181,14 +180,14 @@ fn get_language_coords( Ok(( build_script.clone(), - GitRef::from_version_or_ref(git_ref).map_err(|e| { + parser::Ref::parse(git_ref).map_err(|e| { TsdlError::context(format!("Parsing git ref {git_ref:?} for {language}"), e) })?, repo, )) } - None => Ok((None, GitRef::head(), default_repo(language)?)), + None => Ok((None, parser::Ref::head(), default_repo(language)?)), } } diff --git a/src/cache.rs b/src/cache.rs index 8e41c9c..fe520e0 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -15,8 +15,7 @@ use crate::{ build::BuildSpec, consts::TSDL_CACHE_FILE, error::TsdlError, - git::GitRef, - TsdlResult, + git, parser, TsdlResult, }; /// The build cache stored in `[build-dir]/[TSDL_CACHE_FILE]` @@ -33,6 +32,45 @@ pub struct Entry { pub hash: Arc, /// Complete build definition that affects parser output pub spec: Arc, + /// Parser source identity used by the cache. Moving refs include the + /// resolved commit; older cache files do not have this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum Source { + Stable { + requested: git::Ref, + }, + Moving { + requested: git::Ref, + commit: git::Sha, + }, +} + +impl Source { + #[must_use] + pub const fn is_moving(&self) -> bool { + matches!(self, Self::Moving { .. }) + } +} + +impl fmt::Display for Source { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stable { requested } => write!(f, "requested={}", requested.as_str()), + Self::Moving { requested, commit } => { + write!( + f, + "requested={} commit={}", + requested.as_str(), + commit.as_str() + ) + } + } + } } /// A cache lookup result for a requested parser build. @@ -61,9 +99,16 @@ pub enum CacheMissReason { cached: String, current: String, }, - GitRefChanged { - cached: GitRef, - current: GitRef, + RefChanged { + cached: parser::Ref, + current: parser::Ref, + }, + SourceIdentityMissing { + current: Source, + }, + SourceIdentityChanged { + cached: Source, + current: Source, }, TreeSitterChanged { cached: TreeSitter, @@ -173,7 +218,8 @@ impl CacheMissReason { Self::CacheIgnored => "ignored", Self::HashChanged { .. } => "hash", Self::RepoChanged { .. } => "repo", - Self::GitRefChanged { .. } => "ref", + Self::RefChanged { .. } => "ref", + Self::SourceIdentityMissing { .. } | Self::SourceIdentityChanged { .. } => "source", Self::TreeSitterChanged { .. } => "tree-sitter", Self::BuildScriptChanged => "script", Self::PrefixChanged { .. } => "prefix", @@ -191,7 +237,9 @@ impl CacheMissReason { Self::CacheIgnored => "cache ignored", Self::HashChanged { .. } => "grammar changed", Self::RepoChanged { .. } => "repo changed", - Self::GitRefChanged { .. } => "git ref changed", + Self::RefChanged { .. } => "git ref changed", + Self::SourceIdentityMissing { .. } => "cache identity upgraded", + Self::SourceIdentityChanged { .. } => "git ref resolved commit changed", Self::TreeSitterChanged { .. } => "tree-sitter changed", Self::BuildScriptChanged => "build script changed", Self::PrefixChanged { .. } => "prefix changed", @@ -214,12 +262,18 @@ impl fmt::Display for CacheMissReason { Self::RepoChanged { cached, current } => { write!(f, "repo changed cached={cached} current={current}") } - Self::GitRefChanged { cached, current } => write!( + Self::RefChanged { cached, current } => write!( f, "git ref changed cached={} current={}", - cached.as_str(), - current.as_str() + cached.requested().as_str(), + current.requested().as_str() ), + Self::SourceIdentityMissing { current } => { + write!(f, "cache identity upgraded current={current}") + } + Self::SourceIdentityChanged { cached, current } => { + write!(f, "source changed cached={cached} current={current}") + } Self::TreeSitterChanged { cached, current } => write!( f, "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", @@ -252,7 +306,7 @@ impl fmt::Display for CacheMissReason { impl Entry { #[must_use] - pub fn rebuild_decision(&self, hash: &str, spec: &BuildSpec) -> CacheDecision { + pub fn rebuild_decision(&self, hash: &str, spec: &BuildSpec, source: &Source) -> CacheDecision { let mut reasons = Vec::new(); let cached = self.spec.as_ref(); @@ -270,13 +324,31 @@ impl Entry { }); } - if cached.git_ref != spec.git_ref { - reasons.push(CacheMissReason::GitRefChanged { + let git_ref_changed = cached.git_ref != spec.git_ref; + if git_ref_changed { + reasons.push(CacheMissReason::RefChanged { cached: cached.git_ref.clone(), current: spec.git_ref.clone(), }); } + if !git_ref_changed { + match (&self.source, source) { + (None, Source::Moving { .. }) => { + reasons.push(CacheMissReason::SourceIdentityMissing { + current: source.clone(), + }); + } + (Some(cached_source), current_source) if cached_source != current_source => { + reasons.push(CacheMissReason::SourceIdentityChanged { + cached: cached_source.clone(), + current: current_source.clone(), + }); + } + _ => {} + } + } + if cached.tree_sitter != spec.tree_sitter { reasons.push(CacheMissReason::TreeSitterChanged { cached: cached.tree_sitter.clone(), @@ -360,11 +432,17 @@ impl Db { } /// Explain whether a parser cache entry can satisfy the requested build. - pub fn rebuild_decision(&self, name: &str, hash: &str, spec: &BuildSpec) -> CacheDecision { + pub fn rebuild_decision( + &self, + name: &str, + hash: &str, + spec: &BuildSpec, + source: &Source, + ) -> CacheDecision { // TODO: hash and name are plain str, I'd like strong types here. let decision = match self.get(name) { None => CacheDecision::miss(CacheMissReason::MissingEntry), - Some(entry) => entry.rebuild_decision(hash, spec), + Some(entry) => entry.rebuild_decision(hash, spec, source), }; debug!("Cache decision for {name}: {decision}"); @@ -373,8 +451,9 @@ impl Db { /// Check if a parser needs rebuilding by comparing grammar hash and build definition. #[must_use] - pub fn needs_rebuild(&self, name: &str, hash: &str, spec: &BuildSpec) -> bool { - self.rebuild_decision(name, hash, spec).needs_rebuild() + pub fn needs_rebuild(&self, name: &str, hash: &str, spec: &BuildSpec, source: &Source) -> bool { + self.rebuild_decision(name, hash, spec, source) + .needs_rebuild() } /// Save the cache to disk @@ -429,11 +508,15 @@ pub async fn hash_file(path: &Path) -> TsdlResult { #[cfg(test)] mod tests { use super::*; + use crate::{git, parser}; + + const SHA1: &str = "636801770eea172d140e64b691815ff11f6b556f"; + const SHA2: &str = "736801770eea172d140e64b691815ff11f6b556f"; fn test_spec() -> BuildSpec { BuildSpec { build_script: None, - git_ref: GitRef::new("master").unwrap(), + git_ref: parser::Ref::parse("v1.0.0").unwrap(), repo: "https://github.com/example/parser".parse().unwrap(), tree_sitter: TreeSitter::default(), prefix: String::new(), @@ -441,13 +524,33 @@ mod tests { } } - fn cache_with_entry(hash: &str, spec: BuildSpec) -> Db { + fn moving_spec() -> BuildSpec { + BuildSpec { + git_ref: parser::Ref::parse("master").unwrap(), + ..test_spec() + } + } + + fn stable_source(spec: &BuildSpec) -> Source { + spec.git_ref.stable_source() + } + + fn moving_source(spec: &BuildSpec, sha: &str) -> Source { + spec.git_ref.moving_source(git::Sha::new(sha).unwrap()) + } + + fn cache_with_entry(hash: &str, spec: &BuildSpec) -> Db { + cache_with_entry_and_source(hash, spec, Some(stable_source(spec))) + } + + fn cache_with_entry_and_source(hash: &str, spec: &BuildSpec, source: Option) -> Db { let mut cache = Db::default(); cache.set( "test-parser".to_string(), Entry { hash: hash.into(), - spec: Arc::new(spec), + spec: Arc::new(spec.clone()), + source, }, ); cache @@ -466,7 +569,7 @@ mod tests { let spec = test_spec(); assert_miss( - cache.rebuild_decision("test-parser", "abc123", &spec), + cache.rebuild_decision("test-parser", "abc123", &spec, &stable_source(&spec)), &[CacheMissReason::MissingEntry], ); } @@ -474,10 +577,10 @@ mod tests { #[test] fn test_rebuild_decision_hash_mismatch() { let spec = test_spec(); - let cache = cache_with_entry("abc123", spec.clone()); + let cache = cache_with_entry("abc123", &spec); assert_miss( - cache.rebuild_decision("test-parser", "def456", &spec), + cache.rebuild_decision("test-parser", "def456", &spec, &stable_source(&spec)), &[CacheMissReason::HashChanged { cached: "abc123".into(), current: "def456".into(), @@ -489,14 +592,19 @@ mod tests { fn test_rebuild_decision_git_ref_mismatch() { let cached = test_spec(); let mut requested = cached.clone(); - requested.git_ref = GitRef::new("v1.0.0").unwrap(); - let cache = cache_with_entry("abc123", cached); + requested.git_ref = parser::Ref::parse("v2.0.0").unwrap(); + let cache = cache_with_entry("abc123", &cached); assert_miss( - cache.rebuild_decision("test-parser", "abc123", &requested), - &[CacheMissReason::GitRefChanged { - cached: GitRef::new("master").unwrap(), - current: GitRef::new("v1.0.0").unwrap(), + cache.rebuild_decision( + "test-parser", + "abc123", + &requested, + &stable_source(&requested), + ), + &[CacheMissReason::RefChanged { + cached: parser::Ref::parse("v1.0.0").unwrap(), + current: parser::Ref::parse("v2.0.0").unwrap(), }], ); } @@ -506,10 +614,15 @@ mod tests { let cached = test_spec(); let mut requested = cached.clone(); requested.target = Target::Wasm; - let cache = cache_with_entry("abc123", cached); + let cache = cache_with_entry("abc123", &cached); assert_miss( - cache.rebuild_decision("test-parser", "abc123", &requested), + cache.rebuild_decision( + "test-parser", + "abc123", + &requested, + &stable_source(&requested), + ), &[CacheMissReason::TargetChanged { cached: Target::Native, current: Target::Wasm, @@ -520,13 +633,64 @@ mod tests { #[test] fn test_rebuild_decision_cache_hit_exact() { let spec = test_spec(); - let cache = cache_with_entry("abc123", spec.clone()); + let cache = cache_with_entry("abc123", &spec); + + assert_eq!( + cache.rebuild_decision("test-parser", "abc123", &spec, &stable_source(&spec)), + CacheDecision::Hit + ); + assert!(!cache.needs_rebuild("test-parser", "abc123", &spec, &stable_source(&spec))); + } + + #[test] + fn test_rebuild_decision_old_stable_entry_without_source_still_hits() { + let spec = test_spec(); + let cache = cache_with_entry_and_source("abc123", &spec, None); + + assert_eq!( + cache.rebuild_decision("test-parser", "abc123", &spec, &stable_source(&spec)), + CacheDecision::Hit + ); + } + + #[test] + fn test_rebuild_decision_old_moving_entry_without_source_misses_once() { + let spec = moving_spec(); + let source = moving_source(&spec, SHA1); + let cache = cache_with_entry_and_source("abc123", &spec, None); + + assert_miss( + cache.rebuild_decision("test-parser", "abc123", &spec, &source), + &[CacheMissReason::SourceIdentityMissing { current: source }], + ); + } + + #[test] + fn test_rebuild_decision_moving_source_commit_changed() { + let spec = moving_spec(); + let cached_source = moving_source(&spec, SHA1); + let current_source = moving_source(&spec, SHA2); + let cache = cache_with_entry_and_source("abc123", &spec, Some(cached_source.clone())); + + assert_miss( + cache.rebuild_decision("test-parser", "abc123", &spec, ¤t_source), + &[CacheMissReason::SourceIdentityChanged { + cached: cached_source, + current: current_source, + }], + ); + } + + #[test] + fn test_rebuild_decision_moving_source_commit_unchanged_hits() { + let spec = moving_spec(); + let source = moving_source(&spec, SHA1); + let cache = cache_with_entry_and_source("abc123", &spec, Some(source.clone())); assert_eq!( - cache.rebuild_decision("test-parser", "abc123", &spec), + cache.rebuild_decision("test-parser", "abc123", &spec, &source), CacheDecision::Hit ); - assert!(!cache.needs_rebuild("test-parser", "abc123", &spec)); } #[test] @@ -536,10 +700,15 @@ mod tests { requested.build_script = Some("make".to_string()); requested.prefix = "custom-".to_string(); requested.target = Target::All; - let cache = cache_with_entry("abc123", cached); + let cache = cache_with_entry("abc123", &cached); assert_miss( - cache.rebuild_decision("test-parser", "def456", &requested), + cache.rebuild_decision( + "test-parser", + "def456", + &requested, + &stable_source(&requested), + ), &[ CacheMissReason::HashChanged { cached: "abc123".into(), diff --git a/src/config.rs b/src/config.rs index ca437e7..df061f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,16 +5,13 @@ use std::{ }; use clap::{ - parser::ValueSource, - value_parser, Arg, ArgAction, ArgMatches, CommandFactory, FromArgMatches, + parser::ValueSource, value_parser, Arg, ArgAction, ArgMatches, CommandFactory, FromArgMatches, }; use serde::Serialize; use tracing::debug; use crate::{ - args::{ - Args, BuildCommand, ConfigCommand, OptionalBuildCommand, Target, - }, + args::{Args, BuildCommand, ConfigCommand, OptionalBuildCommand, Target}, columns, error::TsdlError, TsdlResult, @@ -240,7 +237,10 @@ pub fn build_cli(defaults: &BuildCommand) -> Vec { .short('b') .env("TSDL_BUILD_DIR") .value_parser(value_parser!(PathBuf)) - .help(format!("Build Directory [default: {}]", defaults.build_dir.display())), + .help(format!( + "Build Directory [default: {}]", + defaults.build_dir.display() + )), Arg::new("force") .long("force") .env("TSDL_FORCE") @@ -281,13 +281,19 @@ pub fn build_cli(defaults: &BuildCommand) -> Vec { .short('o') .env("TSDL_OUT_DIR") .value_parser(value_parser!(PathBuf)) - .help(format!("Output Directory [default: {}]", defaults.out_dir.display())), + .help(format!( + "Output Directory [default: {}]", + defaults.out_dir.display() + )), Arg::new("prefix") .long("prefix") .short('p') .env("TSDL_PREFIX") .value_parser(value_parser!(String)) - .help(format!("Prefix parser names [default: {}]", defaults.prefix)), + .help(format!( + "Prefix parser names [default: {}]", + defaults.prefix + )), Arg::new("show-config") .long("show-config") .env("TSDL_SHOW_CONFIG") @@ -309,7 +315,10 @@ pub fn build_cli(defaults: &BuildCommand) -> Vec { clap::builder::PossibleValue::new("wasm"), clap::builder::PossibleValue::new("all"), ]) - .help(format!("Build target [default: {}]", defaults.target.to_lowercase())), + .help(format!( + "Build target [default: {}]", + defaults.target.to_lowercase() + )), Arg::new("tree-sitter-version") .long("tree-sitter-version") .short('V') @@ -340,7 +349,9 @@ pub fn build_cli(defaults: &BuildCommand) -> Vec { .long("unlock-timeout") .env("TSDL_UNLOCK_TIMEOUT") .value_parser(value_parser!(u64).range(1..)) - .help(format!("Seconds to wait after terminating a lock owner [default: {ut_default}]")), + .help(format!( + "Seconds to wait after terminating a lock owner [default: {ut_default}]" + )), ] } @@ -352,12 +363,8 @@ pub fn extract_overrides( let mut p = BuildProvenance::default(); extract_simple(matches, "build-dir", &mut o.build_dir, &mut p.build_dir); - extract_bool( - matches, "force", "no-force", &mut o.force, &mut p.force, - ); - extract_bool( - matches, "fresh", "no-fresh", &mut o.fresh, &mut p.fresh, - ); + extract_bool(matches, "force", "no-force", &mut o.force, &mut p.force); + extract_bool(matches, "fresh", "no-fresh", &mut o.fresh, &mut p.fresh); if let Some(source) = source_for(matches, "languages") { let vals: Vec = matches @@ -384,11 +391,31 @@ pub fn extract_overrides( extract_target(matches, "target", &mut o.target, &mut p.target); - extract_simple(matches, "tree-sitter-version", &mut o.tree_sitter.version, &mut p.tree_sitter.version); - extract_simple(matches, "tree-sitter-platform", &mut o.tree_sitter.platform, &mut p.tree_sitter.platform); - extract_simple(matches, "tree-sitter-repo", &mut o.tree_sitter.repo, &mut p.tree_sitter.repo); + extract_simple( + matches, + "tree-sitter-version", + &mut o.tree_sitter.version, + &mut p.tree_sitter.version, + ); + extract_simple( + matches, + "tree-sitter-platform", + &mut o.tree_sitter.platform, + &mut p.tree_sitter.platform, + ); + extract_simple( + matches, + "tree-sitter-repo", + &mut o.tree_sitter.repo, + &mut p.tree_sitter.repo, + ); - extract_simple_u64(matches, "unlock-timeout", &mut o.unlock_timeout, &mut p.unlock_timeout); + extract_simple_u64( + matches, + "unlock-timeout", + &mut o.unlock_timeout, + &mut p.unlock_timeout, + ); (o, p) } @@ -553,9 +580,7 @@ pub fn parse_with_matches() -> (Args, ArgMatches) { (args, matches) } -pub fn try_parse_from_with_matches( - itr: I, -) -> Result<(Args, ArgMatches), clap::Error> +pub fn try_parse_from_with_matches(itr: I) -> Result<(Args, ArgMatches), clap::Error> where I: IntoIterator, T: Into + Clone, diff --git a/src/display.rs b/src/display.rs index 4e5c1c8..a53e19c 100644 --- a/src/display.rs +++ b/src/display.rs @@ -9,7 +9,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use crate::args::ProgressStyle; -use crate::git::GitRef; +use crate::git; // ── Column geometry ────────────────────────────────────────────── /// Fixed width of the time column (right-aligned, " 0.00s" … "59:59"). @@ -165,7 +165,7 @@ impl ItemState { #[derive(Debug, Clone)] pub(crate) struct RepoEntry { pub name: Arc, - pub git_ref: GitRef, + pub git_ref: git::Ref, pub state: ItemState, pub msg: Arc, pub step: usize, @@ -188,7 +188,7 @@ pub(crate) struct GrammarEntry { pub repo: Arc, pub repo_id: u64, pub name: Arc, - pub git_ref: GitRef, + pub git_ref: git::Ref, pub state: ItemState, pub msg: Arc, pub step: usize, @@ -213,7 +213,7 @@ impl GrammarEntry { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum Column { Time, - GitRef, + Ref, Step, Icon, Name, @@ -384,7 +384,7 @@ impl ItemInfo<'_> { } } - pub(crate) fn git_ref(&self) -> &GitRef { + pub(crate) fn git_ref(&self) -> &git::Ref { match self { ItemInfo::Repo(r) => &r.git_ref, ItemInfo::Grammar(g) => &g.git_ref, diff --git a/src/git.rs b/src/git.rs index 8ed5543..874a93a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -11,7 +11,7 @@ use tokio::{fs, process::Command}; use crate::{error::TsdlError, sh::Exec, TsdlResult}; #[derive(Debug, Clone, PartialEq, Eq)] -pub enum GitRefParseError { +pub enum RefError { EmptyRef, InvalidRefCharacter { index: usize, character: char }, InvalidRefSyntax { reason: &'static str }, @@ -19,7 +19,7 @@ pub enum GitRefParseError { InvalidShaHex { index: usize, character: char }, } -impl fmt::Display for GitRefParseError { +impl fmt::Display for RefError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::EmptyRef => write!(f, "git ref cannot be empty"), @@ -39,20 +39,20 @@ impl fmt::Display for GitRefParseError { } } -impl std::error::Error for GitRefParseError {} +impl std::error::Error for RefError {} -impl From for TsdlError { - fn from(error: GitRefParseError) -> Self { +impl From for TsdlError { + fn from(error: RefError) -> Self { TsdlError::message(error.to_string()) } } #[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct GitRef(Arc); +pub struct Ref(Arc); -impl GitRef { +impl Ref { /// Create a validated git ref. - pub fn new(value: impl Into>) -> Result { + pub fn new(value: impl Into>) -> Result { let value = value.into(); validate_git_ref(&value)?; Ok(Self(value)) @@ -66,8 +66,8 @@ impl GitRef { /// Create a validated git ref from user input, normalizing dotted numeric /// versions such as `0.21.0` to the conventional git tag `v0.21.0`. - pub fn from_version_or_ref(value: &str) -> Result { - if GitSha::is_full_sha(value) || value.starts_with('v') { + pub fn from_version_or_ref(value: &str) -> Result { + if Sha::is_full_sha(value) || value.starts_with('v') { return Self::new(value); } @@ -87,7 +87,7 @@ impl GitRef { /// Get a human-oriented representation, shortening full commit SHAs. #[must_use] pub fn short(&self) -> &str { - if GitSha::is_full_sha(self.as_str()) { + if Sha::is_full_sha(self.as_str()) { &self.0[..7] } else { &self.0 @@ -96,47 +96,47 @@ impl GitRef { #[must_use] pub fn is_exact_sha(&self) -> bool { - GitSha::is_full_sha(self.as_str()) + Sha::is_full_sha(self.as_str()) } } -impl AsRef for GitRef { +impl AsRef for Ref { fn as_ref(&self) -> &str { self.as_str() } } -impl TryFrom<&str> for GitRef { - type Error = GitRefParseError; +impl TryFrom<&str> for Ref { + type Error = RefError; fn try_from(value: &str) -> Result { Self::new(value) } } -impl TryFrom for GitRef { - type Error = GitRefParseError; +impl TryFrom for Ref { + type Error = RefError; fn try_from(value: String) -> Result { Self::new(value) } } -impl std::str::FromStr for GitRef { - type Err = GitRefParseError; +impl std::str::FromStr for Ref { + type Err = RefError; fn from_str(value: &str) -> Result { Self::new(value) } } -impl fmt::Display for GitRef { +impl fmt::Display for Ref { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.short()) } } -impl Serialize for GitRef { +impl Serialize for Ref { fn serialize(&self, serializer: S) -> Result where S: Serializer, @@ -145,7 +145,7 @@ impl Serialize for GitRef { } } -impl<'de> Deserialize<'de> for GitRef { +impl<'de> Deserialize<'de> for Ref { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -156,11 +156,11 @@ impl<'de> Deserialize<'de> for GitRef { } #[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct GitSha(Arc); +pub struct Sha(Arc); -impl GitSha { +impl Sha { /// Create a validated full 40-character git SHA-1. - pub fn new(value: impl Into>) -> Result { + pub fn new(value: impl Into>) -> Result { let value = value.into(); validate_git_sha(&value)?; Ok(Self(value)) @@ -182,43 +182,43 @@ impl GitSha { } } -impl AsRef for GitSha { +impl AsRef for Sha { fn as_ref(&self) -> &str { self.as_str() } } -impl TryFrom<&str> for GitSha { - type Error = GitRefParseError; +impl TryFrom<&str> for Sha { + type Error = RefError; fn try_from(value: &str) -> Result { Self::new(value) } } -impl TryFrom for GitSha { - type Error = GitRefParseError; +impl TryFrom for Sha { + type Error = RefError; fn try_from(value: String) -> Result { Self::new(value) } } -impl std::str::FromStr for GitSha { - type Err = GitRefParseError; +impl std::str::FromStr for Sha { + type Err = RefError; fn from_str(value: &str) -> Result { Self::new(value) } } -impl fmt::Display for GitSha { +impl fmt::Display for Sha { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.short()) } } -impl Serialize for GitSha { +impl Serialize for Sha { fn serialize(&self, serializer: S) -> Result where S: Serializer, @@ -227,7 +227,7 @@ impl Serialize for GitSha { } } -impl<'de> Deserialize<'de> for GitSha { +impl<'de> Deserialize<'de> for Sha { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -237,16 +237,16 @@ impl<'de> Deserialize<'de> for GitSha { } } -impl From for GitRef { - fn from(sha: GitSha) -> Self { +impl From for Ref { + fn from(sha: Sha) -> Self { Self(sha.0) } } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum ResolvedRef { - Tag { label: String, sha: GitSha }, - Ref(GitRef), + Tag { label: String, sha: Sha }, + Ref(Ref), } impl fmt::Display for ResolvedRef { @@ -258,37 +258,37 @@ impl fmt::Display for ResolvedRef { } } -fn validate_git_ref(value: &str) -> Result<(), GitRefParseError> { +fn validate_git_ref(value: &str) -> Result<(), RefError> { if value.is_empty() { - return Err(GitRefParseError::EmptyRef); + return Err(RefError::EmptyRef); } if value == "@" { - return Err(GitRefParseError::InvalidRefSyntax { + return Err(RefError::InvalidRefSyntax { reason: "single @ is not a ref", }); } if value.starts_with('/') || value.ends_with('/') { - return Err(GitRefParseError::InvalidRefSyntax { + return Err(RefError::InvalidRefSyntax { reason: "refs cannot start or end with /", }); } if value.ends_with('.') { - return Err(GitRefParseError::InvalidRefSyntax { + return Err(RefError::InvalidRefSyntax { reason: "refs cannot end with .", }); } if value.contains("..") { - return Err(GitRefParseError::InvalidRefSyntax { + return Err(RefError::InvalidRefSyntax { reason: "refs cannot contain ..", }); } if value.contains("@{") { - return Err(GitRefParseError::InvalidRefSyntax { + return Err(RefError::InvalidRefSyntax { reason: "refs cannot contain @{", }); } @@ -298,24 +298,24 @@ fn validate_git_ref(value: &str) -> Result<(), GitRefParseError> { || c.is_ascii_whitespace() || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\') }) { - return Err(GitRefParseError::InvalidRefCharacter { index, character }); + return Err(RefError::InvalidRefCharacter { index, character }); } for component in value.split('/') { if component.is_empty() { - return Err(GitRefParseError::InvalidRefSyntax { + return Err(RefError::InvalidRefSyntax { reason: "refs cannot contain empty path components", }); } if component.starts_with('.') { - return Err(GitRefParseError::InvalidRefSyntax { + return Err(RefError::InvalidRefSyntax { reason: "ref path components cannot start with .", }); } if component.strip_suffix(".lock").is_some() { - return Err(GitRefParseError::InvalidRefSyntax { + return Err(RefError::InvalidRefSyntax { reason: "ref path components cannot end with .lock", }); } @@ -324,15 +324,15 @@ fn validate_git_ref(value: &str) -> Result<(), GitRefParseError> { Ok(()) } -fn validate_git_sha(value: &str) -> Result<(), GitRefParseError> { +fn validate_git_sha(value: &str) -> Result<(), RefError> { if value.len() != 40 { - return Err(GitRefParseError::InvalidShaLength { + return Err(RefError::InvalidShaLength { actual: value.len(), }); } if let Some((index, character)) = value.char_indices().find(|(_, c)| !c.is_ascii_hexdigit()) { - return Err(GitRefParseError::InvalidShaHex { index, character }); + return Err(RefError::InvalidShaHex { index, character }); } Ok(()) @@ -373,16 +373,16 @@ pub async fn clone(repo: &str, cwd: &Path) -> TsdlResult<()> { Ok(()) } -pub async fn clone_fast(repo: &str, git_ref: &GitRef, cwd: &Path) -> TsdlResult<()> { +pub async fn clone_fast(repo: &str, git_ref: &Ref, cwd: &Path) -> TsdlResult { clone_fast_with_force(repo, git_ref, cwd, false).await } pub async fn clone_fast_with_force( repo: &str, - git_ref: &GitRef, + git_ref: &Ref, cwd: &Path, force: bool, -) -> TsdlResult<()> { +) -> TsdlResult { if force || !is_same_remote(cwd, repo).await { clean_anyway(cwd).await?; } @@ -391,10 +391,15 @@ pub async fn clone_fast_with_force( } else { init_fetch_and_checkout(cwd, repo, git_ref).await?; } - Ok(()) + get_head_sha(cwd).await.map_err(|err| { + TsdlError::context( + format!("Resolving checked out commit for {}", cwd.display()), + err, + ) + }) } -async fn fetch_and_checkout(cwd: &Path, git_ref: &GitRef) -> TsdlResult<()> { +async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> TsdlResult<()> { Command::new("git") .env("GIT_TERMINAL_PROMPT", "0") .current_dir(cwd) @@ -421,6 +426,11 @@ async fn get_head_sha1(cwd: &Path) -> TsdlResult { .map_err(|e| TsdlError::context("rev-parse HEAD is not a valid utf-8", e)) } +async fn get_head_sha(cwd: &Path) -> TsdlResult { + let value = get_head_sha1(cwd).await?; + Sha::new(value.trim()).map_err(|e| TsdlError::context("Parsing HEAD commit", e)) +} + async fn get_remote_url(cwd: &Path) -> TsdlResult { String::from_utf8( Command::new("git") @@ -433,7 +443,7 @@ async fn get_remote_url(cwd: &Path) -> TsdlResult { .map_err(|e| TsdlError::context("remote get-url origin did not return a valid utf-8", e)) } -async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &GitRef) -> TsdlResult<()> { +async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> TsdlResult<()> { clean_anyway(cwd).await?; fs::create_dir_all(cwd).await?; @@ -524,7 +534,7 @@ pub async fn list_grammar_files(cwd: &Path) -> TsdlResult> { Ok(result) } -async fn reset_head_hard(cwd: &Path, git_ref: &GitRef) -> TsdlResult<()> { +async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> TsdlResult<()> { if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { Command::new("git") .current_dir(cwd) @@ -536,7 +546,7 @@ async fn reset_head_hard(cwd: &Path, git_ref: &GitRef) -> TsdlResult<()> { Ok(()) } -pub async fn tag_for_ref(cwd: &Path, git_ref: &GitRef) -> TsdlResult { +pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> TsdlResult { // Try to find a tag for this ref let tag = Command::new("git") .current_dir(cwd) @@ -570,14 +580,14 @@ mod tests { #[test] fn git_ref_rejects_empty_refs() { - assert_eq!(GitRef::new(""), Err(GitRefParseError::EmptyRef)); + assert_eq!(Ref::new(""), Err(RefError::EmptyRef)); } #[test] fn git_ref_rejects_whitespace() { assert_eq!( - GitRef::new("feature branch"), - Err(GitRefParseError::InvalidRefCharacter { + Ref::new("feature branch"), + Err(RefError::InvalidRefCharacter { index: 7, character: ' ', }) @@ -587,31 +597,31 @@ mod tests { #[test] fn git_ref_rejects_invalid_git_ref_syntax() { assert_eq!( - GitRef::new("feature..branch"), - Err(GitRefParseError::InvalidRefSyntax { + Ref::new("feature..branch"), + Err(RefError::InvalidRefSyntax { reason: "refs cannot contain ..", }) ); assert_eq!( - GitRef::new("feature.lock"), - Err(GitRefParseError::InvalidRefSyntax { + Ref::new("feature.lock"), + Err(RefError::InvalidRefSyntax { reason: "ref path components cannot end with .lock", }) ); assert_eq!( - GitRef::new("refs/heads/main"), - Ok(GitRef::new("refs/heads/main").unwrap()) + Ref::new("refs/heads/main"), + Ok(Ref::new("refs/heads/main").unwrap()) ); } #[test] fn git_ref_normalizes_dotted_numeric_versions() { assert_eq!( - GitRef::from_version_or_ref("0.21.0").unwrap().as_str(), + Ref::from_version_or_ref("0.21.0").unwrap().as_str(), "v0.21.0" ); assert_eq!( - GitRef::from_version_or_ref("v0.21.0").unwrap().as_str(), + Ref::from_version_or_ref("v0.21.0").unwrap().as_str(), "v0.21.0" ); } @@ -619,18 +629,18 @@ mod tests { #[test] fn git_ref_preserves_branches_and_full_shas() { assert_eq!( - GitRef::from_version_or_ref("master").unwrap().as_str(), + Ref::from_version_or_ref("master").unwrap().as_str(), "master" ); assert_eq!( - GitRef::from_version_or_ref(FULL_SHA).unwrap().as_str(), + Ref::from_version_or_ref(FULL_SHA).unwrap().as_str(), FULL_SHA ); } #[test] fn git_ref_display_is_short_but_as_str_is_exact() { - let git_ref = GitRef::new(FULL_SHA).unwrap(); + let git_ref = Ref::new(FULL_SHA).unwrap(); assert_eq!(git_ref.as_str(), FULL_SHA); assert_eq!(git_ref.short(), "6368017"); @@ -639,15 +649,15 @@ mod tests { #[test] fn git_sha_requires_full_hex_sha() { - assert_eq!(GitSha::new(FULL_SHA).unwrap().as_str(), FULL_SHA); - assert_eq!(GitSha::new(FULL_SHA).unwrap().short(), "6368017"); + assert_eq!(Sha::new(FULL_SHA).unwrap().as_str(), FULL_SHA); + assert_eq!(Sha::new(FULL_SHA).unwrap().short(), "6368017"); assert_eq!( - GitSha::new("6368017"), - Err(GitRefParseError::InvalidShaLength { actual: 7 }) + Sha::new("6368017"), + Err(RefError::InvalidShaLength { actual: 7 }) ); assert_eq!( - GitSha::new("636801770eea172d140e64b691815ff11f6b556x"), - Err(GitRefParseError::InvalidShaHex { + Sha::new("636801770eea172d140e64b691815ff11f6b556x"), + Err(RefError::InvalidShaHex { index: 39, character: 'x', }) @@ -656,7 +666,7 @@ mod tests { #[test] fn serde_rejects_invalid_git_refs() { - let err = toml::from_str::>(r#"git_ref = """#) + let err = toml::from_str::>(r#"git_ref = """#) .unwrap_err(); assert!(err.to_string().contains("git ref cannot be empty")); diff --git a/src/logging.rs b/src/logging.rs index 17b9b7b..211f15c 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -40,7 +40,11 @@ pub fn init( Ok((path, Guard(guard))) } -fn init_tracing(writer: tracing_appender::non_blocking::NonBlocking, color: bool, filter: LevelFilter) { +fn init_tracing( + writer: tracing_appender::non_blocking::NonBlocking, + color: bool, + filter: LevelFilter, +) { let stdout_layer = tracing_subscriber::fmt::layer() .compact() .with_ansi(color) @@ -74,8 +78,7 @@ fn init_tracing(writer: tracing_appender::non_blocking::NonBlocking, color: bool } fn resolve_log_path(log: Option<&PathBuf>, build_dir: &Path) -> TsdlResult { - let log = log - .map_or_else(|| build_dir.join(TSDL_LOG_FILE), Clone::clone); + let log = log.map_or_else(|| build_dir.join(TSDL_LOG_FILE), Clone::clone); validate_log_path(build_dir, &log) } diff --git a/src/main.rs b/src/main.rs index f869f9d..65c1b0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,7 @@ fn run(app: app::App) -> TsdlResult<()> { args::Command::Config { command } => tsdl::config::run(&app.config_path, &command), args::Command::Selfupdate{ force, target} => tsdl::selfupdate::run(force, target.as_str()), } - } +} pub fn set_panic_hook() { std::panic::set_hook(Box::new(move |info| { diff --git a/src/parser.rs b/src/parser.rs index 4865638..a5f00d1 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,5 +1,6 @@ use std::{ env::consts::DLL_EXTENSION, + fmt, fs::Metadata, io, os::unix::fs::MetadataExt, @@ -12,12 +13,15 @@ use std::{ use tokio::{fs, process::Command}; use tracing::{debug, warn}; +use crate::args::TreeSitter; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; + use crate::{ actors::ProgressAddr, build::{BuildContext, BuildSpec, OutputConfig}, cache::{self, CacheDecision, Entry, Update}, error::{self, TsdlError}, - git::clone_fast, + git::{self, clone_fast, Sha}, sh::{Exec, Script}, shutdown, walk::collect_grammar_paths, @@ -26,6 +30,108 @@ use crate::{ pub const WASM_EXTENSION: &str = "wasm"; +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum Ref { + Stable(git::Ref), + Moving(git::Ref), +} + +impl Ref { + /// The default source ref used for unpinned parser builds. + #[must_use] + pub fn head() -> Self { + Self::Moving(git::Ref::head()) + } + + /// Create a parser source ref from user input, preserving parser-version + /// normalization while classifying refs for cache semantics. + pub fn parse(value: &str) -> Result { + let git_ref = git::Ref::from_version_or_ref(value)?; + if is_stable_source_ref(value, &git_ref) { + Ok(Self::Stable(git_ref)) + } else { + Ok(Self::Moving(git_ref)) + } + } + + #[must_use] + pub const fn is_moving(&self) -> bool { + matches!(self, Self::Moving(_)) + } + + #[must_use] + pub const fn is_stable(&self) -> bool { + matches!(self, Self::Stable(_)) + } + + #[must_use] + pub fn requested(&self) -> &git::Ref { + match self { + Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, + } + } + + #[must_use] + pub fn stable_source(&self) -> cache::Source { + cache::Source::Stable { + requested: self.requested().clone(), + } + } + + #[must_use] + pub fn moving_source(&self, commit: Sha) -> cache::Source { + cache::Source::Moving { + requested: self.requested().clone(), + commit, + } + } +} + +impl Serialize for Ref { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.requested().as_str()) + } +} + +impl<'de> Deserialize<'de> for Ref { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } +} + +impl fmt::Display for Ref { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.requested()) + } +} + +fn is_dotted_numeric_version(value: &str) -> bool { + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} + +fn is_v_dotted_numeric_version(value: &str) -> bool { + value + .strip_prefix('v') + .is_some_and(is_dotted_numeric_version) +} + +fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { + git_ref.is_exact_sha() + || is_dotted_numeric_version(input) + || is_v_dotted_numeric_version(input) + || git_ref.as_str().starts_with("refs/tags/") +} + /// A grammar ready to be built, combining definition and cache state #[derive(Clone, Debug)] pub struct GrammarBuild { @@ -37,6 +143,7 @@ pub struct GrammarBuild { pub name: Arc, pub output: OutputConfig, pub progress: ProgressAddr, // Use language's handle + pub source: cache::Source, pub spec: Arc, pub ts_cli: Arc, } @@ -100,6 +207,7 @@ impl GrammarBuild { entry: Entry { hash: self.hash.clone(), spec: self.spec.clone(), + source: Some(self.source.clone()), }, }; @@ -544,10 +652,18 @@ impl LanguageBuild { Ok(grammars) } - pub async fn clone(&self) -> TsdlResult<()> { + #[must_use] + pub fn with_tree_sitter(mut self, tree_sitter: TreeSitter) -> Self { + let mut spec = self.spec.as_ref().clone(); + spec.tree_sitter = tree_sitter; + self.spec = Arc::new(spec); + self + } + + pub async fn checkout(&self) -> TsdlResult { clone_fast( self.spec.repo.as_str(), - &self.spec.git_ref, + self.spec.git_ref.requested(), &self.output.build_dir, ) .await @@ -702,10 +818,43 @@ mod tests { actors::{DisplayActor, DisplayAddr}, args::{Target, TreeSitter}, display::Mode, - git::GitRef, + git, }; use tempfile::TempDir; + const FULL_SHA: &str = "636801770eea172d140e64b691815ff11f6b556f"; + + #[test] + fn ref_classifies_stable_refs() { + assert!(Ref::parse("0.21.0").unwrap().is_stable()); + assert!(Ref::parse("v0.21.0").unwrap().is_stable()); + assert!(Ref::parse("refs/tags/release").unwrap().is_stable()); + assert!(Ref::parse(FULL_SHA).unwrap().is_stable()); + } + + #[test] + fn ref_classifies_moving_refs() { + assert!(Ref::head().is_moving()); + assert!(Ref::parse("master").unwrap().is_moving()); + assert!(Ref::parse("refs/heads/main").unwrap().is_moving()); + assert!(Ref::parse("vnext").unwrap().is_moving()); + } + + #[test] + fn ref_serializes_as_requested_ref() { + #[derive(serde::Serialize)] + struct Wrapper { + git_ref: Ref, + } + + let source = Ref::parse("0.21.0").unwrap(); + + assert_eq!( + toml::to_string(&Wrapper { git_ref: source }).unwrap(), + "git_ref = \"v0.21.0\"\n" + ); + } + /// Extract directory name from a path fn extract_dir_name(dir: &Path) -> TsdlResult { dir.file_name() @@ -752,7 +901,10 @@ mod tests { Arc::new(grammar_dir.clone()), Arc::new(out_dir.clone()), ); - let progress = display.add_grammar(GitRef::head(), "rust", "rust", 1).await; + let progress = display + .add_grammar(git::Ref::head(), "rust", "rust", 1) + .await; + let source_ref = Ref::parse("v1.0.0").unwrap(); let build = GrammarBuild { context: BuildContext { overwrite_output }, cache_decision: CacheDecision::miss(cache::CacheMissReason::MissingEntry), @@ -765,9 +917,10 @@ mod tests { out_dir: out_dir.into(), }, progress, + source: source_ref.stable_source(), spec: Arc::new(BuildSpec { build_script: None, - git_ref: GitRef::head(), + git_ref: source_ref, prefix: String::new(), repo: "https://example.com/tree-sitter-rust".parse().unwrap(), target: Target::Native, diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 23ff17e..ef61086 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -5,17 +5,23 @@ use std::path::{Path, PathBuf}; use async_compression::tokio::bufread::GzipDecoder; use tokio::{fs, io, process::Command}; -use tracing::{debug, trace}; +use tracing::{debug, info, trace}; use url::Url; use crate::actors::{DisplayAddr, ProgressAddr}; use crate::args::TreeSitter; -use crate::git::{self, GitRef, GitSha, ResolvedRef}; +use crate::git::{self, Ref, ResolvedRef, Sha}; use crate::sh::Exec; use crate::shutdown; use crate::SafeCanonicalize; use crate::{error::TsdlError, TsdlResult}; +#[derive(Debug, Clone)] +pub struct PreparedCli { + pub path: PathBuf, + pub tree_sitter: TreeSitter, +} + async fn chmod_x(prog: &Path) -> TsdlResult<()> { let metadata = fs::metadata(prog) .await @@ -32,17 +38,8 @@ async fn cli( handle: &ProgressAddr, platform: &str, repo: &str, - resolved_ref: &ResolvedRef, + tag: &str, ) -> TsdlResult { - let tag = match resolved_ref { - ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), - ResolvedRef::Ref(git_ref) => { - handle.msg(format!("resolving exact tag for {resolved_ref}")); - let tree_sitter = PathBuf::new().join(build_dir).join("tree-sitter"); - git::clone(repo, &tree_sitter).await?; - Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) - } - }; let cli = format!("tree-sitter-{platform}"); let res = PathBuf::new() .join(build_dir) @@ -65,6 +62,24 @@ async fn cli( Ok(res) } +async fn resolve_release_tag( + build_dir: &PathBuf, + handle: &ProgressAddr, + repo: &str, + resolved_ref: &ResolvedRef, +) -> TsdlResult { + let tag = match resolved_ref { + ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), + ResolvedRef::Ref(git_ref) => { + handle.msg(format!("resolving exact tag for {resolved_ref}")); + let tree_sitter = PathBuf::new().join(build_dir).join("tree-sitter"); + git::clone(repo, &tree_sitter).await?; + Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) + } + }; + Ok(tag.into_owned()) +} + async fn download(gz: &Path, url: &str) -> TsdlResult<()> { fs::write( gz, @@ -92,15 +107,15 @@ async fn download_and_extract(gz: &Path, url: &str, res: &Path) -> TsdlResult<() fn find_tag( refs: &HashMap, version: &str, -) -> Result { +) -> Result { refs.get_key_value(&format!("v{version}")) .or_else(|| refs.get_key_value(version)) .map_or_else( - || GitRef::from_version_or_ref(version).map(ResolvedRef::Ref), + || Ref::from_version_or_ref(version).map(ResolvedRef::Ref), |(k, v)| { trace!("Found! {k} -> {v}"); Ok(ResolvedRef::Tag { - sha: GitSha::new(v.as_str())?, + sha: Sha::new(v.as_str())?, label: k.clone(), }) }, @@ -130,7 +145,7 @@ fn parse_refs(stdout: &str) -> HashMap { for line in stdout.lines() { let ref_line = line.split('\t').map(str::trim).collect::>(); let (sha1, full_ref) = (ref_line[0], ref_line[1]); - let Some(tag) = full_ref.split('/').next_back() else { + let Some(tag) = full_ref.strip_prefix("refs/tags/") else { continue; }; trace!("insert {tag} -> {sha1}"); @@ -144,7 +159,7 @@ pub async fn prepare( build_dir: &PathBuf, display: DisplayAddr, tree_sitter: &TreeSitter, -) -> TsdlResult { +) -> TsdlResult { shutdown::test_delay().await; shutdown::check()?; debug!("[prepare] tree-sitter-cli version={}", tree_sitter.version); @@ -173,13 +188,26 @@ pub async fn prepare( return Err(e); } }; + let release_tag = match resolve_release_tag(build_dir, &progress, &tree_sitter.repo, &tag).await + { + Ok(tag) => tag, + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("resolve failed").await; + } + return Err(e); + } + }; + info!("Resolved tree-sitter CLI ref {git_ref:?} to release tag {release_tag:?}"); let cli = match cli( build_dir, &progress, &tree_sitter.platform, &tree_sitter.repo, - &tag, + &release_tag, ) .await { @@ -195,13 +223,18 @@ pub async fn prepare( }; progress.fin("done").await; - Ok(cli) + Ok(PreparedCli { + path: cli, + tree_sitter: TreeSitter { + version: release_tag, + platform: tree_sitter.platform.clone(), + repo: tree_sitter.repo.clone(), + }, + }) } -pub(crate) fn display_tree_sitter_ref( - version: &str, -) -> Result { - GitRef::from_version_or_ref(version) +pub(crate) fn display_tree_sitter_ref(version: &str) -> Result { + Ref::from_version_or_ref(version) } #[allow(clippy::missing_panics_doc)] @@ -229,12 +262,13 @@ mod tests { #[test] fn test_parse_refs() { - let stdout = - "abc123\trefs/tags/v1.0.0\nuwu456\trefs/tags/release\nxyz789\trefs/tags/v2.0.0"; + let stdout = "abc123\trefs/tags/v1.0.0\nuwu456\trefs/tags/release\nxyz789\trefs/tags/v2.0.0\nbranch\trefs/heads/main\nslash\trefs/tags/channel/release"; let refs = parse_refs(stdout); assert_eq!(refs.get("v1.0.0"), Some(&"abc123".to_string())); assert_eq!(refs.get("release"), Some(&"uwu456".to_string())); assert_eq!(refs.get("v2.0.0"), Some(&"xyz789".to_string())); + assert_eq!(refs.get("channel/release"), Some(&"slash".to_string())); + assert_eq!(refs.get("main"), None); } #[test] diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index c253e36..da7b335 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -6,7 +6,8 @@ use predicates::{self as p, prelude::*}; use rstest::*; use tsdl::consts::{ - TREE_SITTER_PLATFORM, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_OUT_DIR, TSDL_PREFIX, + TREE_SITTER_PLATFORM, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_CONFIG_FILE, TSDL_OUT_DIR, + TSDL_PREFIX, }; use crate::cmd::Sandbox; @@ -14,6 +15,11 @@ use crate::cmd::Sandbox; #[rstest] fn cache_hit_skips_build() { let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(TSDL_CONFIG_FILE) + .write_str("[parsers]\njson = \"0.21.0\"\n") + .unwrap(); // First build sandbox.cmd.arg("build").arg("json").assert().success(); @@ -260,4 +266,8 @@ fn cache_file_structure() { cache_content.contains("git_ref"), "Cache should have git_ref field" ); + assert!( + cache_content.contains("source"), + "Cache should have source identity field" + ); } diff --git a/tests/config.rs b/tests/config.rs index f1941b5..bb2b58a 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -221,10 +221,8 @@ fn negative_boolean_flag_overrides_positive() -> Result<()> { let generated = temp.child("generated.toml"); generated.touch()?; - let (cmd, prov) = current_with_cli_provenance( - &generated, - &["tsdl", "build", "--force=true", "--no-force"], - ); + let (cmd, prov) = + current_with_cli_provenance(&generated, &["tsdl", "build", "--force=true", "--no-force"]); assert!(!cmd.force); assert_eq!(prov.force, ConfigSource::CommandLine); @@ -240,11 +238,9 @@ fn env_can_override_config_to_builtin_default_value() -> Result<()> { let generated = temp.child("generated.toml"); generated.write_str("prefix = \"\"\n")?; - let (_parsed_args, matches) = - config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); let build_matches = args::build_matches(&matches); - let (cmd, prov) = - config::current_with_provenance(generated.path(), build_matches)?; + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; assert_eq!(cmd.prefix, TSDL_PREFIX); assert_eq!(prov.prefix, ConfigSource::Environment); @@ -260,11 +256,9 @@ fn boolean_env_can_override_config_file() -> Result<()> { let generated = temp.child("generated.toml"); generated.write_str("force = true\n")?; - let (_parsed_args, matches) = - config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); let build_matches = args::build_matches(&matches); - let (cmd, prov) = - config::current_with_provenance(generated.path(), build_matches)?; + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; assert!(!cmd.force); assert_eq!(prov.force, ConfigSource::Environment); @@ -281,11 +275,9 @@ fn cli_has_precedence_over_env() -> Result<()> { generated.touch()?; let (_parsed_args, matches) = - config::try_parse_from_with_matches(["tsdl", "build", "--target", "native"]) - .unwrap(); + config::try_parse_from_with_matches(["tsdl", "build", "--target", "native"]).unwrap(); let build_matches = args::build_matches(&matches); - let (cmd, prov) = - config::current_with_provenance(generated.path(), build_matches)?; + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; assert_eq!(cmd.target, Target::Native); assert_eq!(prov.target, ConfigSource::CommandLine); @@ -298,17 +290,10 @@ fn cli_explicit_default_value_overrides_config_file() -> Result<()> { let generated = temp.child("generated.toml"); generated.write_str("build-dir = \"/custom\"\n")?; - let (cmd, prov) = current_with_cli_provenance( - &generated, - &["tsdl", "build", "--build-dir", "tmp"], - ); + let (cmd, prov) = + current_with_cli_provenance(&generated, &["tsdl", "build", "--build-dir", "tmp"]); assert_eq!(cmd.build_dir, PathBuf::from("tmp")); - assert_eq!( - prov.build_dir, - ConfigSource::CommandLine - ); + assert_eq!(prov.build_dir, ConfigSource::CommandLine); Ok(()) } - - From f35cf646c36526469cdffea0211a1ac840da6279 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Wed, 27 May 2026 17:43:10 +0200 Subject: [PATCH 39/88] ref: separate different semantics and responsibilities --- CHANGELOG.md | 5 +- docs/cache.md | 19 ++++---- src/actors/cache.rs | 24 ++++++---- src/actors/mod.rs | 15 +++--- src/cache.rs | 109 +++++++++++++------------------------------- src/git.rs | 65 ++++++++------------------ src/parser.rs | 42 ++++++++--------- src/tree_sitter.rs | 21 ++++++++- 8 files changed, 121 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9c667..8f01d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,8 @@ informative and more coherent. ### Changed - **cache**: Parser builds that track moving refs such as `HEAD` or branches now - notice when those refs move and rebuild the affected parsers. Existing cache - entries for moving refs are rebuilt once so TSDL can record the checked-out - commit for future runs. + notice when those refs move and rebuild the affected parsers. The parser cache + format changed for this major release and older cache files are not migrated. ### Bug Fixes diff --git a/docs/cache.md b/docs/cache.md index 319a6ef..1d8b0a9 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -36,7 +36,8 @@ Stable parser refs are cached by the requested ref only: - `v` plus dotted versions such as `v0.21.0`; - explicit tag refs under `refs/tags/...`. -Moving parser refs are cached by both the requested ref and the resolved commit: +Moving parser refs are cached by both the requested ref and the checked-out +commit: - `HEAD`; - branches such as `master`, `main`, or `dev`; @@ -76,18 +77,14 @@ parser cache treats them as the same tree-sitter CLI identity. Changing the tree-sitter CLI release tag, platform, or repository invalidates the affected parser cache entries. -## Cache Migration +## Cache Format -Older cache files do not contain parser source cache identities. +The parser source identity is intentionally small. Stable refs store only that +they are stable because the requested ref is already part of the build spec. +Moving refs additionally store the checked-out commit. -Stable parser refs can keep using old cache entries because their cache identity -is still the requested ref. Moving parser refs miss once after the cache format -upgrade because TSDL needs to store the resolved commit for future comparisons. -After that rebuild, the cache entry contains the resolved commit and normal -moving-ref cache behavior applies. - -Old entries may also miss once when their tree-sitter CLI version was stored as -raw input and the current run resolves it to a canonical release tag. +Older cache files from previous major versions are not migrated; rebuild after +upgrading when needed. ## Manual Controls diff --git a/src/actors/cache.rs b/src/actors/cache.rs index d62fcf4..590f02a 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -17,7 +17,7 @@ use crate::{ #[allow(dead_code)] enum ResponseKind<'a> { CacheGet { name: &'a str }, - NeedsClone { language: &'a str }, + HasCompatibleEntries { language: &'a str }, NeedsRebuild { name: &'a str, hash: &'a str }, SaveComplete, } @@ -37,8 +37,8 @@ pub enum CacheMessage { Update { entry: Entry, name: Arc }, /// Save cache to disk Save { tx: oneshot::Sender> }, - /// Check if clone is needed for a language - NeedsClone { + /// Check if cache contains entries compatible with a language spec. + HasCompatibleEntries { language: Arc, spec: Arc, tx: oneshot::Sender, @@ -83,8 +83,12 @@ impl CacheAddr { .await } - pub async fn needs_clone>>(&self, language: S, spec: Arc) -> bool { - self.request(|tx| CacheMessage::NeedsClone { + pub async fn has_compatible_entries>>( + &self, + language: S, + spec: Arc, + ) -> bool { + self.request(|tx| CacheMessage::HasCompatibleEntries { language: language.into(), spec, tx, @@ -199,21 +203,21 @@ impl CacheActor { .send(self.db.save().await); } - CacheMessage::NeedsClone { language, spec, tx } => { + CacheMessage::HasCompatibleEntries { language, spec, tx } => { Response { tx, - kind: ResponseKind::NeedsClone { + kind: ResponseKind::HasCompatibleEntries { language: &language, }, } .send( - self.force - || self + !self.force + && self .db .parsers .iter() .find(|(key, _)| key.starts_with(&format!("{language}/"))) - .is_none_or(|(_, entry)| entry.spec != spec), + .is_some_and(|(_, entry)| entry.spec == spec), ); } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 04bbf09..ac1959d 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -304,14 +304,14 @@ async fn resolve_source( progress.set_outcome_built().await; progress.step("cloning"); match language.checkout().await { - Ok(commit) => { + Ok(checkout) => { info!( "Resolved parser {} git ref {} to commit {}", language.name, language.spec.git_ref.requested().as_str(), - commit.as_str() + checkout.commit.as_str() ); - Ok(language.spec.git_ref.moving_source(commit)) + Ok(Source::moving(checkout.commit)) } Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { @@ -323,11 +323,12 @@ async fn resolve_source( } } } else { - let needs_clone = cache - .needs_clone(language.name.clone(), language.spec.clone()) + let has_compatible_entries = cache + .has_compatible_entries(language.name.clone(), language.spec.clone()) .await; + let checkout_needed = !has_compatible_entries || !language.is_checkout_usable().await; - if needs_clone { + if checkout_needed { progress.set_outcome_built().await; progress.step("cloning"); if let Err(e) = language.checkout().await { @@ -342,6 +343,6 @@ async fn resolve_source( progress.set_outcome_cached().await; } - Ok(language.spec.git_ref.stable_source()) + Ok(Source::stable()) } } diff --git a/src/cache.rs b/src/cache.rs index fe520e0..bf9875a 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -33,42 +33,34 @@ pub struct Entry { /// Complete build definition that affects parser output pub spec: Arc, /// Parser source identity used by the cache. Moving refs include the - /// resolved commit; older cache files do not have this field. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option, + /// checked-out commit. + pub source: Source, } #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", tag = "kind")] pub enum Source { - Stable { - requested: git::Ref, - }, - Moving { - requested: git::Ref, - commit: git::Sha, - }, + Stable, + Moving { commit: git::Sha }, } impl Source { #[must_use] - pub const fn is_moving(&self) -> bool { - matches!(self, Self::Moving { .. }) + pub const fn stable() -> Self { + Self::Stable + } + + #[must_use] + pub const fn moving(commit: git::Sha) -> Self { + Self::Moving { commit } } } impl fmt::Display for Source { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Stable { requested } => write!(f, "requested={}", requested.as_str()), - Self::Moving { requested, commit } => { - write!( - f, - "requested={} commit={}", - requested.as_str(), - commit.as_str() - ) - } + Self::Stable => write!(f, "stable"), + Self::Moving { commit } => write!(f, "moving commit={}", commit.as_str()), } } } @@ -103,10 +95,7 @@ pub enum CacheMissReason { cached: parser::Ref, current: parser::Ref, }, - SourceIdentityMissing { - current: Source, - }, - SourceIdentityChanged { + SourceChanged { cached: Source, current: Source, }, @@ -219,7 +208,7 @@ impl CacheMissReason { Self::HashChanged { .. } => "hash", Self::RepoChanged { .. } => "repo", Self::RefChanged { .. } => "ref", - Self::SourceIdentityMissing { .. } | Self::SourceIdentityChanged { .. } => "source", + Self::SourceChanged { .. } => "source", Self::TreeSitterChanged { .. } => "tree-sitter", Self::BuildScriptChanged => "script", Self::PrefixChanged { .. } => "prefix", @@ -238,8 +227,7 @@ impl CacheMissReason { Self::HashChanged { .. } => "grammar changed", Self::RepoChanged { .. } => "repo changed", Self::RefChanged { .. } => "git ref changed", - Self::SourceIdentityMissing { .. } => "cache identity upgraded", - Self::SourceIdentityChanged { .. } => "git ref resolved commit changed", + Self::SourceChanged { .. } => "git ref resolved commit changed", Self::TreeSitterChanged { .. } => "tree-sitter changed", Self::BuildScriptChanged => "build script changed", Self::PrefixChanged { .. } => "prefix changed", @@ -268,10 +256,7 @@ impl fmt::Display for CacheMissReason { cached.requested().as_str(), current.requested().as_str() ), - Self::SourceIdentityMissing { current } => { - write!(f, "cache identity upgraded current={current}") - } - Self::SourceIdentityChanged { cached, current } => { + Self::SourceChanged { cached, current } => { write!(f, "source changed cached={cached} current={current}") } Self::TreeSitterChanged { cached, current } => write!( @@ -332,21 +317,11 @@ impl Entry { }); } - if !git_ref_changed { - match (&self.source, source) { - (None, Source::Moving { .. }) => { - reasons.push(CacheMissReason::SourceIdentityMissing { - current: source.clone(), - }); - } - (Some(cached_source), current_source) if cached_source != current_source => { - reasons.push(CacheMissReason::SourceIdentityChanged { - cached: cached_source.clone(), - current: current_source.clone(), - }); - } - _ => {} - } + if !git_ref_changed && self.source != *source { + reasons.push(CacheMissReason::SourceChanged { + cached: self.source.clone(), + current: source.clone(), + }); } if cached.tree_sitter != spec.tree_sitter { @@ -531,19 +506,20 @@ mod tests { } } - fn stable_source(spec: &BuildSpec) -> Source { - spec.git_ref.stable_source() + fn stable_source(_spec: &BuildSpec) -> Source { + Source::stable() } fn moving_source(spec: &BuildSpec, sha: &str) -> Source { - spec.git_ref.moving_source(git::Sha::new(sha).unwrap()) + assert!(spec.git_ref.is_moving()); + Source::moving(git::Sha::new(sha).unwrap()) } fn cache_with_entry(hash: &str, spec: &BuildSpec) -> Db { - cache_with_entry_and_source(hash, spec, Some(stable_source(spec))) + cache_with_entry_and_source(hash, spec, stable_source(spec)) } - fn cache_with_entry_and_source(hash: &str, spec: &BuildSpec, source: Option) -> Db { + fn cache_with_entry_and_source(hash: &str, spec: &BuildSpec, source: Source) -> Db { let mut cache = Db::default(); cache.set( "test-parser".to_string(), @@ -642,39 +618,16 @@ mod tests { assert!(!cache.needs_rebuild("test-parser", "abc123", &spec, &stable_source(&spec))); } - #[test] - fn test_rebuild_decision_old_stable_entry_without_source_still_hits() { - let spec = test_spec(); - let cache = cache_with_entry_and_source("abc123", &spec, None); - - assert_eq!( - cache.rebuild_decision("test-parser", "abc123", &spec, &stable_source(&spec)), - CacheDecision::Hit - ); - } - - #[test] - fn test_rebuild_decision_old_moving_entry_without_source_misses_once() { - let spec = moving_spec(); - let source = moving_source(&spec, SHA1); - let cache = cache_with_entry_and_source("abc123", &spec, None); - - assert_miss( - cache.rebuild_decision("test-parser", "abc123", &spec, &source), - &[CacheMissReason::SourceIdentityMissing { current: source }], - ); - } - #[test] fn test_rebuild_decision_moving_source_commit_changed() { let spec = moving_spec(); let cached_source = moving_source(&spec, SHA1); let current_source = moving_source(&spec, SHA2); - let cache = cache_with_entry_and_source("abc123", &spec, Some(cached_source.clone())); + let cache = cache_with_entry_and_source("abc123", &spec, cached_source.clone()); assert_miss( cache.rebuild_decision("test-parser", "abc123", &spec, ¤t_source), - &[CacheMissReason::SourceIdentityChanged { + &[CacheMissReason::SourceChanged { cached: cached_source, current: current_source, }], @@ -685,7 +638,7 @@ mod tests { fn test_rebuild_decision_moving_source_commit_unchanged_hits() { let spec = moving_spec(); let source = moving_source(&spec, SHA1); - let cache = cache_with_entry_and_source("abc123", &spec, Some(source.clone())); + let cache = cache_with_entry_and_source("abc123", &spec, source.clone()); assert_eq!( cache.rebuild_decision("test-parser", "abc123", &spec, &source), diff --git a/src/git.rs b/src/git.rs index 874a93a..00bff15 100644 --- a/src/git.rs +++ b/src/git.rs @@ -64,20 +64,6 @@ impl Ref { Self(Arc::from("HEAD")) } - /// Create a validated git ref from user input, normalizing dotted numeric - /// versions such as `0.21.0` to the conventional git tag `v0.21.0`. - pub fn from_version_or_ref(value: &str) -> Result { - if Sha::is_full_sha(value) || value.starts_with('v') { - return Self::new(value); - } - - if is_dotted_numeric_version(value) { - Self::new(format!("v{value}")) - } else { - Self::new(value) - } - } - /// Get the exact git ref string. #[must_use] pub fn as_str(&self) -> &str { @@ -249,6 +235,11 @@ pub enum ResolvedRef { Ref(Ref), } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Checkout { + pub commit: Sha, +} + impl fmt::Display for ResolvedRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -338,13 +329,6 @@ fn validate_git_sha(value: &str) -> Result<(), RefError> { Ok(()) } -fn is_dotted_numeric_version(value: &str) -> bool { - !value.is_empty() - && value - .split('.') - .all(|part| !part.is_empty() && part.parse::().is_ok()) -} - // TODO: get rid of async fs completely. async fn clean_anyway(cwd: &Path) -> TsdlResult<()> { if cwd.exists() { @@ -373,16 +357,16 @@ pub async fn clone(repo: &str, cwd: &Path) -> TsdlResult<()> { Ok(()) } -pub async fn clone_fast(repo: &str, git_ref: &Ref, cwd: &Path) -> TsdlResult { - clone_fast_with_force(repo, git_ref, cwd, false).await +pub async fn checkout(repo: &str, git_ref: &Ref, cwd: &Path) -> TsdlResult { + checkout_with_force(repo, git_ref, cwd, false).await } -pub async fn clone_fast_with_force( +pub async fn checkout_with_force( repo: &str, git_ref: &Ref, cwd: &Path, force: bool, -) -> TsdlResult { +) -> TsdlResult { if force || !is_same_remote(cwd, repo).await { clean_anyway(cwd).await?; } @@ -391,12 +375,13 @@ pub async fn clone_fast_with_force( } else { init_fetch_and_checkout(cwd, repo, git_ref).await?; } - get_head_sha(cwd).await.map_err(|err| { + let commit = get_head_sha(cwd).await.map_err(|err| { TsdlError::context( format!("Resolving checked out commit for {}", cwd.display()), err, ) - }) + })?; + Ok(Checkout { commit }) } async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> TsdlResult<()> { @@ -468,6 +453,10 @@ async fn is_same_remote(cwd: &Path, remote: &str) -> bool { remote == get_remote_url(cwd).await.unwrap_or_default().trim() } +pub async fn is_checkout_usable(repo: &str, cwd: &Path) -> bool { + is_valid_git_dir(cwd).await && is_same_remote(cwd, repo).await +} + async fn is_valid_git_dir(cwd: &Path) -> bool { let is_inside_work_tree = Command::new("git") .current_dir(cwd) @@ -614,28 +603,10 @@ mod tests { ); } - #[test] - fn git_ref_normalizes_dotted_numeric_versions() { - assert_eq!( - Ref::from_version_or_ref("0.21.0").unwrap().as_str(), - "v0.21.0" - ); - assert_eq!( - Ref::from_version_or_ref("v0.21.0").unwrap().as_str(), - "v0.21.0" - ); - } - #[test] fn git_ref_preserves_branches_and_full_shas() { - assert_eq!( - Ref::from_version_or_ref("master").unwrap().as_str(), - "master" - ); - assert_eq!( - Ref::from_version_or_ref(FULL_SHA).unwrap().as_str(), - FULL_SHA - ); + assert_eq!(Ref::new("master").unwrap().as_str(), "master"); + assert_eq!(Ref::new(FULL_SHA).unwrap().as_str(), FULL_SHA); } #[test] diff --git a/src/parser.rs b/src/parser.rs index a5f00d1..14415fc 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -21,7 +21,7 @@ use crate::{ build::{BuildContext, BuildSpec, OutputConfig}, cache::{self, CacheDecision, Entry, Update}, error::{self, TsdlError}, - git::{self, clone_fast, Sha}, + git, sh::{Exec, Script}, shutdown, walk::collect_grammar_paths, @@ -46,7 +46,8 @@ impl Ref { /// Create a parser source ref from user input, preserving parser-version /// normalization while classifying refs for cache semantics. pub fn parse(value: &str) -> Result { - let git_ref = git::Ref::from_version_or_ref(value)?; + let normalized = normalize_source_ref(value); + let git_ref = git::Ref::new(normalized)?; if is_stable_source_ref(value, &git_ref) { Ok(Self::Stable(git_ref)) } else { @@ -70,21 +71,6 @@ impl Ref { Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, } } - - #[must_use] - pub fn stable_source(&self) -> cache::Source { - cache::Source::Stable { - requested: self.requested().clone(), - } - } - - #[must_use] - pub fn moving_source(&self, commit: Sha) -> cache::Source { - cache::Source::Moving { - requested: self.requested().clone(), - commit, - } - } } impl Serialize for Ref { @@ -119,6 +105,16 @@ fn is_dotted_numeric_version(value: &str) -> bool { .all(|part| !part.is_empty() && part.parse::().is_ok()) } +fn normalize_source_ref(value: &str) -> String { + if git::Sha::is_full_sha(value) || value.starts_with('v') { + value.to_string() + } else if is_dotted_numeric_version(value) { + format!("v{value}") + } else { + value.to_string() + } +} + fn is_v_dotted_numeric_version(value: &str) -> bool { value .strip_prefix('v') @@ -207,7 +203,7 @@ impl GrammarBuild { entry: Entry { hash: self.hash.clone(), spec: self.spec.clone(), - source: Some(self.source.clone()), + source: self.source.clone(), }, }; @@ -660,8 +656,8 @@ impl LanguageBuild { self } - pub async fn checkout(&self) -> TsdlResult { - clone_fast( + pub async fn checkout(&self) -> TsdlResult { + git::checkout( self.spec.repo.as_str(), self.spec.git_ref.requested(), &self.output.build_dir, @@ -677,6 +673,10 @@ impl LanguageBuild { )) }) } + + pub async fn is_checkout_usable(&self) -> bool { + git::is_checkout_usable(self.spec.repo.as_str(), &self.output.build_dir).await + } } fn parser_name_and_ext(prefix: &str, grammar_name: &str, ext: &str) -> String { @@ -917,7 +917,7 @@ mod tests { out_dir: out_dir.into(), }, progress, - source: source_ref.stable_source(), + source: cache::Source::stable(), spec: Arc::new(BuildSpec { build_script: None, git_ref: source_ref, diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index ef61086..562cc87 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -111,7 +111,7 @@ fn find_tag( refs.get_key_value(&format!("v{version}")) .or_else(|| refs.get_key_value(version)) .map_or_else( - || Ref::from_version_or_ref(version).map(ResolvedRef::Ref), + || Ref::new(normalize_release_ref(version)).map(ResolvedRef::Ref), |(k, v)| { trace!("Found! {k} -> {v}"); Ok(ResolvedRef::Tag { @@ -122,6 +122,23 @@ fn find_tag( ) } +fn is_dotted_numeric_version(value: &str) -> bool { + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} + +fn normalize_release_ref(value: &str) -> String { + if Sha::is_full_sha(value) || value.starts_with('v') { + value.to_string() + } else if is_dotted_numeric_version(value) { + format!("v{value}") + } else { + value.to_string() + } +} + async fn gunzip(gz: &Path, to: &Path) -> TsdlResult<()> { let file = fs::File::open(gz) .await @@ -234,7 +251,7 @@ pub async fn prepare( } pub(crate) fn display_tree_sitter_ref(version: &str) -> Result { - Ref::from_version_or_ref(version) + Ref::new(normalize_release_ref(version)) } #[allow(clippy::missing_panics_doc)] From fef00e66a6a56f0db5851f4737a795073251c55b Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Wed, 27 May 2026 18:43:12 +0200 Subject: [PATCH 40/88] all: more new types especially on boundaries --- src/actors/cache.rs | 63 ++++---- src/actors/display.rs | 61 ++++---- src/actors/mod.rs | 19 ++- src/build.rs | 2 +- src/cache.rs | 174 +++++++++++++++++----- src/display.rs | 55 +++++-- src/parser.rs | 338 ++++++++++++++++++++++++------------------ src/walk.rs | 7 +- 8 files changed, 456 insertions(+), 263 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 590f02a..65fb274 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -9,16 +9,24 @@ use tracing::info; use crate::{ actors::{Addr, Response}, build::BuildSpec, - cache::{CacheDecision, CacheMissReason, Db, Entry, Source, Update}, + cache::{CacheDecision, CacheKey, CacheMissReason, Db, Entry, GrammarHash, Source, Update}, + parser::LanguageName, TsdlResult, }; #[derive(Debug)] #[allow(dead_code)] enum ResponseKind<'a> { - CacheGet { name: &'a str }, - HasCompatibleEntries { language: &'a str }, - NeedsRebuild { name: &'a str, hash: &'a str }, + CacheGet { + name: &'a CacheKey, + }, + HasCompatibleEntries { + language: &'a LanguageName, + }, + NeedsRebuild { + name: &'a CacheKey, + hash: &'a GrammarHash, + }, SaveComplete, } @@ -26,26 +34,26 @@ enum ResponseKind<'a> { pub enum CacheMessage { /// Query if a parser needs rebuild NeedsRebuild { - hash: Arc, - name: Arc, + hash: GrammarHash, + name: CacheKey, source: Source, spec: Arc, artifacts: Vec, tx: oneshot::Sender, }, /// Update a cache entry - Update { entry: Entry, name: Arc }, + Update { entry: Entry, name: CacheKey }, /// Save cache to disk Save { tx: oneshot::Sender> }, /// Check if cache contains entries compatible with a language spec. HasCompatibleEntries { - language: Arc, + language: LanguageName, spec: Arc, tx: oneshot::Sender, }, /// Get a cache entry Get { - name: Arc, + name: CacheKey, tx: oneshot::Sender>, }, } @@ -75,38 +83,30 @@ impl CacheAddr { } /// Accepts any string type (String, &str, Arc) with minimal cloning - pub async fn get>>(&self, name: S) -> Option { - self.request(|tx| CacheMessage::Get { - name: name.into(), - tx, - }) - .await + pub async fn get(&self, name: CacheKey) -> Option { + self.request(|tx| CacheMessage::Get { name, tx }).await } - pub async fn has_compatible_entries>>( + pub async fn has_compatible_entries( &self, - language: S, + language: LanguageName, spec: Arc, ) -> bool { - self.request(|tx| CacheMessage::HasCompatibleEntries { - language: language.into(), - spec, - tx, - }) - .await + self.request(|tx| CacheMessage::HasCompatibleEntries { language, spec, tx }) + .await } - pub async fn needs_rebuild>>( + pub async fn needs_rebuild( &self, - name: S, - hash: S, + name: CacheKey, + hash: GrammarHash, spec: Arc, source: Source, artifacts: Vec, ) -> CacheDecision { self.request(|tx| CacheMessage::NeedsRebuild { - name: name.into(), - hash: hash.into(), + name, + hash, source, spec, artifacts, @@ -192,7 +192,7 @@ impl CacheActor { } CacheMessage::Update { entry, name } => { - self.db.set(name.to_string(), entry); + self.db.set(name, entry); } CacheMessage::Save { tx } => { @@ -216,7 +216,10 @@ impl CacheActor { .db .parsers .iter() - .find(|(key, _)| key.starts_with(&format!("{language}/"))) + .find(|(key, _)| { + key.as_str() + .starts_with(&CacheKey::language_prefix(&language)) + }) .is_some_and(|(_, entry)| entry.spec == spec), ); } diff --git a/src/actors/display.rs b/src/actors/display.rs index d97a11a..b1ffea0 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -1,4 +1,5 @@ use std::io; +use std::num::NonZeroU64; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -13,8 +14,8 @@ use tracing::error; use crate::actors::Addr; use crate::display::{ compute_icon_cell, compute_msg_cell, compute_name_cell, compute_ref_cell, compute_step_cell, - compute_time_cell, Column, DisplayState, GrammarEntry, GridCache, ItemState, Mode, RepoEntry, - RowSpec, SuccessOutcome, + compute_time_cell, Column, DisplayState, GrammarEntry, GridCache, ItemId, ItemState, Mode, + RepoEntry, RowSpec, SuccessOutcome, }; use crate::git; @@ -46,7 +47,7 @@ pub enum DisplayMessage { /// Update a specific bar. Update { - id: u64, + id: ItemId, kind: UpdateKind, msg: Arc, }, @@ -148,7 +149,7 @@ impl DisplayAddr { /// Handle for updating a specific progress bar (repo or grammar). #[derive(Debug, Clone)] pub struct ProgressAddr { - id: u64, + id: ItemId, tx: mpsc::Sender, } @@ -353,7 +354,7 @@ fn clear_from_cursor_down() -> io::Result<()> { pub struct DisplayActor { state: DisplayState, - next_id: u64, + next_id: ItemId, plain_name_width: usize, plain_progress_started: bool, grid: GridCache, @@ -370,7 +371,7 @@ impl DisplayActor { let (tx, rx) = mpsc::channel(256); let actor = Self { state: DisplayState::new(mode, build_dir, out_dir), - next_id: 1, + next_id: ItemId::new(NonZeroU64::MIN), plain_name_width: 16, plain_progress_started: false, grid: GridCache::new(), @@ -700,7 +701,7 @@ impl DisplayActor { println!("✓ {cached} cached ✓ {built} built ✗ {cancelled} cancelled ✗ {failed} failed"); } - fn plain_progress_line(&self, id: u64, kind: UpdateKind) -> Option { + fn plain_progress_line(&self, id: ItemId, kind: UpdateKind) -> Option { if let Some(repo) = self.state.repos.get(&id) { return Some(PlainLine { name: repo.name.to_string(), @@ -761,7 +762,7 @@ impl DisplayActor { num_tasks: usize, ) -> ProgressAddr { let id = self.next_id; - self.next_id += 1; + self.next_id = self.next_id.next_after(); self.state.repos.insert( id, @@ -793,14 +794,14 @@ impl DisplayActor { num_tasks: usize, ) -> ProgressAddr { let id = self.next_id; - self.next_id += 1; + self.next_id = self.next_id.next_after(); let repo_id = self .state .repos .iter() .find(|(_, r)| r.name == language) - .map_or(0, |(id, _)| *id); + .map(|(id, _)| *id); self.state.grammars.insert( id, @@ -819,7 +820,7 @@ impl DisplayActor { ); self.rows_dirty = true; - if repo_id != 0 { + if let Some(repo_id) = repo_id { self.sync_parent_repo(repo_id); } @@ -838,7 +839,9 @@ impl DisplayActor { grammar.state = ItemState::Cancelled; grammar.step = grammar.total; grammar.msg = Arc::from("cancelled"); - parent_ids.push(grammar.repo_id); + if let Some(repo_id) = grammar.repo_id { + parent_ids.push(repo_id); + } self.grid.mark_dirty(*id); } } @@ -846,9 +849,7 @@ impl DisplayActor { parent_ids.sort_unstable(); parent_ids.dedup(); for repo_id in parent_ids { - if repo_id != 0 { - self.sync_parent_repo(repo_id); - } + self.sync_parent_repo(repo_id); } for (id, repo) in &mut self.state.repos { @@ -864,14 +865,14 @@ impl DisplayActor { } } - fn apply_update(&mut self, id: u64, kind: UpdateKind, msg: Arc) { + fn apply_update(&mut self, id: ItemId, kind: UpdateKind, msg: Arc) { if let Some(repo) = self.state.repos.get_mut(&id) { Self::apply_repo_update(repo, kind, msg); self.grid.mark_dirty(id); return; } - let mut maybe_parent_id: Option = None; + let mut maybe_parent_id: Option = None; if let Some(grammar) = self.state.grammars.get_mut(&id) { Self::apply_grammar_update(grammar, kind, msg); self.grid.mark_dirty(id); @@ -885,7 +886,7 @@ impl DisplayActor { | UpdateKind::Cancel | UpdateKind::Step ) { - maybe_parent_id = Some(grammar.repo_id); + maybe_parent_id = grammar.repo_id; } } @@ -1008,8 +1009,12 @@ impl DisplayActor { } } - fn sync_parent_repo(&mut self, repo_id: u64) { - let has_any = self.state.grammars.values().any(|g| g.repo_id == repo_id); + fn sync_parent_repo(&mut self, repo_id: ItemId) { + let has_any = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id)); if !has_any { return; } @@ -1018,17 +1023,17 @@ impl DisplayActor { .state .grammars .values() - .any(|g| g.repo_id == repo_id && g.state == ItemState::Failed); + .any(|g| g.repo_id == Some(repo_id) && g.state == ItemState::Failed); let any_cancelled = self .state .grammars .values() - .any(|g| g.repo_id == repo_id && g.state == ItemState::Cancelled); + .any(|g| g.repo_id == Some(repo_id) && g.state == ItemState::Cancelled); let any_active = self .state .grammars .values() - .any(|g| g.repo_id == repo_id && g.state.is_live()); + .any(|g| g.repo_id == Some(repo_id) && g.state.is_live()); let live_outcome = self.aggregate_child_live_outcome(repo_id); let done_outcome = self.aggregate_child_done_outcome(repo_id); @@ -1059,7 +1064,7 @@ impl DisplayActor { self.grid.mark_dirty(repo_id); } - fn aggregate_child_live_outcome(&self, repo_id: u64) -> Option { + fn aggregate_child_live_outcome(&self, repo_id: ItemId) -> Option { let mut saw_cached = false; let mut saw_unknown = false; @@ -1067,7 +1072,7 @@ impl DisplayActor { .state .grammars .values() - .filter(|g| g.repo_id == repo_id) + .filter(|g| g.repo_id == Some(repo_id)) { match grammar.state { ItemState::InProgress(Some(SuccessOutcome::Built)) @@ -1088,14 +1093,14 @@ impl DisplayActor { } } - fn aggregate_child_done_outcome(&self, repo_id: u64) -> Option { + fn aggregate_child_done_outcome(&self, repo_id: ItemId) -> Option { let mut saw_cached = false; for grammar in self .state .grammars .values() - .filter(|g| g.repo_id == repo_id) + .filter(|g| g.repo_id == Some(repo_id)) { match grammar.state { ItemState::Done(SuccessOutcome::Built) => return Some(SuccessOutcome::Built), @@ -1162,7 +1167,7 @@ mod tests { Arc::new(PathBuf::from("build")), Arc::new(PathBuf::from("out")), ), - next_id: 1, + next_id: ItemId::new(NonZeroU64::MIN), plain_name_width: 16, plain_progress_started: false, grid: GridCache::new(), diff --git a/src/actors/mod.rs b/src/actors/mod.rs index ac1959d..be46745 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -12,7 +12,7 @@ use tracing::{debug, info}; use crate::{ args::TreeSitter, - cache::Source, + cache::{CacheKey, Source}, error::TsdlError, parser::{GrammarBuild, LanguageBuild}, shutdown, tree_sitter, TsdlResult, @@ -90,7 +90,7 @@ pub async fn run( display .reference( language.spec.git_ref.requested().clone(), - language.name.clone(), + language.name.as_arc(), ) .await; } @@ -218,7 +218,7 @@ async fn discover_grammars( let progress = display .add_language( language.spec.git_ref.requested().clone(), - language.name.clone(), + language.name.as_arc(), 2, ) .await; @@ -245,13 +245,12 @@ async fn discover_grammars( shutdown::test_delay().await; shutdown::check()?; - let key = format!("{}/{}", language.name, name); - let name_arc: std::sync::Arc = name.into(); + let key = CacheKey::new(&language.name, &name); let artifacts = GrammarBuild::required_artifacts_for( &language.output.build_dir, &ts_cli, &language.spec, - &name_arc, + &name, )?; let cache_decision = cache .needs_rebuild( @@ -266,8 +265,8 @@ async fn discover_grammars( let progress = display .add_grammar( language.spec.git_ref.requested().clone(), - language.name.clone(), - name_arc.clone(), + language.name.as_arc(), + name.as_arc(), 4, ) .await; @@ -276,9 +275,9 @@ async fn discover_grammars( context: language.context.clone(), cache_decision, dir: dir.into(), - hash: hash.into(), + hash, language: language.name.clone(), - name: name_arc, + name, output: language.output.clone(), progress, source: source.clone(), diff --git a/src/build.rs b/src/build.rs index fca27d4..ff840a2 100644 --- a/src/build.rs +++ b/src/build.rs @@ -260,7 +260,7 @@ fn unique_languages(app: &App) -> Vec> { prefix: app.command.prefix.clone(), target: app.command.target, }), - language.clone().into(), + parser::LanguageName::from(language.clone()), OutputConfig { build_dir: app .command diff --git a/src/cache.rs b/src/cache.rs index bf9875a..86552be 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -18,10 +18,78 @@ use crate::{ git, parser, TsdlResult, }; +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CacheKey(Arc); + +impl CacheKey { + #[must_use] + pub fn new(language: &parser::LanguageName, grammar: &parser::GrammarName) -> Self { + Self(Arc::from(format!("{language}/{grammar}"))) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn language_prefix(language: &parser::LanguageName) -> String { + format!("{language}/") + } +} + +impl fmt::Display for CacheKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for CacheKey { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From<&str> for CacheKey { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GrammarHash(Arc); + +impl GrammarHash { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for GrammarHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for GrammarHash { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From<&str> for GrammarHash { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + /// The build cache stored in `[build-dir]/[TSDL_CACHE_FILE]` #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { - pub parsers: BTreeMap, + pub parsers: BTreeMap, pub file: PathBuf, } @@ -29,7 +97,7 @@ pub struct Db { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { /// Hash of the grammar.js file(s) - pub hash: Arc, + pub hash: GrammarHash, /// Complete build definition that affects parser output pub spec: Arc, /// Parser source identity used by the cache. Moving refs include the @@ -84,8 +152,8 @@ pub enum CacheMissReason { MissingEntry, CacheIgnored, HashChanged { - cached: Arc, - current: Arc, + cached: GrammarHash, + current: GrammarHash, }, RepoChanged { cached: String, @@ -291,14 +359,19 @@ impl fmt::Display for CacheMissReason { impl Entry { #[must_use] - pub fn rebuild_decision(&self, hash: &str, spec: &BuildSpec, source: &Source) -> CacheDecision { + pub fn rebuild_decision( + &self, + hash: &GrammarHash, + spec: &BuildSpec, + source: &Source, + ) -> CacheDecision { let mut reasons = Vec::new(); let cached = self.spec.as_ref(); - if self.hash.as_ref() != hash { + if &self.hash != hash { reasons.push(CacheMissReason::HashChanged { cached: self.hash.clone(), - current: Arc::from(hash), + current: hash.clone(), }); } @@ -357,7 +430,7 @@ impl Entry { #[derive(Debug, Clone)] pub struct Update { pub entry: Entry, - pub name: Arc, + pub name: CacheKey, } impl Db { @@ -380,7 +453,7 @@ impl Db { /// Get cache entry for a parser #[must_use] - pub fn get(&self, name: &str) -> Option<&Entry> { + pub fn get(&self, name: &CacheKey) -> Option<&Entry> { self.parsers.get(name) } @@ -409,12 +482,11 @@ impl Db { /// Explain whether a parser cache entry can satisfy the requested build. pub fn rebuild_decision( &self, - name: &str, - hash: &str, + name: &CacheKey, + hash: &GrammarHash, spec: &BuildSpec, source: &Source, ) -> CacheDecision { - // TODO: hash and name are plain str, I'd like strong types here. let decision = match self.get(name) { None => CacheDecision::miss(CacheMissReason::MissingEntry), Some(entry) => entry.rebuild_decision(hash, spec, source), @@ -426,7 +498,13 @@ impl Db { /// Check if a parser needs rebuilding by comparing grammar hash and build definition. #[must_use] - pub fn needs_rebuild(&self, name: &str, hash: &str, spec: &BuildSpec, source: &Source) -> bool { + pub fn needs_rebuild( + &self, + name: &CacheKey, + hash: &GrammarHash, + spec: &BuildSpec, + source: &Source, + ) -> bool { self.rebuild_decision(name, hash, spec, source) .needs_rebuild() } @@ -445,13 +523,13 @@ impl Db { } /// Insert or update a parser cache entry - pub fn set(&mut self, name: String, entry: Entry) { + pub fn set(&mut self, name: CacheKey, entry: Entry) { self.parsers.insert(name, entry); } } /// Hash the contents of a file using SHA-1 and return the hex string. -pub async fn hash_file(path: &Path) -> TsdlResult { +pub async fn hash_file(path: &Path) -> TsdlResult { let mut file = tokio::fs::File::open(path).await.map_err(|e| { TsdlError::context(format!("Opening file for hashing: {}", path.display()), e) })?; @@ -477,7 +555,7 @@ pub async fn hash_file(path: &Path) -> TsdlResult { for byte in result { write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail"); } - Ok(hex) + Ok(GrammarHash::from(hex)) } #[cfg(test)] @@ -515,6 +593,14 @@ mod tests { Source::moving(git::Sha::new(sha).unwrap()) } + fn key() -> CacheKey { + CacheKey::from("test-parser") + } + + fn grammar_hash(value: &str) -> GrammarHash { + GrammarHash::from(value) + } + fn cache_with_entry(hash: &str, spec: &BuildSpec) -> Db { cache_with_entry_and_source(hash, spec, stable_source(spec)) } @@ -522,9 +608,9 @@ mod tests { fn cache_with_entry_and_source(hash: &str, spec: &BuildSpec, source: Source) -> Db { let mut cache = Db::default(); cache.set( - "test-parser".to_string(), + key(), Entry { - hash: hash.into(), + hash: grammar_hash(hash), spec: Arc::new(spec.clone()), source, }, @@ -545,7 +631,12 @@ mod tests { let spec = test_spec(); assert_miss( - cache.rebuild_decision("test-parser", "abc123", &spec, &stable_source(&spec)), + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &spec, + &stable_source(&spec), + ), &[CacheMissReason::MissingEntry], ); } @@ -556,10 +647,15 @@ mod tests { let cache = cache_with_entry("abc123", &spec); assert_miss( - cache.rebuild_decision("test-parser", "def456", &spec, &stable_source(&spec)), + cache.rebuild_decision( + &key(), + &grammar_hash("def456"), + &spec, + &stable_source(&spec), + ), &[CacheMissReason::HashChanged { - cached: "abc123".into(), - current: "def456".into(), + cached: grammar_hash("abc123"), + current: grammar_hash("def456"), }], ); } @@ -573,8 +669,8 @@ mod tests { assert_miss( cache.rebuild_decision( - "test-parser", - "abc123", + &key(), + &grammar_hash("abc123"), &requested, &stable_source(&requested), ), @@ -594,8 +690,8 @@ mod tests { assert_miss( cache.rebuild_decision( - "test-parser", - "abc123", + &key(), + &grammar_hash("abc123"), &requested, &stable_source(&requested), ), @@ -612,10 +708,20 @@ mod tests { let cache = cache_with_entry("abc123", &spec); assert_eq!( - cache.rebuild_decision("test-parser", "abc123", &spec, &stable_source(&spec)), + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &spec, + &stable_source(&spec) + ), CacheDecision::Hit ); - assert!(!cache.needs_rebuild("test-parser", "abc123", &spec, &stable_source(&spec))); + assert!(!cache.needs_rebuild( + &key(), + &grammar_hash("abc123"), + &spec, + &stable_source(&spec) + )); } #[test] @@ -626,7 +732,7 @@ mod tests { let cache = cache_with_entry_and_source("abc123", &spec, cached_source.clone()); assert_miss( - cache.rebuild_decision("test-parser", "abc123", &spec, ¤t_source), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, ¤t_source), &[CacheMissReason::SourceChanged { cached: cached_source, current: current_source, @@ -641,7 +747,7 @@ mod tests { let cache = cache_with_entry_and_source("abc123", &spec, source.clone()); assert_eq!( - cache.rebuild_decision("test-parser", "abc123", &spec, &source), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &source), CacheDecision::Hit ); } @@ -657,15 +763,15 @@ mod tests { assert_miss( cache.rebuild_decision( - "test-parser", - "def456", + &key(), + &grammar_hash("def456"), &requested, &stable_source(&requested), ), &[ CacheMissReason::HashChanged { - cached: "abc123".into(), - current: "def456".into(), + cached: grammar_hash("abc123"), + current: grammar_hash("def456"), }, CacheMissReason::BuildScriptChanged, CacheMissReason::PrefixChanged { diff --git a/src/display.rs b/src/display.rs index a53e19c..11d1318 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::num::NonZeroU64; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -186,7 +187,7 @@ impl RepoEntry { #[derive(Debug, Clone)] pub(crate) struct GrammarEntry { pub repo: Arc, - pub repo_id: u64, + pub repo_id: Option, pub name: Arc, pub git_ref: git::Ref, pub state: ItemState, @@ -222,10 +223,31 @@ pub(crate) enum Column { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct CellKey { - item_id: u64, + item_id: ItemId, column: Column, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ItemId(NonZeroU64); + +impl ItemId { + #[must_use] + pub(crate) fn new(value: NonZeroU64) -> Self { + Self(value) + } + + #[must_use] + pub(crate) fn next_after(self) -> Self { + let next = self + .0 + .get() + .checked_add(1) + .and_then(NonZeroU64::new) + .expect("display item id exhausted"); + Self(next) + } +} + // --------------------------------------------------------------------------- // Cached layout (column widths) // --------------------------------------------------------------------------- @@ -274,7 +296,7 @@ impl CachedLayout { pub(crate) struct GridCache { cells: HashMap>, - pub dirty_items: HashSet, + pub dirty_items: HashSet, pub layout: CachedLayout, } @@ -292,7 +314,7 @@ impl GridCache { /// returned. Otherwise the cached cell is cloned. pub fn cell( &mut self, - item_id: u64, + item_id: ItemId, column: Column, stale: bool, compute: impl FnOnce() -> Span<'static>, @@ -311,7 +333,7 @@ impl GridCache { } } - pub fn mark_dirty(&mut self, item_id: u64) { + pub fn mark_dirty(&mut self, item_id: ItemId) { self.dirty_items.insert(item_id); } @@ -338,7 +360,7 @@ pub(crate) enum RowKind { #[derive(Debug, Clone)] pub(crate) struct RowSpec { - pub id: u64, + pub id: ItemId, pub kind: RowKind, /// The text to display in the name column (already includes indent). pub display_name: Arc, @@ -484,8 +506,8 @@ fn dim_line(text: String) -> Line<'static> { pub(crate) struct DisplayState { pub mode: Mode, - pub repos: HashMap, - pub grammars: HashMap, + pub repos: HashMap, + pub grammars: HashMap, pub build_dir: Arc, pub out_dir: Arc, footer_build: Line<'static>, @@ -544,17 +566,16 @@ impl DisplayState { let mut rows = Vec::new(); // Sort repos alphabetically by name - let mut sorted_repos: Vec<(u64, &RepoEntry)> = + let mut sorted_repos: Vec<(ItemId, &RepoEntry)> = self.repos.iter().map(|(id, r)| (*id, r)).collect(); sorted_repos.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); // Gather grammar (id, entry) pairs grouped by repo, sorted by name - let mut grammars_by_repo: HashMap> = HashMap::new(); + let mut grammars_by_repo: HashMap> = HashMap::new(); for (gid, g) in &self.grammars { - grammars_by_repo - .entry(g.repo_id) - .or_default() - .push((*gid, g)); + if let Some(repo_id) = g.repo_id { + grammars_by_repo.entry(repo_id).or_default().push((*gid, g)); + } } for list in grammars_by_repo.values_mut() { list.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); @@ -652,9 +673,11 @@ impl DisplayState { let mut failed = 0; let mut cancelled = 0; - let mut repos_with_grammars: HashSet = HashSet::new(); + let mut repos_with_grammars: HashSet = HashSet::new(); for grammar in self.grammars.values() { - repos_with_grammars.insert(grammar.repo_id); + if let Some(repo_id) = grammar.repo_id { + repos_with_grammars.insert(repo_id); + } count_item_state( grammar.state, &mut cached, diff --git a/src/parser.rs b/src/parser.rs index 14415fc..7920ef8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -19,7 +19,7 @@ use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use crate::{ actors::ProgressAddr, build::{BuildContext, BuildSpec, OutputConfig}, - cache::{self, CacheDecision, Entry, Update}, + cache::{self, CacheDecision, CacheKey, Entry, GrammarHash, Update}, error::{self, TsdlError}, git, sh::{Exec, Script}, @@ -30,6 +30,107 @@ use crate::{ pub const WASM_EXTENSION: &str = "wasm"; +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct LanguageName(Arc); + +impl LanguageName { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn as_arc(&self) -> Arc { + self.0.clone() + } +} + +impl fmt::Display for LanguageName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for LanguageName { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From<&str> for LanguageName { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +impl From> for LanguageName { + fn from(value: Arc) -> Self { + Self(value) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GrammarName(Arc); + +impl GrammarName { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn as_arc(&self) -> Arc { + self.0.clone() + } +} + +impl fmt::Display for GrammarName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for GrammarName { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From<&str> for GrammarName { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +impl From> for GrammarName { + fn from(value: Arc) -> Self { + Self(value) + } +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +enum ArtifactKind { + Native, + Wasm, +} + +impl ArtifactKind { + #[must_use] + fn extension(self) -> &'static str { + match self { + Self::Native => DLL_EXTENSION, + Self::Wasm => WASM_EXTENSION, + } + } + + #[must_use] + const fn is_wasm(self) -> bool { + matches!(self, Self::Wasm) + } +} + #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum Ref { Stable(git::Ref), @@ -134,9 +235,9 @@ pub struct GrammarBuild { pub context: BuildContext, pub cache_decision: CacheDecision, pub dir: Arc, - pub hash: Arc, - pub language: Arc, // Required for error reporting and cache keys; set from parent LanguageBuild - pub name: Arc, + pub hash: GrammarHash, + pub language: LanguageName, // Required for error reporting and cache keys; set from parent LanguageBuild + pub name: GrammarName, pub output: OutputConfig, pub progress: ProgressAddr, // Use language's handle pub source: cache::Source, @@ -156,7 +257,7 @@ impl GrammarBuild { ); self.progress.step("checking cache"); - let key = format!("{}/{}", self.language, self.name); + let key = CacheKey::new(&self.language, &self.name); // Cache decisions are computed centrally by the cache actor, including // verification that all required managed artifacts exist. @@ -199,7 +300,7 @@ impl GrammarBuild { // Return cache update for this grammar let update = Update { - name: key.into(), + name: key, entry: Entry { hash: self.hash.clone(), spec: self.spec.clone(), @@ -212,11 +313,11 @@ impl GrammarBuild { Ok(Some(update)) } - fn builtin_build_command(&self, ext: &str, output_path: &Path) -> Command { + fn builtin_build_command(&self, kind: ArtifactKind, output_path: &Path) -> Command { let mut cmd = Command::new(self.ts_cli.as_os_str()); cmd.arg("build"); - if ext == WASM_EXTENSION { + if kind.is_wasm() { cmd.arg("--wasm"); } @@ -247,26 +348,27 @@ impl GrammarBuild { Ok(()) } - async fn build_target(&self, ext: &str) -> TsdlResult { + async fn build_target(&self, kind: ArtifactKind) -> TsdlResult { shutdown::test_delay().await; shutdown::check()?; + let ext = kind.extension(); debug!( "[grammar:build_target] lang={} grammar={} ext={ext}", self.language, self.name ); if let Some(script) = &self.spec.build_script { - return self.build_custom_target(ext, script).await; + return self.build_custom_target(kind, script).await; } - self.build_builtin_target(ext).await + self.build_builtin_target(kind).await } - async fn build_builtin_target(&self, ext: &str) -> TsdlResult { - let artifact = self.artifact_path(ext)?; + async fn build_builtin_target(&self, kind: ArtifactKind) -> TsdlResult { + let artifact = self.artifact_path(kind)?; ensure_parent_dir(&artifact).await?; - let mut cmd = self.builtin_build_command(ext, &artifact); + let mut cmd = self.builtin_build_command(kind, &artifact); cmd.current_dir(self.dir.as_ref()) .exec() .await @@ -276,15 +378,15 @@ impl GrammarBuild { Ok(artifact) } - async fn build_custom_target(&self, ext: &str, script: &str) -> TsdlResult { + async fn build_custom_target(&self, kind: ArtifactKind, script: &str) -> TsdlResult { let mut cmd = Command::from_str(script); cmd.current_dir(self.dir.as_ref()) .exec() .await .map_err(|err| self.build_step_error(err))?; - let discovered = self.brute_force_discover(ext).await?; - let artifact = self.artifact_path(ext)?; + let discovered = self.brute_force_discover(kind).await?; + let artifact = self.artifact_path(kind)?; self.stage_artifact(&discovered, &artifact).await?; verify_artifact(&artifact).await?; Ok(artifact) @@ -292,7 +394,7 @@ impl GrammarBuild { fn build_step_error(&self, err: TsdlError) -> TsdlError { error::TsdlError::Step(error::Step::new( - self.language.clone(), + self.language.as_arc(), error::ParserOp::Build { dir: self.dir.to_path_buf(), }, @@ -302,11 +404,11 @@ impl GrammarBuild { async fn build_targets(&self) -> TsdlResult<()> { if self.spec.target.native() { - self.build_target(DLL_EXTENSION).await?; + self.build_target(ArtifactKind::Native).await?; } if self.spec.target.wasm() { - self.build_target(WASM_EXTENSION).await?; + self.build_target(ArtifactKind::Wasm).await?; } Ok(()) @@ -325,8 +427,9 @@ impl GrammarBuild { }) } - async fn brute_force_discover(&self, ext: &str) -> TsdlResult { - let expected_name = self.parser_name_and_ext(ext); + async fn brute_force_discover(&self, kind: ArtifactKind) -> TsdlResult { + let ext = kind.extension(); + let expected_name = self.parser_name_and_ext(kind); let mut files = fs::read_dir(self.dir.as_ref()).await.map_err(|e| { TsdlError::context( format!("Failed to read directory {}", self.dir.display()), @@ -390,7 +493,7 @@ impl GrammarBuild { .map(|_| ()) .map_err(|err| { error::TsdlError::Step(error::Step::new( - self.language.clone(), + self.language.as_arc(), error::ParserOp::Generate { dir: self.dir.to_path_buf(), }, @@ -402,19 +505,19 @@ impl GrammarBuild { async fn install(&self) -> TsdlResult<()> { // Find and install parser binary for each extension if self.spec.target.native() { - self.install_binary(DLL_EXTENSION).await?; + self.install_binary(ArtifactKind::Native).await?; } if self.spec.target.wasm() { - self.install_binary(WASM_EXTENSION).await?; + self.install_binary(ArtifactKind::Wasm).await?; } Ok(()) } - async fn install_binary(&self, ext: &str) -> TsdlResult<()> { - let src = self.artifact_path(ext)?; - let dst = self.output.out_dir.join(self.parser_name_and_ext(ext)); + async fn install_binary(&self, kind: ArtifactKind) -> TsdlResult<()> { + let src = self.artifact_path(kind)?; + let dst = self.output.out_dir.join(self.parser_name_and_ext(kind)); let src_metadata = fs::metadata(&src) .await .map_err(|e| TsdlError::context(format!("Reading {}", src.display()), e))?; @@ -528,7 +631,7 @@ impl GrammarBuild { build_dir: &Path, ts_cli: &Path, spec: &BuildSpec, - grammar_name: &str, + grammar_name: &GrammarName, ) -> TsdlResult> { let mut artifacts = Vec::new(); @@ -538,7 +641,7 @@ impl GrammarBuild { ts_cli, spec, grammar_name, - DLL_EXTENSION, + ArtifactKind::Native, )?); } @@ -548,20 +651,20 @@ impl GrammarBuild { ts_cli, spec, grammar_name, - WASM_EXTENSION, + ArtifactKind::Wasm, )?); } Ok(artifacts) } - fn artifact_path(&self, ext: &str) -> TsdlResult { + fn artifact_path(&self, kind: ArtifactKind) -> TsdlResult { artifact_path_for( &self.output.build_dir, &self.ts_cli, &self.spec, &self.name, - ext, + kind, ) } @@ -581,7 +684,7 @@ impl GrammarBuild { } fn missing_parser_error(&self, ext: &str) -> TsdlError { error::TsdlError::Step(error::Step::new( - self.language.clone(), + self.language.as_arc(), error::ParserOp::Copy { src: self.output.out_dir.to_path_buf(), dst: self.output.build_dir.to_path_buf(), @@ -592,7 +695,7 @@ impl GrammarBuild { fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> TsdlError { error::TsdlError::Step(error::Step::new( - self.language.clone(), + self.language.as_arc(), error::ParserOp::Copy { src: self.output.out_dir.to_path_buf(), dst: self.output.build_dir.to_path_buf(), @@ -601,8 +704,8 @@ impl GrammarBuild { )) } - fn parser_name_and_ext(&self, ext: &str) -> String { - format!("{}{}.{}", self.spec.prefix, self.name, ext) + fn parser_name_and_ext(&self, kind: ArtifactKind) -> String { + parser_name_and_ext(&self.spec.prefix, &self.name, kind) } } @@ -610,7 +713,7 @@ impl GrammarBuild { pub struct LanguageBuild { pub context: BuildContext, pub spec: Arc, - pub name: Arc, + pub name: LanguageName, pub output: OutputConfig, } @@ -619,7 +722,7 @@ impl LanguageBuild { pub fn new( context: BuildContext, spec: Arc, - name: Arc, + name: LanguageName, output: OutputConfig, ) -> Self { Self { @@ -630,7 +733,7 @@ impl LanguageBuild { } } - pub async fn discover_grammars(&self) -> TsdlResult> { + pub async fn discover_grammars(&self) -> TsdlResult> { let file_results = collect_grammar_paths(self.output.build_dir.clone()).await?; let mut grammars = Vec::new(); @@ -665,7 +768,7 @@ impl LanguageBuild { .await .map_err(|err| { error::TsdlError::Step(error::Step::new( - self.name.clone(), + self.name.as_arc(), error::ParserOp::Clone { dir: self.output.build_dir.to_path_buf(), }, @@ -679,20 +782,20 @@ impl LanguageBuild { } } -fn parser_name_and_ext(prefix: &str, grammar_name: &str, ext: &str) -> String { - format!("{prefix}{grammar_name}.{ext}") +fn parser_name_and_ext(prefix: &str, grammar_name: &GrammarName, kind: ArtifactKind) -> String { + format!("{prefix}{grammar_name}.{}", kind.extension()) } fn artifact_path_for( build_dir: &Path, ts_cli: &Path, spec: &BuildSpec, - grammar_name: &str, - ext: &str, + grammar_name: &GrammarName, + kind: ArtifactKind, ) -> TsdlResult { Ok(build_dir .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) - .join(parser_name_and_ext(&spec.prefix, grammar_name, ext))) + .join(parser_name_and_ext(&spec.prefix, grammar_name, kind))) } fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> TsdlResult { @@ -803,10 +906,10 @@ fn extract_dir_name(dir: &Path) -> TsdlResult { } /// Extract grammar name from directory (strips "tree-sitter-" prefix if present) -fn extract_grammar_name(dir: &Path) -> TsdlResult { +fn extract_grammar_name(dir: &Path) -> TsdlResult { let dir_name = extract_dir_name(dir)?; let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); - Ok(name.to_string()) + Ok(GrammarName::from(name)) } #[cfg(test)] @@ -855,42 +958,6 @@ mod tests { ); } - /// Extract directory name from a path - fn extract_dir_name(dir: &Path) -> TsdlResult { - dir.file_name() - .ok_or_else(|| TsdlError::message("Could not extract directory name")) - .map(|name| name.to_string_lossy().to_string()) - } - - /// Extract grammar name from directory (strips "tree-sitter-" prefix if present) - fn extract_grammar_name(dir: &Path) -> TsdlResult { - let dir_name = extract_dir_name(dir)?; - let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); - Ok(name.to_string()) - } - - /// Generate cache key for a grammar: `language_name/grammar_name` - fn make_cache_key(language_name: &str, grammar_path: &Path) -> TsdlResult { - let grammar_dir = grammar_path.parent().ok_or_else(|| { - TsdlError::Message(format!( - "Could not get parent directory for {}", - grammar_path.display() - )) - })?; - - let grammar_name = extract_grammar_name(grammar_dir)?; - Ok(format!("{language_name}/{grammar_name}")) - } - - /// Parse parser name and extension - fn parser_name_and_ext(grammar_name: &str, prefix: &str, ext: &str) -> String { - if prefix.is_empty() { - format!("{grammar_name}.{ext}") - } else { - format!("{prefix}{grammar_name}.{ext}") - } - } - async fn test_grammar_build( grammar_dir: PathBuf, out_dir: PathBuf, @@ -909,7 +976,7 @@ mod tests { context: BuildContext { overwrite_output }, cache_decision: CacheDecision::miss(cache::CacheMissReason::MissingEntry), dir: grammar_dir.clone().into(), - hash: "test".into(), + hash: cache::GrammarHash::from("test"), language: "rust".into(), name: "rust".into(), output: OutputConfig { @@ -932,8 +999,8 @@ mod tests { (build, display) } - async fn write_artifact(build: &GrammarBuild, ext: &str, contents: &[u8]) -> PathBuf { - let path = build.artifact_path(ext).unwrap(); + async fn write_artifact(build: &GrammarBuild, kind: ArtifactKind, contents: &[u8]) -> PathBuf { + let path = build.artifact_path(kind).unwrap(); tokio::fs::create_dir_all(path.parent().unwrap()) .await .unwrap(); @@ -948,25 +1015,26 @@ mod tests { } #[test] - fn test_make_cache_key() { - let path = PathBuf::from("/tmp/build/tree-sitter-typescript/grammar.js"); - let key = make_cache_key("typescript", &path).unwrap(); - assert_eq!(key, "typescript/typescript"); + fn test_cache_key_format() { + let key = CacheKey::new( + &LanguageName::from("typescript"), + &GrammarName::from("typescript"), + ); + assert_eq!(key.as_str(), "typescript/typescript"); - let path = PathBuf::from("/tmp/build/tree-sitter-tsx/grammar.js"); - let key = make_cache_key("typescript", &path).unwrap(); - assert_eq!(key, "typescript/tsx"); + let key = CacheKey::new(&LanguageName::from("typescript"), &GrammarName::from("tsx")); + assert_eq!(key.as_str(), "typescript/tsx"); } #[test] fn test_extract_grammar_name() { let dir = Path::new("/tmp/build/tree-sitter-typescript"); let name = extract_grammar_name(dir).unwrap(); - assert_eq!(name, "typescript"); + assert_eq!(name.as_str(), "typescript"); let dir = Path::new("/tmp/build/custom-parser"); let name = extract_grammar_name(dir).unwrap(); - assert_eq!(name, "custom-parser"); + assert_eq!(name.as_str(), "custom-parser"); } #[test] @@ -997,17 +1065,14 @@ mod tests { #[test] fn test_parser_name_and_ext() { - let name = parser_name_and_ext("typescript", "", "so"); - assert_eq!(name, "typescript.so"); - - let name = parser_name_and_ext("typescript", "", "wasm"); - assert_eq!(name, "typescript.wasm"); + let name = parser_name_and_ext("", &GrammarName::from("typescript"), ArtifactKind::Native); + assert_eq!(name, format!("typescript.{DLL_EXTENSION}")); } #[test] fn test_parser_name_with_prefix() { - let name = parser_name_and_ext("typescript", "lib", "so"); - assert_eq!(name, "libtypescript.so"); + let name = parser_name_and_ext("lib", &GrammarName::from("typescript"), ArtifactKind::Native); + assert_eq!(name, format!("libtypescript.{DLL_EXTENSION}")); } #[tokio::test] @@ -1018,11 +1083,11 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let dst = out_dir.join("rust.so"); + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; - let src = write_artifact(&build, "so", b"parser").await; - build.install_binary("so").await.unwrap(); + let src = write_artifact(&build, ArtifactKind::Native, b"parser").await; + build.install_binary(ArtifactKind::Native).await.unwrap(); display.shutdown(false).await; assert!(same_identity(&src, &dst)); @@ -1036,14 +1101,14 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let dst = out_dir.join("rust.so"); + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; - let src = write_artifact(&build, "so", b"parser").await; + let src = write_artifact(&build, ArtifactKind::Native, b"parser").await; tokio::fs::write(&dst, b"parser").await.unwrap(); assert!(!same_identity(&src, &dst)); - build.install_binary("so").await.unwrap(); + build.install_binary(ArtifactKind::Native).await.unwrap(); display.shutdown(false).await; assert!(same_identity(&src, &dst)); @@ -1057,12 +1122,15 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let dst = out_dir.join("rust.so"); + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; - let src = write_artifact(&build, "so", b"new").await; + let src = write_artifact(&build, ArtifactKind::Native, b"new").await; tokio::fs::write(&dst, b"old").await.unwrap(); - let err = build.install_binary("so").await.unwrap_err(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); display.shutdown(false).await; assert!(err.to_string().contains("differs from the built parser")); @@ -1078,12 +1146,12 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let dst = out_dir.join("rust.so"); + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; - let src = write_artifact(&build, "so", b"new").await; + let src = write_artifact(&build, ArtifactKind::Native, b"new").await; tokio::fs::write(&dst, b"old").await.unwrap(); - build.install_binary("so").await.unwrap(); + build.install_binary(ArtifactKind::Native).await.unwrap(); display.shutdown(false).await; assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"new"); @@ -1098,14 +1166,17 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let dst = out_dir.join("rust.so"); - let target = out_dir.join("target.so"); + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + let target = out_dir.join(format!("target.{DLL_EXTENSION}")); let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; - let _src = write_artifact(&build, "so", b"new").await; + let _src = write_artifact(&build, ArtifactKind::Native, b"new").await; tokio::fs::write(&target, b"old").await.unwrap(); symlink(&target, &dst).unwrap(); - let err = build.install_binary("so").await.unwrap_err(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); display.shutdown(false).await; assert!(err.to_string().contains("symlink")); @@ -1121,35 +1192,18 @@ mod tests { tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); tokio::fs::create_dir_all(&out_dir).await.unwrap(); - let dst = out_dir.join("rust.so"); + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; - let _src = write_artifact(&build, "so", b"new").await; + let _src = write_artifact(&build, ArtifactKind::Native, b"new").await; tokio::fs::create_dir(&dst).await.unwrap(); - let err = build.install_binary("so").await.unwrap_err(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); display.shutdown(false).await; assert!(err.to_string().contains("directory")); assert!(dst.is_dir()); } - - #[tokio::test] - async fn test_per_grammar_cache_key_format() { - // Test that cache keys follow the "language/grammar" format - let temp_dir = TempDir::new().unwrap(); - let grammar_dir = temp_dir.path().join("tree-sitter-tsx"); - tokio::fs::create_dir(&grammar_dir).await.unwrap(); - let grammar_file = grammar_dir.join("grammar.js"); - tokio::fs::write(&grammar_file, "module.exports = {};") - .await - .unwrap(); - - let cache_key = make_cache_key("typescript", &grammar_file).unwrap(); - - assert_eq!(cache_key, "typescript/tsx"); - assert!( - cache_key.contains('/'), - "Cache key should use language/grammar format" - ); - } } diff --git a/src/walk.rs b/src/walk.rs index c1a42a1..30996ae 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -1,9 +1,12 @@ use std::{path::PathBuf, sync::Arc}; -use crate::{cache, git, shutdown, TsdlResult}; +use crate::{ + cache::{self, GrammarHash}, + git, shutdown, TsdlResult, +}; /// Collect grammar.js paths via git ls-files and compute their hashes. -pub async fn collect_grammar_paths(root: Arc) -> TsdlResult> { +pub async fn collect_grammar_paths(root: Arc) -> TsdlResult> { let files = git::list_grammar_files(root.as_ref()).await?; let mut results = Vec::with_capacity(files.len()); From a194b576c2b943d9358fed32893dae754dc4bd4e Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Wed, 27 May 2026 19:22:36 +0200 Subject: [PATCH 41/88] all: prefer wualified mod name to struct prefix --- src/actors/cache.rs | 82 +++++------ src/actors/display.rs | 333 +++++++++++++++++++++++------------------- src/actors/mod.rs | 44 +++--- src/app.rs | 9 +- src/args.rs | 1 + src/build.rs | 95 ++++++------ src/cache.rs | 193 ++++++++++++------------ src/config.rs | 186 +++++++++++------------ src/display.rs | 6 +- src/error.rs | 4 +- src/lock.rs | 181 +++++++++++------------ src/logging.rs | 38 +++-- src/parser.rs | 130 +++++++++-------- src/shutdown.rs | 62 ++++---- src/tree_sitter.rs | 79 +++++----- src/walk.rs | 9 +- tests/config.rs | 12 +- 17 files changed, 741 insertions(+), 723 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 65fb274..7cb640a 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -8,24 +8,21 @@ use tracing::info; use crate::{ actors::{Addr, Response}, - build::BuildSpec, - cache::{CacheDecision, CacheKey, CacheMissReason, Db, Entry, GrammarHash, Source, Update}, - parser::LanguageName, - TsdlResult, + build, cache, parser, TsdlResult, }; #[derive(Debug)] #[allow(dead_code)] enum ResponseKind<'a> { CacheGet { - name: &'a CacheKey, + name: &'a cache::Key, }, HasCompatibleEntries { - language: &'a LanguageName, + language: &'a parser::LanguageName, }, NeedsRebuild { - name: &'a CacheKey, - hash: &'a GrammarHash, + name: &'a cache::Key, + hash: &'a cache::GrammarHash, }, SaveComplete, } @@ -34,27 +31,30 @@ enum ResponseKind<'a> { pub enum CacheMessage { /// Query if a parser needs rebuild NeedsRebuild { - hash: GrammarHash, - name: CacheKey, - source: Source, - spec: Arc, + hash: cache::GrammarHash, + name: cache::Key, + source: cache::Source, + spec: Arc, artifacts: Vec, - tx: oneshot::Sender, + tx: oneshot::Sender, + }, + /// cache::Update a cache entry + Update { + entry: cache::Entry, + name: cache::Key, }, - /// Update a cache entry - Update { entry: Entry, name: CacheKey }, /// Save cache to disk Save { tx: oneshot::Sender> }, /// Check if cache contains entries compatible with a language spec. HasCompatibleEntries { - language: LanguageName, - spec: Arc, + language: parser::LanguageName, + spec: Arc, tx: oneshot::Sender, }, /// Get a cache entry Get { - name: CacheKey, - tx: oneshot::Sender>, + name: cache::Key, + tx: oneshot::Sender>, }, } @@ -83,14 +83,14 @@ impl CacheAddr { } /// Accepts any string type (String, &str, Arc) with minimal cloning - pub async fn get(&self, name: CacheKey) -> Option { + pub async fn get(&self, name: cache::Key) -> Option { self.request(|tx| CacheMessage::Get { name, tx }).await } pub async fn has_compatible_entries( &self, - language: LanguageName, - spec: Arc, + language: parser::LanguageName, + spec: Arc, ) -> bool { self.request(|tx| CacheMessage::HasCompatibleEntries { language, spec, tx }) .await @@ -98,12 +98,12 @@ impl CacheAddr { pub async fn needs_rebuild( &self, - name: CacheKey, - hash: GrammarHash, - spec: Arc, - source: Source, + name: cache::Key, + hash: cache::GrammarHash, + spec: Arc, + source: cache::Source, artifacts: Vec, - ) -> CacheDecision { + ) -> cache::Decision { self.request(|tx| CacheMessage::NeedsRebuild { name, hash, @@ -119,7 +119,7 @@ impl CacheAddr { self.request(|tx| CacheMessage::Save { tx }).await } - pub async fn update(&self, update: Update) { + pub async fn update(&self, update: cache::Update) { self.fire(CacheMessage::Update { entry: update.entry, name: update.name, @@ -130,29 +130,29 @@ impl CacheAddr { /// The Cache Actor: Manages cache state and processes messages pub struct CacheActor { - db: Db, + db: cache::Db, force: bool, rx: mpsc::Receiver, } -async fn verify_artifacts(artifacts: Vec) -> CacheDecision { +async fn verify_artifacts(artifacts: Vec) -> cache::Decision { let mut reasons = Vec::new(); for path in artifacts { match fs::metadata(&path).await { Ok(metadata) if metadata.is_file() => {} - Ok(_) => reasons.push(CacheMissReason::ArtifactNotFile { path }), + Ok(_) => reasons.push(cache::MissReason::ArtifactNotFile { path }), Err(err) if err.kind() == io::ErrorKind::NotFound => { - reasons.push(CacheMissReason::ArtifactMissing { path }); + reasons.push(cache::MissReason::ArtifactMissing { path }); } - Err(err) => reasons.push(CacheMissReason::ArtifactInaccessible { + Err(err) => reasons.push(cache::MissReason::ArtifactInaccessible { path, error: err.to_string(), }), } } - CacheDecision::from_reasons(reasons) + cache::Decision::from_reasons(reasons) } impl CacheActor { @@ -168,7 +168,7 @@ impl CacheActor { tx, } => { let decision = if self.force { - CacheDecision::miss(CacheMissReason::CacheIgnored) + cache::Decision::miss(cache::MissReason::CacheIgnored) } else { let decision = self.db.rebuild_decision(&name, &hash, &spec, &source); if decision.is_hit() { @@ -218,7 +218,7 @@ impl CacheActor { .iter() .find(|(key, _)| { key.as_str() - .starts_with(&CacheKey::language_prefix(&language)) + .starts_with(&cache::Key::language_prefix(&language)) }) .is_some_and(|(_, entry)| entry.spec == spec), ); @@ -236,7 +236,7 @@ impl CacheActor { } #[must_use] - pub fn spawn(db: Db, force: bool) -> CacheAddr { + pub fn spawn(db: cache::Db, force: bool) -> CacheAddr { let (tx, rx) = mpsc::channel(64); let actor = Self { db, force, rx }; tokio::spawn(actor.run()); @@ -255,7 +255,7 @@ mod tests { let artifact = temp.path().join("parser.so"); fs::write(&artifact, b"parser").await.unwrap(); - assert_eq!(verify_artifacts(vec![artifact]).await, CacheDecision::Hit); + assert_eq!(verify_artifacts(vec![artifact]).await, cache::Decision::Hit); } #[tokio::test] @@ -267,10 +267,10 @@ mod tests { assert_eq!( verify_artifacts(vec![missing.clone(), directory.clone()]).await, - CacheDecision::Miss(crate::cache::CacheMiss { + cache::Decision::Miss(crate::cache::Miss { reasons: vec![ - CacheMissReason::ArtifactMissing { path: missing }, - CacheMissReason::ArtifactNotFile { path: directory }, + cache::MissReason::ArtifactMissing { path: missing }, + cache::MissReason::ArtifactNotFile { path: directory }, ], }) ); diff --git a/src/actors/display.rs b/src/actors/display.rs index b1ffea0..c97e51f 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -12,11 +12,7 @@ use tokio::time; use tracing::error; use crate::actors::Addr; -use crate::display::{ - compute_icon_cell, compute_msg_cell, compute_name_cell, compute_ref_cell, compute_step_cell, - compute_time_cell, Column, DisplayState, GrammarEntry, GridCache, ItemId, ItemState, Mode, - RepoEntry, RowSpec, SuccessOutcome, -}; +use crate::display; use crate::git; // --------------------------------------------------------------------------- @@ -24,7 +20,7 @@ use crate::git; // --------------------------------------------------------------------------- #[derive(Debug)] -pub enum DisplayMessage { +pub enum Message { /// Register a repo-level progress line. Returns a `ProgressAddr`. RegisterLanguage { git_ref: git::Ref, @@ -47,7 +43,7 @@ pub enum DisplayMessage { /// Update a specific bar. Update { - id: ItemId, + id: display::ItemId, kind: UpdateKind, msg: Arc, }, @@ -77,13 +73,13 @@ pub enum UpdateKind { #[derive(Debug, Clone)] pub struct DisplayAddr { - tx: mpsc::Sender, + tx: mpsc::Sender, #[allow(dead_code)] - mode: Mode, + mode: display::Mode, } impl Addr for DisplayAddr { - type Message = DisplayMessage; + type Message = Message; fn name() -> &'static str { "DisplayAddr" @@ -96,7 +92,7 @@ impl Addr for DisplayAddr { impl DisplayAddr { #[must_use] - pub fn new(tx: mpsc::Sender, mode: Mode) -> Self { + pub fn new(tx: mpsc::Sender, mode: display::Mode) -> Self { Self { tx, mode } } @@ -106,7 +102,7 @@ impl DisplayAddr { name: S, num_tasks: usize, ) -> ProgressAddr { - self.request(|tx| DisplayMessage::RegisterLanguage { + self.request(|tx| Message::RegisterLanguage { git_ref, name: name.into(), num_tasks, @@ -122,7 +118,7 @@ impl DisplayAddr { name: S, num_tasks: usize, ) -> ProgressAddr { - self.request(|tx| DisplayMessage::RegisterGrammar { + self.request(|tx| Message::RegisterGrammar { git_ref, language: language.into(), name: name.into(), @@ -133,7 +129,7 @@ impl DisplayAddr { } pub async fn reference>>(&self, git_ref: git::Ref, name: S) { - self.fire(DisplayMessage::RegisterReference { + self.fire(Message::RegisterReference { git_ref, name: name.into(), }) @@ -141,7 +137,7 @@ impl DisplayAddr { } pub async fn shutdown(&self, interrupted: bool) { - self.request(|tx| DisplayMessage::Shutdown { interrupted, tx }) + self.request(|tx| Message::Shutdown { interrupted, tx }) .await; } } @@ -149,13 +145,13 @@ impl DisplayAddr { /// Handle for updating a specific progress bar (repo or grammar). #[derive(Debug, Clone)] pub struct ProgressAddr { - id: ItemId, - tx: mpsc::Sender, + id: display::ItemId, + tx: mpsc::Sender, } impl ProgressAddr { pub fn msg>>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { + let _ = self.tx.try_send(Message::Update { id: self.id, kind: UpdateKind::Msg, msg: msg.into(), @@ -163,7 +159,7 @@ impl ProgressAddr { } pub fn step>>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { + let _ = self.tx.try_send(Message::Update { id: self.id, kind: UpdateKind::Step, msg: msg.into(), @@ -173,7 +169,7 @@ impl ProgressAddr { async fn send_state_update(&self, kind: UpdateKind, msg: Arc) { let _ = self .tx - .send(DisplayMessage::Update { + .send(Message::Update { id: self.id, kind, msg, @@ -220,16 +216,16 @@ struct PlainLine { message: String, } -fn plain_repo_message(kind: UpdateKind, state: ItemState, msg: &str) -> String { +fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { match kind { UpdateKind::Err => format!("failed: {msg}"), UpdateKind::Cached => "cached".to_string(), UpdateKind::Fin => match state { - ItemState::Done(_) => "done".to_string(), - ItemState::New - | ItemState::InProgress(_) - | ItemState::Cancelled - | ItemState::Failed => msg.to_string(), + display::ItemState::Done(_) => "done".to_string(), + display::ItemState::New + | display::ItemState::InProgress(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => msg.to_string(), }, UpdateKind::SetOutcomeBuilt | UpdateKind::SetOutcomeCached @@ -239,17 +235,17 @@ fn plain_repo_message(kind: UpdateKind, state: ItemState, msg: &str) -> String { } } -fn plain_grammar_message(kind: UpdateKind, state: ItemState, msg: &str) -> String { +fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { match kind { UpdateKind::Err => format!("failed: {msg}"), UpdateKind::Cached => "cached".to_string(), UpdateKind::Fin => match state { - ItemState::Done(SuccessOutcome::Cached) => "cached".to_string(), - ItemState::Done(SuccessOutcome::Built) => "built".to_string(), - ItemState::New - | ItemState::InProgress(_) - | ItemState::Cancelled - | ItemState::Failed => msg.to_string(), + display::ItemState::Done(display::SuccessOutcome::Cached) => "cached".to_string(), + display::ItemState::Done(display::SuccessOutcome::Built) => "built".to_string(), + display::ItemState::New + | display::ItemState::InProgress(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => msg.to_string(), }, UpdateKind::SetOutcomeBuilt | UpdateKind::SetOutcomeCached @@ -353,28 +349,32 @@ fn clear_from_cursor_down() -> io::Result<()> { } pub struct DisplayActor { - state: DisplayState, - next_id: ItemId, + state: display::State, + next_id: display::ItemId, plain_name_width: usize, plain_progress_started: bool, - grid: GridCache, - row_specs: Vec, + grid: display::GridCache, + row_specs: Vec, rows_dirty: bool, last_term_width: Option, - rx: mpsc::Receiver, - tx: mpsc::Sender, + rx: mpsc::Receiver, + tx: mpsc::Sender, } impl DisplayActor { #[must_use] - pub fn spawn(mode: Mode, build_dir: Arc, out_dir: Arc) -> DisplayAddr { + pub fn spawn( + mode: display::Mode, + build_dir: Arc, + out_dir: Arc, + ) -> DisplayAddr { let (tx, rx) = mpsc::channel(256); let actor = Self { - state: DisplayState::new(mode, build_dir, out_dir), - next_id: ItemId::new(NonZeroU64::MIN), + state: display::State::new(mode, build_dir, out_dir), + next_id: display::ItemId::new(NonZeroU64::MIN), plain_name_width: 16, plain_progress_started: false, - grid: GridCache::new(), + grid: display::GridCache::new(), row_specs: Vec::new(), rows_dirty: true, last_term_width: None, @@ -390,7 +390,7 @@ impl DisplayActor { } async fn run(mut self) { - if self.state.mode == Mode::Fancy { + if self.state.mode == display::Mode::Fancy { self.run_fancy().await; } else { self.run_plain().await; @@ -433,7 +433,7 @@ impl DisplayActor { let mut shutdown = None; while let Ok(msg) = self.rx.try_recv() { match msg { - DisplayMessage::Shutdown { interrupted, tx } => { + Message::Shutdown { interrupted, tx } => { shutdown = Some((interrupted, tx)); break; } @@ -460,7 +460,7 @@ impl DisplayActor { tokio::select! { msg = self.rx.recv() => { match msg { - Some(DisplayMessage::Shutdown { interrupted, tx }) => { + Some(Message::Shutdown { interrupted, tx }) => { self.finish_fancy(&mut terminal, interrupted, tx); return; } @@ -516,7 +516,7 @@ impl DisplayActor { let term_w = term_width as usize; if self.last_term_width != Some(term_w) { - self.grid.invalidate_column(Column::Msg); + self.grid.invalidate_column(display::Column::Msg); self.last_term_width = Some(term_w); } @@ -527,16 +527,16 @@ impl DisplayActor { // Invalidate columns whose width changed if new_layout.ref_ != self.grid.layout.ref_ { - self.grid.invalidate_column(Column::Ref); + self.grid.invalidate_column(display::Column::Ref); } if new_layout.step != self.grid.layout.step { - self.grid.invalidate_column(Column::Step); + self.grid.invalidate_column(display::Column::Step); } if new_layout.name != self.grid.layout.name { - self.grid.invalidate_column(Column::Name); + self.grid.invalidate_column(display::Column::Name); } if new_layout.fixed_width != self.grid.layout.fixed_width { - self.grid.invalidate_column(Column::Msg); + self.grid.invalidate_column(display::Column::Msg); } self.grid.layout = new_layout; self.rows_dirty = false; @@ -553,28 +553,36 @@ impl DisplayActor { // TIME: stale when dirty OR clock is still running let time_stale = is_dirty || info.state().is_live(); - let time = self.grid.cell(item_id, Column::Time, time_stale, || { - compute_time_cell(&info, &layout) - }); + let time = self + .grid + .cell(item_id, display::Column::Time, time_stale, || { + display::compute_time_cell(&info, &layout) + }); - let gref = self.grid.cell(item_id, Column::Ref, is_dirty, || { - compute_ref_cell(&info, &layout) + let gref = self.grid.cell(item_id, display::Column::Ref, is_dirty, || { + display::compute_ref_cell(&info, &layout) }); - let step = self.grid.cell(item_id, Column::Step, is_dirty, || { - compute_step_cell(&info, &layout) - }); + let step = self + .grid + .cell(item_id, display::Column::Step, is_dirty, || { + display::compute_step_cell(&info, &layout) + }); let icon = self .grid - .cell(item_id, Column::Icon, is_dirty, || compute_icon_cell(&info)); + .cell(item_id, display::Column::Icon, is_dirty, || { + display::compute_icon_cell(&info) + }); - let name = self.grid.cell(item_id, Column::Name, is_dirty, || { - compute_name_cell(&spec.display_name, spec.indent, &info, &layout) - }); + let name = self + .grid + .cell(item_id, display::Column::Name, is_dirty, || { + display::compute_name_cell(&spec.display_name, spec.indent, &info, &layout) + }); - let msg = self.grid.cell(item_id, Column::Msg, is_dirty, || { - compute_msg_cell(&info, &layout, term_w) + let msg = self.grid.cell(item_id, display::Column::Msg, is_dirty, || { + display::compute_msg_cell(&info, &layout, term_w) }); lines.push(Line::from(vec![ @@ -613,7 +621,7 @@ impl DisplayActor { while let Some(msg) = self.rx.recv().await { match msg { - DisplayMessage::RegisterLanguage { + Message::RegisterLanguage { git_ref, name, num_tasks, @@ -622,7 +630,7 @@ impl DisplayActor { let addr = self.register_repo(name, git_ref, num_tasks); let _ = tx.send(addr); } - DisplayMessage::RegisterGrammar { + Message::RegisterGrammar { git_ref, language, name, @@ -634,10 +642,10 @@ impl DisplayActor { let addr = self.register_grammar(language, name, git_ref, num_tasks); let _ = tx.send(addr); } - DisplayMessage::RegisterReference { git_ref, name } => { + Message::RegisterReference { git_ref, name } => { self.print_plain_ref(&name, git_ref.short()); } - DisplayMessage::Update { id, kind, msg } => { + Message::Update { id, kind, msg } => { self.apply_update(id, kind, msg); if matches!( kind, @@ -651,7 +659,7 @@ impl DisplayActor { self.print_plain_progress(&line); } } - DisplayMessage::Shutdown { interrupted, tx } => { + Message::Shutdown { interrupted, tx } => { if interrupted { self.cancel_live_rows(); } @@ -701,7 +709,7 @@ impl DisplayActor { println!("✓ {cached} cached ✓ {built} built ✗ {cancelled} cancelled ✗ {failed} failed"); } - fn plain_progress_line(&self, id: ItemId, kind: UpdateKind) -> Option { + fn plain_progress_line(&self, id: display::ItemId, kind: UpdateKind) -> Option { if let Some(repo) = self.state.repos.get(&id) { return Some(PlainLine { name: repo.name.to_string(), @@ -721,9 +729,9 @@ impl DisplayActor { // ── Message handling ────────────────────────────────────────────── - fn handle_message(&mut self, msg: DisplayMessage) { + fn handle_message(&mut self, msg: Message) { match msg { - DisplayMessage::RegisterLanguage { + Message::RegisterLanguage { git_ref, name, num_tasks, @@ -732,7 +740,7 @@ impl DisplayActor { let addr = self.register_repo(name, git_ref, num_tasks); let _ = tx.send(addr); } - DisplayMessage::RegisterGrammar { + Message::RegisterGrammar { git_ref, language, name, @@ -742,11 +750,11 @@ impl DisplayActor { let addr = self.register_grammar(language, name, git_ref, num_tasks); let _ = tx.send(addr); } - DisplayMessage::RegisterReference { .. } => {} - DisplayMessage::Update { id, kind, msg } => { + Message::RegisterReference { .. } => {} + Message::Update { id, kind, msg } => { self.apply_update(id, kind, msg); } - DisplayMessage::Shutdown { interrupted, tx } => { + Message::Shutdown { interrupted, tx } => { if interrupted { self.cancel_live_rows(); } @@ -766,10 +774,10 @@ impl DisplayActor { self.state.repos.insert( id, - RepoEntry { + display::RepoEntry { name, git_ref, - state: ItemState::New, + state: display::ItemState::New, msg: Arc::from(""), step: 0, total: num_tasks, @@ -805,12 +813,12 @@ impl DisplayActor { self.state.grammars.insert( id, - GrammarEntry { + display::GrammarEntry { repo: language, repo_id, name, git_ref, - state: ItemState::New, + state: display::ItemState::New, msg: Arc::from(""), step: 0, total: num_tasks, @@ -836,7 +844,7 @@ impl DisplayActor { for (id, grammar) in &mut self.state.grammars { if grammar.state.is_live() { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = ItemState::Cancelled; + grammar.state = display::ItemState::Cancelled; grammar.step = grammar.total; grammar.msg = Arc::from("cancelled"); if let Some(repo_id) = grammar.repo_id { @@ -855,7 +863,7 @@ impl DisplayActor { for (id, repo) in &mut self.state.repos { if repo.state.is_live() { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = ItemState::Cancelled; + repo.state = display::ItemState::Cancelled; if repo.total > 0 { repo.step = repo.total; } @@ -865,14 +873,14 @@ impl DisplayActor { } } - fn apply_update(&mut self, id: ItemId, kind: UpdateKind, msg: Arc) { + fn apply_update(&mut self, id: display::ItemId, kind: UpdateKind, msg: Arc) { if let Some(repo) = self.state.repos.get_mut(&id) { Self::apply_repo_update(repo, kind, msg); self.grid.mark_dirty(id); return; } - let mut maybe_parent_id: Option = None; + let mut maybe_parent_id: Option = None; if let Some(grammar) = self.state.grammars.get_mut(&id) { Self::apply_grammar_update(grammar, kind, msg); self.grid.mark_dirty(id); @@ -895,7 +903,7 @@ impl DisplayActor { } } - fn apply_repo_update(repo: &mut RepoEntry, kind: UpdateKind, msg: Arc) { + fn apply_repo_update(repo: &mut display::RepoEntry, kind: UpdateKind, msg: Arc) { if !repo.state.is_live() { return; } @@ -910,22 +918,22 @@ impl DisplayActor { repo.msg = msg; } UpdateKind::SetOutcomeCached => { - repo.state = mark_success(repo.state, SuccessOutcome::Cached); + repo.state = mark_success(repo.state, display::SuccessOutcome::Cached); } UpdateKind::Cancel => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = ItemState::Cancelled; + repo.state = display::ItemState::Cancelled; if repo.total > 0 { repo.step = repo.total; } repo.msg = msg; } UpdateKind::SetOutcomeBuilt => { - repo.state = mark_success(repo.state, SuccessOutcome::Built); + repo.state = mark_success(repo.state, display::SuccessOutcome::Built); } UpdateKind::Cached => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = ItemState::Done(SuccessOutcome::Cached); + repo.state = display::ItemState::Done(display::SuccessOutcome::Cached); if repo.total > 0 { repo.step = repo.total; } @@ -939,7 +947,7 @@ impl DisplayActor { repo.msg = msg; } Err(message) => { - repo.state = ItemState::Failed; + repo.state = display::ItemState::Failed; repo.msg = message.into(); } } @@ -949,13 +957,13 @@ impl DisplayActor { } UpdateKind::Err => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = ItemState::Failed; + repo.state = display::ItemState::Failed; repo.msg = msg; } } } - fn apply_grammar_update(grammar: &mut GrammarEntry, kind: UpdateKind, msg: Arc) { + fn apply_grammar_update(grammar: &mut display::GrammarEntry, kind: UpdateKind, msg: Arc) { if !grammar.state.is_live() { return; } @@ -970,20 +978,20 @@ impl DisplayActor { grammar.msg = msg; } UpdateKind::SetOutcomeCached => { - grammar.state = mark_success(grammar.state, SuccessOutcome::Cached); + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Cached); } UpdateKind::Cancel => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = ItemState::Cancelled; + grammar.state = display::ItemState::Cancelled; grammar.step = grammar.total; grammar.msg = msg; } UpdateKind::SetOutcomeBuilt => { - grammar.state = mark_success(grammar.state, SuccessOutcome::Built); + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Built); } UpdateKind::Cached => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = ItemState::Done(SuccessOutcome::Cached); + grammar.state = display::ItemState::Done(display::SuccessOutcome::Cached); grammar.step = grammar.total; grammar.msg = msg; } @@ -995,7 +1003,7 @@ impl DisplayActor { grammar.msg = msg; } Err(message) => { - grammar.state = ItemState::Failed; + grammar.state = display::ItemState::Failed; grammar.msg = message.into(); } } @@ -1003,13 +1011,13 @@ impl DisplayActor { } UpdateKind::Err => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = ItemState::Failed; + grammar.state = display::ItemState::Failed; grammar.msg = msg; } } } - fn sync_parent_repo(&mut self, repo_id: ItemId) { + fn sync_parent_repo(&mut self, repo_id: display::ItemId) { let has_any = self .state .grammars @@ -1023,12 +1031,12 @@ impl DisplayActor { .state .grammars .values() - .any(|g| g.repo_id == Some(repo_id) && g.state == ItemState::Failed); + .any(|g| g.repo_id == Some(repo_id) && g.state == display::ItemState::Failed); let any_cancelled = self .state .grammars .values() - .any(|g| g.repo_id == Some(repo_id) && g.state == ItemState::Cancelled); + .any(|g| g.repo_id == Some(repo_id) && g.state == display::ItemState::Cancelled); let any_active = self .state .grammars @@ -1040,21 +1048,23 @@ impl DisplayActor { if let Some(repo) = self.state.repos.get_mut(&repo_id) { if any_active { - repo.state = ItemState::InProgress(live_outcome); + repo.state = display::ItemState::InProgress(live_outcome); repo.msg = Arc::from("building"); repo.frozen_elapsed = None; } else if any_failed { - repo.state = ItemState::Failed; + repo.state = display::ItemState::Failed; repo.msg = Arc::from("failed"); repo.frozen_elapsed .get_or_insert_with(|| repo.started_at.elapsed()); } else if any_cancelled { - repo.state = ItemState::Cancelled; + repo.state = display::ItemState::Cancelled; repo.msg = Arc::from("cancelled"); repo.frozen_elapsed .get_or_insert_with(|| repo.started_at.elapsed()); } else { - repo.state = ItemState::Done(done_outcome.unwrap_or(SuccessOutcome::Built)); + repo.state = display::ItemState::Done( + done_outcome.unwrap_or(display::SuccessOutcome::Built), + ); repo.msg = Arc::from("done"); repo.frozen_elapsed .get_or_insert_with(|| repo.started_at.elapsed()); @@ -1064,7 +1074,10 @@ impl DisplayActor { self.grid.mark_dirty(repo_id); } - fn aggregate_child_live_outcome(&self, repo_id: ItemId) -> Option { + fn aggregate_child_live_outcome( + &self, + repo_id: display::ItemId, + ) -> Option { let mut saw_cached = false; let mut saw_unknown = false; @@ -1075,25 +1088,32 @@ impl DisplayActor { .filter(|g| g.repo_id == Some(repo_id)) { match grammar.state { - ItemState::InProgress(Some(SuccessOutcome::Built)) - | ItemState::Done(SuccessOutcome::Built) => return Some(SuccessOutcome::Built), - ItemState::InProgress(Some(SuccessOutcome::Cached)) - | ItemState::Done(SuccessOutcome::Cached) => saw_cached = true, - ItemState::New | ItemState::InProgress(None) => saw_unknown = true, - ItemState::Cancelled | ItemState::Failed => {} + display::ItemState::InProgress(Some(display::SuccessOutcome::Built)) + | display::ItemState::Done(display::SuccessOutcome::Built) => { + return Some(display::SuccessOutcome::Built) + } + display::ItemState::InProgress(Some(display::SuccessOutcome::Cached)) + | display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, + display::ItemState::New | display::ItemState::InProgress(None) => { + saw_unknown = true + } + display::ItemState::Cancelled | display::ItemState::Failed => {} } } if saw_unknown { None } else if saw_cached { - Some(SuccessOutcome::Cached) + Some(display::SuccessOutcome::Cached) } else { None } } - fn aggregate_child_done_outcome(&self, repo_id: ItemId) -> Option { + fn aggregate_child_done_outcome( + &self, + repo_id: display::ItemId, + ) -> Option { let mut saw_cached = false; for grammar in self @@ -1103,51 +1123,64 @@ impl DisplayActor { .filter(|g| g.repo_id == Some(repo_id)) { match grammar.state { - ItemState::Done(SuccessOutcome::Built) => return Some(SuccessOutcome::Built), - ItemState::Done(SuccessOutcome::Cached) => saw_cached = true, - ItemState::New - | ItemState::InProgress(_) - | ItemState::Cancelled - | ItemState::Failed => {} + display::ItemState::Done(display::SuccessOutcome::Built) => { + return Some(display::SuccessOutcome::Built) + } + display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, + display::ItemState::New + | display::ItemState::InProgress(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => {} } } - saw_cached.then_some(SuccessOutcome::Cached) + saw_cached.then_some(display::SuccessOutcome::Cached) } } -fn start_item(state: ItemState) -> ItemState { +fn start_item(state: display::ItemState) -> display::ItemState { match state { - ItemState::New => ItemState::InProgress(None), - ItemState::InProgress(outcome) => ItemState::InProgress(outcome), - ItemState::Done(_) | ItemState::Cancelled | ItemState::Failed => state, + display::ItemState::New => display::ItemState::InProgress(None), + display::ItemState::InProgress(outcome) => display::ItemState::InProgress(outcome), + display::ItemState::Done(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => state, } } -fn mark_success(state: ItemState, outcome: SuccessOutcome) -> ItemState { +fn mark_success(state: display::ItemState, outcome: display::SuccessOutcome) -> display::ItemState { match state { - ItemState::New | ItemState::InProgress(_) => ItemState::InProgress(Some(outcome)), - ItemState::Done(_) | ItemState::Cancelled | ItemState::Failed => state, + display::ItemState::New | display::ItemState::InProgress(_) => { + display::ItemState::InProgress(Some(outcome)) + } + display::ItemState::Done(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => state, } } -fn finish_item(state: ItemState) -> Result { +fn finish_item(state: display::ItemState) -> Result { match state { - ItemState::InProgress(Some(outcome)) => Ok(ItemState::Done(outcome)), - ItemState::New | ItemState::InProgress(None) => invalid_finish( + display::ItemState::InProgress(Some(outcome)) => Ok(display::ItemState::Done(outcome)), + display::ItemState::New | display::ItemState::InProgress(None) => invalid_finish( "finish update received before cached/built path was set", state, ), - ItemState::Done(_) | ItemState::Cancelled | ItemState::Failed => { + display::ItemState::Done(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => { invalid_finish("finish update received for terminal item state", state) } } } -fn invalid_finish(reason: &str, state: ItemState) -> Result { +fn invalid_finish(reason: &str, state: display::ItemState) -> Result { let message = format!("{reason}: {state:?}"); error!("{message}"); - debug_assert!(matches!(state, ItemState::InProgress(Some(_))), "{message}"); + debug_assert!( + matches!(state, display::ItemState::InProgress(Some(_))), + "{message}" + ); Err(message) } @@ -1162,15 +1195,15 @@ mod tests { fn actor() -> DisplayActor { let (tx, rx) = mpsc::channel(1); DisplayActor { - state: DisplayState::new( - Mode::Fancy, + state: display::State::new( + display::Mode::Fancy, Arc::new(PathBuf::from("build")), Arc::new(PathBuf::from("out")), ), - next_id: ItemId::new(NonZeroU64::MIN), + next_id: display::ItemId::new(NonZeroU64::MIN), plain_name_width: 16, plain_progress_started: false, - grid: GridCache::new(), + grid: display::GridCache::new(), row_specs: Vec::new(), rows_dirty: true, last_term_width: None, @@ -1297,13 +1330,13 @@ mod tests { actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); - assert_eq!(grammar_entry.state, ItemState::Cancelled); + assert_eq!(grammar_entry.state, display::ItemState::Cancelled); assert!(!grammar_entry.state.is_live()); assert_eq!(grammar_entry.step, grammar_entry.total); assert!(grammar_entry.frozen_elapsed.is_some()); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, ItemState::Cancelled); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); assert!(!repo_entry.state.is_live()); assert_eq!(repo_entry.msg.as_ref(), "cancelled"); assert!(repo_entry.frozen_elapsed.is_some()); @@ -1329,14 +1362,14 @@ mod tests { actor.apply_update(active.id, UpdateKind::Step, Arc::from("building")); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, ItemState::InProgress(None)); + assert_eq!(repo_entry.state, display::ItemState::InProgress(None)); assert_eq!(repo_entry.msg.as_ref(), "building"); assert!(repo_entry.frozen_elapsed.is_none()); actor.apply_update(active.id, UpdateKind::Cancel, Arc::from("cancelled")); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, ItemState::Cancelled); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); assert_eq!(repo_entry.msg.as_ref(), "cancelled"); assert!(repo_entry.frozen_elapsed.is_some()); } @@ -1361,7 +1394,7 @@ mod tests { actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, ItemState::Failed); + assert_eq!(repo_entry.state, display::ItemState::Failed); assert_eq!(repo_entry.msg.as_ref(), "failed"); assert!(repo_entry.frozen_elapsed.is_some()); @@ -1390,11 +1423,11 @@ mod tests { actor.cancel_live_rows(); let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); - assert_eq!(grammar_entry.state, ItemState::Cancelled); + assert_eq!(grammar_entry.state, display::ItemState::Cancelled); assert_eq!(grammar_entry.step, grammar_entry.total); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, ItemState::Cancelled); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); assert_eq!(repo_entry.msg.as_ref(), "cancelled"); assert_eq!(actor.state.summary_counts(), (0, 0, 0, 0, 1)); @@ -1411,7 +1444,7 @@ mod tests { actor.apply_update(repo.id, UpdateKind::Err, Arc::from("failed")); let entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(entry.state, ItemState::Cancelled); + assert_eq!(entry.state, display::ItemState::Cancelled); assert_eq!(entry.msg.as_ref(), "cancelled"); let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); @@ -1421,7 +1454,7 @@ mod tests { actor.apply_update(grammar.id, UpdateKind::Err, Arc::from("failed")); let entry = actor.state.grammars.get(&grammar.id).unwrap(); - assert_eq!(entry.state, ItemState::Cancelled); + assert_eq!(entry.state, display::ItemState::Cancelled); assert_eq!(entry.msg.as_ref(), "cancelled"); } @@ -1443,7 +1476,7 @@ mod tests { actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, ItemState::InProgress(None)); + assert_eq!(repo_entry.state, display::ItemState::InProgress(None)); assert_eq!(repo_entry.msg.as_ref(), "building"); assert!(repo_entry.frozen_elapsed.is_none()); @@ -1451,7 +1484,7 @@ mod tests { actor.apply_update(pending.id, UpdateKind::Fin, Arc::from("done")); let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, ItemState::Failed); + assert_eq!(repo_entry.state, display::ItemState::Failed); assert_eq!(repo_entry.msg.as_ref(), "failed"); assert!(repo_entry.frozen_elapsed.is_some()); } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index be46745..2eab407 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -4,19 +4,13 @@ mod display; use std::{num::NonZeroUsize, path::PathBuf, sync::Arc}; pub use cache::{CacheActor, CacheAddr}; -pub use display::{DisplayActor, DisplayAddr, DisplayMessage, ProgressAddr}; +pub use display::{DisplayActor, DisplayAddr, Message, ProgressAddr}; use futures::{stream, StreamExt}; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info}; -use crate::{ - args::TreeSitter, - cache::{CacheKey, Source}, - error::TsdlError, - parser::{GrammarBuild, LanguageBuild}, - shutdown, tree_sitter, TsdlResult, -}; +use crate::{args, error, parser, shutdown, tree_sitter, TsdlResult}; pub trait Addr { type Message; @@ -71,14 +65,14 @@ pub async fn run( cache: CacheAddr, display: DisplayAddr, jobs: NonZeroUsize, - languages: Vec, - tree_sitter: &TreeSitter, + languages: Vec, + tree_sitter: &args::TreeSitter, ) -> TsdlResult<()> { let tree_sitter_ref = match tree_sitter::display_tree_sitter_ref(&tree_sitter.version) { Ok(git_ref) => git_ref, Err(err) => { display.shutdown(false).await; - return Err(TsdlError::context( + return Err(error::TsdlError::context( format!("Parsing tree-sitter git ref {:?}", tree_sitter.version), err, )); @@ -116,7 +110,7 @@ pub async fn run( // interruption so the top-level process exits with the signal status. if let Some(signal) = interrupted { info!("pipeline shutdown signalled by {signal}, suppressing build errors"); - return Err(TsdlError::Interrupted(signal)); + return Err(error::TsdlError::Interrupted(signal)); } result @@ -127,8 +121,8 @@ async fn run_inner( cache: CacheAddr, display: DisplayAddr, jobs: NonZeroUsize, - languages: Vec, - tree_sitter: &TreeSitter, + languages: Vec, + tree_sitter: &args::TreeSitter, ) -> TsdlResult<()> { let prepared = tree_sitter::prepare(build_dir, display.clone(), tree_sitter).await?; let ts_cli = Arc::new(prepared.path); @@ -137,8 +131,8 @@ async fn run_inner( .map(|language| language.with_tree_sitter(prepared.tree_sitter.clone())) .collect::>(); - let mut errors : Vec = - // 1. Source: Create a stream from the input list + let mut errors : Vec = + // 1. crate::cache::Source: Create a stream from the input list stream::iter(languages) // 2. Stage: Discovery // Transform Language -> Future>> @@ -199,7 +193,7 @@ async fn run_inner( if errors.is_empty() { Ok(()) } else { - Err(TsdlError::Build(errors)) + Err(error::TsdlError::Build(errors)) } } @@ -208,7 +202,7 @@ async fn run_inner( async fn discover_grammars( cache: CacheAddr, display: DisplayAddr, - language: LanguageBuild, + language: parser::LanguageBuild, ts_cli: Arc, ) -> TsdlResult> { shutdown::test_delay().await; @@ -245,8 +239,8 @@ async fn discover_grammars( shutdown::test_delay().await; shutdown::check()?; - let key = CacheKey::new(&language.name, &name); - let artifacts = GrammarBuild::required_artifacts_for( + let key = crate::cache::Key::new(&language.name, &name); + let artifacts = parser::GrammarBuild::required_artifacts_for( &language.output.build_dir, &ts_cli, &language.spec, @@ -271,7 +265,7 @@ async fn discover_grammars( ) .await; - builds.push(GrammarBuild { + builds.push(parser::GrammarBuild { context: language.context.clone(), cache_decision, dir: dir.into(), @@ -291,9 +285,9 @@ async fn discover_grammars( async fn resolve_source( cache: &CacheAddr, - language: &LanguageBuild, + language: &parser::LanguageBuild, progress: &ProgressAddr, -) -> TsdlResult { +) -> TsdlResult { if language.spec.git_ref.is_moving() { info!( "Resolving moving parser git ref for {}: {}", @@ -310,7 +304,7 @@ async fn resolve_source( language.spec.git_ref.requested().as_str(), checkout.commit.as_str() ); - Ok(Source::moving(checkout.commit)) + Ok(crate::cache::Source::moving(checkout.commit)) } Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { @@ -342,6 +336,6 @@ async fn resolve_source( progress.set_outcome_cached().await; } - Ok(Source::stable()) + Ok(crate::cache::Source::stable()) } } diff --git a/src/app.rs b/src/app.rs index a932632..e49afda 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,15 +2,12 @@ use std::path::PathBuf; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use crate::{ - args::{BuildCommand, Command}, - config, display, logging, TsdlResult, -}; +use crate::{args, config, display, logging, TsdlResult}; /// Resolved application state, ready to run. pub struct App { - pub subcommand: Command, - pub command: BuildCommand, + pub subcommand: args::Command, + pub command: args::BuildCommand, pub provenance: config::BuildProvenance, pub config_path: PathBuf, pub log_path: PathBuf, diff --git a/src/args.rs b/src/args.rs index 6e5eb90..714e71c 100644 --- a/src/args.rs +++ b/src/args.rs @@ -145,6 +145,7 @@ impl Target { matches!(self, Self::All | Self::Wasm) } + #[must_use] pub fn to_lowercase(&self) -> &'static str { match self { Target::Native => "native", diff --git a/src/build.rs b/src/build.rs index ff840a2..2446fb2 100644 --- a/src/build.rs +++ b/src/build.rs @@ -11,28 +11,18 @@ use tracing::info; use url::Url; use crate::{ - actors::{self, CacheActor, DisplayActor}, - app::App, - args::{ParserConfig, Target, TreeSitter}, - cache::Db, - consts::TSDL_FROM, - error::{self, TsdlError}, - format_duration, - lock::{Lock, LockGuard, LockOwner, LockStatus, LockTakeoverError}, - parser::{self, LanguageBuild}, - prompt_user, - shutdown::{self, Shutdown}, + actors, app, args, cache, consts, error, format_duration, lock, parser, prompt_user, shutdown, SafeCanonicalize, TsdlResult, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct BuildSpec { +pub struct Spec { pub build_script: Option, pub git_ref: parser::Ref, pub prefix: String, pub repo: Url, - pub target: Target, - pub tree_sitter: TreeSitter, + pub target: args::Target, + pub tree_sitter: args::TreeSitter, } #[derive(Debug, Clone)] @@ -42,16 +32,16 @@ pub struct OutputConfig { } #[derive(Debug, Clone, PartialEq)] -pub struct BuildContext { +pub struct Context { pub overwrite_output: bool, } -pub fn run(app: &App) -> TsdlResult<()> { +pub fn run(app: &app::App) -> TsdlResult<()> { if app.command.show_config { crate::config::show(&app.command)?; } - let lock = Lock::new(&app.command.build_dir); + let lock = lock::Lock::new(&app.command.build_dir); let guard = acquire_lock(&lock, Duration::from_secs(app.command.unlock_timeout))?; clear(app, &guard)?; @@ -59,20 +49,20 @@ pub fn run(app: &App) -> TsdlResult<()> { Ok(()) } -fn acquire_lock(lock: &Lock, unlock_timeout: Duration) -> TsdlResult { +fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> TsdlResult { // Loop because the lock owner may exit naturally between the prompt and // SIGTERM, or another process may replace it. Each iteration re-checks // the lock status and presents the current owner to the user. loop { match lock.try_acquire()? { - LockStatus::Acquired(guard) => return Ok(guard), + lock::Status::Acquired(guard) => return Ok(guard), - LockStatus::Cyclic => { - info!("Lock already held by this process (cyclic)."); - return Err(TsdlError::message("1+ lock acquisition")); + lock::Status::Cyclic => { + info!("lock::Lock already held by this process (cyclic)."); + return Err(error::TsdlError::message("1+ lock acquisition")); } - LockStatus::LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { + lock::Status::LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { Ok(guard) => return Ok(guard), Err(ref err) if err.is_retryable() => { info!("{err}; re-checking lock status..."); @@ -81,13 +71,13 @@ fn acquire_lock(lock: &Lock, unlock_timeout: Duration) -> TsdlResult Err(err) => return Err(err.into()), }, - LockStatus::Unknown { pid, reason } => { + lock::Status::Unknown { pid, reason } => { if let Some(pid) = pid { info!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"); } else { info!("Build directory is locked, but tsdl could not identify the owner: {reason}"); } - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "Could not identify build lock owner: {reason}" ))); } @@ -96,10 +86,10 @@ fn acquire_lock(lock: &Lock, unlock_timeout: Duration) -> TsdlResult } fn handle_locked_by( - lock: &Lock, - owner: &LockOwner, + lock: &lock::Lock, + owner: &lock::Owner, unlock_timeout: Duration, -) -> Result { +) -> Result { info!("Build directory is locked by another process:"); info!("{owner}"); eprintln!( @@ -111,7 +101,7 @@ fn handle_locked_by( ); if !prompt_user("Terminate this process and continue?", false)? { - return Err(TsdlError::message("Lock acquisition cancelled by user").into()); + return Err(error::TsdlError::message("lock::Lock acquisition cancelled by user").into()); } lock.terminate_owner(owner)?; @@ -122,7 +112,7 @@ fn handle_locked_by( lock.wait_for_release(owner, unlock_timeout) } -fn clear(app: &App, guard: &LockGuard) -> TsdlResult<()> { +fn clear(app: &app::App, guard: &lock::Guard) -> TsdlResult<()> { if app.command.fresh && app.command.build_dir.exists() { guard.clear_directory(std::slice::from_ref(&app.log_path))?; } @@ -132,7 +122,9 @@ fn clear(app: &App, guard: &LockGuard) -> TsdlResult<()> { Ok(()) } -fn collect_languages(app: &App) -> Result, error::LanguageCollection> { +fn collect_languages( + app: &app::App, +) -> Result, error::LanguageCollection> { let results = unique_languages(app); let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); @@ -146,34 +138,34 @@ fn collect_languages(app: &App) -> Result, error::LanguageCol } fn default_repo(language: &str) -> TsdlResult { - let url = format!("{TSDL_FROM}{language}"); + let url = format!("{}{language}", consts::TSDL_FROM); Url::parse(&url) - .map_err(|e| TsdlError::context(format!("Creating url {url} for {language}"), e)) + .map_err(|e| error::TsdlError::context(format!("Creating url {url} for {language}"), e)) } fn get_language_coords( language: &str, - defined_parsers: Option<&BTreeMap>, + defined_parsers: Option<&BTreeMap>, ) -> TsdlResult<(Option, parser::Ref, Url)> { let config = defined_parsers.and_then(|parsers| parsers.get(language)); match config { - Some(ParserConfig::Ref(git_ref)) => Ok(( + Some(args::ParserConfig::Ref(git_ref)) => Ok(( None, parser::Ref::parse(git_ref).map_err(|e| { - TsdlError::context(format!("Parsing git ref {git_ref:?} for {language}"), e) + error::TsdlError::context(format!("Parsing git ref {git_ref:?} for {language}"), e) })?, default_repo(language)?, )), - Some(ParserConfig::Full { + Some(args::ParserConfig::Full { build_script, git_ref, from, }) => { let repo = match from { Some(url_str) => Url::parse(url_str).map_err(|e| { - TsdlError::context(format!("Parsing {url_str} for {language}"), e) + error::TsdlError::context(format!("Parsing {url_str} for {language}"), e) })?, None => default_repo(language)?, }; @@ -181,7 +173,10 @@ fn get_language_coords( Ok(( build_script.clone(), parser::Ref::parse(git_ref).map_err(|e| { - TsdlError::context(format!("Parsing git ref {git_ref:?} for {language}"), e) + error::TsdlError::context( + format!("Parsing git ref {git_ref:?} for {language}"), + e, + ) })?, repo, )) @@ -191,7 +186,7 @@ fn get_language_coords( } } -fn ignite(app: &App) -> TsdlResult<()> { +fn ignite(app: &app::App) -> TsdlResult<()> { fs::create_dir_all(&app.command.out_dir)?; let rt = tokio::runtime::Builder::new_current_thread() @@ -200,16 +195,16 @@ fn ignite(app: &App) -> TsdlResult<()> { let guard = rt.enter(); - let db = Db::load(&app.command.build_dir)?; + let db = cache::Db::load(&app.command.build_dir)?; let languages = collect_languages(app)?; let result = rt.block_on(async move { - let shutdown = Shutdown::new(); + let shutdown = shutdown::Handle::new(); let _signals = shutdown.spawn_signal_listener()?; - let cache = CacheActor::spawn(db, app.command.force); + let cache = actors::CacheActor::spawn(db, app.command.force); let build_dir: Arc = app.command.build_dir.canon()?.into(); let out_dir: Arc = app.command.out_dir.canon()?.into(); - let display = DisplayActor::spawn(app.progress_mode, build_dir, out_dir); + let display = actors::DisplayActor::spawn(app.progress_mode, build_dir, out_dir); shutdown::scope(shutdown, async move { actors::run( @@ -232,7 +227,7 @@ fn ignite(app: &App) -> TsdlResult<()> { result } -fn unique_languages(app: &App) -> Vec> { +fn unique_languages(app: &app::App) -> Vec> { let requested_languages = &app.command.languages; let defined_parsers = app.command.parsers.as_ref(); @@ -248,11 +243,11 @@ fn unique_languages(app: &App) -> Vec> { for language in unique { let result = match get_language_coords(&language, defined_parsers) { - Ok((build_script, git_ref, repo)) => Ok(LanguageBuild::new( - BuildContext { + Ok((build_script, git_ref, repo)) => Ok(parser::LanguageBuild::new( + Context { overwrite_output: app.command.force, }, - Arc::new(BuildSpec { + Arc::new(Spec { build_script, git_ref, repo, @@ -290,7 +285,7 @@ mod tests { use super::*; use crate::{args::BuildCommand, config::BuildProvenance, display::Mode}; - fn app_with_languages(languages: &[&str]) -> App { + fn app_with_languages(languages: &[&str]) -> app::App { let command = BuildCommand { languages: Some( languages @@ -301,7 +296,7 @@ mod tests { ..BuildCommand::default() }; - App { + app::App { subcommand: crate::args::Command::Build, command, config_path: PathBuf::from("parsers.toml"), diff --git a/src/cache.rs b/src/cache.rs index 86552be..60e3e65 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -10,19 +10,13 @@ use sha1::{Digest, Sha1}; use tokio::io::{AsyncReadExt, ReadBuf}; use tracing::debug; -use crate::{ - args::{Target, TreeSitter}, - build::BuildSpec, - consts::TSDL_CACHE_FILE, - error::TsdlError, - git, parser, TsdlResult, -}; +use crate::{args, build, consts, error, git, parser, TsdlResult}; #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] -pub struct CacheKey(Arc); +pub struct Key(Arc); -impl CacheKey { +impl Key { #[must_use] pub fn new(language: &parser::LanguageName, grammar: &parser::GrammarName) -> Self { Self(Arc::from(format!("{language}/{grammar}"))) @@ -39,19 +33,19 @@ impl CacheKey { } } -impl fmt::Display for CacheKey { +impl fmt::Display for Key { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } -impl From for CacheKey { +impl From for Key { fn from(value: String) -> Self { Self(value.into()) } } -impl From<&str> for CacheKey { +impl From<&str> for Key { fn from(value: &str) -> Self { Self(Arc::from(value)) } @@ -86,10 +80,10 @@ impl From<&str> for GrammarHash { } } -/// The build cache stored in `[build-dir]/[TSDL_CACHE_FILE]` +/// The build cache stored in `[build-dir]/[consts::TSDL_CACHE_FILE]` #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { - pub parsers: BTreeMap, + pub parsers: BTreeMap, pub file: PathBuf, } @@ -99,7 +93,7 @@ pub struct Entry { /// Hash of the grammar.js file(s) pub hash: GrammarHash, /// Complete build definition that affects parser output - pub spec: Arc, + pub spec: Arc, /// Parser source identity used by the cache. Moving refs include the /// checked-out commit. pub source: Source, @@ -135,20 +129,20 @@ impl fmt::Display for Source { /// A cache lookup result for a requested parser build. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum CacheDecision { +pub enum Decision { Hit, - Miss(CacheMiss), + Miss(Miss), } /// Details explaining why a cache entry cannot satisfy a requested parser build. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct CacheMiss { - pub reasons: Vec, +pub struct Miss { + pub reasons: Vec, } /// One reason a cached parser build cannot be reused. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum CacheMissReason { +pub enum MissReason { MissingEntry, CacheIgnored, HashChanged { @@ -168,8 +162,8 @@ pub enum CacheMissReason { current: Source, }, TreeSitterChanged { - cached: TreeSitter, - current: TreeSitter, + cached: args::TreeSitter, + current: args::TreeSitter, }, BuildScriptChanged, PrefixChanged { @@ -177,8 +171,8 @@ pub enum CacheMissReason { current: String, }, TargetChanged { - cached: Target, - current: Target, + cached: args::Target, + current: args::Target, }, ArtifactMissing { path: PathBuf, @@ -192,20 +186,20 @@ pub enum CacheMissReason { }, } -impl CacheDecision { +impl Decision { #[must_use] - pub fn miss(reason: CacheMissReason) -> Self { - Self::Miss(CacheMiss { + pub fn miss(reason: MissReason) -> Self { + Self::Miss(Miss { reasons: vec![reason], }) } #[must_use] - pub fn from_reasons(reasons: Vec) -> Self { + pub fn from_reasons(reasons: Vec) -> Self { if reasons.is_empty() { Self::Hit } else { - Self::Miss(CacheMiss { reasons }) + Self::Miss(Miss { reasons }) } } @@ -228,16 +222,16 @@ impl CacheDecision { } } -impl fmt::Display for CacheDecision { +impl fmt::Display for Decision { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - CacheDecision::Hit => write!(f, "cache hit"), - CacheDecision::Miss(miss) => write!(f, "cache miss: {miss}"), + Decision::Hit => write!(f, "cache hit"), + Decision::Miss(miss) => write!(f, "cache miss: {miss}"), } } } -impl CacheMiss { +impl Miss { #[must_use] pub fn short_message(&self) -> String { match self.reasons.as_slice() { @@ -246,7 +240,7 @@ impl CacheMiss { reasons => { let labels = reasons .iter() - .map(CacheMissReason::short_label) + .map(MissReason::short_label) .collect::>() .join(", "); format!("cache changed: {labels}") @@ -255,7 +249,7 @@ impl CacheMiss { } } -impl fmt::Display for CacheMiss { +impl fmt::Display for Miss { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for (index, reason) in self.reasons.iter().enumerate() { if index > 0 { @@ -267,7 +261,7 @@ impl fmt::Display for CacheMiss { } } -impl CacheMissReason { +impl MissReason { #[must_use] pub fn short_label(&self) -> &'static str { match self { @@ -307,7 +301,7 @@ impl CacheMissReason { } } -impl fmt::Display for CacheMissReason { +impl fmt::Display for MissReason { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingEntry => write!(f, "missing cache entry"), @@ -362,21 +356,21 @@ impl Entry { pub fn rebuild_decision( &self, hash: &GrammarHash, - spec: &BuildSpec, + spec: &build::Spec, source: &Source, - ) -> CacheDecision { + ) -> Decision { let mut reasons = Vec::new(); let cached = self.spec.as_ref(); if &self.hash != hash { - reasons.push(CacheMissReason::HashChanged { + reasons.push(MissReason::HashChanged { cached: self.hash.clone(), current: hash.clone(), }); } if cached.repo != spec.repo { - reasons.push(CacheMissReason::RepoChanged { + reasons.push(MissReason::RepoChanged { cached: cached.repo.to_string(), current: spec.repo.to_string(), }); @@ -384,45 +378,45 @@ impl Entry { let git_ref_changed = cached.git_ref != spec.git_ref; if git_ref_changed { - reasons.push(CacheMissReason::RefChanged { + reasons.push(MissReason::RefChanged { cached: cached.git_ref.clone(), current: spec.git_ref.clone(), }); } if !git_ref_changed && self.source != *source { - reasons.push(CacheMissReason::SourceChanged { + reasons.push(MissReason::SourceChanged { cached: self.source.clone(), current: source.clone(), }); } if cached.tree_sitter != spec.tree_sitter { - reasons.push(CacheMissReason::TreeSitterChanged { + reasons.push(MissReason::TreeSitterChanged { cached: cached.tree_sitter.clone(), current: spec.tree_sitter.clone(), }); } if cached.build_script != spec.build_script { - reasons.push(CacheMissReason::BuildScriptChanged); + reasons.push(MissReason::BuildScriptChanged); } if cached.prefix != spec.prefix { - reasons.push(CacheMissReason::PrefixChanged { + reasons.push(MissReason::PrefixChanged { cached: cached.prefix.clone(), current: spec.prefix.clone(), }); } if cached.target != spec.target { - reasons.push(CacheMissReason::TargetChanged { + reasons.push(MissReason::TargetChanged { cached: cached.target, current: spec.target, }); } - CacheDecision::from_reasons(reasons) + Decision::from_reasons(reasons) } } @@ -430,7 +424,7 @@ impl Entry { #[derive(Debug, Clone)] pub struct Update { pub entry: Entry, - pub name: CacheKey, + pub name: Key, } impl Db { @@ -441,10 +435,10 @@ impl Db { /// Delete the cache file from disk pub async fn delete(build_dir: &Path) -> TsdlResult<()> { - let file = build_dir.join(TSDL_CACHE_FILE); + let file = build_dir.join(consts::TSDL_CACHE_FILE); if tokio::fs::metadata(&file).await.is_ok() { tokio::fs::remove_file(&file).await.map_err(|e| { - TsdlError::context(format!("Deleting cache file at {}", file.display()), e) + error::TsdlError::context(format!("Deleting cache file at {}", file.display()), e) })?; debug!("Cache file deleted"); } @@ -453,13 +447,13 @@ impl Db { /// Get cache entry for a parser #[must_use] - pub fn get(&self, name: &CacheKey) -> Option<&Entry> { + pub fn get(&self, name: &Key) -> Option<&Entry> { self.parsers.get(name) } /// Load the cache from disk, or return the empty cache. pub fn load(build_dir: &Path) -> TsdlResult { - let file = build_dir.join(TSDL_CACHE_FILE); + let file = build_dir.join(consts::TSDL_CACHE_FILE); if !file.exists() { debug!( "Cache file not found at {}, returning empty cache", @@ -472,23 +466,24 @@ impl Db { } let contents = std::fs::read_to_string(&file).map_err(|e| { - TsdlError::context(format!("Reading cache file at {}", file.display()), e) + error::TsdlError::context(format!("Reading cache file at {}", file.display()), e) })?; - toml::from_str(&contents) - .map_err(|e| TsdlError::context(format!("Parsing cache file at {}", file.display()), e)) + toml::from_str(&contents).map_err(|e| { + error::TsdlError::context(format!("Parsing cache file at {}", file.display()), e) + }) } /// Explain whether a parser cache entry can satisfy the requested build. pub fn rebuild_decision( &self, - name: &CacheKey, + name: &Key, hash: &GrammarHash, - spec: &BuildSpec, + spec: &build::Spec, source: &Source, - ) -> CacheDecision { + ) -> Decision { let decision = match self.get(name) { - None => CacheDecision::miss(CacheMissReason::MissingEntry), + None => Decision::miss(MissReason::MissingEntry), Some(entry) => entry.rebuild_decision(hash, spec, source), }; @@ -500,9 +495,9 @@ impl Db { #[must_use] pub fn needs_rebuild( &self, - name: &CacheKey, + name: &Key, hash: &GrammarHash, - spec: &BuildSpec, + spec: &build::Spec, source: &Source, ) -> bool { self.rebuild_decision(name, hash, spec, source) @@ -512,10 +507,10 @@ impl Db { /// Save the cache to disk pub async fn save(&self) -> TsdlResult<()> { let contents = toml::to_string_pretty(self) - .map_err(|e| TsdlError::context("Serializing cache to TOML", e))?; + .map_err(|e| error::TsdlError::context("Serializing cache to TOML", e))?; tokio::fs::write(&self.file, contents).await.map_err(|e| { - TsdlError::context(format!("Writing cache file to {}", self.file.display()), e) + error::TsdlError::context(format!("Writing cache file to {}", self.file.display()), e) })?; debug!("Cache saved to {}", self.file.display()); @@ -523,7 +518,7 @@ impl Db { } /// Insert or update a parser cache entry - pub fn set(&mut self, name: CacheKey, entry: Entry) { + pub fn set(&mut self, name: Key, entry: Entry) { self.parsers.insert(name, entry); } } @@ -531,7 +526,7 @@ impl Db { /// Hash the contents of a file using SHA-1 and return the hex string. pub async fn hash_file(path: &Path) -> TsdlResult { let mut file = tokio::fs::File::open(path).await.map_err(|e| { - TsdlError::context(format!("Opening file for hashing: {}", path.display()), e) + error::TsdlError::context(format!("Opening file for hashing: {}", path.display()), e) })?; let mut hasher = Sha1::new(); @@ -540,7 +535,7 @@ pub async fn hash_file(path: &Path) -> TsdlResult { loop { let mut read_buf = ReadBuf::new(&mut buffer); file.read_buf(&mut read_buf).await.map_err(|e| { - TsdlError::context(format!("Reading file for hashing: {}", path.display()), e) + error::TsdlError::context(format!("Reading file for hashing: {}", path.display()), e) })?; if read_buf.filled().is_empty() { @@ -566,46 +561,46 @@ mod tests { const SHA1: &str = "636801770eea172d140e64b691815ff11f6b556f"; const SHA2: &str = "736801770eea172d140e64b691815ff11f6b556f"; - fn test_spec() -> BuildSpec { - BuildSpec { + fn test_spec() -> build::Spec { + build::Spec { build_script: None, git_ref: parser::Ref::parse("v1.0.0").unwrap(), repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), + tree_sitter: args::TreeSitter::default(), prefix: String::new(), - target: Target::Native, + target: args::Target::Native, } } - fn moving_spec() -> BuildSpec { - BuildSpec { + fn moving_spec() -> build::Spec { + build::Spec { git_ref: parser::Ref::parse("master").unwrap(), ..test_spec() } } - fn stable_source(_spec: &BuildSpec) -> Source { + fn stable_source(_spec: &build::Spec) -> Source { Source::stable() } - fn moving_source(spec: &BuildSpec, sha: &str) -> Source { + fn moving_source(spec: &build::Spec, sha: &str) -> Source { assert!(spec.git_ref.is_moving()); Source::moving(git::Sha::new(sha).unwrap()) } - fn key() -> CacheKey { - CacheKey::from("test-parser") + fn key() -> Key { + Key::from("test-parser") } fn grammar_hash(value: &str) -> GrammarHash { GrammarHash::from(value) } - fn cache_with_entry(hash: &str, spec: &BuildSpec) -> Db { + fn cache_with_entry(hash: &str, spec: &build::Spec) -> Db { cache_with_entry_and_source(hash, spec, stable_source(spec)) } - fn cache_with_entry_and_source(hash: &str, spec: &BuildSpec, source: Source) -> Db { + fn cache_with_entry_and_source(hash: &str, spec: &build::Spec, source: Source) -> Db { let mut cache = Db::default(); cache.set( key(), @@ -618,10 +613,10 @@ mod tests { cache } - fn assert_miss(decision: CacheDecision, expected: &[CacheMissReason]) { + fn assert_miss(decision: Decision, expected: &[MissReason]) { match decision { - CacheDecision::Hit => panic!("expected cache miss"), - CacheDecision::Miss(miss) => assert_eq!(miss.reasons, expected), + Decision::Hit => panic!("expected cache miss"), + Decision::Miss(miss) => assert_eq!(miss.reasons, expected), } } @@ -637,7 +632,7 @@ mod tests { &spec, &stable_source(&spec), ), - &[CacheMissReason::MissingEntry], + &[MissReason::MissingEntry], ); } @@ -653,7 +648,7 @@ mod tests { &spec, &stable_source(&spec), ), - &[CacheMissReason::HashChanged { + &[MissReason::HashChanged { cached: grammar_hash("abc123"), current: grammar_hash("def456"), }], @@ -674,7 +669,7 @@ mod tests { &requested, &stable_source(&requested), ), - &[CacheMissReason::RefChanged { + &[MissReason::RefChanged { cached: parser::Ref::parse("v1.0.0").unwrap(), current: parser::Ref::parse("v2.0.0").unwrap(), }], @@ -685,7 +680,7 @@ mod tests { fn test_rebuild_decision_target_changed() { let cached = test_spec(); let mut requested = cached.clone(); - requested.target = Target::Wasm; + requested.target = args::Target::Wasm; let cache = cache_with_entry("abc123", &cached); assert_miss( @@ -695,9 +690,9 @@ mod tests { &requested, &stable_source(&requested), ), - &[CacheMissReason::TargetChanged { - cached: Target::Native, - current: Target::Wasm, + &[MissReason::TargetChanged { + cached: args::Target::Native, + current: args::Target::Wasm, }], ); } @@ -714,7 +709,7 @@ mod tests { &spec, &stable_source(&spec) ), - CacheDecision::Hit + Decision::Hit ); assert!(!cache.needs_rebuild( &key(), @@ -733,7 +728,7 @@ mod tests { assert_miss( cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, ¤t_source), - &[CacheMissReason::SourceChanged { + &[MissReason::SourceChanged { cached: cached_source, current: current_source, }], @@ -748,7 +743,7 @@ mod tests { assert_eq!( cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &source), - CacheDecision::Hit + Decision::Hit ); } @@ -758,7 +753,7 @@ mod tests { let mut requested = cached.clone(); requested.build_script = Some("make".to_string()); requested.prefix = "custom-".to_string(); - requested.target = Target::All; + requested.target = args::Target::All; let cache = cache_with_entry("abc123", &cached); assert_miss( @@ -769,18 +764,18 @@ mod tests { &stable_source(&requested), ), &[ - CacheMissReason::HashChanged { + MissReason::HashChanged { cached: grammar_hash("abc123"), current: grammar_hash("def456"), }, - CacheMissReason::BuildScriptChanged, - CacheMissReason::PrefixChanged { + MissReason::BuildScriptChanged, + MissReason::PrefixChanged { cached: String::new(), current: "custom-".to_string(), }, - CacheMissReason::TargetChanged { - cached: Target::Native, - current: Target::All, + MissReason::TargetChanged { + cached: args::Target::Native, + current: args::Target::All, }, ], ); diff --git a/src/config.rs b/src/config.rs index df061f8..55294a2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,16 +10,11 @@ use clap::{ use serde::Serialize; use tracing::debug; -use crate::{ - args::{Args, BuildCommand, ConfigCommand, OptionalBuildCommand, Target}, - columns, - error::TsdlError, - TsdlResult, -}; +use crate::{args, columns, error, TsdlResult}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] -pub enum ConfigSource { +pub enum Source { #[default] BuiltInDefault, ConfigFile, @@ -27,7 +22,7 @@ pub enum ConfigSource { CommandLine, } -impl ConfigSource { +impl Source { fn from_value_source(source: ValueSource) -> Option { match source { ValueSource::CommandLine => Some(Self::CommandLine), @@ -40,29 +35,29 @@ impl ConfigSource { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct TreeSitterProvenance { - pub version: ConfigSource, - pub platform: ConfigSource, - pub repo: ConfigSource, + pub version: Source, + pub platform: Source, + pub repo: Source, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct BuildProvenance { - pub build_dir: ConfigSource, - pub force: ConfigSource, - pub fresh: ConfigSource, - pub languages: ConfigSource, - pub jobs: ConfigSource, - pub out_dir: ConfigSource, - pub parsers: ConfigSource, - pub prefix: ConfigSource, - pub show_config: ConfigSource, - pub target: ConfigSource, + pub build_dir: Source, + pub force: Source, + pub fresh: Source, + pub languages: Source, + pub jobs: Source, + pub out_dir: Source, + pub parsers: Source, + pub prefix: Source, + pub show_config: Source, + pub target: Source, pub tree_sitter: TreeSitterProvenance, - pub unlock_timeout: ConfigSource, + pub unlock_timeout: Source, } -pub fn current(config: &Path, matches: Option<&ArgMatches>) -> TsdlResult { +pub fn current(config: &Path, matches: Option<&ArgMatches>) -> TsdlResult { let (cmd, _provenance) = current_with_provenance(config, matches)?; Ok(cmd) } @@ -70,15 +65,18 @@ pub fn current(config: &Path, matches: Option<&ArgMatches>) -> TsdlResult, -) -> TsdlResult<(BuildCommand, BuildProvenance)> { - let defaults = BuildCommand::default(); +) -> TsdlResult<(args::BuildCommand, BuildProvenance)> { + let defaults = args::BuildCommand::default(); let file_overrides = read_file_overrides(config)?; let file_provenance = file_provenance_from(&file_overrides); let (cli_overrides, cli_provenance) = if let Some(matches) = matches { extract_overrides(matches, &defaults) } else { - (OptionalBuildCommand::default(), BuildProvenance::default()) + ( + args::OptionalBuildCommand::default(), + BuildProvenance::default(), + ) }; let command = merge(defaults, file_overrides, cli_overrides); @@ -90,10 +88,10 @@ pub fn current_with_provenance( } fn merge( - defaults: BuildCommand, - file: OptionalBuildCommand, - cli: OptionalBuildCommand, -) -> BuildCommand { + defaults: args::BuildCommand, + file: args::OptionalBuildCommand, + cli: args::OptionalBuildCommand, +) -> args::BuildCommand { let mut cmd = defaults; apply_opt(&mut cmd.build_dir, file.build_dir); apply_opt(&mut cmd.build_dir, cli.build_dir); @@ -137,63 +135,65 @@ fn apply_opt(field: &mut T, value: Option) { } } -fn read_file_overrides(config: &Path) -> TsdlResult { +fn read_file_overrides(config: &Path) -> TsdlResult { if !config.exists() { - return Ok(OptionalBuildCommand::default()); + return Ok(args::OptionalBuildCommand::default()); } - let contents = fs::read_to_string(config) - .map_err(|e| TsdlError::context(format!("Reading config file {}", config.display()), e))?; + let contents = fs::read_to_string(config).map_err(|e| { + error::TsdlError::context(format!("Reading config file {}", config.display()), e) + })?; if contents.trim().is_empty() { - return Ok(OptionalBuildCommand::default()); + return Ok(args::OptionalBuildCommand::default()); } - toml::from_str(&contents) - .map_err(|e| TsdlError::context(format!("Parsing config file {}", config.display()), e)) + toml::from_str(&contents).map_err(|e| { + error::TsdlError::context(format!("Parsing config file {}", config.display()), e) + }) } -fn file_provenance_from(overrides: &OptionalBuildCommand) -> BuildProvenance { +fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenance { let mut p = BuildProvenance::default(); if overrides.build_dir.is_some() { - p.build_dir = ConfigSource::ConfigFile; + p.build_dir = Source::ConfigFile; } if overrides.force.is_some() { - p.force = ConfigSource::ConfigFile; + p.force = Source::ConfigFile; } if overrides.fresh.is_some() { - p.fresh = ConfigSource::ConfigFile; + p.fresh = Source::ConfigFile; } if overrides.jobs.is_some() { - p.jobs = ConfigSource::ConfigFile; + p.jobs = Source::ConfigFile; } if overrides.out_dir.is_some() { - p.out_dir = ConfigSource::ConfigFile; + p.out_dir = Source::ConfigFile; } if overrides.parsers.is_some() { - p.parsers = ConfigSource::ConfigFile; + p.parsers = Source::ConfigFile; } if overrides.prefix.is_some() { - p.prefix = ConfigSource::ConfigFile; + p.prefix = Source::ConfigFile; } if overrides.show_config.is_some() { - p.show_config = ConfigSource::ConfigFile; + p.show_config = Source::ConfigFile; } if overrides.target.is_some() { - p.target = ConfigSource::ConfigFile; + p.target = Source::ConfigFile; } if overrides.unlock_timeout.is_some() { - p.unlock_timeout = ConfigSource::ConfigFile; + p.unlock_timeout = Source::ConfigFile; } let ts = &overrides.tree_sitter; if ts.version.is_some() { - p.tree_sitter.version = ConfigSource::ConfigFile; + p.tree_sitter.version = Source::ConfigFile; } if ts.platform.is_some() { - p.tree_sitter.platform = ConfigSource::ConfigFile; + p.tree_sitter.platform = Source::ConfigFile; } if ts.repo.is_some() { - p.tree_sitter.repo = ConfigSource::ConfigFile; + p.tree_sitter.repo = Source::ConfigFile; } p } @@ -219,15 +219,16 @@ fn merge_provenance(file: BuildProvenance, cli: BuildProvenance) -> BuildProvena } } -fn merge_source(file: ConfigSource, cli: ConfigSource) -> ConfigSource { - if cli != ConfigSource::default() { - cli - } else { +fn merge_source(file: Source, cli: Source) -> Source { + if cli == Source::default() { file + } else { + cli } } -pub fn build_cli(defaults: &BuildCommand) -> Vec { +#[must_use] +pub fn build_cli(defaults: &args::BuildCommand) -> Vec { let jobs_default = defaults.jobs.to_string(); let ut_default = defaults.unlock_timeout.to_string(); @@ -355,11 +356,12 @@ pub fn build_cli(defaults: &BuildCommand) -> Vec { ] } +#[must_use] pub fn extract_overrides( matches: &ArgMatches, - _defaults: &BuildCommand, -) -> (OptionalBuildCommand, BuildProvenance) { - let mut o = OptionalBuildCommand::default(); + _defaults: &args::BuildCommand, +) -> (args::OptionalBuildCommand, BuildProvenance) { + let mut o = args::OptionalBuildCommand::default(); let mut p = BuildProvenance::default(); extract_simple(matches, "build-dir", &mut o.build_dir, &mut p.build_dir); @@ -424,7 +426,7 @@ fn extract_simple( matches: &ArgMatches, id: &str, field: &mut Option, - provenance: &mut ConfigSource, + provenance: &mut Source, ) { if let Some(source) = source_for(matches, id) { if let Some(val) = matches.get_one::(id) { @@ -438,7 +440,7 @@ fn extract_simple_usize( matches: &ArgMatches, id: &str, field: &mut Option, - provenance: &mut ConfigSource, + provenance: &mut Source, ) { if let Some(source) = source_for(matches, id) { if let Some(&n) = matches.get_one::(id) { @@ -454,7 +456,7 @@ fn extract_simple_u64( matches: &ArgMatches, id: &str, field: &mut Option, - provenance: &mut ConfigSource, + provenance: &mut Source, ) { if let Some(source) = source_for(matches, id) { if let Some(&val) = matches.get_one::(id) { @@ -467,22 +469,22 @@ fn extract_simple_u64( fn extract_target( matches: &ArgMatches, id: &str, - field: &mut Option, - provenance: &mut ConfigSource, + field: &mut Option, + provenance: &mut Source, ) { if let Some(source) = source_for(matches, id) { if let Some(raw) = matches.get_one::(id) { match raw.to_lowercase().as_str() { "native" => { - *field = Some(Target::Native); + *field = Some(args::Target::Native); *provenance = source; } "wasm" => { - *field = Some(Target::Wasm); + *field = Some(args::Target::Wasm); *provenance = source; } "all" => { - *field = Some(Target::All); + *field = Some(args::Target::All); *provenance = source; } _ => {} @@ -496,14 +498,14 @@ fn extract_bool( positive_id: &str, negative_id: &str, field: &mut Option, - provenance: &mut ConfigSource, + provenance: &mut Source, ) { let pos_source = source_for(matches, positive_id); let neg_source = source_for(matches, negative_id); - if matches!(neg_source, Some(ConfigSource::CommandLine)) { + if matches!(neg_source, Some(Source::CommandLine)) { *field = Some(false); - *provenance = ConfigSource::CommandLine; + *provenance = Source::CommandLine; return; } @@ -515,36 +517,35 @@ fn extract_bool( } } -fn source_for(matches: &ArgMatches, id: &str) -> Option { - matches - .value_source(id) - .and_then(ConfigSource::from_value_source) +fn source_for(matches: &ArgMatches, id: &str) -> Option { + matches.value_source(id).and_then(Source::from_value_source) } pub fn print_indent(s: &str, indent: &str) { s.lines().for_each(|line| println!("{indent}{line}")); } -pub fn run(config_path: &Path, command: &ConfigCommand) -> TsdlResult<()> { +pub fn run(config_path: &Path, command: &args::ConfigCommand) -> TsdlResult<()> { match command { - ConfigCommand::Current => { - let cmd: BuildCommand = current(config_path, None)?; + args::ConfigCommand::Current => { + let cmd: args::BuildCommand = current(config_path, None)?; println!( "{}", - toml::to_string(&cmd) - .map_err(|e| { TsdlError::context("Generating default TOML config", e) })? + toml::to_string(&cmd).map_err(|e| { + error::TsdlError::context("Generating default TOML config", e) + })? ); } - ConfigCommand::Default => println!( + args::ConfigCommand::Default => println!( "{}", - toml::to_string(&BuildCommand::default()) - .map_err(|e| { TsdlError::context("Generating default TOML config", e) })? + toml::to_string(&args::BuildCommand::default()) + .map_err(|e| { error::TsdlError::context("Generating default TOML config", e) })? ), } Ok(()) } -pub fn show(command: &BuildCommand) -> TsdlResult<()> { +pub fn show(command: &args::BuildCommand) -> TsdlResult<()> { if let Some(langs) = &command.languages { println!("Building the following languages:"); println!(); @@ -557,17 +558,18 @@ pub fn show(command: &BuildCommand) -> TsdlResult<()> { println!("Running with the following configuration:"); println!(); print_indent( - &toml::to_string(&command).map_err(|e| TsdlError::context("Showing config", e))?, + &toml::to_string(&command).map_err(|e| error::TsdlError::context("Showing config", e))?, " ", ); println!(); Ok(()) } -pub fn parse_with_matches() -> (Args, ArgMatches) { - let defaults = BuildCommand::default(); +#[must_use] +pub fn parse_with_matches() -> (args::Args, ArgMatches) { + let defaults = args::BuildCommand::default(); let build_args = build_cli(&defaults); - let mut cmd = Args::command(); + let mut cmd = args::Args::command(); if let Some(build_sub) = cmd.find_subcommand_mut("build") { let mut new_sub = build_sub.clone(); for arg in build_args { @@ -576,18 +578,18 @@ pub fn parse_with_matches() -> (Args, ArgMatches) { *build_sub = new_sub; } let matches = cmd.get_matches(); - let args = Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); + let args = args::Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); (args, matches) } -pub fn try_parse_from_with_matches(itr: I) -> Result<(Args, ArgMatches), clap::Error> +pub fn try_parse_from_with_matches(itr: I) -> Result<(args::Args, ArgMatches), clap::Error> where I: IntoIterator, T: Into + Clone, { - let defaults = BuildCommand::default(); + let defaults = args::BuildCommand::default(); let build_args = build_cli(&defaults); - let mut cmd = Args::command(); + let mut cmd = args::Args::command(); if let Some(build_sub) = cmd.find_subcommand_mut("build") { let mut new_sub = build_sub.clone(); for arg in build_args { @@ -596,6 +598,6 @@ where *build_sub = new_sub; } let matches = cmd.try_get_matches_from(itr)?; - let args = Args::from_arg_matches(&matches)?; + let args = args::Args::from_arg_matches(&matches)?; Ok((args, matches)) } diff --git a/src/display.rs b/src/display.rs index 11d1318..3ef6e1f 100644 --- a/src/display.rs +++ b/src/display.rs @@ -491,7 +491,7 @@ pub(crate) fn compute_msg_cell( } // --------------------------------------------------------------------------- -// DisplayState — the source of truth +// State — the source of truth // --------------------------------------------------------------------------- /// Pre-computed dimmed footer lines (static; never change). @@ -504,7 +504,7 @@ fn dim_line(text: String) -> Line<'static> { )) } -pub(crate) struct DisplayState { +pub(crate) struct State { pub mode: Mode, pub repos: HashMap, pub grammars: HashMap, @@ -514,7 +514,7 @@ pub(crate) struct DisplayState { footer_out: Line<'static>, } -impl DisplayState { +impl State { pub fn new(mode: Mode, build_dir: Arc, out_dir: Arc) -> Self { let footer_build = dim_line(format!("build: {}", build_dir.display())); let footer_out = dim_line(format!("out: {}", out_dir.display())); diff --git a/src/error.rs b/src/error.rs index 7c98544..6c81f82 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use derive_more::derive::Display; -use crate::shutdown::ShutdownSignal; +use crate::shutdown::Signal; /// Represents a single layer in the context chain #[derive(Debug)] @@ -272,7 +272,7 @@ pub enum TsdlError { Io(std::io::Error), /// Build was interrupted by a Unix signal. - Interrupted(ShutdownSignal), + Interrupted(Signal), /// Simple error message Message(String), diff --git a/src/lock.rs b/src/lock.rs index 16e6bb8..3caece7 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -13,13 +13,11 @@ use fs2::FileExt; use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, UpdateKind}; use tracing::info; -use crate::{ - absolute_normalize, consts::TSDL_LOCK_FILE, error::TsdlError, format_duration, TsdlResult, -}; +use crate::{absolute_normalize, consts, error, format_duration, TsdlResult}; /// Information about the process currently holding the build lock. #[derive(Debug, Clone)] -pub struct LockOwner { +pub struct Owner { pub pid: Pid, pub name: String, pub command: Option, @@ -30,7 +28,7 @@ pub struct LockOwner { pub start_time: u64, } -impl fmt::Display for LockOwner { +impl fmt::Display for Owner { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, " pid: {}", self.pid)?; writeln!(f, " process: {}", self.name)?; @@ -64,27 +62,27 @@ impl fmt::Display for LockOwner { /// Result of checking lock status. #[derive(Debug)] -pub enum LockStatus { +pub enum Status { /// Lock acquired successfully. - Acquired(LockGuard), + Acquired(Guard), /// Acquired lock is cyclic (same process). Cyclic, /// Lock is held by a different process. - LockedBy(LockOwner), + LockedBy(Owner), /// Lock is held, but the owner could not be identified. Unknown { pid: Option, reason: String }, } /// Last observed state while waiting for a lock takeover to complete. #[derive(Debug, Clone)] -pub enum LockObservation { +pub enum Observation { /// The lock is still held by an identifiable owner. - LockedBy(Box), + LockedBy(Box), /// The lock is still held, but owner metadata could not be resolved. Unknown { pid: Option, reason: String }, } -impl fmt::Display for LockObservation { +impl fmt::Display for Observation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::LockedBy(owner) => { @@ -106,33 +104,33 @@ impl fmt::Display for LockObservation { /// Error returned while terminating a lock owner or waiting for its lock to release. #[derive(Debug)] -pub enum LockTakeoverError { +pub enum TakeoverError { /// The previously observed owner exited before it could be signalled. - OwnerDisappeared { previous: Box }, + OwnerDisappeared { previous: Box }, /// The lock owner changed while takeover was in progress. OwnerChanged { - previous: Box, - current: Box, + previous: Box, + current: Box, }, /// Lock status became cyclic while waiting. Cyclic, /// The lock remained held by an unidentifiable owner. Unknown { pid: Option, reason: String }, /// Sending SIGTERM to the owner failed. - SignalFailed { owner: Box }, + SignalFailed { owner: Box }, /// SIGTERM is unavailable on this platform. - SignalUnsupported { owner: Box }, + SignalUnsupported { owner: Box }, /// The timeout elapsed before the lock could be acquired. Timeout { - previous: Box, + previous: Box, timeout: Duration, - last_observation: Box, + last_observation: Box, }, /// An underlying tsdl error occurred while taking over the lock. - Source(TsdlError), + Source(error::TsdlError), } -impl LockTakeoverError { +impl TakeoverError { /// Whether the caller should re-check lock status and retry the outer takeover loop. #[must_use] pub fn is_retryable(&self) -> bool { @@ -143,7 +141,7 @@ impl LockTakeoverError { } } -impl fmt::Display for LockTakeoverError { +impl fmt::Display for TakeoverError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::OwnerDisappeared { previous } => write!( @@ -189,16 +187,16 @@ impl fmt::Display for LockTakeoverError { } } -impl std::error::Error for LockTakeoverError {} +impl std::error::Error for TakeoverError {} -impl From for LockTakeoverError { - fn from(err: TsdlError) -> Self { +impl From for TakeoverError { + fn from(err: error::TsdlError) -> Self { Self::Source(err) } } -impl From for TsdlError { - fn from(err: LockTakeoverError) -> Self { +impl From for error::TsdlError { + fn from(err: TakeoverError) -> Self { Self::message(err.to_string()) } } @@ -209,18 +207,18 @@ impl From for TsdlError { /// intentionally left on disk because OS locks are tied to open file handles, /// not to path existence. #[derive(Debug)] -pub struct LockGuard { +pub struct Guard { file: File, lock_path: PathBuf, } -impl Drop for LockGuard { +impl Drop for Guard { fn drop(&mut self) { let _ = self.file.unlock(); } } -impl LockGuard { +impl Guard { /// Delete every entry in the build directory except the lock file itself /// and explicitly protected root-level files, such as the active log file. /// @@ -229,7 +227,7 @@ impl LockGuard { /// invalidating OS locks or unlinking files held open by the current process. pub fn clear_directory(&self, protected_files: &[PathBuf]) -> TsdlResult<()> { let build_dir = self.lock_path.parent().ok_or_else(|| { - TsdlError::message(format!( + error::TsdlError::message(format!( "Lock path has no parent directory: {}", self.lock_path.display() )) @@ -240,7 +238,7 @@ impl LockGuard { let lock_name = self .lock_path .file_name() - .map_or_else(|| OsString::from(TSDL_LOCK_FILE), OsString::from); + .map_or_else(|| OsString::from(consts::TSDL_LOCK_FILE), OsString::from); protected_names.insert(lock_name); for protected in protected_files { @@ -253,13 +251,13 @@ impl LockGuard { } for entry in fs::read_dir(build_dir).map_err(|e| { - TsdlError::context( + error::TsdlError::context( format!("Reading build directory {}", build_dir.display()), e, ) })? { let entry = entry.map_err(|e| { - TsdlError::context( + error::TsdlError::context( format!("Reading directory entry in {}", build_dir.display()), e, ) @@ -272,17 +270,17 @@ impl LockGuard { } let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|e| TsdlError::context(format!("Statting {}", path.display()), e))?; + let file_type = entry.file_type().map_err(|e| { + error::TsdlError::context(format!("Statting {}", path.display()), e) + })?; if file_type.is_dir() { fs::remove_dir_all(&path).map_err(|e| { - TsdlError::context(format!("Removing directory {}", path.display()), e) + error::TsdlError::context(format!("Removing directory {}", path.display()), e) })?; } else { fs::remove_file(&path).map_err(|e| { - TsdlError::context(format!("Removing file {}", path.display()), e) + error::TsdlError::context(format!("Removing file {}", path.display()), e) })?; } } @@ -302,19 +300,19 @@ impl Lock { #[must_use] pub fn new(build_dir: &Path) -> Self { Self { - lock_path: build_dir.join(TSDL_LOCK_FILE), + lock_path: build_dir.join(consts::TSDL_LOCK_FILE), current_pid: Pid::from(process::id() as usize), } } /// Check lock status and acquire the OS lock if available. - pub fn try_acquire(&self) -> TsdlResult { + pub fn try_acquire(&self) -> TsdlResult { let file = self.open_lock_file()?; match file.try_lock_exclusive() { - Ok(()) => self.activate(file).map(LockStatus::Acquired), + Ok(()) => self.activate(file).map(Status::Acquired), Err(err) if is_lock_contention(&err) => Ok(self.lock_status()), - Err(err) => Err(TsdlError::context( + Err(err) => Err(error::TsdlError::context( format!("Acquiring build lock {}", self.lock_path.display()), err, )), @@ -322,26 +320,24 @@ impl Lock { } /// Send SIGTERM to the process that held the lock when `owner` was captured. - pub fn terminate_owner(&self, owner: &LockOwner) -> Result<(), LockTakeoverError> { + pub fn terminate_owner(&self, owner: &Owner) -> Result<(), TakeoverError> { info!( "Sending SIGTERM to lock owner PID {} ({})", owner.pid, owner.name ); let system = Self::system_for_pid(owner.pid); - let process = - system - .process(owner.pid) - .ok_or_else(|| LockTakeoverError::OwnerDisappeared { - previous: Box::new(owner.clone()), - })?; + let process = system + .process(owner.pid) + .ok_or_else(|| TakeoverError::OwnerDisappeared { + previous: Box::new(owner.clone()), + })?; if process.start_time() != owner.start_time { - let current = Self::owner_for_pid(owner.pid).ok_or_else(|| { - LockTakeoverError::OwnerDisappeared { + let current = + Self::owner_for_pid(owner.pid).ok_or_else(|| TakeoverError::OwnerDisappeared { previous: Box::new(owner.clone()), - } - })?; - return Err(LockTakeoverError::OwnerChanged { + })?; + return Err(TakeoverError::OwnerChanged { previous: Box::new(owner.clone()), current: Box::new(current), }); @@ -352,10 +348,10 @@ impl Lock { info!("Sent SIGTERM to lock owner PID {}", owner.pid); Ok(()) } - Some(false) => Err(LockTakeoverError::SignalFailed { + Some(false) => Err(TakeoverError::SignalFailed { owner: Box::new(owner.clone()), }), - None => Err(LockTakeoverError::SignalUnsupported { + None => Err(TakeoverError::SignalUnsupported { owner: Box::new(owner.clone()), }), } @@ -368,9 +364,9 @@ impl Lock { /// are reported explicitly instead of waiting on stale information. pub fn wait_for_release( &self, - owner: &LockOwner, + owner: &Owner, timeout: Duration, - ) -> Result { + ) -> Result { info!( "Waiting up to {} for lock release from PID {}", crate::format_duration(timeout), @@ -379,12 +375,12 @@ impl Lock { let deadline = Instant::now() + timeout; let mut delay = Duration::from_millis(50); - let mut last_observation = LockObservation::LockedBy(Box::new(owner.clone())); + let mut last_observation = Observation::LockedBy(Box::new(owner.clone())); loop { let now = Instant::now(); if now >= deadline { - return Err(LockTakeoverError::Timeout { + return Err(TakeoverError::Timeout { previous: Box::new(owner.clone()), timeout, last_observation: Box::new(last_observation), @@ -392,30 +388,30 @@ impl Lock { } match self.try_acquire()? { - LockStatus::Acquired(guard) => return Ok(guard), + Status::Acquired(guard) => return Ok(guard), - LockStatus::Cyclic => return Err(LockTakeoverError::Cyclic), + Status::Cyclic => return Err(TakeoverError::Cyclic), - LockStatus::LockedBy(current) => { + Status::LockedBy(current) => { if same_owner(¤t, owner) { - last_observation = LockObservation::LockedBy(Box::new(current)); + last_observation = Observation::LockedBy(Box::new(current)); } else { - return Err(LockTakeoverError::OwnerChanged { + return Err(TakeoverError::OwnerChanged { previous: Box::new(owner.clone()), current: Box::new(current), }); } } - LockStatus::Unknown { pid, reason } => { + Status::Unknown { pid, reason } => { // If metadata now points at a different PID that we cannot inspect, // stop waiting on the previous owner. Otherwise keep polling until // timeout so transient metadata reads or inherited lock handles have // time to settle. if pid.is_some_and(|pid| pid != owner.pid) { - return Err(LockTakeoverError::Unknown { pid, reason }); + return Err(TakeoverError::Unknown { pid, reason }); } - last_observation = LockObservation::Unknown { pid, reason }; + last_observation = Observation::Unknown { pid, reason }; } } @@ -424,10 +420,10 @@ impl Lock { delay = delay.saturating_mul(2).min(Duration::from_millis(500)); } } - fn activate(&self, mut file: File) -> TsdlResult { + fn activate(&self, mut file: File) -> TsdlResult { self.write_metadata(&mut file)?; info!("Acquired lock on build directory"); - Ok(LockGuard { + Ok(Guard { file, lock_path: self.lock_path.clone(), }) @@ -436,7 +432,10 @@ impl Lock { fn open_lock_file(&self) -> TsdlResult { if let Some(parent) = self.lock_path.parent() { fs::create_dir_all(parent).map_err(|e| { - TsdlError::context(format!("Creating build directory {}", parent.display()), e) + error::TsdlError::context( + format!("Creating build directory {}", parent.display()), + e, + ) })?; } @@ -447,16 +446,19 @@ impl Lock { .truncate(false) .open(&self.lock_path) .map_err(|e| { - TsdlError::context(format!("Opening lock file {}", self.lock_path.display()), e) + error::TsdlError::context( + format!("Opening lock file {}", self.lock_path.display()), + e, + ) }) } /// Helper for checking process status and determining lock conflicts. - fn lock_status(&self) -> LockStatus { + fn lock_status(&self) -> Status { let lock_pid = match self.read_pid() { Ok(pid) => pid, Err(err) => { - return LockStatus::Unknown { + return Status::Unknown { pid: None, reason: format!("lock is held, but owner metadata could not be read: {err}"), }; @@ -464,12 +466,12 @@ impl Lock { }; if lock_pid == self.current_pid { - return LockStatus::Cyclic; + return Status::Cyclic; } match Self::owner_for_pid(lock_pid) { - Some(owner) => LockStatus::LockedBy(owner), - None => LockStatus::Unknown { + Some(owner) => Status::LockedBy(owner), + None => Status::Unknown { pid: Some(lock_pid), reason: "lock is held, but the metadata PID is not running or cannot be inspected" .to_string(), @@ -477,11 +479,11 @@ impl Lock { } } - fn owner_for_pid(pid: Pid) -> Option { + fn owner_for_pid(pid: Pid) -> Option { let system = Self::system_for_pid(pid); let process = system.process(pid)?; - Some(LockOwner { + Some(Owner { pid: process.pid(), name: process.name().to_string_lossy().to_string(), command: command_line(process.cmd()), @@ -495,11 +497,11 @@ impl Lock { fn read_pid(&self) -> TsdlResult { let content = fs::read_to_string(&self.lock_path).map_err(|e| { - TsdlError::context(format!("Reading lock file {}", self.lock_path.display()), e) + error::TsdlError::context(format!("Reading lock file {}", self.lock_path.display()), e) })?; let pid: usize = content.trim().parse().map_err(|_| { - TsdlError::message(format!( + error::TsdlError::message(format!( "Invalid PID '{}' in lock file {}", content.trim(), self.lock_path.display() @@ -525,16 +527,16 @@ impl Lock { fn write_metadata(&self, file: &mut File) -> TsdlResult<()> { file.set_len(0).map_err(|e| { - TsdlError::context( + error::TsdlError::context( format!("Truncating lock file {}", self.lock_path.display()), e, ) })?; file.seek(SeekFrom::Start(0)).map_err(|e| { - TsdlError::context(format!("Seeking lock file {}", self.lock_path.display()), e) + error::TsdlError::context(format!("Seeking lock file {}", self.lock_path.display()), e) })?; write!(file, "{}", self.current_pid.as_u32()).map_err(|e| { - TsdlError::context( + error::TsdlError::context( format!( "Writing lock file {} with PID {}", self.lock_path.display(), @@ -544,7 +546,7 @@ impl Lock { ) })?; file.sync_all().map_err(|e| { - TsdlError::context(format!("Syncing lock file {}", self.lock_path.display()), e) + error::TsdlError::context(format!("Syncing lock file {}", self.lock_path.display()), e) }) } } @@ -562,7 +564,7 @@ fn command_line(cmd: &[std::ffi::OsString]) -> Option { ) } -fn same_owner(current: &LockOwner, previous: &LockOwner) -> bool { +fn same_owner(current: &Owner, previous: &Owner) -> bool { current.pid == previous.pid && current.start_time == previous.start_time } @@ -573,7 +575,6 @@ fn is_lock_contention(err: &io::Error) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::consts::TSDL_LOCK_FILE; fn unused_pid() -> usize { let system = System::new_all(); @@ -585,14 +586,14 @@ mod tests { #[test] fn stale_lock_metadata_does_not_prevent_acquiring_free_os_lock() { let temp = tempfile::tempdir().unwrap(); - let lock_file = temp.path().join(TSDL_LOCK_FILE); + let lock_file = temp.path().join(consts::TSDL_LOCK_FILE); fs::write(&lock_file, unused_pid().to_string()).unwrap(); let lock = Lock::new(temp.path()); let status = lock.try_acquire().unwrap(); assert!( - matches!(status, LockStatus::Acquired(_)), + matches!(status, Status::Acquired(_)), "stale lock metadata should not block acquiring a free OS lock" ); } diff --git a/src/logging.rs b/src/logging.rs index 211f15c..831c2cf 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -9,27 +9,21 @@ use tracing_appender::non_blocking::WorkerGuard; use tracing_log::AsTrace; use tracing_subscriber::{layer::SubscriberExt, Layer}; -use crate::{ - absolute_normalize, - args::LogColor, - consts::{TSDL_CACHE_FILE, TSDL_LOCK_FILE, TSDL_LOG_FILE}, - error::TsdlError, - TsdlResult, -}; +use crate::{absolute_normalize, args, consts, error, TsdlResult}; #[allow(dead_code)] pub struct Guard(WorkerGuard); pub fn init( log: Option, - log_color: LogColor, + log_color: args::LogColor, verbose: clap_verbosity_flag::Verbosity, build_dir: &Path, ) -> TsdlResult<(PathBuf, Guard)> { let color = match log_color { - LogColor::Auto => atty::is(atty::Stream::Stdout), - LogColor::No => false, - LogColor::Yes => true, + args::LogColor::Auto => atty::is(atty::Stream::Stdout), + args::LogColor::No => false, + args::LogColor::Yes => true, }; console::set_colors_enabled(color); let filter = verbose.log_level_filter().as_trace(); @@ -78,7 +72,7 @@ fn init_tracing( } fn resolve_log_path(log: Option<&PathBuf>, build_dir: &Path) -> TsdlResult { - let log = log.map_or_else(|| build_dir.join(TSDL_LOG_FILE), Clone::clone); + let log = log.map_or_else(|| build_dir.join(consts::TSDL_LOG_FILE), Clone::clone); validate_log_path(build_dir, &log) } @@ -88,14 +82,14 @@ fn validate_log_path(build_dir: &Path, log: &Path) -> TsdlResult { let log = absolute_normalize(log)?; if log == build_dir { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "--log must be a file path, not the build directory {}", build_dir.display() ))); } if log.is_dir() { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "--log must be a file path, not a directory: {}", log.display() ))); @@ -103,7 +97,7 @@ fn validate_log_path(build_dir: &Path, log: &Path) -> TsdlResult { if log.starts_with(&build_dir) { let relative = log.strip_prefix(&build_dir).map_err(|e| { - TsdlError::message(format!( + error::TsdlError::message(format!( "Could not compare log path {} with build directory {}: {e}", log.display(), build_dir.display() @@ -112,7 +106,7 @@ fn validate_log_path(build_dir: &Path, log: &Path) -> TsdlResult { let component_count = relative.components().count(); if component_count != 1 { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", log.display(), build_dir.display() @@ -120,14 +114,15 @@ fn validate_log_path(build_dir: &Path, log: &Path) -> TsdlResult { } let Some(name) = relative.file_name() else { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "--log must be a file path: {}", log.display() ))); }; - if name == OsStr::new(TSDL_LOCK_FILE) || name == OsStr::new(TSDL_CACHE_FILE) { - return Err(TsdlError::message(format!( + if name == OsStr::new(consts::TSDL_LOCK_FILE) || name == OsStr::new(consts::TSDL_CACHE_FILE) + { + return Err(error::TsdlError::message(format!( "--log path {} conflicts with a tsdl runtime/build file", log.display() ))); @@ -140,7 +135,8 @@ fn validate_log_path(build_dir: &Path, log: &Path) -> TsdlResult { fn open_log_file(log: &Path) -> TsdlResult { let parent = log.parent().unwrap_or(Path::new(".")); if !parent.exists() { - fs::create_dir_all(parent).map_err(|e| TsdlError::context("Preparing log directory", e))?; + fs::create_dir_all(parent) + .map_err(|e| error::TsdlError::context("Preparing log directory", e))?; } - File::create(log).map_err(|e| TsdlError::context("Creating log file", e)) + File::create(log).map_err(|e| error::TsdlError::context("Creating log file", e)) } diff --git a/src/parser.rs b/src/parser.rs index 7920ef8..faed235 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -17,11 +17,7 @@ use crate::args::TreeSitter; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use crate::{ - actors::ProgressAddr, - build::{BuildContext, BuildSpec, OutputConfig}, - cache::{self, CacheDecision, CacheKey, Entry, GrammarHash, Update}, - error::{self, TsdlError}, - git, + actors, build, cache, error, git, sh::{Exec, Script}, shutdown, walk::collect_grammar_paths, @@ -232,23 +228,23 @@ fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { /// A grammar ready to be built, combining definition and cache state #[derive(Clone, Debug)] pub struct GrammarBuild { - pub context: BuildContext, - pub cache_decision: CacheDecision, + pub context: build::Context, + pub cache_decision: cache::Decision, pub dir: Arc, - pub hash: GrammarHash, + pub hash: cache::GrammarHash, pub language: LanguageName, // Required for error reporting and cache keys; set from parent LanguageBuild pub name: GrammarName, - pub output: OutputConfig, - pub progress: ProgressAddr, // Use language's handle + pub output: build::OutputConfig, + pub progress: actors::ProgressAddr, // Use language's handle pub source: cache::Source, - pub spec: Arc, + pub spec: Arc, pub ts_cli: Arc, } impl GrammarBuild { /// Build this grammar, returning a cache update if it was built. /// Uses the language's progress handle for progress reporting. - pub async fn build(&self) -> TsdlResult> { + pub async fn build(&self) -> TsdlResult> { shutdown::test_delay().await; shutdown::check()?; debug!( @@ -257,7 +253,7 @@ impl GrammarBuild { ); self.progress.step("checking cache"); - let key = CacheKey::new(&self.language, &self.name); + let key = cache::Key::new(&self.language, &self.name); // Cache decisions are computed centrally by the cache actor, including // verification that all required managed artifacts exist. @@ -280,7 +276,7 @@ impl GrammarBuild { // Use the grammar directory path provided if !self.dir.exists() { - let err = TsdlError::message(format!( + let err = error::TsdlError::message(format!( "Grammar directory not found: {}", self.dir.display() )); @@ -299,9 +295,9 @@ impl GrammarBuild { } // Return cache update for this grammar - let update = Update { + let update = cache::Update { name: key, - entry: Entry { + entry: cache::Entry { hash: self.hash.clone(), spec: self.spec.clone(), source: self.source.clone(), @@ -392,7 +388,7 @@ impl GrammarBuild { Ok(artifact) } - fn build_step_error(&self, err: TsdlError) -> TsdlError { + fn build_step_error(&self, err: error::TsdlError) -> error::TsdlError { error::TsdlError::Step(error::Step::new( self.language.as_arc(), error::ParserOp::Build { @@ -416,7 +412,7 @@ impl GrammarBuild { async fn create_hardlink(&self, src: &Path, dst: &Path) -> TsdlResult<()> { fs::hard_link(src, dst).await.map_err(|e| { - TsdlError::context( + error::TsdlError::context( format!( "Could not hardlink {} to {}. build-dir and out-dir must be on the same filesystem", src.display(), @@ -431,7 +427,7 @@ impl GrammarBuild { let ext = kind.extension(); let expected_name = self.parser_name_and_ext(kind); let mut files = fs::read_dir(self.dir.as_ref()).await.map_err(|e| { - TsdlError::context( + error::TsdlError::context( format!("Failed to read directory {}", self.dir.display()), e, ) @@ -442,7 +438,7 @@ impl GrammarBuild { loop { let Some(entry) = files.next_entry().await.map_err(|e| { - TsdlError::context( + error::TsdlError::context( format!("Failed to read directory entry in {}", self.dir.display()), e, ) @@ -453,7 +449,7 @@ impl GrammarBuild { let path = entry.path(); let file_type = entry.file_type().await.map_err(|e| { - TsdlError::context( + error::TsdlError::context( format!("Failed to read file type for {}", path.display()), e, ) @@ -520,7 +516,7 @@ impl GrammarBuild { let dst = self.output.out_dir.join(self.parser_name_and_ext(kind)); let src_metadata = fs::metadata(&src) .await - .map_err(|e| TsdlError::context(format!("Reading {}", src.display()), e))?; + .map_err(|e| error::TsdlError::context(format!("Reading {}", src.display()), e))?; let dst_link_metadata = match fs::symlink_metadata(&dst).await { Ok(metadata) => metadata, @@ -529,7 +525,7 @@ impl GrammarBuild { return Ok(()); } Err(err) => { - return Err(TsdlError::context( + return Err(error::TsdlError::context( format!("Reading {}", dst.display()), err, )); @@ -550,7 +546,7 @@ impl GrammarBuild { let dst_file_type = dst_link_metadata.file_type(); if dst_file_type.is_dir() { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "Output path is a directory and cannot be replaced: {}", dst.display() ))); @@ -558,7 +554,7 @@ impl GrammarBuild { if dst_file_type.is_symlink() { if !self.context.overwrite_output { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "Output path is a symlink and will not be replaced without --force: {}", dst.display() ))); @@ -570,7 +566,7 @@ impl GrammarBuild { } if !dst_file_type.is_file() { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "Output path is not a regular file and cannot be replaced: {}", dst.display() ))); @@ -578,7 +574,7 @@ impl GrammarBuild { let dst_metadata = fs::metadata(dst) .await - .map_err(|e| TsdlError::context(format!("Reading {}", dst.display()), e))?; + .map_err(|e| error::TsdlError::context(format!("Reading {}", dst.display()), e))?; if same_file_identity(src_metadata, &dst_metadata) { return Ok(()); @@ -588,7 +584,7 @@ impl GrammarBuild { same_regular_file_contents(src, src_metadata, dst, &dst_metadata).await?; if !same_contents && !self.context.overwrite_output { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "Output already exists and differs from the built parser: {}. Use --force to replace it.", dst.display() ))); @@ -603,10 +599,10 @@ impl GrammarBuild { ensure_parent_dir(dst).await?; let src_metadata = fs::metadata(src) .await - .map_err(|e| TsdlError::context(format!("Reading {}", src.display()), e))?; + .map_err(|e| error::TsdlError::context(format!("Reading {}", src.display()), e))?; if !src_metadata.is_file() { - return Err(TsdlError::message(format!( + return Err(error::TsdlError::message(format!( "Discovered parser artifact is not a regular file: {}", src.display() ))); @@ -617,7 +613,7 @@ impl GrammarBuild { Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} Err(err) => { - return Err(TsdlError::context( + return Err(error::TsdlError::context( format!("Reading {}", dst.display()), err, )); @@ -630,7 +626,7 @@ impl GrammarBuild { pub fn required_artifacts_for( build_dir: &Path, ts_cli: &Path, - spec: &BuildSpec, + spec: &build::Spec, grammar_name: &GrammarName, ) -> TsdlResult> { let mut artifacts = Vec::new(); @@ -674,7 +670,7 @@ impl GrammarBuild { if let Err(err) = fs::rename(&tmp, dst).await { let _ = fs::remove_file(&tmp).await; - return Err(TsdlError::context( + return Err(error::TsdlError::context( format!("Installing {} to {}", src.display(), dst.display()), err, )); @@ -682,25 +678,25 @@ impl GrammarBuild { Ok(()) } - fn missing_parser_error(&self, ext: &str) -> TsdlError { + fn missing_parser_error(&self, ext: &str) -> error::TsdlError { error::TsdlError::Step(error::Step::new( self.language.as_arc(), error::ParserOp::Copy { src: self.output.out_dir.to_path_buf(), dst: self.output.build_dir.to_path_buf(), }, - TsdlError::message(format!("Couldn't find any {ext} file")), + error::TsdlError::message(format!("Couldn't find any {ext} file")), )) } - fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> TsdlError { + fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> error::TsdlError { error::TsdlError::Step(error::Step::new( self.language.as_arc(), error::ParserOp::Copy { src: self.output.out_dir.to_path_buf(), dst: self.output.build_dir.to_path_buf(), }, - TsdlError::message(format!("Found multiple {ext} files: {candidates:?}")), + error::TsdlError::message(format!("Found multiple {ext} files: {candidates:?}")), )) } @@ -711,19 +707,19 @@ impl GrammarBuild { #[derive(Clone, Debug)] pub struct LanguageBuild { - pub context: BuildContext, - pub spec: Arc, + pub context: build::Context, + pub spec: Arc, pub name: LanguageName, - pub output: OutputConfig, + pub output: build::OutputConfig, } impl LanguageBuild { #[must_use] pub fn new( - context: BuildContext, - spec: Arc, + context: build::Context, + spec: Arc, name: LanguageName, - output: OutputConfig, + output: build::OutputConfig, ) -> Self { Self { context, @@ -733,13 +729,15 @@ impl LanguageBuild { } } - pub async fn discover_grammars(&self) -> TsdlResult> { + pub async fn discover_grammars( + &self, + ) -> TsdlResult> { let file_results = collect_grammar_paths(self.output.build_dir.clone()).await?; let mut grammars = Vec::new(); for (grammar_path, hash) in file_results { let grammar_dir = grammar_path.parent().ok_or_else(|| { - TsdlError::Message(format!( + error::TsdlError::Message(format!( "Could not get parent directory for {}", grammar_path.display() )) @@ -789,7 +787,7 @@ fn parser_name_and_ext(prefix: &str, grammar_name: &GrammarName, kind: ArtifactK fn artifact_path_for( build_dir: &Path, ts_cli: &Path, - spec: &BuildSpec, + spec: &build::Spec, grammar_name: &GrammarName, kind: ArtifactKind, ) -> TsdlResult { @@ -803,7 +801,7 @@ fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> TsdlResult { .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| { - TsdlError::message(format!( + error::TsdlError::message(format!( "Could not derive artifact id from tree-sitter CLI path {}", ts_cli.display() )) @@ -833,7 +831,7 @@ fn sanitize_path_component(value: &str) -> String { async fn ensure_parent_dir(path: &Path) -> TsdlResult<()> { let parent = path.parent().ok_or_else(|| { - TsdlError::message(format!( + error::TsdlError::message(format!( "Could not determine parent directory for {}", path.display() )) @@ -841,18 +839,18 @@ async fn ensure_parent_dir(path: &Path) -> TsdlResult<()> { fs::create_dir_all(parent) .await - .map_err(|e| TsdlError::context(format!("Creating {}", parent.display()), e)) + .map_err(|e| error::TsdlError::context(format!("Creating {}", parent.display()), e)) } async fn verify_artifact(path: &Path) -> TsdlResult<()> { - let metadata = fs::metadata(path) - .await - .map_err(|e| TsdlError::context(format!("Reading built artifact {}", path.display()), e))?; + let metadata = fs::metadata(path).await.map_err(|e| { + error::TsdlError::context(format!("Reading built artifact {}", path.display()), e) + })?; if metadata.is_file() { Ok(()) } else { - Err(TsdlError::message(format!( + Err(error::TsdlError::message(format!( "Built artifact is not a regular file: {}", path.display() ))) @@ -880,7 +878,7 @@ async fn same_regular_file_contents( fn temp_install_path(dst: &Path) -> TsdlResult { let file_name = dst.file_name().ok_or_else(|| { - TsdlError::message(format!( + error::TsdlError::message(format!( "Could not create temporary install path for {}", dst.display() )) @@ -888,7 +886,7 @@ fn temp_install_path(dst: &Path) -> TsdlResult { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| { - TsdlError::message(format!( + error::TsdlError::message(format!( "System clock is before UNIX epoch while creating temporary install path: {e}" )) })? @@ -902,7 +900,9 @@ fn temp_install_path(dst: &Path) -> TsdlResult { fn extract_dir_name(dir: &Path) -> TsdlResult { dir.file_name() .map(|n| n.to_string_lossy().to_string()) - .ok_or_else(|| TsdlError::Message(format!("Could not get dir name for {}", dir.display()))) + .ok_or_else(|| { + error::TsdlError::Message(format!("Could not get dir name for {}", dir.display())) + }) } /// Extract grammar name from directory (strips "tree-sitter-" prefix if present) @@ -973,19 +973,19 @@ mod tests { .await; let source_ref = Ref::parse("v1.0.0").unwrap(); let build = GrammarBuild { - context: BuildContext { overwrite_output }, - cache_decision: CacheDecision::miss(cache::CacheMissReason::MissingEntry), + context: build::Context { overwrite_output }, + cache_decision: cache::Decision::miss(cache::MissReason::MissingEntry), dir: grammar_dir.clone().into(), hash: cache::GrammarHash::from("test"), language: "rust".into(), name: "rust".into(), - output: OutputConfig { + output: build::OutputConfig { build_dir: grammar_dir.into(), out_dir: out_dir.into(), }, progress, source: cache::Source::stable(), - spec: Arc::new(BuildSpec { + spec: Arc::new(build::Spec { build_script: None, git_ref: source_ref, prefix: String::new(), @@ -1016,13 +1016,13 @@ mod tests { #[test] fn test_cache_key_format() { - let key = CacheKey::new( + let key = cache::Key::new( &LanguageName::from("typescript"), &GrammarName::from("typescript"), ); assert_eq!(key.as_str(), "typescript/typescript"); - let key = CacheKey::new(&LanguageName::from("typescript"), &GrammarName::from("tsx")); + let key = cache::Key::new(&LanguageName::from("typescript"), &GrammarName::from("tsx")); assert_eq!(key.as_str(), "typescript/tsx"); } @@ -1071,7 +1071,11 @@ mod tests { #[test] fn test_parser_name_with_prefix() { - let name = parser_name_and_ext("lib", &GrammarName::from("typescript"), ArtifactKind::Native); + let name = parser_name_and_ext( + "lib", + &GrammarName::from("typescript"), + ArtifactKind::Native, + ); assert_eq!(name, format!("libtypescript.{DLL_EXTENSION}")); } diff --git a/src/shutdown.rs b/src/shutdown.rs index c8bfa9f..f62bd18 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -12,7 +12,7 @@ use tracing::{debug, info}; use crate::{error::TsdlError, TsdlResult}; tokio::task_local! { - static CURRENT_SHUTDOWN: Shutdown; + static CURRENT_SHUTDOWN: Handle; } /// Test helper: when `TSDL_TEST_DELAY_MS` is set, inserts a sleep at every step @@ -37,12 +37,12 @@ pub async fn test_delay() { /// A Unix signal that requested shutdown. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ShutdownSignal { +pub struct Signal { pub number: i32, pub name: &'static str, } -impl ShutdownSignal { +impl Signal { pub const HUP: Self = Self { number: libc::SIGHUP, name: "SIGHUP", @@ -71,7 +71,7 @@ impl ShutdownSignal { } } -impl fmt::Display for ShutdownSignal { +impl fmt::Display for Signal { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.name) } @@ -137,9 +137,9 @@ impl std::error::Error for PgIdError {} /// Cooperative shutdown signal shared across build tasks. #[derive(Clone, Debug)] -pub struct Shutdown { - tx: watch::Sender>, - rx: watch::Receiver>, +pub struct Handle { + tx: watch::Sender>, + rx: watch::Receiver>, /// Active child process group IDs. Build commands are put in their own /// process groups so a signal can target each command's whole subprocess tree. active_pgids: Arc>>, @@ -155,7 +155,7 @@ pub struct Shutdown { /// later shutdown escalation. #[derive(Debug)] pub struct PgIdGuard { - shutdown: Shutdown, + shutdown: Handle, pgid: PgId, } @@ -165,7 +165,7 @@ impl Drop for PgIdGuard { } } -impl Shutdown { +impl Handle { #[must_use] pub fn new() -> Self { let (tx, rx) = watch::channel(None); @@ -177,7 +177,7 @@ impl Shutdown { } /// Record shutdown and forward the received signal to all active children. - pub fn cancel_with_signal(&self, signal: ShutdownSignal) { + pub fn cancel_with_signal(&self, signal: Signal) { if self.reason().is_none() { let _ = self.tx.send(Some(signal)); } @@ -222,11 +222,11 @@ impl Shutdown { } #[must_use] - pub fn reason(&self) -> Option { + pub fn reason(&self) -> Option { *self.rx.borrow() } - pub async fn cancelled(&self) -> ShutdownSignal { + pub async fn cancelled(&self) -> Signal { if let Some(signal) = self.reason() { return signal; } @@ -234,7 +234,7 @@ impl Shutdown { let mut rx = self.rx.clone(); loop { if rx.changed().await.is_err() { - return std::future::pending::().await; + return std::future::pending::().await; } if let Some(signal) = *rx.borrow() { return signal; @@ -251,14 +251,14 @@ impl Shutdown { } /// Send a signal to every active child process group. - pub fn signal_children(&self, signal: ShutdownSignal) { + pub fn signal_children(&self, signal: Signal) { for pgid in self.active_pgids() { self.signal_pgid(pgid, signal); } } /// Send a signal to one child process group. - pub fn signal_pgid(&self, pgid: PgId, signal: ShutdownSignal) { + pub fn signal_pgid(&self, pgid: PgId, signal: Signal) { info!("SHUTDOWN sending {} to pgid={pgid}", signal.name); signal_process_group(pgid, signal); } @@ -273,7 +273,7 @@ impl Shutdown { /// Send SIGKILL to one child process group. pub fn kill_pgid(&self, pgid: PgId) { info!("SHUTDOWN killing pgid={pgid}"); - signal_process_group(pgid, ShutdownSignal::KILL); + signal_process_group(pgid, Signal::KILL); } fn active_pgids(&self) -> Vec { @@ -291,13 +291,13 @@ impl Shutdown { use tokio::signal::unix::{signal, SignalKind}; - let mut sighup = signal(SignalKind::from_raw(ShutdownSignal::HUP.number)) + let mut sighup = signal(SignalKind::from_raw(Signal::HUP.number)) .map_err(|e| TsdlError::context("Installing SIGHUP handler", e))?; - let mut sigint = signal(SignalKind::from_raw(ShutdownSignal::INT.number)) + let mut sigint = signal(SignalKind::from_raw(Signal::INT.number)) .map_err(|e| TsdlError::context("Installing SIGINT handler", e))?; - let mut sigquit = signal(SignalKind::from_raw(ShutdownSignal::QUIT.number)) + let mut sigquit = signal(SignalKind::from_raw(Signal::QUIT.number)) .map_err(|e| TsdlError::context("Installing SIGQUIT handler", e))?; - let mut sigterm = signal(SignalKind::from_raw(ShutdownSignal::TERM.number)) + let mut sigterm = signal(SignalKind::from_raw(Signal::TERM.number)) .map_err(|e| TsdlError::context("Installing SIGTERM handler", e))?; let shutdown = self.clone(); @@ -306,10 +306,10 @@ impl Shutdown { loop { let signal = tokio::select! { - s = sighup.recv() => s.map(|()| ShutdownSignal::HUP), - s = sigint.recv() => s.map(|()| ShutdownSignal::INT), - s = sigquit.recv() => s.map(|()| ShutdownSignal::QUIT), - s = sigterm.recv() => s.map(|()| ShutdownSignal::TERM), + s = sighup.recv() => s.map(|()| Signal::HUP), + s = sigint.recv() => s.map(|()| Signal::INT), + s = sigquit.recv() => s.map(|()| Signal::QUIT), + s = sigterm.recv() => s.map(|()| Signal::TERM), }; let Some(signal) = signal else { @@ -333,13 +333,13 @@ impl Shutdown { } } -impl Default for Shutdown { +impl Default for Handle { fn default() -> Self { Self::new() } } -fn signal_process_group(pgid: PgId, signal: ShutdownSignal) { +fn signal_process_group(pgid: PgId, signal: Signal) { let rc = unsafe { libc::killpg(pgid.as_pid_t(), signal.number) }; if rc != 0 { debug!( @@ -350,7 +350,7 @@ fn signal_process_group(pgid: PgId, signal: ShutdownSignal) { } } -pub async fn scope(shutdown: Shutdown, future: F) -> F::Output +pub async fn scope(shutdown: Handle, future: F) -> F::Output where F: Future, { @@ -358,12 +358,12 @@ where } #[must_use] -pub fn current() -> Option { +pub fn current() -> Option { CURRENT_SHUTDOWN.try_with(Clone::clone).ok() } #[must_use] -pub fn current_or_default() -> Shutdown { +pub fn current_or_default() -> Handle { current().unwrap_or_default() } @@ -371,10 +371,10 @@ pub fn check() -> TsdlResult<()> { current().map_or(Ok(()), |shutdown| shutdown.check()) } -pub async fn cancelled() -> ShutdownSignal { +pub async fn cancelled() -> Signal { match current() { Some(shutdown) => shutdown.cancelled().await, - None => std::future::pending::().await, + None => std::future::pending::().await, } } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 562cc87..07f5e32 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -8,34 +8,34 @@ use tokio::{fs, io, process::Command}; use tracing::{debug, info, trace}; use url::Url; -use crate::actors::{DisplayAddr, ProgressAddr}; -use crate::args::TreeSitter; -use crate::git::{self, Ref, ResolvedRef, Sha}; +use crate::actors; +use crate::args; +use crate::git; use crate::sh::Exec; use crate::shutdown; use crate::SafeCanonicalize; -use crate::{error::TsdlError, TsdlResult}; +use crate::{error, TsdlResult}; #[derive(Debug, Clone)] pub struct PreparedCli { pub path: PathBuf, - pub tree_sitter: TreeSitter, + pub tree_sitter: args::TreeSitter, } async fn chmod_x(prog: &Path) -> TsdlResult<()> { - let metadata = fs::metadata(prog) - .await - .map_err(|e| TsdlError::context(format!("getting metadata for {}", prog.display()), e))?; + let metadata = fs::metadata(prog).await.map_err(|e| { + error::TsdlError::context(format!("getting metadata for {}", prog.display()), e) + })?; let mut permissions = metadata.permissions(); permissions.set_mode(permissions.mode() | 0o111); fs::set_permissions(prog, permissions) .await - .map_err(|e| TsdlError::context(format!("chmod +x {}", prog.display()), e)) + .map_err(|e| error::TsdlError::context(format!("chmod +x {}", prog.display()), e)) } async fn cli( build_dir: &PathBuf, - handle: &ProgressAddr, + handle: &actors::ProgressAddr, platform: &str, repo: &str, tag: &str, @@ -64,13 +64,13 @@ async fn cli( async fn resolve_release_tag( build_dir: &PathBuf, - handle: &ProgressAddr, + handle: &actors::ProgressAddr, repo: &str, - resolved_ref: &ResolvedRef, + resolved_ref: &git::ResolvedRef, ) -> TsdlResult { let tag = match resolved_ref { - ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), - ResolvedRef::Ref(git_ref) => { + git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), + git::ResolvedRef::Ref(git_ref) => { handle.msg(format!("resolving exact tag for {resolved_ref}")); let tree_sitter = PathBuf::new().join(build_dir).join("tree-sitter"); git::clone(repo, &tree_sitter).await?; @@ -85,13 +85,13 @@ async fn download(gz: &Path, url: &str) -> TsdlResult<()> { gz, reqwest::get(url) .await - .map_err(|e| TsdlError::context("fetch", e))? + .map_err(|e| error::TsdlError::context("fetch", e))? .bytes() .await - .map_err(|e| TsdlError::context("fetching bytes", e))?, + .map_err(|e| error::TsdlError::context("fetching bytes", e))?, ) .await - .map_err(|e| TsdlError::context(format!("downloading {url} to {}", gz.display()), e)) + .map_err(|e| error::TsdlError::context(format!("downloading {url} to {}", gz.display()), e)) } async fn download_and_extract(gz: &Path, url: &str, res: &Path) -> TsdlResult<()> { @@ -100,22 +100,22 @@ async fn download_and_extract(gz: &Path, url: &str, res: &Path) -> TsdlResult<() chmod_x(res).await?; fs::remove_file(gz) .await - .map_err(|e| TsdlError::context(format!("removing {}", gz.display()), e))?; + .map_err(|e| error::TsdlError::context(format!("removing {}", gz.display()), e))?; Ok(()) } fn find_tag( refs: &HashMap, version: &str, -) -> Result { +) -> Result { refs.get_key_value(&format!("v{version}")) .or_else(|| refs.get_key_value(version)) .map_or_else( - || Ref::new(normalize_release_ref(version)).map(ResolvedRef::Ref), + || git::Ref::new(normalize_release_ref(version)).map(git::ResolvedRef::Ref), |(k, v)| { trace!("Found! {k} -> {v}"); - Ok(ResolvedRef::Tag { - sha: Sha::new(v.as_str())?, + Ok(git::ResolvedRef::Tag { + sha: git::Sha::new(v.as_str())?, label: k.clone(), }) }, @@ -130,7 +130,7 @@ fn is_dotted_numeric_version(value: &str) -> bool { } fn normalize_release_ref(value: &str) -> String { - if Sha::is_full_sha(value) || value.starts_with('v') { + if git::Sha::is_full_sha(value) || value.starts_with('v') { value.to_string() } else if is_dotted_numeric_version(value) { format!("v{value}") @@ -142,18 +142,18 @@ fn normalize_release_ref(value: &str) -> String { async fn gunzip(gz: &Path, to: &Path) -> TsdlResult<()> { let file = fs::File::open(gz) .await - .map_err(|e| TsdlError::context(format!("opening {}", gz.display()), e))?; + .map_err(|e| error::TsdlError::context(format!("opening {}", gz.display()), e))?; let mut decompressor = GzipDecoder::new(tokio::io::BufReader::new(file)); // let path = gz.with_extension(""); let mut file = tokio::fs::File::create(to) .await - .map_err(|e| TsdlError::context(format!("creating {}", to.display()), e))?; + .map_err(|e| error::TsdlError::context(format!("creating {}", to.display()), e))?; io::copy(&mut decompressor, &mut file) .await .and(Ok(())) - .map_err(|e| TsdlError::context(format!("decompressing {}", gz.display()), e)) + .map_err(|e| error::TsdlError::context(format!("decompressing {}", gz.display()), e)) } fn parse_refs(stdout: &str) -> HashMap { @@ -174,8 +174,8 @@ fn parse_refs(stdout: &str) -> HashMap { pub async fn prepare( build_dir: &PathBuf, - display: DisplayAddr, - tree_sitter: &TreeSitter, + display: actors::DisplayAddr, + tree_sitter: &args::TreeSitter, ) -> TsdlResult { shutdown::test_delay().await; shutdown::check()?; @@ -190,7 +190,7 @@ pub async fn prepare( .await; let repo = Url::parse(&tree_sitter.repo) - .map_err(|e| TsdlError::context("Parsing the tree-sitter URL", e))?; + .map_err(|e| error::TsdlError::context("Parsing the tree-sitter URL", e))?; let git_ref = &tree_sitter.version; progress.step(format!("resolving {git_ref}")); @@ -242,7 +242,7 @@ pub async fn prepare( Ok(PreparedCli { path: cli, - tree_sitter: TreeSitter { + tree_sitter: args::TreeSitter { version: release_tag, platform: tree_sitter.platform.clone(), repo: tree_sitter.repo.clone(), @@ -250,20 +250,21 @@ pub async fn prepare( }) } -pub(crate) fn display_tree_sitter_ref(version: &str) -> Result { - Ref::new(normalize_release_ref(version)) +pub(crate) fn display_tree_sitter_ref(version: &str) -> Result { + git::Ref::new(normalize_release_ref(version)) } #[allow(clippy::missing_panics_doc)] -pub async fn tag(repo: &str, version: &str) -> TsdlResult { +pub async fn tag(repo: &str, version: &str) -> TsdlResult { let output = Command::new("git") .args(["ls-remote", "--refs", "--tags", repo]) .exec() .await?; let stdout = String::from_utf8_lossy(&output.stdout); let refs = parse_refs(&stdout); - find_tag(&refs, version) - .map_err(|e| TsdlError::context(format!("Parsing tree-sitter git ref {version:?}"), e)) + find_tag(&refs, version).map_err(|e| { + error::TsdlError::context(format!("Parsing tree-sitter git ref {version:?}"), e) + }) } #[cfg(test)] @@ -297,11 +298,11 @@ mod tests { ); let tag = find_tag(&refs, "1.0.0").unwrap(); match tag { - ResolvedRef::Tag { sha, label } => { + git::ResolvedRef::Tag { sha, label } => { assert_eq!(sha.as_str(), "636801770eea172d140e64b691815ff11f6b556f"); assert_eq!(label, "v1.0.0"); } - ResolvedRef::Ref(_) => panic!("Expected ResolvedRef::Tag"), + git::ResolvedRef::Ref(_) => panic!("Expected git::ResolvedRef::Tag"), } } @@ -310,10 +311,10 @@ mod tests { let refs = HashMap::new(); let tag = find_tag(&refs, "1.0.0").unwrap(); match tag { - ResolvedRef::Ref(git_ref) => { + git::ResolvedRef::Ref(git_ref) => { assert_eq!(git_ref.as_str(), "v1.0.0"); } - ResolvedRef::Tag { .. } => panic!("Expected ResolvedRef::Ref"), + git::ResolvedRef::Tag { .. } => panic!("Expected git::ResolvedRef::Ref"), } } } diff --git a/src/walk.rs b/src/walk.rs index 30996ae..cba46d1 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -1,12 +1,11 @@ use std::{path::PathBuf, sync::Arc}; -use crate::{ - cache::{self, GrammarHash}, - git, shutdown, TsdlResult, -}; +use crate::{cache, git, shutdown, TsdlResult}; /// Collect grammar.js paths via git ls-files and compute their hashes. -pub async fn collect_grammar_paths(root: Arc) -> TsdlResult> { +pub async fn collect_grammar_paths( + root: Arc, +) -> TsdlResult> { let files = git::list_grammar_files(root.as_ref()).await?; let mut results = Vec::with_capacity(files.len()); diff --git a/tests/config.rs b/tests/config.rs index bb2b58a..ec3d714 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use tsdl::{ args::{self, BuildCommand, Target}, - config::{self, ConfigSource}, + config::{self, Source}, consts::{ TREE_SITTER_PLATFORM, TREE_SITTER_REPO, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_FRESH, TSDL_OUT_DIR, TSDL_PREFIX, TSDL_SHOW_CONFIG, @@ -225,7 +225,7 @@ fn negative_boolean_flag_overrides_positive() -> Result<()> { current_with_cli_provenance(&generated, &["tsdl", "build", "--force=true", "--no-force"]); assert!(!cmd.force); - assert_eq!(prov.force, ConfigSource::CommandLine); + assert_eq!(prov.force, Source::CommandLine); Ok(()) } @@ -243,7 +243,7 @@ fn env_can_override_config_to_builtin_default_value() -> Result<()> { let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; assert_eq!(cmd.prefix, TSDL_PREFIX); - assert_eq!(prov.prefix, ConfigSource::Environment); + assert_eq!(prov.prefix, Source::Environment); Ok(()) } @@ -261,7 +261,7 @@ fn boolean_env_can_override_config_file() -> Result<()> { let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; assert!(!cmd.force); - assert_eq!(prov.force, ConfigSource::Environment); + assert_eq!(prov.force, Source::Environment); Ok(()) } @@ -280,7 +280,7 @@ fn cli_has_precedence_over_env() -> Result<()> { let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; assert_eq!(cmd.target, Target::Native); - assert_eq!(prov.target, ConfigSource::CommandLine); + assert_eq!(prov.target, Source::CommandLine); Ok(()) } @@ -294,6 +294,6 @@ fn cli_explicit_default_value_overrides_config_file() -> Result<()> { current_with_cli_provenance(&generated, &["tsdl", "build", "--build-dir", "tmp"]); assert_eq!(cmd.build_dir, PathBuf::from("tmp")); - assert_eq!(prov.build_dir, ConfigSource::CommandLine); + assert_eq!(prov.build_dir, Source::CommandLine); Ok(()) } From 3ada11450383827f48ebf452449abfa3f5977acc Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Wed, 27 May 2026 20:19:24 +0200 Subject: [PATCH 42/88] all: refactor names --- CHANGELOG.md | 2 +- build.rs | 34 +-- src/actors/cache.rs | 8 +- src/actors/display.rs | 2 +- src/actors/mod.rs | 24 +- src/app.rs | 12 +- src/args.rs | 34 +-- src/build.rs | 73 +++--- src/cache.rs | 53 ++-- src/config.rs | 60 ++--- src/error.rs | 584 +++++++++++++++++------------------------- src/git.rs | 86 ++++--- src/lib.rs | 23 +- src/lock.rs | 144 +++++------ src/logging.rs | 79 +++--- src/main.rs | 9 +- src/parser.rs | 306 +++++++++++----------- src/selfupdate.rs | 54 ++-- src/sh.rs | 47 ++-- src/shutdown.rs | 21 +- src/tree_sitter.rs | 53 ++-- src/walk.rs | 4 +- tests/cmd/build.rs | 76 ++---- tests/cmd/cache.rs | 39 ++- tests/cmd/config.rs | 6 +- tests/cmd/log.rs | 8 +- tests/cmd/mod.rs | 4 +- tests/config.rs | 34 +-- 28 files changed, 845 insertions(+), 1034 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f01d00..8249087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ informative and more coherent. - **install**: Avoid replacing existing output files unless they are already the same file, have identical contents, or `--force` is used; replacements are now installed through visible temporary hardlinks. -- `--jobs` is a strictly positive numbet now. +- `--jobs` is a strictly positive number now. ## [2.0.0] - 2026-02-20 diff --git a/build.rs b/build.rs index 6a1536e..f6d0536 100644 --- a/build.rs +++ b/build.rs @@ -103,20 +103,20 @@ fn main() { let tsdl = meta.get("tsdl").expect("missing [metadata.tsdl]"); generate_consts!( Path::new(&out_dir).join("tsdl_consts.rs"), - TSDL_BIN_BUILD_DIR : str = expr(tsdl_bin_str), - TSDL_BUILD_DIR : str = json(tsdl, "build-dir"), - TSDL_CACHE_FILE : str = json(tsdl, "cache-file"), - TSDL_CONFIG_FILE : str = json(tsdl, "config-file"), - TSDL_FORCE : bool = json(tsdl, "force"), - TSDL_FRESH : bool = json(tsdl, "fresh"), - TSDL_FROM : str = json(tsdl, "from"), - TSDL_LOCK_FILE : str = json(tsdl, "lock-file"), - TSDL_LOG_FILE : str = json(tsdl, "log-file"), - TSDL_OUT_DIR : str = json(tsdl, "out-dir"), - TSDL_UNLOCK_TIMEOUT: u64 = json(tsdl, "unlock-timeout"), - TSDL_PREFIX : str = json(tsdl, "prefix"), - TSDL_REF : str = json(tsdl, "ref"), - TSDL_SHOW_CONFIG : bool = json(tsdl, "show-config"), + BIN_BUILD_DIR : str = expr(tsdl_bin_str), + BUILD_DIR : str = json(tsdl, "build-dir"), + CACHE_FILE : str = json(tsdl, "cache-file"), + CONFIG_FILE : str = json(tsdl, "config-file"), + FORCE : bool = json(tsdl, "force"), + FRESH : bool = json(tsdl, "fresh"), + FROM : str = json(tsdl, "from"), + LOCK_FILE : str = json(tsdl, "lock-file"), + LOG_FILE : str = json(tsdl, "log-file"), + PARSER_OUT_DIR : str = json(tsdl, "out-dir"), + UNLOCK_TIMEOUT: u64 = json(tsdl, "unlock-timeout"), + PREFIX : str = json(tsdl, "prefix"), + REF : str = json(tsdl, "ref"), + SHOW_CONFIG : bool = json(tsdl, "show-config"), ); // 4. Generate Tree Sitter Consts @@ -125,9 +125,9 @@ fn main() { .expect("missing [metadata.tree-sitter]"); generate_consts!( Path::new(&out_dir).join("tree_sitter_consts.rs"), - TREE_SITTER_PLATFORM : str = expr(ts_platform), - TREE_SITTER_REPO : str = json(tree_sitter, "repo"), - TREE_SITTER_VERSION : str = json(tree_sitter, "version"), + PLATFORM : str = expr(ts_platform), + REPO : str = json(tree_sitter, "repo"), + VERSION : str = json(tree_sitter, "version"), ); // 5. Generate Version/SHA diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 7cb640a..620b387 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -8,7 +8,7 @@ use tracing::info; use crate::{ actors::{Addr, Response}, - build, cache, parser, TsdlResult, + build, cache, parser, Result, }; #[derive(Debug)] @@ -38,13 +38,13 @@ pub enum CacheMessage { artifacts: Vec, tx: oneshot::Sender, }, - /// cache::Update a cache entry + /// `cache::Update` a cache entry Update { entry: cache::Entry, name: cache::Key, }, /// Save cache to disk - Save { tx: oneshot::Sender> }, + Save { tx: oneshot::Sender> }, /// Check if cache contains entries compatible with a language spec. HasCompatibleEntries { language: parser::LanguageName, @@ -115,7 +115,7 @@ impl CacheAddr { .await } - pub async fn save(&self) -> TsdlResult<()> { + pub async fn save(&self) -> Result<()> { self.request(|tx| CacheMessage::Save { tx }).await } diff --git a/src/actors/display.rs b/src/actors/display.rs index c97e51f..9ed55dc 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -1095,7 +1095,7 @@ impl DisplayActor { display::ItemState::InProgress(Some(display::SuccessOutcome::Cached)) | display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, display::ItemState::New | display::ItemState::InProgress(None) => { - saw_unknown = true + saw_unknown = true; } display::ItemState::Cancelled | display::ItemState::Failed => {} } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 2eab407..a8418ad 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -10,7 +10,7 @@ use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info}; -use crate::{args, error, parser, shutdown, tree_sitter, TsdlResult}; +use crate::{args, parser, shutdown, tree_sitter, Error, Result}; pub trait Addr { type Message; @@ -67,15 +67,15 @@ pub async fn run( jobs: NonZeroUsize, languages: Vec, tree_sitter: &args::TreeSitter, -) -> TsdlResult<()> { +) -> Result<()> { let tree_sitter_ref = match tree_sitter::display_tree_sitter_ref(&tree_sitter.version) { Ok(git_ref) => git_ref, Err(err) => { display.shutdown(false).await; - return Err(error::TsdlError::context( - format!("Parsing tree-sitter git ref {:?}", tree_sitter.version), - err, - )); + return Err(Error::Context { + message: format!("Parsing tree-sitter git ref {:?}", tree_sitter.version), + source: err.into(), + }); } }; @@ -110,7 +110,7 @@ pub async fn run( // interruption so the top-level process exits with the signal status. if let Some(signal) = interrupted { info!("pipeline shutdown signalled by {signal}, suppressing build errors"); - return Err(error::TsdlError::Interrupted(signal)); + return Err(Error::Interrupted { signal }); } result @@ -123,7 +123,7 @@ async fn run_inner( jobs: NonZeroUsize, languages: Vec, tree_sitter: &args::TreeSitter, -) -> TsdlResult<()> { +) -> Result<()> { let prepared = tree_sitter::prepare(build_dir, display.clone(), tree_sitter).await?; let ts_cli = Arc::new(prepared.path); let languages = languages @@ -131,7 +131,7 @@ async fn run_inner( .map(|language| language.with_tree_sitter(prepared.tree_sitter.clone())) .collect::>(); - let mut errors : Vec = + let mut errors: Vec = // 1. crate::cache::Source: Create a stream from the input list stream::iter(languages) // 2. Stage: Discovery @@ -193,7 +193,7 @@ async fn run_inner( if errors.is_empty() { Ok(()) } else { - Err(error::TsdlError::Build(errors)) + Err(Error::Build { errors }) } } @@ -204,7 +204,7 @@ async fn discover_grammars( display: DisplayAddr, language: parser::LanguageBuild, ts_cli: Arc, -) -> TsdlResult> { +) -> Result> { shutdown::test_delay().await; shutdown::check()?; debug!("[discover] lang={}", language.name); @@ -287,7 +287,7 @@ async fn resolve_source( cache: &CacheAddr, language: &parser::LanguageBuild, progress: &ProgressAddr, -) -> TsdlResult { +) -> Result { if language.spec.git_ref.is_moving() { info!( "Resolving moving parser git ref for {}: {}", diff --git a/src/app.rs b/src/app.rs index e49afda..c646606 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use crate::{args, config, display, logging, TsdlResult}; +use crate::{args, config, display, logging, Result}; /// Resolved application state, ready to run. pub struct App { @@ -13,17 +13,17 @@ pub struct App { pub log_path: PathBuf, pub progress_mode: display::Mode, pub verbose: Verbosity, - pub _logging: Option, + pub logging_guard: Option, } -pub fn setup() -> TsdlResult { +pub fn setup() -> Result { let (args, matches) = config::parse_with_matches(); let build_matches = crate::args::build_matches(&matches); let (command, provenance) = config::current_with_provenance(&args.config, build_matches)?; - let (log_path, _logging) = logging::init( - args.log.clone(), + let (log_path, logging_guard) = logging::init( + args.log.as_ref(), args.log_color, args.verbose, &command.build_dir, @@ -39,6 +39,6 @@ pub fn setup() -> TsdlResult { log_path, progress_mode, verbose: args.verbose, - _logging: Some(_logging), + logging_guard: Some(logging_guard), }) } diff --git a/src/args.rs b/src/args.rs index 714e71c..65c2289 100644 --- a/src/args.rs +++ b/src/args.rs @@ -8,8 +8,8 @@ use clap_verbosity_flag::{InfoLevel, Verbosity}; use serde::{Deserialize, Serialize}; use crate::consts::{ - TREE_SITTER_PLATFORM, TREE_SITTER_REPO, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_CONFIG_FILE, - TSDL_FORCE, TSDL_FRESH, TSDL_OUT_DIR, TSDL_PREFIX, TSDL_SHOW_CONFIG, TSDL_UNLOCK_TIMEOUT, + BUILD_DIR, CONFIG_FILE, FORCE, FRESH, PARSER_OUT_DIR, PLATFORM, PREFIX, REPO, SHOW_CONFIG, + UNLOCK_TIMEOUT, VERSION, }; const TSDL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/tsdl.version")); @@ -28,7 +28,7 @@ pub struct Args { pub command: Command, /// Path to the config file (TOML). - #[arg(short, long, env = "TSDL_CONFIG", default_value = TSDL_CONFIG_FILE, global = true)] + #[arg(short, long, env = "TSDL_CONFIG", default_value = CONFIG_FILE, global = true)] pub config: PathBuf, /// Path to the logging file. If unspecified, it will go to `build-dir/log`. @@ -49,7 +49,7 @@ pub struct Args { pub verbose: Verbosity, } -#[derive(clap::ValueEnum, Clone, Debug, Deserialize, Serialize)] +#[derive(clap::ValueEnum, Clone, Copy, Debug, Deserialize, Serialize)] pub enum LogColor { Auto, No, @@ -237,9 +237,9 @@ pub struct TreeSitter { impl Default for TreeSitter { fn default() -> Self { Self { - version: TREE_SITTER_VERSION.to_string(), - platform: TREE_SITTER_PLATFORM.to_string(), - repo: TREE_SITTER_REPO.to_string(), + version: VERSION.to_string(), + platform: PLATFORM.to_string(), + repo: REPO.to_string(), } } } @@ -271,18 +271,18 @@ pub struct BuildCommand { impl Default for BuildCommand { fn default() -> Self { Self { - build_dir: PathBuf::from(TSDL_BUILD_DIR), - force: TSDL_FORCE, - fresh: TSDL_FRESH, + build_dir: PathBuf::from(BUILD_DIR), + force: FORCE, + fresh: FRESH, languages: None, jobs: default_jobs(), - out_dir: PathBuf::from(TSDL_OUT_DIR), + out_dir: PathBuf::from(PARSER_OUT_DIR), parsers: None, - prefix: String::from(TSDL_PREFIX), - show_config: TSDL_SHOW_CONFIG, + prefix: String::from(PREFIX), + show_config: SHOW_CONFIG, target: Target::default(), tree_sitter: TreeSitter::default(), - unlock_timeout: TSDL_UNLOCK_TIMEOUT, + unlock_timeout: UNLOCK_TIMEOUT, } } } @@ -293,15 +293,15 @@ pub fn default_jobs() -> NonZeroUsize { } fn default_tree_sitter_version() -> String { - TREE_SITTER_VERSION.to_string() + VERSION.to_string() } fn default_tree_sitter_platform() -> String { - TREE_SITTER_PLATFORM.to_string() + PLATFORM.to_string() } fn default_tree_sitter_repo() -> String { - TREE_SITTER_REPO.to_string() + REPO.to_string() } #[derive(clap::Subcommand, Clone, Debug, Default)] diff --git a/src/build.rs b/src/build.rs index 2446fb2..3d54f1c 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2,6 +2,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, fs, path::PathBuf, + result::Result as StdResult, sync::Arc, time::Duration, }; @@ -10,9 +11,10 @@ use serde::{Deserialize, Serialize}; use tracing::info; use url::Url; +use crate::consts::FROM; use crate::{ - actors, app, args, cache, consts, error, format_duration, lock, parser, prompt_user, shutdown, - SafeCanonicalize, TsdlResult, + actors, app, args, cache, format_duration, lock, parser, prompt_user, shutdown, Error, Result, + ResultExt, SafeCanonicalize, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -36,7 +38,7 @@ pub struct Context { pub overwrite_output: bool, } -pub fn run(app: &app::App) -> TsdlResult<()> { +pub fn run(app: &app::App) -> Result<()> { if app.command.show_config { crate::config::show(&app.command)?; } @@ -49,7 +51,7 @@ pub fn run(app: &app::App) -> TsdlResult<()> { Ok(()) } -fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> TsdlResult { +fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result { // Loop because the lock owner may exit naturally between the prompt and // SIGTERM, or another process may replace it. Each iteration re-checks // the lock status and presents the current owner to the user. @@ -59,7 +61,9 @@ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> TsdlResult { info!("lock::Lock already held by this process (cyclic)."); - return Err(error::TsdlError::message("1+ lock acquisition")); + return Err(Error::Message { + message: "1+ lock acquisition".into(), + }); } lock::Status::LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { @@ -77,9 +81,9 @@ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> TsdlResult Result { +) -> StdResult { info!("Build directory is locked by another process:"); info!("{owner}"); eprintln!( @@ -101,7 +105,10 @@ fn handle_locked_by( ); if !prompt_user("Terminate this process and continue?", false)? { - return Err(error::TsdlError::message("lock::Lock acquisition cancelled by user").into()); + return Err(Error::Message { + message: "lock::Lock acquisition cancelled by user".into(), + } + .into()); } lock.terminate_owner(owner)?; @@ -112,7 +119,7 @@ fn handle_locked_by( lock.wait_for_release(owner, unlock_timeout) } -fn clear(app: &app::App, guard: &lock::Guard) -> TsdlResult<()> { +fn clear(app: &app::App, guard: &lock::Guard) -> Result<()> { if app.command.fresh && app.command.build_dir.exists() { guard.clear_directory(std::slice::from_ref(&app.log_path))?; } @@ -122,39 +129,35 @@ fn clear(app: &app::App, guard: &lock::Guard) -> TsdlResult<()> { Ok(()) } -fn collect_languages( - app: &app::App, -) -> Result, error::LanguageCollection> { +fn collect_languages(app: &app::App) -> Result> { let results = unique_languages(app); let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); if err.is_empty() { Ok(ok.into_iter().map(Result::unwrap).collect()) } else { - Err(error::LanguageCollection { + Err(Error::LanguageCollection { related: err.into_iter().map(Result::unwrap_err).collect(), }) } } -fn default_repo(language: &str) -> TsdlResult { - let url = format!("{}{language}", consts::TSDL_FROM); - Url::parse(&url) - .map_err(|e| error::TsdlError::context(format!("Creating url {url} for {language}"), e)) +fn default_repo(language: &str) -> Result { + let url = format!("{FROM}{language}"); + Url::parse(&url).with_context(|| format!("Creating url {url} for {language}")) } fn get_language_coords( language: &str, defined_parsers: Option<&BTreeMap>, -) -> TsdlResult<(Option, parser::Ref, Url)> { +) -> Result<(Option, parser::Ref, Url)> { let config = defined_parsers.and_then(|parsers| parsers.get(language)); match config { Some(args::ParserConfig::Ref(git_ref)) => Ok(( None, - parser::Ref::parse(git_ref).map_err(|e| { - error::TsdlError::context(format!("Parsing git ref {git_ref:?} for {language}"), e) - })?, + parser::Ref::parse(git_ref) + .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, default_repo(language)?, )), @@ -164,20 +167,15 @@ fn get_language_coords( from, }) => { let repo = match from { - Some(url_str) => Url::parse(url_str).map_err(|e| { - error::TsdlError::context(format!("Parsing {url_str} for {language}"), e) - })?, + Some(url_str) => Url::parse(url_str) + .with_context(|| format!("Parsing {url_str} for {language}"))?, None => default_repo(language)?, }; Ok(( build_script.clone(), - parser::Ref::parse(git_ref).map_err(|e| { - error::TsdlError::context( - format!("Parsing git ref {git_ref:?} for {language}"), - e, - ) - })?, + parser::Ref::parse(git_ref) + .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, repo, )) } @@ -186,7 +184,7 @@ fn get_language_coords( } } -fn ignite(app: &app::App) -> TsdlResult<()> { +fn ignite(app: &app::App) -> Result<()> { fs::create_dir_all(&app.command.out_dir)?; let rt = tokio::runtime::Builder::new_current_thread() @@ -227,7 +225,7 @@ fn ignite(app: &app::App) -> TsdlResult<()> { result } -fn unique_languages(app: &app::App) -> Vec> { +fn unique_languages(app: &app::App) -> Vec> { let requested_languages = &app.command.languages; let defined_parsers = app.command.parsers.as_ref(); @@ -272,7 +270,10 @@ fn unique_languages(app: &app::App) -> Vec Err(error::Language::new(language, err)), + Err(err) => Err(Error::Language { + name: language, + source: err.into(), + }), }; results.push(result); } @@ -304,7 +305,7 @@ mod tests { progress_mode: Mode::Plain, provenance: BuildProvenance::default(), verbose: clap_verbosity_flag::Verbosity::default(), - _logging: None, + logging_guard: None, } } diff --git a/src/cache.rs b/src/cache.rs index 60e3e65..aa4442c 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -10,7 +10,7 @@ use sha1::{Digest, Sha1}; use tokio::io::{AsyncReadExt, ReadBuf}; use tracing::debug; -use crate::{args, build, consts, error, git, parser, TsdlResult}; +use crate::{args, build, consts, git, parser, Result, ResultExt}; #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] @@ -80,7 +80,7 @@ impl From<&str> for GrammarHash { } } -/// The build cache stored in `[build-dir]/[consts::TSDL_CACHE_FILE]` +/// The build cache stored in `[build-dir]/[consts::CACHE_FILE]` #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { pub parsers: BTreeMap, @@ -434,12 +434,12 @@ impl Db { } /// Delete the cache file from disk - pub async fn delete(build_dir: &Path) -> TsdlResult<()> { - let file = build_dir.join(consts::TSDL_CACHE_FILE); + pub async fn delete(build_dir: &Path) -> Result<()> { + let file = build_dir.join(consts::CACHE_FILE); if tokio::fs::metadata(&file).await.is_ok() { - tokio::fs::remove_file(&file).await.map_err(|e| { - error::TsdlError::context(format!("Deleting cache file at {}", file.display()), e) - })?; + tokio::fs::remove_file(&file) + .await + .with_context(|| format!("Deleting cache file at {}", file.display()))?; debug!("Cache file deleted"); } Ok(()) @@ -452,8 +452,8 @@ impl Db { } /// Load the cache from disk, or return the empty cache. - pub fn load(build_dir: &Path) -> TsdlResult { - let file = build_dir.join(consts::TSDL_CACHE_FILE); + pub fn load(build_dir: &Path) -> Result { + let file = build_dir.join(consts::CACHE_FILE); if !file.exists() { debug!( "Cache file not found at {}, returning empty cache", @@ -465,13 +465,11 @@ impl Db { }); } - let contents = std::fs::read_to_string(&file).map_err(|e| { - error::TsdlError::context(format!("Reading cache file at {}", file.display()), e) - })?; + let contents = std::fs::read_to_string(&file) + .with_context(|| format!("Reading cache file at {}", file.display()))?; - toml::from_str(&contents).map_err(|e| { - error::TsdlError::context(format!("Parsing cache file at {}", file.display()), e) - }) + toml::from_str(&contents) + .with_context(|| format!("Parsing cache file at {}", file.display())) } /// Explain whether a parser cache entry can satisfy the requested build. @@ -505,13 +503,12 @@ impl Db { } /// Save the cache to disk - pub async fn save(&self) -> TsdlResult<()> { - let contents = toml::to_string_pretty(self) - .map_err(|e| error::TsdlError::context("Serializing cache to TOML", e))?; + pub async fn save(&self) -> Result<()> { + let contents = toml::to_string_pretty(self).context("Serializing cache to TOML")?; - tokio::fs::write(&self.file, contents).await.map_err(|e| { - error::TsdlError::context(format!("Writing cache file to {}", self.file.display()), e) - })?; + tokio::fs::write(&self.file, contents) + .await + .with_context(|| format!("Writing cache file to {}", self.file.display()))?; debug!("Cache saved to {}", self.file.display()); Ok(()) @@ -524,19 +521,19 @@ impl Db { } /// Hash the contents of a file using SHA-1 and return the hex string. -pub async fn hash_file(path: &Path) -> TsdlResult { - let mut file = tokio::fs::File::open(path).await.map_err(|e| { - error::TsdlError::context(format!("Opening file for hashing: {}", path.display()), e) - })?; +pub async fn hash_file(path: &Path) -> Result { + let mut file = tokio::fs::File::open(path) + .await + .with_context(|| format!("Opening file for hashing: {}", path.display()))?; let mut hasher = Sha1::new(); let mut buffer = vec![0u8; 8192]; loop { let mut read_buf = ReadBuf::new(&mut buffer); - file.read_buf(&mut read_buf).await.map_err(|e| { - error::TsdlError::context(format!("Reading file for hashing: {}", path.display()), e) - })?; + file.read_buf(&mut read_buf) + .await + .with_context(|| format!("Reading file for hashing: {}", path.display()))?; if read_buf.filled().is_empty() { break; diff --git a/src/config.rs b/src/config.rs index 55294a2..f5c4106 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use std::{ ffi::OsString, fs, path::{Path, PathBuf}, + result::Result as StdResult, }; use clap::{ @@ -10,7 +11,7 @@ use clap::{ use serde::Serialize; use tracing::debug; -use crate::{args, columns, error, TsdlResult}; +use crate::{args, columns, Result, ResultExt}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -57,7 +58,7 @@ pub struct BuildProvenance { pub unlock_timeout: Source, } -pub fn current(config: &Path, matches: Option<&ArgMatches>) -> TsdlResult { +pub fn current(config: &Path, matches: Option<&ArgMatches>) -> Result { let (cmd, _provenance) = current_with_provenance(config, matches)?; Ok(cmd) } @@ -65,7 +66,7 @@ pub fn current(config: &Path, matches: Option<&ArgMatches>) -> TsdlResult, -) -> TsdlResult<(args::BuildCommand, BuildProvenance)> { +) -> Result<(args::BuildCommand, BuildProvenance)> { let defaults = args::BuildCommand::default(); let file_overrides = read_file_overrides(config)?; let file_provenance = file_provenance_from(&file_overrides); @@ -80,7 +81,7 @@ pub fn current_with_provenance( }; let command = merge(defaults, file_overrides, cli_overrides); - let provenance = merge_provenance(file_provenance, cli_provenance); + let provenance = merge_provenance(&file_provenance, &cli_provenance); debug!(?provenance, ?command, "Resolved build configuration"); @@ -135,22 +136,19 @@ fn apply_opt(field: &mut T, value: Option) { } } -fn read_file_overrides(config: &Path) -> TsdlResult { +fn read_file_overrides(config: &Path) -> Result { if !config.exists() { return Ok(args::OptionalBuildCommand::default()); } - let contents = fs::read_to_string(config).map_err(|e| { - error::TsdlError::context(format!("Reading config file {}", config.display()), e) - })?; + let contents = fs::read_to_string(config) + .with_context(|| format!("Reading config file {}", config.display()))?; if contents.trim().is_empty() { return Ok(args::OptionalBuildCommand::default()); } - toml::from_str(&contents).map_err(|e| { - error::TsdlError::context(format!("Parsing config file {}", config.display()), e) - }) + toml::from_str(&contents).with_context(|| format!("Parsing config file {}", config.display())) } fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenance { @@ -198,7 +196,7 @@ fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenan p } -fn merge_provenance(file: BuildProvenance, cli: BuildProvenance) -> BuildProvenance { +fn merge_provenance(file: &BuildProvenance, cli: &BuildProvenance) -> BuildProvenance { BuildProvenance { build_dir: merge_source(file.build_dir, cli.build_dir), force: merge_source(file.force, cli.force), @@ -228,6 +226,7 @@ fn merge_source(file: Source, cli: Source) -> Source { } #[must_use] +#[allow(clippy::too_many_lines)] pub fn build_cli(defaults: &args::BuildCommand) -> Vec { let jobs_default = defaults.jobs.to_string(); let ut_default = defaults.unlock_timeout.to_string(); @@ -236,7 +235,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { Arg::new("build-dir") .long("build-dir") .short('b') - .env("TSDL_BUILD_DIR") + .env("BUILD_DIR") .value_parser(value_parser!(PathBuf)) .help(format!( "Build Directory [default: {}]", @@ -244,7 +243,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { )), Arg::new("force") .long("force") - .env("TSDL_FORCE") + .env("FORCE") .num_args(0..=1) .require_equals(true) .default_missing_value("true") @@ -257,7 +256,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { Arg::new("fresh") .long("fresh") .short('f') - .env("TSDL_FRESH") + .env("FRESH") .num_args(0..=1) .require_equals(true) .default_missing_value("true") @@ -280,7 +279,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { Arg::new("out-dir") .long("out-dir") .short('o') - .env("TSDL_OUT_DIR") + .env("PARSER_OUT_DIR") .value_parser(value_parser!(PathBuf)) .help(format!( "Output Directory [default: {}]", @@ -289,7 +288,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { Arg::new("prefix") .long("prefix") .short('p') - .env("TSDL_PREFIX") + .env("PREFIX") .value_parser(value_parser!(String)) .help(format!( "Prefix parser names [default: {}]", @@ -297,7 +296,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { )), Arg::new("show-config") .long("show-config") - .env("TSDL_SHOW_CONFIG") + .env("SHOW_CONFIG") .num_args(0..=1) .require_equals(true) .default_missing_value("true") @@ -323,7 +322,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { Arg::new("tree-sitter-version") .long("tree-sitter-version") .short('V') - .env("TSDL_TREE_SITTER_VERSION") + .env("TSDL_VERSION") .value_parser(value_parser!(String)) .help(format!( "Tree-sitter version [default: {}]", @@ -331,7 +330,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { )), Arg::new("tree-sitter-platform") .long("tree-sitter-platform") - .env("TSDL_TREE_SITTER_PLATFORM") + .env("TSDL_PLATFORM") .value_parser(value_parser!(String)) .help(format!( "Tree-sitter platform to build [default: {}]", @@ -340,7 +339,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { Arg::new("tree-sitter-repo") .long("tree-sitter-repo") .short('R') - .env("TSDL_TREE_SITTER_REPO") + .env("TSDL_REPO") .value_parser(value_parser!(String)) .help(format!( "Tree-sitter repo [default: {}]", @@ -348,7 +347,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { )), Arg::new("unlock-timeout") .long("unlock-timeout") - .env("TSDL_UNLOCK_TIMEOUT") + .env("UNLOCK_TIMEOUT") .value_parser(value_parser!(u64).range(1..)) .help(format!( "Seconds to wait after terminating a lock owner [default: {ut_default}]" @@ -525,27 +524,25 @@ pub fn print_indent(s: &str, indent: &str) { s.lines().for_each(|line| println!("{indent}{line}")); } -pub fn run(config_path: &Path, command: &args::ConfigCommand) -> TsdlResult<()> { +pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { match command { args::ConfigCommand::Current => { let cmd: args::BuildCommand = current(config_path, None)?; println!( "{}", - toml::to_string(&cmd).map_err(|e| { - error::TsdlError::context("Generating default TOML config", e) - })? + toml::to_string(&cmd).context("Generating default TOML config")? ); } args::ConfigCommand::Default => println!( "{}", toml::to_string(&args::BuildCommand::default()) - .map_err(|e| { error::TsdlError::context("Generating default TOML config", e) })? + .context("Generating default TOML config")? ), } Ok(()) } -pub fn show(command: &args::BuildCommand) -> TsdlResult<()> { +pub fn show(command: &args::BuildCommand) -> Result<()> { if let Some(langs) = &command.languages { println!("Building the following languages:"); println!(); @@ -557,10 +554,7 @@ pub fn show(command: &args::BuildCommand) -> TsdlResult<()> { } println!("Running with the following configuration:"); println!(); - print_indent( - &toml::to_string(&command).map_err(|e| error::TsdlError::context("Showing config", e))?, - " ", - ); + print_indent(&toml::to_string(&command).context("Showing config")?, " "); println!(); Ok(()) } @@ -582,7 +576,7 @@ pub fn parse_with_matches() -> (args::Args, ArgMatches) { (args, matches) } -pub fn try_parse_from_with_matches(itr: I) -> Result<(args::Args, ArgMatches), clap::Error> +pub fn try_parse_from_with_matches(itr: I) -> StdResult<(args::Args, ArgMatches), clap::Error> where I: IntoIterator, T: Into + Clone, diff --git a/src/error.rs b/src/error.rs index 6c81f82..4970794 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,212 +6,54 @@ use derive_more::derive::Display; use crate::shutdown::Signal; -/// Represents a single layer in the context chain -#[derive(Debug)] -pub struct ContextKind { - /// The wrapped error - pub error: TsdlError, - /// The context message - pub message: String, -} - -impl fmt::Display for ContextKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}: {}", self.message, self.error) - } -} +pub type Result = std::result::Result; #[derive(Debug)] -pub struct Command { - pub msg: String, - pub stderr: String, - pub stdout: String, -} - -impl std::error::Error for Command {} - -impl fmt::Display for Command { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) - } -} - -impl Command { - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!(w, "{}$ {}", prefix, self.msg)?; - - let has_stdout = !self.stdout.is_empty(); - let has_stderr = !self.stderr.is_empty(); - - if has_stdout && has_stderr { - let mut write_section = |header: &str, content: &str| -> fmt::Result { - writeln!(w, "\n{prefix} {header}:")?; - - let mut lines = content.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix} {first}")?; - for line in lines { - write!(w, "\n{prefix} {line}")?; - } - } - Ok(()) - }; - - write_section("stdout", &self.stdout)?; - write_section("stderr", &self.stderr)?; - } else if has_stderr { - writeln!(w)?; - let mut lines = self.stderr.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix}{first}")?; - for line in lines { - write!(w, "\n{prefix}{line}")?; - } - } - } else if has_stdout { - writeln!(w)?; - let mut lines = self.stdout.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix}{first}")?; - for line in lines { - write!(w, "\n{prefix}{line}")?; - } - } - } - - Ok(()) - } +pub struct Cause(Box); +impl Cause { #[must_use] - pub fn format_with_indent(&self, indent: usize) -> String { - let mut s = String::new(); - let _ = self.format(&mut s, indent); - s - } -} - -#[derive(Debug)] -pub struct LanguageCollection { - pub related: Vec, -} - -impl fmt::Display for LanguageCollection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Could not figure out all languages:")?; - format_languages_inner(f, &self.related) - } -} - -impl std::error::Error for LanguageCollection {} - -#[derive(Debug)] -pub struct Language { - pub name: String, - pub source: Box, -} - -impl fmt::Display for Language { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) - } -} - -impl Language { - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!( - w, - "{}{}\n{}", - prefix, - self.name, - self.source.format_indent(indent + 2) - ) + pub fn new(source: impl Into) -> Self { + Self(Box::new(source.into())) } - /// Format with indentation - /// - /// # Panics - /// - /// This function will panic if writing to the string fails, which should never happen - /// since we're writing to a String which doesn't fail. #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - self.format(&mut s, indent) - .expect("Failed to format with indent"); - s - } -} - -impl Language { - pub fn new(name: String, source: impl Into) -> Language { - Language { - name, - source: Box::new(source.into()), - } - } -} - -impl std::error::Error for Language { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(self.source.as_ref()) + pub fn as_error(&self) -> &Error { + &self.0 } } -#[derive(Debug)] -pub struct Step { - pub name: Arc, - pub kind: ParserOp, - pub source: Box, -} - -impl fmt::Display for Step { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) +impl From for Cause +where + E: Into, +{ + fn from(source: E) -> Self { + Self::new(source) } } -impl Step { - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!( - w, - "{}{}: {}.\n{}", - prefix, - self.name, - self.kind, - self.source.format_indent(indent + 4) - ) - } +pub trait ResultExt { + fn context(self, message: impl Into) -> Result; - /// Format with indentation - /// - /// # Panics - /// - /// This function will panic if writing to the string fails, which should never happen - /// since we're writing to a String which doesn't fail. - #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - self.format(&mut s, indent).unwrap(); - s - } + fn with_context(self, message: impl FnOnce() -> String) -> Result; } -impl Step { - pub fn new(name: Arc, kind: ParserOp, source: impl Into) -> Step { - Step { - name, - kind, - source: Box::new(source.into()), - } +impl ResultExt for std::result::Result +where + E: Into, +{ + fn context(self, message: impl Into) -> Result { + self.map_err(|source| Error::Context { + message: message.into(), + source: Cause::new(source), + }) } -} -impl std::error::Error for Step { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(self.source.as_ref()) + fn with_context(self, message: impl FnOnce() -> String) -> Result { + self.map_err(|source| Error::Context { + message: message(), + source: Cause::new(source), + }) } } @@ -227,251 +69,292 @@ pub enum ParserOp { Generate { dir: PathBuf }, } -fn format_languages_inner(w: &mut impl fmt::Write, langs: &[Language]) -> fmt::Result { - for (i, lang) in langs.iter().enumerate() { - if i > 0 { - write!(w, ", ")?; - } - write!(w, "{}", lang.name)?; - } - Ok(()) -} - -fn format_build_errors( - w: &mut impl fmt::Write, - errors: &[TsdlError], - indent: usize, -) -> fmt::Result { - let prefix = " ".repeat(indent); - write!(w, "{prefix}Could not build all parsers.")?; - - for error in errors { - write!(w, "\n\n")?; - error.format(w, indent + 2)?; - } - - Ok(()) -} - /// Main error type for tsdl operations #[derive(Debug)] -pub enum TsdlError { +pub enum Error { /// Build errors - Build(Vec), + Build { errors: Vec }, /// Command execution failed - Command(Command), + Command { + msg: String, + stderr: String, + stdout: String, + }, /// Configuration error - Config(String), + Config { message: String }, /// Context chain (linked list of context layers) - Context(Box), + Context { message: String, source: Cause }, /// Generic IO error - Io(std::io::Error), + Io { source: std::io::Error }, /// Build was interrupted by a Unix signal. - Interrupted(Signal), + Interrupted { signal: Signal }, /// Simple error message - Message(String), + Message { message: String }, /// Language collection failed - LanguageCollection(LanguageCollection), + LanguageCollection { related: Vec }, /// Individual language failed - Language(Language), + Language { name: String, source: Cause }, /// Specific step failed - Step(Step), + Step { + name: Arc, + kind: ParserOp, + source: Cause, + }, } -impl fmt::Display for TsdlError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - TsdlError::Build(errs) => format_build_errors(f, errs, 0), - TsdlError::Command(e) => write!(f, "{e}"), - TsdlError::Config(msg) => write!(f, "Configuration error: {msg}"), - TsdlError::Context(kind) => write!(f, "{kind}"), - TsdlError::Io(e) => write!(f, "IO error: {e}"), - TsdlError::Interrupted(signal) => write!(f, "Interrupted by {signal}"), - TsdlError::Language(e) => write!(f, "{e}"), - TsdlError::LanguageCollection(e) => write!(f, "{e}"), - TsdlError::Message(msg) => write!(f, "{msg}"), - TsdlError::Step(e) => write!(f, "{e}"), +fn format_command( + w: &mut impl fmt::Write, + indent: usize, + msg: &str, + stdout: &str, + stderr: &str, +) -> fmt::Result { + let prefix = " ".repeat(indent); + write!(w, "{prefix}$ {msg}")?; + + let has_stdout = !stdout.is_empty(); + let has_stderr = !stderr.is_empty(); + + if has_stdout && has_stderr { + let mut write_section = |header: &str, content: &str| -> fmt::Result { + writeln!(w, "\n{prefix} {header}:")?; + + let mut lines = content.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix} {first}")?; + for line in lines { + write!(w, "\n{prefix} {line}")?; + } + } + Ok(()) + }; + + write_section("stdout", stdout)?; + write_section("stderr", stderr)?; + } else if has_stderr { + writeln!(w)?; + let mut lines = stderr.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix}{first}")?; + for line in lines { + write!(w, "\n{prefix}{line}")?; + } + } + } else if has_stdout { + writeln!(w)?; + let mut lines = stdout.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix}{first}")?; + for line in lines { + write!(w, "\n{prefix}{line}")?; + } } } + + Ok(()) } -impl std::error::Error for TsdlError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - TsdlError::Build(_) - | TsdlError::Config(_) - | TsdlError::Interrupted(_) - | TsdlError::Message(_) => None, - TsdlError::Command(e) => Some(e), - TsdlError::Context(kind) => Some(&kind.error), - TsdlError::Io(e) => Some(e), - TsdlError::Language(e) => Some(e), - TsdlError::LanguageCollection(e) => Some(e), - TsdlError::Step(e) => Some(e), +fn format_language_collection( + w: &mut impl fmt::Write, + related: &[Error], + indent: usize, +) -> fmt::Result { + let prefix = " ".repeat(indent); + writeln!(w, "{prefix}Could not figure out all languages:")?; + + for (i, error) in related.iter().enumerate() { + if i > 0 { + write!(w, ", ")?; + } + match error { + Error::Language { name, .. } => write!(w, "{name}")?, + _ => error.format(w, 0)?, } } -} -// From trait implementations to preserve #[from] functionality -impl From for TsdlError { - fn from(e: Command) -> Self { - TsdlError::Command(e) - } + Ok(()) } -impl From for TsdlError { - fn from(e: LanguageCollection) -> Self { - TsdlError::LanguageCollection(e) - } -} +fn format_build_errors(w: &mut impl fmt::Write, errors: &[Error], indent: usize) -> fmt::Result { + let prefix = " ".repeat(indent); + write!(w, "{prefix}Could not build all parsers.")?; -impl From for TsdlError { - fn from(e: Language) -> Self { - TsdlError::Language(e) + for error in errors { + write!(w, "\n\n")?; + error.format(w, indent + 2)?; } -} -impl From for TsdlError { - fn from(e: Step) -> Self { - TsdlError::Step(e) - } + Ok(()) } -impl From for TsdlError { - fn from(e: std::io::Error) -> Self { - TsdlError::Io(e) +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.format(f, 0) } } -impl From for TsdlError { - fn from(e: std::fmt::Error) -> Self { - TsdlError::Message(format!("formatting error: {e}")) +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Build { .. } + | Error::Command { .. } + | Error::Config { .. } + | Error::Interrupted { .. } + | Error::LanguageCollection { .. } + | Error::Message { .. } => None, + Error::Context { source, .. } + | Error::Language { source, .. } + | Error::Step { source, .. } => Some(source.as_error()), + Error::Io { source } => Some(source), + } } } -impl From for TsdlError { - fn from(e: std::string::FromUtf8Error) -> Self { - TsdlError::Message(format!("UTF-8 conversion error: {e}")) +impl From for Error { + fn from(source: std::io::Error) -> Self { + Error::Io { source } } } -impl From for TsdlError { - fn from(e: reqwest::Error) -> Self { - TsdlError::Message(format!("HTTP request error: {e}")) +impl From for Error { + fn from(error: std::fmt::Error) -> Self { + Error::Message { + message: format!("formatting error: {error}"), + } } } -impl From for TsdlError { - fn from(e: url::ParseError) -> Self { - TsdlError::Message(format!("URL parse error: {e}")) +impl From for Error { + fn from(error: std::string::FromUtf8Error) -> Self { + Error::Message { + message: format!("UTF-8 conversion error: {error}"), + } } } -impl From for TsdlError { - fn from(e: toml::ser::Error) -> Self { - TsdlError::Message(format!("TOML serialization error: {e}")) +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Error::Message { + message: format!("HTTP request error: {error}"), + } } } -impl From for TsdlError { - fn from(e: toml::de::Error) -> Self { - TsdlError::Message(format!("TOML deserialization error: {e}")) +impl From for Error { + fn from(error: url::ParseError) -> Self { + Error::Message { + message: format!("URL parse error: {error}"), + } } } -impl From for TsdlError { - fn from(e: semver::Error) -> Self { - TsdlError::Message(format!("Semver error: {e}")) +impl From for Error { + fn from(error: toml::ser::Error) -> Self { + Error::Message { + message: format!("TOML serialization error: {error}"), + } } } -impl From for TsdlError { - fn from(e: self_update::errors::Error) -> Self { - TsdlError::Message(format!("Self-update error: {e}")) +impl From for Error { + fn from(error: toml::de::Error) -> Self { + Error::Message { + message: format!("TOML deserialization error: {error}"), + } } } -impl From for TsdlError { - fn from(e: reqwest::header::InvalidHeaderValue) -> Self { - TsdlError::Message(format!("Invalid header value: {e}")) +impl From for Error { + fn from(error: semver::Error) -> Self { + Error::Message { + message: format!("Semver error: {error}"), + } } } -impl From for TsdlError { - fn from(e: tokio::task::JoinError) -> Self { - TsdlError::Message(format!("Task join error: {e}")) +impl From for Error { + fn from(error: self_update::errors::Error) -> Self { + Error::Message { + message: format!("Self-update error: {error}"), + } } } -impl TsdlError { - /// Wrap a `TsdlError` with additional context message - /// The error parameter must be convertible to `TsdlError` - pub fn context(context: C, error: E) -> Self - where - C: Into, - E: Into, - { - let message = context.into(); - let tsdl_err = error.into(); - - // Create a context wrapper linking the message to the error - TsdlError::Context(Box::new(ContextKind { - message, - error: tsdl_err, - })) +impl From for Error { + fn from(error: reqwest::header::InvalidHeaderValue) -> Self { + Error::Message { + message: format!("Invalid header value: {error}"), + } } +} - /// Create a simple error message - pub fn message(message: M) -> Self - where - M: Into, - { - TsdlError::Message(message.into()) +impl From for Error { + fn from(error: tokio::task::JoinError) -> Self { + Error::Message { + message: format!("Task join error: {error}"), + } } +} - /// Format the error with indentation support - /// - /// # Panics - /// - /// This function will panic if writing to the string fails, which should never happen - /// since we're writing to a String which doesn't fail. +impl Error { #[must_use] pub fn format_indent(&self, indent: usize) -> String { let mut s = String::new(); - self.format(&mut s, indent).unwrap(); + let _ = self.format(&mut s, indent); s } fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { let prefix = " ".repeat(indent); match self { - TsdlError::Build(errs) => format_build_errors(w, errs, indent), - TsdlError::Command(e) => e.format(w, indent), - TsdlError::Config(msg) => write!(w, "{prefix}Configuration error: {msg}"), - TsdlError::Context(kind) => { + Error::Build { errors } => format_build_errors(w, errors, indent), + Error::Command { + msg, + stderr, + stdout, + } => format_command(w, indent, msg, stdout, stderr), + Error::Config { message } => write!(w, "{prefix}Configuration error: {message}"), + Error::Context { message, source } => { + write!( + w, + "{}{}\n{}", + prefix, + message, + source.as_error().format_indent(indent + 2) + ) + } + Error::Io { source } => write!(w, "{prefix}IO error: {source}"), + Error::Interrupted { signal } => write!(w, "{prefix}Interrupted by {signal}"), + Error::Language { name, source } => { write!( w, "{}{}\n{}", prefix, - kind.message, - kind.error.format_indent(indent + 2) + name, + source.as_error().format_indent(indent + 2) + ) + } + Error::LanguageCollection { related } => format_language_collection(w, related, indent), + Error::Message { message } => write!(w, "{prefix}{message}"), + Error::Step { name, kind, source } => { + write!( + w, + "{}{}: {}.\n{}", + prefix, + name, + kind, + source.as_error().format_indent(indent + 4) ) } - TsdlError::Io(e) => write!(w, "{prefix}IO error: {e}"), - TsdlError::Interrupted(signal) => write!(w, "{prefix}Interrupted by {signal}"), - TsdlError::Language(e) => e.format(w, indent), - TsdlError::LanguageCollection(e) => write!(w, "{prefix}{e}"), - TsdlError::Message(msg) => write!(w, "{prefix}{msg}"), - TsdlError::Step(e) => e.format(w, indent), } } } @@ -482,26 +365,27 @@ mod tests { #[test] fn test_error_formatting_with_indentation() { - // Simulate the jsonxxx error structure let stderr = "remote: Repository not found.\nfatal: repository 'https://github.com/tree-sitter/tree-sitter-jsonxxx/' not found"; - let command_error = Command { + let command_error = Error::Command { msg: "git fetch origin --depth 1 HEAD failed with exit status 128.".to_string(), stderr: stderr.to_string(), stdout: String::new(), }; - let step_error = Step { + let step_error = Error::Step { name: "jsonxxx".into(), kind: ParserOp::Clone { dir: PathBuf::from( "/home/firas/src/github.com/stackmystack/tsdl/tmp/tree-sitter-jsonxxx", ), }, - source: Box::new(command_error.into()), + source: command_error.into(), }; - let tsdl_error = TsdlError::Build(vec![TsdlError::Step(step_error)]); - let formatted = tsdl_error.format_indent(0); + let err = Error::Build { + errors: vec![step_error], + }; + let formatted = err.format_indent(0); let expected = r"Could not build all parsers. diff --git a/src/git.rs b/src/git.rs index 00bff15..2d5a61b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -2,13 +2,14 @@ use std::{ ffi::OsStr, fmt, path::{Component, Path, PathBuf}, + result::Result as StdResult, sync::Arc, }; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use tokio::{fs, process::Command}; -use crate::{error::TsdlError, sh::Exec, TsdlResult}; +use crate::{sh::Exec, Error, Result, ResultExt}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RefError { @@ -41,9 +42,13 @@ impl fmt::Display for RefError { impl std::error::Error for RefError {} -impl From for TsdlError { +type RefResult = StdResult; + +impl From for Error { fn from(error: RefError) -> Self { - TsdlError::message(error.to_string()) + Error::Message { + message: error.to_string(), + } } } @@ -52,7 +57,7 @@ pub struct Ref(Arc); impl Ref { /// Create a validated git ref. - pub fn new(value: impl Into>) -> Result { + pub fn new(value: impl Into>) -> RefResult { let value = value.into(); validate_git_ref(&value)?; Ok(Self(value)) @@ -95,7 +100,7 @@ impl AsRef for Ref { impl TryFrom<&str> for Ref { type Error = RefError; - fn try_from(value: &str) -> Result { + fn try_from(value: &str) -> StdResult { Self::new(value) } } @@ -103,7 +108,7 @@ impl TryFrom<&str> for Ref { impl TryFrom for Ref { type Error = RefError; - fn try_from(value: String) -> Result { + fn try_from(value: String) -> StdResult { Self::new(value) } } @@ -111,7 +116,7 @@ impl TryFrom for Ref { impl std::str::FromStr for Ref { type Err = RefError; - fn from_str(value: &str) -> Result { + fn from_str(value: &str) -> StdResult { Self::new(value) } } @@ -123,7 +128,7 @@ impl fmt::Display for Ref { } impl Serialize for Ref { - fn serialize(&self, serializer: S) -> Result + fn serialize(&self, serializer: S) -> StdResult where S: Serializer, { @@ -132,7 +137,7 @@ impl Serialize for Ref { } impl<'de> Deserialize<'de> for Ref { - fn deserialize(deserializer: D) -> Result + fn deserialize(deserializer: D) -> StdResult where D: Deserializer<'de>, { @@ -146,7 +151,7 @@ pub struct Sha(Arc); impl Sha { /// Create a validated full 40-character git SHA-1. - pub fn new(value: impl Into>) -> Result { + pub fn new(value: impl Into>) -> RefResult { let value = value.into(); validate_git_sha(&value)?; Ok(Self(value)) @@ -177,7 +182,7 @@ impl AsRef for Sha { impl TryFrom<&str> for Sha { type Error = RefError; - fn try_from(value: &str) -> Result { + fn try_from(value: &str) -> StdResult { Self::new(value) } } @@ -185,7 +190,7 @@ impl TryFrom<&str> for Sha { impl TryFrom for Sha { type Error = RefError; - fn try_from(value: String) -> Result { + fn try_from(value: String) -> StdResult { Self::new(value) } } @@ -193,7 +198,7 @@ impl TryFrom for Sha { impl std::str::FromStr for Sha { type Err = RefError; - fn from_str(value: &str) -> Result { + fn from_str(value: &str) -> StdResult { Self::new(value) } } @@ -205,7 +210,7 @@ impl fmt::Display for Sha { } impl Serialize for Sha { - fn serialize(&self, serializer: S) -> Result + fn serialize(&self, serializer: S) -> StdResult where S: Serializer, { @@ -214,7 +219,7 @@ impl Serialize for Sha { } impl<'de> Deserialize<'de> for Sha { - fn deserialize(deserializer: D) -> Result + fn deserialize(deserializer: D) -> StdResult where D: Deserializer<'de>, { @@ -249,7 +254,7 @@ impl fmt::Display for ResolvedRef { } } -fn validate_git_ref(value: &str) -> Result<(), RefError> { +fn validate_git_ref(value: &str) -> RefResult<()> { if value.is_empty() { return Err(RefError::EmptyRef); } @@ -315,7 +320,7 @@ fn validate_git_ref(value: &str) -> Result<(), RefError> { Ok(()) } -fn validate_git_sha(value: &str) -> Result<(), RefError> { +fn validate_git_sha(value: &str) -> RefResult<()> { if value.len() != 40 { return Err(RefError::InvalidShaLength { actual: value.len(), @@ -330,7 +335,7 @@ fn validate_git_sha(value: &str) -> Result<(), RefError> { } // TODO: get rid of async fs completely. -async fn clean_anyway(cwd: &Path) -> TsdlResult<()> { +async fn clean_anyway(cwd: &Path) -> Result<()> { if cwd.exists() { if cwd.is_dir() { fs::remove_dir_all(cwd).await @@ -341,7 +346,7 @@ async fn clean_anyway(cwd: &Path) -> TsdlResult<()> { Ok(()) } -pub async fn clone(repo: &str, cwd: &Path) -> TsdlResult<()> { +pub async fn clone(repo: &str, cwd: &Path) -> Result<()> { if cwd.exists() { Command::new("git") .current_dir(cwd) @@ -357,7 +362,7 @@ pub async fn clone(repo: &str, cwd: &Path) -> TsdlResult<()> { Ok(()) } -pub async fn checkout(repo: &str, git_ref: &Ref, cwd: &Path) -> TsdlResult { +pub async fn checkout(repo: &str, git_ref: &Ref, cwd: &Path) -> Result { checkout_with_force(repo, git_ref, cwd, false).await } @@ -366,7 +371,7 @@ pub async fn checkout_with_force( git_ref: &Ref, cwd: &Path, force: bool, -) -> TsdlResult { +) -> Result { if force || !is_same_remote(cwd, repo).await { clean_anyway(cwd).await?; } @@ -375,16 +380,13 @@ pub async fn checkout_with_force( } else { init_fetch_and_checkout(cwd, repo, git_ref).await?; } - let commit = get_head_sha(cwd).await.map_err(|err| { - TsdlError::context( - format!("Resolving checked out commit for {}", cwd.display()), - err, - ) - })?; + let commit = get_head_sha(cwd) + .await + .with_context(|| format!("Resolving checked out commit for {}", cwd.display()))?; Ok(Checkout { commit }) } -async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> TsdlResult<()> { +async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> Result<()> { Command::new("git") .env("GIT_TERMINAL_PROMPT", "0") .current_dir(cwd) @@ -399,7 +401,7 @@ async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> TsdlResult<()> { Ok(()) } -async fn get_head_sha1(cwd: &Path) -> TsdlResult { +async fn get_head_sha1(cwd: &Path) -> Result { String::from_utf8( Command::new("git") .current_dir(cwd) @@ -408,15 +410,15 @@ async fn get_head_sha1(cwd: &Path) -> TsdlResult { .await? .stdout, ) - .map_err(|e| TsdlError::context("rev-parse HEAD is not a valid utf-8", e)) + .context("rev-parse HEAD is not a valid utf-8") } -async fn get_head_sha(cwd: &Path) -> TsdlResult { +async fn get_head_sha(cwd: &Path) -> Result { let value = get_head_sha1(cwd).await?; - Sha::new(value.trim()).map_err(|e| TsdlError::context("Parsing HEAD commit", e)) + Sha::new(value.trim()).context("Parsing HEAD commit") } -async fn get_remote_url(cwd: &Path) -> TsdlResult { +async fn get_remote_url(cwd: &Path) -> Result { String::from_utf8( Command::new("git") .current_dir(cwd) @@ -425,10 +427,10 @@ async fn get_remote_url(cwd: &Path) -> TsdlResult { .await? .stdout, ) - .map_err(|e| TsdlError::context("remote get-url origin did not return a valid utf-8", e)) + .context("remote get-url origin did not return a valid utf-8") } -async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> TsdlResult<()> { +async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> Result<()> { clean_anyway(cwd).await?; fs::create_dir_all(cwd).await?; @@ -474,15 +476,15 @@ async fn is_valid_git_dir(cwd: &Path) -> bool { is_inside_work_tree && can_parse_head } -pub async fn list_grammar_files(cwd: &Path) -> TsdlResult> { +pub async fn list_grammar_files(cwd: &Path) -> Result> { let output = Command::new("git") .current_dir(cwd) .args(["ls-files", "--cached", "--others", "--exclude-standard"]) .exec() .await?; - let stdout = String::from_utf8(output.stdout) - .map_err(|e| TsdlError::context("git ls-files output is not valid utf-8", e))?; + let stdout = + String::from_utf8(output.stdout).context("git ls-files output is not valid utf-8")?; let exclude = [ ".github", "bindings", "doc", "docs", "examples", "queries", "script", "scripts", "test", @@ -523,7 +525,7 @@ pub async fn list_grammar_files(cwd: &Path) -> TsdlResult> { Ok(result) } -async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> TsdlResult<()> { +async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> Result<()> { if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { Command::new("git") .current_dir(cwd) @@ -535,7 +537,7 @@ async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> TsdlResult<()> { Ok(()) } -pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> TsdlResult { +pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> Result { // Try to find a tag for this ref let tag = Command::new("git") .current_dir(cwd) @@ -546,7 +548,7 @@ pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> TsdlResult { if let Ok(output) = tag { // Found a tag, use it String::from_utf8(output.stdout) - .map_err(|e| TsdlError::context("Failed to parse git tag output as UTF-8", e)) + .context("Failed to parse git tag output as UTF-8") .map(|s| s.trim().to_string()) } else { // No tag found (e.g., ref is a branch), fall back to commit SHA1 @@ -556,7 +558,7 @@ pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> TsdlResult { .exec() .await?; String::from_utf8(sha1.stdout) - .map_err(|e| TsdlError::context("Failed to parse git rev-parse output as UTF-8", e)) + .context("Failed to parse git rev-parse output as UTF-8") .map(|s| s.trim().to_string()) } } diff --git a/src/lib.rs b/src/lib.rs index 98d26f8..3ac179a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,8 +56,6 @@ use std::{ time::Duration, }; -use crate::error::TsdlError; - extern crate log; pub mod actors; @@ -70,6 +68,7 @@ pub mod config; pub mod consts; pub mod display; pub mod error; +pub use error::{Cause, Error, Result, ResultExt}; pub mod git; pub mod lock; pub mod logging; @@ -82,35 +81,34 @@ pub mod tree_sitter; pub mod walk; pub trait SafeCanonicalize { - fn canon(&self) -> TsdlResult; + fn canon(&self) -> Result; } impl SafeCanonicalize for Path { - fn canon(&self) -> TsdlResult { + fn canon(&self) -> Result { if self.is_absolute() { Ok(self.to_path_buf()) } else { - let current_dir = env::current_dir() - .map_err(|e| TsdlError::context("Failed to get current directory", e))?; + let current_dir = env::current_dir().context("Failed to get current directory")?; Ok(current_dir.join(self)) } } } impl SafeCanonicalize for PathBuf { - fn canon(&self) -> TsdlResult { + fn canon(&self) -> Result { self.as_path().canon() } } /// Convert a path to an absolute, lexically-normalized path without requiring /// the final path to exist. -pub fn absolute_normalize(path: &Path) -> TsdlResult { +pub fn absolute_normalize(path: &Path) -> Result { let absolute = if path.is_absolute() { path.to_path_buf() } else { env::current_dir() - .map_err(|e| TsdlError::context("Failed to get current directory", e))? + .context("Failed to get current directory")? .join(path) }; @@ -181,11 +179,8 @@ pub fn relative_to_cwd(dir: &Path) -> PathBuf { } } -/// Result type for tsdl operations -pub type TsdlResult = Result; - /// Prompt user for confirmation with default behavior -pub fn prompt_user(question: &str, default_yes: bool) -> TsdlResult { +pub fn prompt_user(question: &str, default_yes: bool) -> Result { let options = if default_yes { "[Y/n]" } else { "[y/N]" }; eprint!("{question} {options}: "); @@ -195,7 +190,7 @@ pub fn prompt_user(question: &str, default_yes: bool) -> TsdlResult { io::stdin() .read_line(&mut input) - .map_err(|e| TsdlError::context("Reading user input", e))?; + .context("Reading user input")?; let input = input.trim().to_lowercase(); diff --git a/src/lock.rs b/src/lock.rs index 3caece7..76d1bbc 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -5,7 +5,9 @@ use std::{ fs::{self, File, OpenOptions}, io::{self, Seek, SeekFrom, Write}, path::{Path, PathBuf}, - process, thread, + process, + result::Result as StdResult, + thread, time::{Duration, Instant}, }; @@ -13,7 +15,7 @@ use fs2::FileExt; use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, UpdateKind}; use tracing::info; -use crate::{absolute_normalize, consts, error, format_duration, TsdlResult}; +use crate::{absolute_normalize, consts, format_duration, Error, Result, ResultExt}; /// Information about the process currently holding the build lock. #[derive(Debug, Clone)] @@ -127,7 +129,7 @@ pub enum TakeoverError { last_observation: Box, }, /// An underlying tsdl error occurred while taking over the lock. - Source(error::TsdlError), + Source(Error), } impl TakeoverError { @@ -189,15 +191,17 @@ impl fmt::Display for TakeoverError { impl std::error::Error for TakeoverError {} -impl From for TakeoverError { - fn from(err: error::TsdlError) -> Self { +impl From for TakeoverError { + fn from(err: Error) -> Self { Self::Source(err) } } -impl From for error::TsdlError { +impl From for Error { fn from(err: TakeoverError) -> Self { - Self::message(err.to_string()) + Error::Message { + message: err.to_string(), + } } } @@ -225,12 +229,12 @@ impl Guard { /// The build directory itself is preserved; only its children are removed. /// This is used by `--fresh` to clean the build directory without /// invalidating OS locks or unlinking files held open by the current process. - pub fn clear_directory(&self, protected_files: &[PathBuf]) -> TsdlResult<()> { - let build_dir = self.lock_path.parent().ok_or_else(|| { - error::TsdlError::message(format!( + pub fn clear_directory(&self, protected_files: &[PathBuf]) -> Result<()> { + let build_dir = self.lock_path.parent().ok_or_else(|| Error::Message { + message: format!( "Lock path has no parent directory: {}", self.lock_path.display() - )) + ), })?; let build_dir_abs = absolute_normalize(build_dir)?; let mut protected_names = HashSet::::new(); @@ -238,7 +242,7 @@ impl Guard { let lock_name = self .lock_path .file_name() - .map_or_else(|| OsString::from(consts::TSDL_LOCK_FILE), OsString::from); + .map_or_else(|| OsString::from(consts::LOCK_FILE), OsString::from); protected_names.insert(lock_name); for protected in protected_files { @@ -250,18 +254,11 @@ impl Guard { } } - for entry in fs::read_dir(build_dir).map_err(|e| { - error::TsdlError::context( - format!("Reading build directory {}", build_dir.display()), - e, - ) - })? { - let entry = entry.map_err(|e| { - error::TsdlError::context( - format!("Reading directory entry in {}", build_dir.display()), - e, - ) - })?; + for entry in fs::read_dir(build_dir) + .with_context(|| format!("Reading build directory {}", build_dir.display()))? + { + let entry = entry + .with_context(|| format!("Reading directory entry in {}", build_dir.display()))?; let name = entry.file_name(); @@ -270,18 +267,16 @@ impl Guard { } let path = entry.path(); - let file_type = entry.file_type().map_err(|e| { - error::TsdlError::context(format!("Statting {}", path.display()), e) - })?; + let file_type = entry + .file_type() + .with_context(|| format!("Statting {}", path.display()))?; if file_type.is_dir() { - fs::remove_dir_all(&path).map_err(|e| { - error::TsdlError::context(format!("Removing directory {}", path.display()), e) - })?; + fs::remove_dir_all(&path) + .with_context(|| format!("Removing directory {}", path.display()))?; } else { - fs::remove_file(&path).map_err(|e| { - error::TsdlError::context(format!("Removing file {}", path.display()), e) - })?; + fs::remove_file(&path) + .with_context(|| format!("Removing file {}", path.display()))?; } } @@ -300,27 +295,27 @@ impl Lock { #[must_use] pub fn new(build_dir: &Path) -> Self { Self { - lock_path: build_dir.join(consts::TSDL_LOCK_FILE), + lock_path: build_dir.join(consts::LOCK_FILE), current_pid: Pid::from(process::id() as usize), } } /// Check lock status and acquire the OS lock if available. - pub fn try_acquire(&self) -> TsdlResult { + pub fn try_acquire(&self) -> Result { let file = self.open_lock_file()?; match file.try_lock_exclusive() { Ok(()) => self.activate(file).map(Status::Acquired), Err(err) if is_lock_contention(&err) => Ok(self.lock_status()), - Err(err) => Err(error::TsdlError::context( - format!("Acquiring build lock {}", self.lock_path.display()), - err, - )), + Err(err) => Err(Error::Context { + message: format!("Acquiring build lock {}", self.lock_path.display()), + source: err.into(), + }), } } /// Send SIGTERM to the process that held the lock when `owner` was captured. - pub fn terminate_owner(&self, owner: &Owner) -> Result<(), TakeoverError> { + pub fn terminate_owner(&self, owner: &Owner) -> StdResult<(), TakeoverError> { info!( "Sending SIGTERM to lock owner PID {} ({})", owner.pid, owner.name @@ -366,7 +361,7 @@ impl Lock { &self, owner: &Owner, timeout: Duration, - ) -> Result { + ) -> StdResult { info!( "Waiting up to {} for lock release from PID {}", crate::format_duration(timeout), @@ -420,7 +415,7 @@ impl Lock { delay = delay.saturating_mul(2).min(Duration::from_millis(500)); } } - fn activate(&self, mut file: File) -> TsdlResult { + fn activate(&self, mut file: File) -> Result { self.write_metadata(&mut file)?; info!("Acquired lock on build directory"); Ok(Guard { @@ -429,14 +424,10 @@ impl Lock { }) } - fn open_lock_file(&self) -> TsdlResult { + fn open_lock_file(&self) -> Result { if let Some(parent) = self.lock_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - error::TsdlError::context( - format!("Creating build directory {}", parent.display()), - e, - ) - })?; + fs::create_dir_all(parent) + .with_context(|| format!("Creating build directory {}", parent.display()))?; } OpenOptions::new() @@ -445,12 +436,7 @@ impl Lock { .create(true) .truncate(false) .open(&self.lock_path) - .map_err(|e| { - error::TsdlError::context( - format!("Opening lock file {}", self.lock_path.display()), - e, - ) - }) + .with_context(|| format!("Opening lock file {}", self.lock_path.display())) } /// Helper for checking process status and determining lock conflicts. @@ -495,17 +481,16 @@ impl Lock { }) } - fn read_pid(&self) -> TsdlResult { - let content = fs::read_to_string(&self.lock_path).map_err(|e| { - error::TsdlError::context(format!("Reading lock file {}", self.lock_path.display()), e) - })?; + fn read_pid(&self) -> Result { + let content = fs::read_to_string(&self.lock_path) + .with_context(|| format!("Reading lock file {}", self.lock_path.display()))?; - let pid: usize = content.trim().parse().map_err(|_| { - error::TsdlError::message(format!( + let pid: usize = content.trim().parse().map_err(|_| Error::Message { + message: format!( "Invalid PID '{}' in lock file {}", content.trim(), self.lock_path.display() - )) + ), })?; Ok(Pid::from(pid)) @@ -525,29 +510,20 @@ impl Lock { system } - fn write_metadata(&self, file: &mut File) -> TsdlResult<()> { - file.set_len(0).map_err(|e| { - error::TsdlError::context( - format!("Truncating lock file {}", self.lock_path.display()), - e, + fn write_metadata(&self, file: &mut File) -> Result<()> { + file.set_len(0) + .with_context(|| format!("Truncating lock file {}", self.lock_path.display()))?; + file.seek(SeekFrom::Start(0)) + .with_context(|| format!("Seeking lock file {}", self.lock_path.display()))?; + write!(file, "{}", self.current_pid.as_u32()).with_context(|| { + format!( + "Writing lock file {} with PID {}", + self.lock_path.display(), + self.current_pid ) })?; - file.seek(SeekFrom::Start(0)).map_err(|e| { - error::TsdlError::context(format!("Seeking lock file {}", self.lock_path.display()), e) - })?; - write!(file, "{}", self.current_pid.as_u32()).map_err(|e| { - error::TsdlError::context( - format!( - "Writing lock file {} with PID {}", - self.lock_path.display(), - self.current_pid - ), - e, - ) - })?; - file.sync_all().map_err(|e| { - error::TsdlError::context(format!("Syncing lock file {}", self.lock_path.display()), e) - }) + file.sync_all() + .with_context(|| format!("Syncing lock file {}", self.lock_path.display())) } } @@ -586,7 +562,7 @@ mod tests { #[test] fn stale_lock_metadata_does_not_prevent_acquiring_free_os_lock() { let temp = tempfile::tempdir().unwrap(); - let lock_file = temp.path().join(consts::TSDL_LOCK_FILE); + let lock_file = temp.path().join(consts::LOCK_FILE); fs::write(&lock_file, unused_pid().to_string()).unwrap(); let lock = Lock::new(temp.path()); diff --git a/src/logging.rs b/src/logging.rs index 831c2cf..2d31243 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -9,17 +9,17 @@ use tracing_appender::non_blocking::WorkerGuard; use tracing_log::AsTrace; use tracing_subscriber::{layer::SubscriberExt, Layer}; -use crate::{absolute_normalize, args, consts, error, TsdlResult}; +use crate::{absolute_normalize, args, consts, Error, Result, ResultExt}; #[allow(dead_code)] pub struct Guard(WorkerGuard); pub fn init( - log: Option, + log: Option<&PathBuf>, log_color: args::LogColor, verbose: clap_verbosity_flag::Verbosity, build_dir: &Path, -) -> TsdlResult<(PathBuf, Guard)> { +) -> Result<(PathBuf, Guard)> { let color = match log_color { args::LogColor::Auto => atty::is(atty::Stream::Stdout), args::LogColor::No => false, @@ -27,7 +27,7 @@ pub fn init( }; console::set_colors_enabled(color); let filter = verbose.log_level_filter().as_trace(); - let path = resolve_log_path(log.as_ref(), build_dir)?; + let path = resolve_log_path(log, build_dir)?; let file = open_log_file(&path)?; let (writer, guard) = tracing_appender::non_blocking(file); init_tracing(writer, color, filter); @@ -71,72 +71,77 @@ fn init_tracing( } } -fn resolve_log_path(log: Option<&PathBuf>, build_dir: &Path) -> TsdlResult { - let log = log.map_or_else(|| build_dir.join(consts::TSDL_LOG_FILE), Clone::clone); +fn resolve_log_path(log: Option<&PathBuf>, build_dir: &Path) -> Result { + let log = log.map_or_else(|| build_dir.join(consts::LOG_FILE), Clone::clone); validate_log_path(build_dir, &log) } -fn validate_log_path(build_dir: &Path, log: &Path) -> TsdlResult { +fn validate_log_path(build_dir: &Path, log: &Path) -> Result { let build_dir = absolute_normalize(build_dir)?; let log = absolute_normalize(log)?; if log == build_dir { - return Err(error::TsdlError::message(format!( - "--log must be a file path, not the build directory {}", - build_dir.display() - ))); + return Err(Error::Message { + message: format!( + "--log must be a file path, not the build directory {}", + build_dir.display() + ), + }); } if log.is_dir() { - return Err(error::TsdlError::message(format!( - "--log must be a file path, not a directory: {}", - log.display() - ))); + return Err(Error::Message { + message: format!( + "--log must be a file path, not a directory: {}", + log.display() + ), + }); } if log.starts_with(&build_dir) { - let relative = log.strip_prefix(&build_dir).map_err(|e| { - error::TsdlError::message(format!( + let relative = log.strip_prefix(&build_dir).map_err(|e| Error::Message { + message: format!( "Could not compare log path {} with build directory {}: {e}", log.display(), build_dir.display() - )) + ), })?; let component_count = relative.components().count(); if component_count != 1 { - return Err(error::TsdlError::message(format!( - "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", - log.display(), - build_dir.display() - ))); + return Err(Error::Message { + message: format!( + "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", + log.display(), + build_dir.display() + ), + }); } let Some(name) = relative.file_name() else { - return Err(error::TsdlError::message(format!( - "--log must be a file path: {}", - log.display() - ))); + return Err(Error::Message { + message: format!("--log must be a file path: {}", log.display()), + }); }; - if name == OsStr::new(consts::TSDL_LOCK_FILE) || name == OsStr::new(consts::TSDL_CACHE_FILE) - { - return Err(error::TsdlError::message(format!( - "--log path {} conflicts with a tsdl runtime/build file", - log.display() - ))); + if name == OsStr::new(consts::LOCK_FILE) || name == OsStr::new(consts::CACHE_FILE) { + return Err(Error::Message { + message: format!( + "--log path {} conflicts with a tsdl runtime/build file", + log.display() + ), + }); } } Ok(log) } -fn open_log_file(log: &Path) -> TsdlResult { +fn open_log_file(log: &Path) -> Result { let parent = log.parent().unwrap_or(Path::new(".")); if !parent.exists() { - fs::create_dir_all(parent) - .map_err(|e| error::TsdlError::context("Preparing log directory", e))?; + fs::create_dir_all(parent).context("Preparing log directory")?; } - File::create(log).map_err(|e| error::TsdlError::context("Creating log file", e)) + File::create(log).context("Creating log file") } diff --git a/src/main.rs b/src/main.rs index 65c1b0d..f9dc125 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,7 @@ use std::{process::ExitCode, time::Instant}; use console::style; use tracing::{error, info}; - -use tsdl::{TsdlResult, app, args, error::TsdlError}; +use tsdl::{app, args, error::Error, Result}; fn main() -> ExitCode { set_panic_hook(); @@ -17,7 +16,7 @@ fn main() -> ExitCode { info!("Starting"); match run(app) { - Err(TsdlError::Interrupted(signal)) => ExitCode::from(signal.shell_exit_code()), + Err(Error::Interrupted { signal }) => ExitCode::from(signal.shell_exit_code()), Err(e) => { eprintln!("{e}"); ExitCode::FAILURE @@ -26,13 +25,13 @@ fn main() -> ExitCode { } } -fn run(app: app::App) -> TsdlResult<()> { +fn run(app: app::App) -> Result<()> { match app.subcommand { tsdl::args::Command::Build => { let (result, duration) = time(|| tsdl::build::run(&app)); match &result { Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), - Err(TsdlError::Interrupted(signal)) => println!( + Err(Error::Interrupted { signal }) => println!( "{}", style(format!("Interrupted by {signal} after {duration}")).yellow() ), diff --git a/src/parser.rs b/src/parser.rs index faed235..3132471 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -6,6 +6,7 @@ use std::{ os::unix::fs::MetadataExt, path::{Path, PathBuf}, process, + result::Result as StdResult, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -21,7 +22,7 @@ use crate::{ sh::{Exec, Script}, shutdown, walk::collect_grammar_paths, - TsdlResult, + Error, Result, ResultExt, }; pub const WASM_EXTENSION: &str = "wasm"; @@ -142,7 +143,7 @@ impl Ref { /// Create a parser source ref from user input, preserving parser-version /// normalization while classifying refs for cache semantics. - pub fn parse(value: &str) -> Result { + pub fn parse(value: &str) -> StdResult { let normalized = normalize_source_ref(value); let git_ref = git::Ref::new(normalized)?; if is_stable_source_ref(value, &git_ref) { @@ -171,7 +172,7 @@ impl Ref { } impl Serialize for Ref { - fn serialize(&self, serializer: S) -> Result + fn serialize(&self, serializer: S) -> StdResult where S: Serializer, { @@ -180,7 +181,7 @@ impl Serialize for Ref { } impl<'de> Deserialize<'de> for Ref { - fn deserialize(deserializer: D) -> Result + fn deserialize(deserializer: D) -> StdResult where D: Deserializer<'de>, { @@ -244,7 +245,7 @@ pub struct GrammarBuild { impl GrammarBuild { /// Build this grammar, returning a cache update if it was built. /// Uses the language's progress handle for progress reporting. - pub async fn build(&self) -> TsdlResult> { + pub async fn build(&self) -> Result> { shutdown::test_delay().await; shutdown::check()?; debug!( @@ -276,10 +277,9 @@ impl GrammarBuild { // Use the grammar directory path provided if !self.dir.exists() { - let err = error::TsdlError::message(format!( - "Grammar directory not found: {}", - self.dir.display() - )); + let err = Error::Message { + message: format!("Grammar directory not found: {}", self.dir.display()), + }; self.progress.err("missing grammar directory").await; return Err(err); } @@ -321,7 +321,7 @@ impl GrammarBuild { cmd } - async fn build_grammar(&self) -> TsdlResult<()> { + async fn build_grammar(&self) -> Result<()> { shutdown::test_delay().await; shutdown::check()?; @@ -344,7 +344,7 @@ impl GrammarBuild { Ok(()) } - async fn build_target(&self, kind: ArtifactKind) -> TsdlResult { + async fn build_target(&self, kind: ArtifactKind) -> Result { shutdown::test_delay().await; shutdown::check()?; let ext = kind.extension(); @@ -360,7 +360,7 @@ impl GrammarBuild { self.build_builtin_target(kind).await } - async fn build_builtin_target(&self, kind: ArtifactKind) -> TsdlResult { + async fn build_builtin_target(&self, kind: ArtifactKind) -> Result { let artifact = self.artifact_path(kind)?; ensure_parent_dir(&artifact).await?; @@ -374,7 +374,7 @@ impl GrammarBuild { Ok(artifact) } - async fn build_custom_target(&self, kind: ArtifactKind, script: &str) -> TsdlResult { + async fn build_custom_target(&self, kind: ArtifactKind, script: &str) -> Result { let mut cmd = Command::from_str(script); cmd.current_dir(self.dir.as_ref()) .exec() @@ -388,17 +388,17 @@ impl GrammarBuild { Ok(artifact) } - fn build_step_error(&self, err: error::TsdlError) -> error::TsdlError { - error::TsdlError::Step(error::Step::new( - self.language.as_arc(), - error::ParserOp::Build { + fn build_step_error(&self, err: Error) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Build { dir: self.dir.to_path_buf(), }, - err, - )) + source: err.into(), + } } - async fn build_targets(&self) -> TsdlResult<()> { + async fn build_targets(&self) -> Result<()> { if self.spec.target.native() { self.build_target(ArtifactKind::Native).await?; } @@ -410,50 +410,39 @@ impl GrammarBuild { Ok(()) } - async fn create_hardlink(&self, src: &Path, dst: &Path) -> TsdlResult<()> { - fs::hard_link(src, dst).await.map_err(|e| { - error::TsdlError::context( - format!( - "Could not hardlink {} to {}. build-dir and out-dir must be on the same filesystem", - src.display(), - dst.display() - ), - e, + async fn create_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { + fs::hard_link(src, dst).await.with_context(|| { + format!( + "Could not hardlink {} to {}. build-dir and out-dir must be on the same filesystem", + src.display(), + dst.display() ) }) } - async fn brute_force_discover(&self, kind: ArtifactKind) -> TsdlResult { + async fn brute_force_discover(&self, kind: ArtifactKind) -> Result { let ext = kind.extension(); let expected_name = self.parser_name_and_ext(kind); - let mut files = fs::read_dir(self.dir.as_ref()).await.map_err(|e| { - error::TsdlError::context( - format!("Failed to read directory {}", self.dir.display()), - e, - ) - })?; + let mut files = fs::read_dir(self.dir.as_ref()) + .await + .with_context(|| format!("Failed to read directory {}", self.dir.display()))?; let mut exact_match = None; let mut candidates = Vec::new(); loop { - let Some(entry) = files.next_entry().await.map_err(|e| { - error::TsdlError::context( - format!("Failed to read directory entry in {}", self.dir.display()), - e, - ) + let Some(entry) = files.next_entry().await.with_context(|| { + format!("Failed to read directory entry in {}", self.dir.display()) })? else { break; }; let path = entry.path(); - let file_type = entry.file_type().await.map_err(|e| { - error::TsdlError::context( - format!("Failed to read file type for {}", path.display()), - e, - ) - })?; + let file_type = entry + .file_type() + .await + .with_context(|| format!("Failed to read file type for {}", path.display()))?; if !file_type.is_file() { continue; @@ -480,25 +469,23 @@ impl GrammarBuild { } } - async fn generate(&self) -> TsdlResult<()> { + async fn generate(&self) -> Result<()> { Command::new(self.ts_cli.as_os_str()) .current_dir(self.dir.as_path()) .arg("generate") .exec() .await .map(|_| ()) - .map_err(|err| { - error::TsdlError::Step(error::Step::new( - self.language.as_arc(), - error::ParserOp::Generate { - dir: self.dir.to_path_buf(), - }, - err, - )) + .map_err(|err| Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Generate { + dir: self.dir.to_path_buf(), + }, + source: err.into(), }) } - async fn install(&self) -> TsdlResult<()> { + async fn install(&self) -> Result<()> { // Find and install parser binary for each extension if self.spec.target.native() { self.install_binary(ArtifactKind::Native).await?; @@ -511,12 +498,12 @@ impl GrammarBuild { Ok(()) } - async fn install_binary(&self, kind: ArtifactKind) -> TsdlResult<()> { + async fn install_binary(&self, kind: ArtifactKind) -> Result<()> { let src = self.artifact_path(kind)?; let dst = self.output.out_dir.join(self.parser_name_and_ext(kind)); let src_metadata = fs::metadata(&src) .await - .map_err(|e| error::TsdlError::context(format!("Reading {}", src.display()), e))?; + .with_context(|| format!("Reading {}", src.display()))?; let dst_link_metadata = match fs::symlink_metadata(&dst).await { Ok(metadata) => metadata, @@ -525,10 +512,10 @@ impl GrammarBuild { return Ok(()); } Err(err) => { - return Err(error::TsdlError::context( - format!("Reading {}", dst.display()), - err, - )); + return Err(Error::Context { + message: format!("Reading {}", dst.display()), + source: err.into(), + }); } }; @@ -542,22 +529,26 @@ impl GrammarBuild { src_metadata: &Metadata, dst: &Path, dst_link_metadata: &Metadata, - ) -> TsdlResult<()> { + ) -> Result<()> { let dst_file_type = dst_link_metadata.file_type(); if dst_file_type.is_dir() { - return Err(error::TsdlError::message(format!( - "Output path is a directory and cannot be replaced: {}", - dst.display() - ))); + return Err(Error::Message { + message: format!( + "Output path is a directory and cannot be replaced: {}", + dst.display() + ), + }); } if dst_file_type.is_symlink() { if !self.context.overwrite_output { - return Err(error::TsdlError::message(format!( - "Output path is a symlink and will not be replaced without --force: {}", - dst.display() - ))); + return Err(Error::Message { + message: format!( + "Output path is a symlink and will not be replaced without --force: {}", + dst.display() + ), + }); } self.replace_with_hardlink(src, dst).await?; @@ -566,15 +557,17 @@ impl GrammarBuild { } if !dst_file_type.is_file() { - return Err(error::TsdlError::message(format!( - "Output path is not a regular file and cannot be replaced: {}", - dst.display() - ))); + return Err(Error::Message { + message: format!( + "Output path is not a regular file and cannot be replaced: {}", + dst.display() + ), + }); } let dst_metadata = fs::metadata(dst) .await - .map_err(|e| error::TsdlError::context(format!("Reading {}", dst.display()), e))?; + .with_context(|| format!("Reading {}", dst.display()))?; if same_file_identity(src_metadata, &dst_metadata) { return Ok(()); @@ -584,10 +577,12 @@ impl GrammarBuild { same_regular_file_contents(src, src_metadata, dst, &dst_metadata).await?; if !same_contents && !self.context.overwrite_output { - return Err(error::TsdlError::message(format!( - "Output already exists and differs from the built parser: {}. Use --force to replace it.", - dst.display() - ))); + return Err(Error::Message { + message: format!( + "Output already exists and differs from the built parser: {}. Use --force to replace it.", + dst.display() + ), + }); } self.replace_with_hardlink(src, dst).await?; @@ -595,17 +590,19 @@ impl GrammarBuild { Ok(()) } - async fn stage_artifact(&self, src: &Path, dst: &Path) -> TsdlResult<()> { + async fn stage_artifact(&self, src: &Path, dst: &Path) -> Result<()> { ensure_parent_dir(dst).await?; let src_metadata = fs::metadata(src) .await - .map_err(|e| error::TsdlError::context(format!("Reading {}", src.display()), e))?; + .with_context(|| format!("Reading {}", src.display()))?; if !src_metadata.is_file() { - return Err(error::TsdlError::message(format!( - "Discovered parser artifact is not a regular file: {}", - src.display() - ))); + return Err(Error::Message { + message: format!( + "Discovered parser artifact is not a regular file: {}", + src.display() + ), + }); } match fs::metadata(dst).await { @@ -613,10 +610,10 @@ impl GrammarBuild { Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} Err(err) => { - return Err(error::TsdlError::context( - format!("Reading {}", dst.display()), - err, - )); + return Err(Error::Context { + message: format!("Reading {}", dst.display()), + source: err.into(), + }); } } @@ -628,7 +625,7 @@ impl GrammarBuild { ts_cli: &Path, spec: &build::Spec, grammar_name: &GrammarName, - ) -> TsdlResult> { + ) -> Result> { let mut artifacts = Vec::new(); if spec.target.native() { @@ -654,7 +651,7 @@ impl GrammarBuild { Ok(artifacts) } - fn artifact_path(&self, kind: ArtifactKind) -> TsdlResult { + fn artifact_path(&self, kind: ArtifactKind) -> Result { artifact_path_for( &self.output.build_dir, &self.ts_cli, @@ -664,40 +661,46 @@ impl GrammarBuild { ) } - async fn replace_with_hardlink(&self, src: &Path, dst: &Path) -> TsdlResult<()> { + async fn replace_with_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { let tmp = temp_install_path(dst)?; self.create_hardlink(src, &tmp).await?; if let Err(err) = fs::rename(&tmp, dst).await { let _ = fs::remove_file(&tmp).await; - return Err(error::TsdlError::context( - format!("Installing {} to {}", src.display(), dst.display()), - err, - )); + return Err(Error::Context { + message: format!("Installing {} to {}", src.display(), dst.display()), + source: err.into(), + }); } Ok(()) } - fn missing_parser_error(&self, ext: &str) -> error::TsdlError { - error::TsdlError::Step(error::Step::new( - self.language.as_arc(), - error::ParserOp::Copy { + fn missing_parser_error(&self, ext: &str) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Copy { src: self.output.out_dir.to_path_buf(), dst: self.output.build_dir.to_path_buf(), }, - error::TsdlError::message(format!("Couldn't find any {ext} file")), - )) + source: Error::Message { + message: format!("Couldn't find any {ext} file"), + } + .into(), + } } - fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> error::TsdlError { - error::TsdlError::Step(error::Step::new( - self.language.as_arc(), - error::ParserOp::Copy { + fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Copy { src: self.output.out_dir.to_path_buf(), dst: self.output.build_dir.to_path_buf(), }, - error::TsdlError::message(format!("Found multiple {ext} files: {candidates:?}")), - )) + source: Error::Message { + message: format!("Found multiple {ext} files: {candidates:?}"), + } + .into(), + } } fn parser_name_and_ext(&self, kind: ArtifactKind) -> String { @@ -731,16 +734,16 @@ impl LanguageBuild { pub async fn discover_grammars( &self, - ) -> TsdlResult> { + ) -> Result> { let file_results = collect_grammar_paths(self.output.build_dir.clone()).await?; let mut grammars = Vec::new(); for (grammar_path, hash) in file_results { - let grammar_dir = grammar_path.parent().ok_or_else(|| { - error::TsdlError::Message(format!( + let grammar_dir = grammar_path.parent().ok_or_else(|| Error::Message { + message: format!( "Could not get parent directory for {}", grammar_path.display() - )) + ), })?; let grammar_name = extract_grammar_name(grammar_dir)?; grammars.push((grammar_name, grammar_dir.to_path_buf(), hash)); @@ -757,21 +760,19 @@ impl LanguageBuild { self } - pub async fn checkout(&self) -> TsdlResult { + pub async fn checkout(&self) -> Result { git::checkout( self.spec.repo.as_str(), self.spec.git_ref.requested(), &self.output.build_dir, ) .await - .map_err(|err| { - error::TsdlError::Step(error::Step::new( - self.name.as_arc(), - error::ParserOp::Clone { - dir: self.output.build_dir.to_path_buf(), - }, - err, - )) + .map_err(|err| Error::Step { + name: self.name.as_arc(), + kind: error::ParserOp::Clone { + dir: self.output.build_dir.to_path_buf(), + }, + source: err.into(), }) } @@ -790,21 +791,21 @@ fn artifact_path_for( spec: &build::Spec, grammar_name: &GrammarName, kind: ArtifactKind, -) -> TsdlResult { +) -> Result { Ok(build_dir .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) .join(parser_name_and_ext(&spec.prefix, grammar_name, kind))) } -fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> TsdlResult { +fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> Result { let file_name = ts_cli .file_name() .and_then(|name| name.to_str()) - .ok_or_else(|| { - error::TsdlError::message(format!( + .ok_or_else(|| Error::Message { + message: format!( "Could not derive artifact id from tree-sitter CLI path {}", ts_cli.display() - )) + ), })?; let id = file_name.strip_prefix("tree-sitter-").unwrap_or(file_name); Ok(format!("tsdl-{}", sanitize_path_component(id))) @@ -829,31 +830,30 @@ fn sanitize_path_component(value: &str) -> String { } } -async fn ensure_parent_dir(path: &Path) -> TsdlResult<()> { - let parent = path.parent().ok_or_else(|| { - error::TsdlError::message(format!( +async fn ensure_parent_dir(path: &Path) -> Result<()> { + let parent = path.parent().ok_or_else(|| Error::Message { + message: format!( "Could not determine parent directory for {}", path.display() - )) + ), })?; fs::create_dir_all(parent) .await - .map_err(|e| error::TsdlError::context(format!("Creating {}", parent.display()), e)) + .with_context(|| format!("Creating {}", parent.display())) } -async fn verify_artifact(path: &Path) -> TsdlResult<()> { - let metadata = fs::metadata(path).await.map_err(|e| { - error::TsdlError::context(format!("Reading built artifact {}", path.display()), e) - })?; +async fn verify_artifact(path: &Path) -> Result<()> { + let metadata = fs::metadata(path) + .await + .with_context(|| format!("Reading built artifact {}", path.display()))?; if metadata.is_file() { Ok(()) } else { - Err(error::TsdlError::message(format!( - "Built artifact is not a regular file: {}", - path.display() - ))) + Err(Error::Message { + message: format!("Built artifact is not a regular file: {}", path.display()), + }) } } @@ -866,7 +866,7 @@ async fn same_regular_file_contents( src_metadata: &Metadata, dst: &Path, dst_metadata: &Metadata, -) -> TsdlResult { +) -> Result { if src_metadata.len() != dst_metadata.len() { return Ok(false); } @@ -876,19 +876,19 @@ async fn same_regular_file_contents( Ok(src_hash == dst_hash) } -fn temp_install_path(dst: &Path) -> TsdlResult { - let file_name = dst.file_name().ok_or_else(|| { - error::TsdlError::message(format!( +fn temp_install_path(dst: &Path) -> Result { + let file_name = dst.file_name().ok_or_else(|| Error::Message { + message: format!( "Could not create temporary install path for {}", dst.display() - )) + ), })?; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) - .map_err(|e| { - error::TsdlError::message(format!( + .map_err(|e| Error::Message { + message: format!( "System clock is before UNIX epoch while creating temporary install path: {e}" - )) + ), })? .as_nanos(); let mut tmp_name = file_name.to_os_string(); @@ -897,16 +897,16 @@ fn temp_install_path(dst: &Path) -> TsdlResult { Ok(dst.with_file_name(tmp_name)) } -fn extract_dir_name(dir: &Path) -> TsdlResult { +fn extract_dir_name(dir: &Path) -> Result { dir.file_name() .map(|n| n.to_string_lossy().to_string()) - .ok_or_else(|| { - error::TsdlError::Message(format!("Could not get dir name for {}", dir.display())) + .ok_or_else(|| Error::Message { + message: format!("Could not get dir name for {}", dir.display()), }) } /// Extract grammar name from directory (strips "tree-sitter-" prefix if present) -fn extract_grammar_name(dir: &Path) -> TsdlResult { +fn extract_grammar_name(dir: &Path) -> Result { let dir_name = extract_dir_name(dir)?; let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); Ok(GrammarName::from(name)) diff --git a/src/selfupdate.rs b/src/selfupdate.rs index d165d2b..ea9e3a3 100644 --- a/src/selfupdate.rs +++ b/src/selfupdate.rs @@ -3,9 +3,7 @@ use std::{fs, path::PathBuf}; use self_update::self_replace; use semver::Version; -use crate::{ - TsdlResult, args::VersionBump, consts::TREE_SITTER_PLATFORM, error::TsdlError, prompt_user -}; +use crate::{args::VersionBump, consts::PLATFORM, Error, prompt_user, Result, ResultExt }; enum UpdateTarget { Exact(Version), @@ -16,13 +14,13 @@ fn download_and_replace( asset_name: &str, download_url: &str, version: &Version, -) -> TsdlResult<()> { +) -> Result<()> { let tsdl = env!("CARGO_PKG_NAME"); let tmp_dir = tempfile::tempdir() - .map_err(|e| TsdlError::context("Failed to create temporary directory", e))?; + .context("Failed to create temporary directory")?; let tmp_gz_path = tmp_dir.path().join(asset_name); let tmp_gz = fs::File::create_new(&tmp_gz_path) - .map_err(|e| TsdlError::context("Failed to create temporary file", e))?; + .context("Failed to create temporary file")?; eprintln!("downloading {version}"); self_update::Download::from_url(download_url) @@ -30,10 +28,10 @@ fn download_and_replace( reqwest::header::ACCEPT, "application/octet-stream" .parse() - .map_err(|e| TsdlError::context("Failed to parse accept header", e))?, + .context("Failed to parse accept header")?, ) .download_to(&tmp_gz) - .map_err(|e| TsdlError::context("Failed to download release asset", e))?; + .context("Failed to download release asset")?; eprintln!("extracting {version}"); let tsdl_bin = PathBuf::from(tsdl); @@ -42,45 +40,45 @@ fn download_and_replace( self_update::Compression::Gz, ))) .extract_file(tmp_dir.path(), &tsdl_bin) - .map_err(|e| TsdlError::context("Failed to extract release asset", e))?; + .with_context(|| "Failed to extract release asset".to_string())?; let new_exe = tmp_dir.path().join(tsdl_bin); self_replace::self_replace(new_exe) - .map_err(|e| TsdlError::context("Failed to replace current executable", e))?; + .with_context(|| "Failed to replace current executable".to_string())?; eprintln!("{version}"); Ok(()) } -fn parse_target(raw: &str) -> Result { +fn parse_target(raw: &str) -> Result { match raw { "major" => Ok(UpdateTarget::Relative(VersionBump::Major)), "minor" => Ok(UpdateTarget::Relative(VersionBump::Minor)), "patch" => Ok(UpdateTarget::Relative(VersionBump::Patch)), - other => Version::parse(other).map(UpdateTarget::Exact).map_err(|e| { - format!("expected 'patch', 'minor', 'major', or a semver like '2.5.0': {e}") - }), + other => Version::parse(other).map(UpdateTarget::Exact).context( + "expected 'patch', 'minor', 'major', or a semver like '2.5.0'" + ), } } -pub fn run(force: bool, target: &str) -> TsdlResult<()> { - let update_target = parse_target(target).map_err(TsdlError::message)?; +pub fn run(force: bool, target: &str) -> Result<()> { + let update_target = parse_target(target)?; let tsdl = env!("CARGO_PKG_NAME"); let current_version = Version::parse(env!("CARGO_PKG_VERSION")) - .map_err(|e| TsdlError::context("Failed to parse current version", e))?; + .with_context(|| "Failed to parse current version".to_string())?; eprintln!("fetching releases"); let releases = self_update::backends::github::ReleaseList::configure() .repo_owner("stackmystack") .repo_name(tsdl) .build() - .map_err(|e| TsdlError::context("Failed to build release list configuration", e))? + .with_context(|| "Failed to build release list configuration".to_string())? .fetch() - .map_err(|e| TsdlError::context("Failed to fetch releases", e))?; + .with_context(|| "Failed to fetch releases".to_string())?; if releases.is_empty() { - return Err(TsdlError::message("No releases found")); + return Err(Error::Message{ message: "No releases found".to_string() }); } let (release, version) = match update_target { @@ -89,9 +87,9 @@ pub fn run(force: bool, target: &str) -> TsdlResult<()> { .iter() .find(|r| Version::parse(&r.version).is_ok_and(|v| v == target_version)) else { - return Err(TsdlError::message(format!( - "version {target_version} not found in releases" - ))); + return Err(Error::Message { + message: format!("version {target_version} not found in releases") + }); }; if target_version == current_version { @@ -153,16 +151,16 @@ pub fn run(force: bool, target: &str) -> TsdlResult<()> { let latest_release = compatible[0]; let latest_version = Version::parse(&latest_release.version) - .map_err(|e| TsdlError::context("Failed to parse latest version", e))?; + .context("Failed to parse latest version")?; (latest_release.clone(), latest_version) } }; - let asset_name = format!("{tsdl}-{TREE_SITTER_PLATFORM}.gz"); + let asset_name = format!("{tsdl}-{PLATFORM}.gz"); let Some(asset) = release.assets.iter().find(|a| a.name == asset_name) else { - return Err(TsdlError::message( - "Could not find a suitable release for your platform", - )); + return Err(Error::Message { + message: "Could not find a suitable release for your platform".to_string(), + }); }; download_and_replace(&asset.name, &asset.download_url, &version) diff --git a/src/sh.rs b/src/sh.rs index dc755df..0e2f967 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -5,15 +5,14 @@ use tokio::{process::Command, time}; use tracing::{debug, error, info, trace, warn}; use crate::{ - error, shutdown::{self, PgId}, - TsdlResult, + Error, Result, ResultExt, }; pub trait Exec { - fn display(&self) -> TsdlResult; - fn display_full(&self) -> TsdlResult; - fn exec(&mut self) -> impl std::future::Future>; + fn display(&self) -> Result; + fn display_full(&self) -> Result; + fn exec(&mut self) -> impl std::future::Future>; } pub trait Script { @@ -21,25 +20,22 @@ pub trait Script { } impl Exec for Command { - fn display(&self) -> TsdlResult { + fn display(&self) -> Result { let program = self.as_std().get_program().to_string_lossy(); let args = self.as_std().get_args(); let mut res = String::new(); - write!(res, "{program} ").map_err(|e| { - error::TsdlError::context("Failed to write program to display string", e) - })?; + write!(res, "{program} ").context("Failed to write program to display string")?; for arg in args { - write!(res, "{} ", arg.to_string_lossy()).map_err(|e| { - error::TsdlError::context("Failed to write argument to display string", e) - })?; + write!(res, "{} ", arg.to_string_lossy()) + .context("Failed to write argument to display string")?; } Ok(res.trim_end().to_string()) } - fn display_full(&self) -> TsdlResult { + fn display_full(&self) -> Result { let cwd = self.as_std().get_current_dir(); let base = self.display()?; @@ -50,7 +46,7 @@ impl Exec for Command { } #[tracing::instrument(skip(self))] - async fn exec(&mut self) -> TsdlResult { + async fn exec(&mut self) -> Result { let cmd_full = self.display_full()?; let cmd_short = self.display()?; trace!("{cmd_full}"); @@ -68,14 +64,15 @@ impl Exec for Command { // sub-tree (e.g. `tree-sitter build` → `node` → `cc`). self.as_std_mut().process_group(0); - let child = self - .spawn() - .map_err(|e| error::TsdlError::context("Failed to execute command", e))?; + let child = self.spawn().context("Failed to execute command")?; let child_pid = child.id(); - let pgid = child_pid.map(PgId::try_from).transpose().map_err(|e| { - error::TsdlError::message(format!("Invalid child process group id: {e}")) - })?; + let pgid = child_pid + .map(PgId::try_from) + .transpose() + .map_err(|e| Error::Message { + message: format!("Invalid child process group id: {e}"), + })?; debug!("spawned pid={child_pid:?} cmd={cmd_short}"); let pgid_guard = match (pgid, shutdown::current()) { @@ -139,14 +136,13 @@ impl Exec for Command { // tiny window is rare, but this is exactly the kind of edge case // process-group code tries to avoid. drop(pgid_guard); - return Err(error::TsdlError::Interrupted(signal)); + return Err(Error::Interrupted { signal }); } }; drop(pgid_guard); - let output = - output.map_err(|e| error::TsdlError::context("Failed to execute command", e))?; + let output = output.context("Failed to execute command")?; if output.status.success() { return Ok(output); @@ -166,12 +162,11 @@ impl Exec for Command { error!("{msg}\nStdOut:\n{stdout}\nStdErr\n{stderr}"); - Err(error::Command { + Err(Error::Command { msg, stderr, stdout, - } - .into()) + }) } } diff --git a/src/shutdown.rs b/src/shutdown.rs index f62bd18..cff0136 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -3,13 +3,14 @@ use std::{ fmt, future::Future, num::NonZeroU32, + result::Result as StdResult, sync::{Arc, Mutex}, }; use tokio::sync::watch; use tracing::{debug, info}; -use crate::{error::TsdlError, TsdlResult}; +use crate::{Error, Result, ResultExt}; tokio::task_local! { static CURRENT_SHUTDOWN: Handle; @@ -106,7 +107,7 @@ impl fmt::Display for PgId { impl TryFrom for PgId { type Error = PgIdError; - fn try_from(value: u32) -> Result { + fn try_from(value: u32) -> StdResult { let value = NonZeroU32::new(value).ok_or(PgIdError::Zero)?; ::try_from(value.get()).map_err(|_| PgIdError::OutOfRange(value.get()))?; Ok(Self(value)) @@ -242,9 +243,9 @@ impl Handle { } } - pub fn check(&self) -> TsdlResult<()> { + pub fn check(&self) -> Result<()> { if let Some(signal) = self.reason() { - Err(TsdlError::Interrupted(signal)) + Err(Error::Interrupted { signal }) } else { Ok(()) } @@ -286,19 +287,19 @@ impl Handle { } #[cfg(unix)] - pub fn spawn_signal_listener(&self) -> TsdlResult> { + pub fn spawn_signal_listener(&self) -> Result> { use std::process; use tokio::signal::unix::{signal, SignalKind}; let mut sighup = signal(SignalKind::from_raw(Signal::HUP.number)) - .map_err(|e| TsdlError::context("Installing SIGHUP handler", e))?; + .context("Installing SIGHUP handler")?; let mut sigint = signal(SignalKind::from_raw(Signal::INT.number)) - .map_err(|e| TsdlError::context("Installing SIGINT handler", e))?; + .context("Installing SIGINT handler")?; let mut sigquit = signal(SignalKind::from_raw(Signal::QUIT.number)) - .map_err(|e| TsdlError::context("Installing SIGQUIT handler", e))?; + .context("Installing SIGQUIT handler")?; let mut sigterm = signal(SignalKind::from_raw(Signal::TERM.number)) - .map_err(|e| TsdlError::context("Installing SIGTERM handler", e))?; + .context("Installing SIGTERM handler")?; let shutdown = self.clone(); Ok(tokio::spawn(async move { @@ -367,7 +368,7 @@ pub fn current_or_default() -> Handle { current().unwrap_or_default() } -pub fn check() -> TsdlResult<()> { +pub fn check() -> Result<()> { current().map_or(Ok(()), |shutdown| shutdown.check()) } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 07f5e32..f1d3d03 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use std::collections::HashMap; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; +use std::result::Result as StdResult; use async_compression::tokio::bufread::GzipDecoder; use tokio::{fs, io, process::Command}; @@ -13,8 +14,7 @@ use crate::args; use crate::git; use crate::sh::Exec; use crate::shutdown; -use crate::SafeCanonicalize; -use crate::{error, TsdlResult}; +use crate::{Result, ResultExt, SafeCanonicalize}; #[derive(Debug, Clone)] pub struct PreparedCli { @@ -22,15 +22,15 @@ pub struct PreparedCli { pub tree_sitter: args::TreeSitter, } -async fn chmod_x(prog: &Path) -> TsdlResult<()> { - let metadata = fs::metadata(prog).await.map_err(|e| { - error::TsdlError::context(format!("getting metadata for {}", prog.display()), e) - })?; +async fn chmod_x(prog: &Path) -> Result<()> { + let metadata = fs::metadata(prog) + .await + .with_context(|| format!("getting metadata for {}", prog.display()))?; let mut permissions = metadata.permissions(); permissions.set_mode(permissions.mode() | 0o111); fs::set_permissions(prog, permissions) .await - .map_err(|e| error::TsdlError::context(format!("chmod +x {}", prog.display()), e)) + .with_context(|| format!("chmod +x {}", prog.display())) } async fn cli( @@ -39,7 +39,7 @@ async fn cli( platform: &str, repo: &str, tag: &str, -) -> TsdlResult { +) -> Result { let cli = format!("tree-sitter-{platform}"); let res = PathBuf::new() .join(build_dir) @@ -67,7 +67,7 @@ async fn resolve_release_tag( handle: &actors::ProgressAddr, repo: &str, resolved_ref: &git::ResolvedRef, -) -> TsdlResult { +) -> Result { let tag = match resolved_ref { git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), git::ResolvedRef::Ref(git_ref) => { @@ -80,34 +80,34 @@ async fn resolve_release_tag( Ok(tag.into_owned()) } -async fn download(gz: &Path, url: &str) -> TsdlResult<()> { +async fn download(gz: &Path, url: &str) -> Result<()> { fs::write( gz, reqwest::get(url) .await - .map_err(|e| error::TsdlError::context("fetch", e))? + .context("fetch")? .bytes() .await - .map_err(|e| error::TsdlError::context("fetching bytes", e))?, + .context("fetching bytes")?, ) .await - .map_err(|e| error::TsdlError::context(format!("downloading {url} to {}", gz.display()), e)) + .with_context(|| format!("downloading {url} to {}", gz.display())) } -async fn download_and_extract(gz: &Path, url: &str, res: &Path) -> TsdlResult<()> { +async fn download_and_extract(gz: &Path, url: &str, res: &Path) -> Result<()> { download(gz, url).await?; gunzip(gz, res).await?; chmod_x(res).await?; fs::remove_file(gz) .await - .map_err(|e| error::TsdlError::context(format!("removing {}", gz.display()), e))?; + .with_context(|| format!("removing {}", gz.display()))?; Ok(()) } fn find_tag( refs: &HashMap, version: &str, -) -> Result { +) -> StdResult { refs.get_key_value(&format!("v{version}")) .or_else(|| refs.get_key_value(version)) .map_or_else( @@ -139,21 +139,21 @@ fn normalize_release_ref(value: &str) -> String { } } -async fn gunzip(gz: &Path, to: &Path) -> TsdlResult<()> { +async fn gunzip(gz: &Path, to: &Path) -> Result<()> { let file = fs::File::open(gz) .await - .map_err(|e| error::TsdlError::context(format!("opening {}", gz.display()), e))?; + .with_context(|| format!("opening {}", gz.display()))?; let mut decompressor = GzipDecoder::new(tokio::io::BufReader::new(file)); // let path = gz.with_extension(""); let mut file = tokio::fs::File::create(to) .await - .map_err(|e| error::TsdlError::context(format!("creating {}", to.display()), e))?; + .with_context(|| format!("creating {}", to.display()))?; io::copy(&mut decompressor, &mut file) .await .and(Ok(())) - .map_err(|e| error::TsdlError::context(format!("decompressing {}", gz.display()), e)) + .with_context(|| format!("decompressing {}", gz.display())) } fn parse_refs(stdout: &str) -> HashMap { @@ -176,7 +176,7 @@ pub async fn prepare( build_dir: &PathBuf, display: actors::DisplayAddr, tree_sitter: &args::TreeSitter, -) -> TsdlResult { +) -> Result { shutdown::test_delay().await; shutdown::check()?; debug!("[prepare] tree-sitter-cli version={}", tree_sitter.version); @@ -189,8 +189,7 @@ pub async fn prepare( ) .await; - let repo = Url::parse(&tree_sitter.repo) - .map_err(|e| error::TsdlError::context("Parsing the tree-sitter URL", e))?; + let repo = Url::parse(&tree_sitter.repo).context("Parsing the tree-sitter URL")?; let git_ref = &tree_sitter.version; progress.step(format!("resolving {git_ref}")); @@ -250,21 +249,19 @@ pub async fn prepare( }) } -pub(crate) fn display_tree_sitter_ref(version: &str) -> Result { +pub(crate) fn display_tree_sitter_ref(version: &str) -> StdResult { git::Ref::new(normalize_release_ref(version)) } #[allow(clippy::missing_panics_doc)] -pub async fn tag(repo: &str, version: &str) -> TsdlResult { +pub async fn tag(repo: &str, version: &str) -> Result { let output = Command::new("git") .args(["ls-remote", "--refs", "--tags", repo]) .exec() .await?; let stdout = String::from_utf8_lossy(&output.stdout); let refs = parse_refs(&stdout); - find_tag(&refs, version).map_err(|e| { - error::TsdlError::context(format!("Parsing tree-sitter git ref {version:?}"), e) - }) + find_tag(&refs, version).with_context(|| format!("Parsing tree-sitter git ref {version:?}")) } #[cfg(test)] diff --git a/src/walk.rs b/src/walk.rs index cba46d1..9350803 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -1,11 +1,11 @@ use std::{path::PathBuf, sync::Arc}; -use crate::{cache, git, shutdown, TsdlResult}; +use crate::{cache, git, shutdown, Result}; /// Collect grammar.js paths via git ls-files and compute their hashes. pub async fn collect_grammar_paths( root: Arc, -) -> TsdlResult> { +) -> Result> { let files = git::list_grammar_files(root.as_ref()).await?; let mut results = Vec::with_capacity(files.len()); diff --git a/tests/cmd/build.rs b/tests/cmd/build.rs index ba287eb..66abcd3 100644 --- a/tests/cmd/build.rs +++ b/tests/cmd/build.rs @@ -6,10 +6,7 @@ use indoc::{formatdoc, indoc}; use predicates::{self as p}; use rstest::*; -use tsdl::consts::{ - TREE_SITTER_PLATFORM, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_CONFIG_FILE, TSDL_OUT_DIR, - TSDL_PREFIX, -}; +use tsdl::consts::{BUILD_DIR, CONFIG_FILE, PARSER_OUT_DIR, PLATFORM, PREFIX, VERSION}; #[cfg(enable_wasm_cases)] use tsdl::parser::WASM_EXTENSION; @@ -24,13 +21,12 @@ fn no_args_should_download_tree_sitter_cli() { .cmd .assert() .success() - .stdout(p::str::contains(format!( - "tree-sitter-cli @ v{TREE_SITTER_VERSION}" - ))); + .stdout(p::str::contains(format!("tree-sitter-cli @ v{VERSION}"))); assert!(!sandbox.is_empty()); - let tree_sitter_cli = sandbox.tmp.child(TSDL_BUILD_DIR).child(format!( - "tree-sitter-{TREE_SITTER_PLATFORM}-v{TREE_SITTER_VERSION}" - )); + let tree_sitter_cli = sandbox + .tmp + .child(BUILD_DIR) + .child(format!("tree-sitter-{PLATFORM}-v{VERSION}")); tree_sitter_cli .assert(p::path::exists()) @@ -64,8 +60,8 @@ fn no_args_should_build_tree_sitter_with_specific_version( let mut tree_sitter_cli = Command::new( sandbox .tmp - .child(TSDL_BUILD_DIR) - .child(format!("tree-sitter-{TREE_SITTER_PLATFORM}-v{cli_version}")) + .child(BUILD_DIR) + .child(format!("tree-sitter-{PLATFORM}-v{cli_version}")) .to_path_buf(), ); tree_sitter_cli.arg("--version"); @@ -88,7 +84,7 @@ fn unknown_parser_should_fail(#[case] languages: Vec<&str>) { for lang in languages { sandbox .tmp - .child(TSDL_OUT_DIR) + .child(PARSER_OUT_DIR) .child(format!("{lang}.{DLL_EXTENSION}")) .assert(p::path::missing()); } @@ -116,7 +112,7 @@ fn test_real_parser_error_formatting() { sandbox .tmp .path() - .join(TSDL_BUILD_DIR) + .join(BUILD_DIR) .join("tree-sitter-jsonxxx"), ) .unwrap(); @@ -174,8 +170,8 @@ fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { for lang in &languages { let dylib = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{lang}.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{lang}.{DLL_EXTENSION}")); dylib.assert(p::path::exists()).assert(p::path::is_file()); } } @@ -199,11 +195,7 @@ fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: & "# }; let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(TSDL_CONFIG_FILE) - .write_str(config) - .unwrap(); + sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); sandbox .cmd .args(["build", language]) @@ -216,8 +208,8 @@ fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: & ))); let dylib = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{language}.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); dylib.assert(p::path::exists()).assert(p::path::is_file()); } @@ -241,11 +233,7 @@ fn build_implicit_pinned_and_unpinned() { "# }; let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(TSDL_CONFIG_FILE) - .write_str(config) - .unwrap(); + sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); let mut out = sandbox.cmd.arg("build").assert().success(); for (language, _version) in parsers { out = out @@ -255,8 +243,8 @@ fn build_implicit_pinned_and_unpinned() { for (language, _version) in parsers { let dylib = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{language}.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); dylib.assert(p::path::exists()).assert(p::path::is_file()); } } @@ -279,8 +267,8 @@ fn multi_parsers_no_cmd() { for language in languages { let dylib = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{language}.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); dylib.assert(p::path::exists()).assert(p::path::is_file()); } } @@ -297,11 +285,7 @@ fn multi_parsers_cmd() { typescript = {{ ref = "{version}", cmd = "make" }} "# }; - sandbox - .tmp - .child(TSDL_CONFIG_FILE) - .write_str(&config) - .unwrap(); + sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); let assert = sandbox.cmd.args(["build", typescript]).assert().success(); // Check for version in cloning step // TODO: dig for changes in this test and revert. @@ -309,8 +293,8 @@ fn multi_parsers_cmd() { for language in languages { let dylib = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{language}.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); dylib.assert(p::path::exists()).assert(p::path::is_file()); } } @@ -333,18 +317,14 @@ fn build_target(#[case] target: Option<&str>, #[case] exts: &[&str]) { config = format!("target = \"{target}\"\n{config}"); } let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(TSDL_CONFIG_FILE) - .write_str(&config) - .unwrap(); + sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); sandbox.cmd.args(["build"]).assert().success(); for (lang, _) in languages { for ext in exts { let dylib = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{lang}.{ext}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{lang}.{ext}")); dylib.assert(p::path::exists()).assert(p::path::is_file()); } } @@ -377,7 +357,7 @@ fn build_plain_progress_numbered_correctly() { // Verify the output artifact was created let dylib = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); dylib.assert(p::path::exists()).assert(p::path::is_file()); } diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index da7b335..4e3bbac 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -5,10 +5,7 @@ use assert_fs::prelude::*; use predicates::{self as p, prelude::*}; use rstest::*; -use tsdl::consts::{ - TREE_SITTER_PLATFORM, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_CONFIG_FILE, TSDL_OUT_DIR, - TSDL_PREFIX, -}; +use tsdl::consts::{BUILD_DIR, CONFIG_FILE, PARSER_OUT_DIR, PLATFORM, PREFIX, VERSION}; use crate::cmd::Sandbox; @@ -17,7 +14,7 @@ fn cache_hit_skips_build() { let mut sandbox = Sandbox::new(); sandbox .tmp - .child(TSDL_CONFIG_FILE) + .child(CONFIG_FILE) .write_str("[parsers]\njson = \"0.21.0\"\n") .unwrap(); @@ -26,12 +23,12 @@ fn cache_hit_skips_build() { let binary = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); binary.assert(p::path::exists()).assert(p::path::is_file()); // Cache file should exist - let cache_file = sandbox.tmp.child(TSDL_BUILD_DIR).child("cache.toml"); + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); cache_file.assert(p::path::exists()); let first_inode = binary.metadata().unwrap().ino(); @@ -63,7 +60,7 @@ fn cache_miss_on_grammar_modification() { // Modify grammar file let grammar = sandbox .tmp - .child(TSDL_BUILD_DIR) + .child(BUILD_DIR) .child("tree-sitter-json") .child("grammar.js"); let mut content = std::fs::read_to_string(grammar.path()).unwrap(); @@ -87,7 +84,7 @@ fn fresh_flag_clears_build_dir() { // First build sandbox.cmd.arg("build").arg("json").assert().success(); - let build_dir = sandbox.tmp.child(TSDL_BUILD_DIR); + let build_dir = sandbox.tmp.child(BUILD_DIR); build_dir.assert(p::path::exists()); let cache_file = build_dir.child("cache.toml"); @@ -95,8 +92,8 @@ fn fresh_flag_clears_build_dir() { let first_binary = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); let first_inode = first_binary.metadata().unwrap().ino(); // Second build with --fresh (need --force to overwrite existing binary) @@ -126,8 +123,8 @@ fn force_flag_bypasses_cache() { let binary = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); let first_inode = binary.metadata().unwrap().ino(); // Second build with --force @@ -155,15 +152,13 @@ fn force_flag_reinstalls_hardlink() { let binary = sandbox .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); let build_binary = sandbox .tmp - .child(TSDL_BUILD_DIR) + .child(BUILD_DIR) .child("tree-sitter-json") - .child(format!( - "tsdl-{TREE_SITTER_PLATFORM}-v{TREE_SITTER_VERSION}" - )) + .child(format!("tsdl-{PLATFORM}-v{VERSION}")) .child(format!("libtree-sitter-json.{DLL_EXTENSION}")); let first_inode_out = binary.metadata().unwrap().ino(); @@ -209,7 +204,7 @@ fn multi_parser_independent_cache(#[case] languages: Vec<&str>) { sandbox.cmd.arg("build").args(&languages).assert().success(); // Verify cache contains both entries - let cache_file = sandbox.tmp.child(TSDL_BUILD_DIR).child("cache.toml"); + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); for lang in &languages { assert!( @@ -244,7 +239,7 @@ fn cache_file_structure() { .success(); // Read and validate cache file - let cache_file = sandbox.tmp.child(TSDL_BUILD_DIR).child("cache.toml"); + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); cache_file.assert(p::path::exists()); let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); diff --git a/tests/cmd/config.rs b/tests/cmd/config.rs index 95e5769..be0eeed 100644 --- a/tests/cmd/config.rs +++ b/tests/cmd/config.rs @@ -2,7 +2,7 @@ use assert_fs::prelude::*; use indoc::formatdoc; use predicates::{self as p}; -use tsdl::{args::BuildCommand, consts::TSDL_BUILD_DIR}; +use tsdl::{args::BuildCommand, consts::BUILD_DIR}; use crate::cmd::Sandbox; @@ -32,7 +32,7 @@ fn default_is_default_toml() { assert!(!sandbox.is_empty()); sandbox .tmp - .child(TSDL_BUILD_DIR) + .child(BUILD_DIR) .child("log") .assert(p::path::exists()) .assert(p::path::is_file()); @@ -50,7 +50,7 @@ fn current_uses_default() { assert!(!sandbox.is_empty()); sandbox .tmp - .child(TSDL_BUILD_DIR) + .child(BUILD_DIR) .child("log") .assert(p::path::exists()) .assert(p::path::is_file()); diff --git a/tests/cmd/log.rs b/tests/cmd/log.rs index ce175a0..3644962 100644 --- a/tests/cmd/log.rs +++ b/tests/cmd/log.rs @@ -2,7 +2,7 @@ use rstest::*; use assert_fs::prelude::*; use predicates::{self as p}; -use tsdl::consts::TSDL_BUILD_DIR; +use tsdl::consts::BUILD_DIR; use crate::cmd::Sandbox; @@ -18,7 +18,7 @@ fn build_no_args_should_log_to_default_path() { assert!(!sandbox.is_empty()); sandbox .tmp - .child(TSDL_BUILD_DIR) + .child(BUILD_DIR) .child("log") .assert(p::path::exists()) .assert(p::path::is_file()); @@ -65,7 +65,7 @@ fn build_rejects_log_paths_that_conflict_with_fresh_cleanup( #[rstest] fn fresh_preserves_root_level_custom_log_and_removes_build_entries() { let mut sandbox = Sandbox::new(); - let stale = sandbox.tmp.child(TSDL_BUILD_DIR).child("stale.txt"); + let stale = sandbox.tmp.child(BUILD_DIR).child("stale.txt"); stale.write_str("stale").unwrap(); sandbox @@ -75,7 +75,7 @@ fn fresh_preserves_root_level_custom_log_and_removes_build_entries() { sandbox .tmp - .child(TSDL_BUILD_DIR) + .child(BUILD_DIR) .child("custom.log") .assert(p::path::exists()) .assert(p::path::is_file()); diff --git a/tests/cmd/mod.rs b/tests/cmd/mod.rs index 93a95e1..e7cbe9b 100644 --- a/tests/cmd/mod.rs +++ b/tests/cmd/mod.rs @@ -12,7 +12,7 @@ use std::{env, fs, path::Path}; use assert_cmd::{cargo::cargo_bin_cmd, Command}; use assert_fs::TempDir; -use tsdl::{args::BuildCommand, config as tsdl_config, consts::TSDL_CONFIG_FILE}; +use tsdl::{args::BuildCommand, config as tsdl_config, consts::CONFIG_FILE}; pub struct Sandbox { pub build: BuildCommand, @@ -33,7 +33,7 @@ impl Sandbox { } pub fn config(&mut self, config: &str) -> &mut Self { - self.config_at(config, &self.tmp.path().join(TSDL_CONFIG_FILE)) + self.config_at(config, &self.tmp.path().join(CONFIG_FILE)) } pub fn config_at(&mut self, config_contents: &str, dst: &Path) -> &mut Self { diff --git a/tests/config.rs b/tests/config.rs index ec3d714..49305ca 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -11,10 +11,7 @@ use std::path::PathBuf; use tsdl::{ args::{self, BuildCommand, Target}, config::{self, Source}, - consts::{ - TREE_SITTER_PLATFORM, TREE_SITTER_REPO, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_FRESH, - TSDL_OUT_DIR, TSDL_PREFIX, TSDL_SHOW_CONFIG, - }, + consts::{BUILD_DIR, FRESH, PARSER_OUT_DIR, PLATFORM, PREFIX, REPO, SHOW_CONFIG, VERSION}, }; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -118,13 +115,13 @@ fn current_default_is_default() -> Result<()> { repo = "{}" platform = "{}" "#, - TSDL_BUILD_DIR, - TSDL_FRESH, - TSDL_OUT_DIR, - TSDL_SHOW_CONFIG, - TREE_SITTER_VERSION, - TREE_SITTER_REPO, - TREE_SITTER_PLATFORM, + BUILD_DIR, + FRESH, + PARSER_OUT_DIR, + SHOW_CONFIG, + VERSION, + REPO, + PLATFORM, }; let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); @@ -180,15 +177,10 @@ fn cli_can_override_tree_sitter_version_to_builtin_default_value() -> Result<()> let resolved = current_with_cli( &generated, - &[ - "tsdl", - "build", - "--tree-sitter-version", - TREE_SITTER_VERSION, - ], + &["tsdl", "build", "--tree-sitter-version", VERSION], ); - assert_eq!(resolved.tree_sitter.version, TREE_SITTER_VERSION); + assert_eq!(resolved.tree_sitter.version, VERSION); Ok(()) } @@ -232,7 +224,7 @@ fn negative_boolean_flag_overrides_positive() -> Result<()> { #[test] fn env_can_override_config_to_builtin_default_value() -> Result<()> { let _lock = ENV_LOCK.lock().unwrap(); - let _prefix = EnvVarGuard::set("TSDL_PREFIX", TSDL_PREFIX); + let _prefix = EnvVarGuard::set("PREFIX", PREFIX); let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); @@ -242,7 +234,7 @@ fn env_can_override_config_to_builtin_default_value() -> Result<()> { let build_matches = args::build_matches(&matches); let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; - assert_eq!(cmd.prefix, TSDL_PREFIX); + assert_eq!(cmd.prefix, PREFIX); assert_eq!(prov.prefix, Source::Environment); Ok(()) } @@ -250,7 +242,7 @@ fn env_can_override_config_to_builtin_default_value() -> Result<()> { #[test] fn boolean_env_can_override_config_file() -> Result<()> { let _lock = ENV_LOCK.lock().unwrap(); - let _force = EnvVarGuard::set("TSDL_FORCE", "false"); + let _force = EnvVarGuard::set("FORCE", "false"); let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); From c87c8d6b2cdc5feb0c0a0a1e6ee6bb86f7a6244a Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 01:18:08 +0200 Subject: [PATCH 43/88] language: err when no grammars are found in a repo --- CHANGELOG.md | 1 + src/error.rs | 2 ++ src/parser.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8249087..af290bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ informative and more coherent. same file, have identical contents, or `--force` is used; replacements are now installed through visible temporary hardlinks. - `--jobs` is a strictly positive number now. +- `tsdl` now fails on repos with no `grammar.js`. ## [2.0.0] - 2026-02-20 diff --git a/src/error.rs b/src/error.rs index 4970794..de6e773 100644 --- a/src/error.rs +++ b/src/error.rs @@ -63,6 +63,8 @@ pub enum ParserOp { Build { dir: PathBuf }, #[display("Could not clone to {}", dir.display())] Clone { dir: PathBuf }, + #[display("Could not discover grammars in {}", dir.display())] + Discover { dir: PathBuf }, #[display("Could not copy {} to {}", src.display(), dst.display())] Copy { src: PathBuf, dst: PathBuf }, #[display("Could not generate in {}", dir.display())] diff --git a/src/parser.rs b/src/parser.rs index 3132471..fd2243c 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -749,6 +749,24 @@ impl LanguageBuild { grammars.push((grammar_name, grammar_dir.to_path_buf(), hash)); } + if grammars.is_empty() { + return Err(Error::Step { + name: self.name.as_arc(), + kind: error::ParserOp::Discover { + dir: self.output.build_dir.to_path_buf(), + }, + source: Error::Message { + message: format!( + "No grammar.js files found for parser {} (repo: {}, ref: {})", + self.name, + self.spec.repo, + self.spec.git_ref.requested().as_str() + ), + } + .into(), + }); + } + Ok(grammars) } @@ -915,7 +933,7 @@ fn extract_grammar_name(dir: &Path) -> Result { #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::symlink; + use std::{os::unix::fs::symlink, process::Command as StdCommand}; use crate::{ actors::{DisplayActor, DisplayAddr}, @@ -958,6 +976,42 @@ mod tests { ); } + fn test_language_build(build_dir: PathBuf, out_dir: PathBuf) -> LanguageBuild { + LanguageBuild::new( + build::Context { + overwrite_output: false, + }, + Arc::new(build::Spec { + build_script: None, + git_ref: Ref::parse("v1.0.0").unwrap(), + prefix: String::new(), + repo: "https://example.com/tree-sitter-empty".parse().unwrap(), + target: Target::Native, + tree_sitter: TreeSitter::default(), + }), + LanguageName::from("empty"), + build::OutputConfig { + build_dir: build_dir.into(), + out_dir: out_dir.into(), + }, + ) + } + + fn init_git_repo(path: &Path) { + std::fs::create_dir_all(path).unwrap(); + let output = StdCommand::new("git") + .arg("init") + .arg("--quiet") + .current_dir(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + async fn test_grammar_build( grammar_dir: PathBuf, out_dir: PathBuf, @@ -1014,6 +1068,30 @@ mod tests { same_file_identity(&a, &b) } + #[tokio::test] + async fn discover_grammars_errors_when_no_grammar_js_files_are_found() { + let temp_dir = TempDir::new().unwrap(); + let build_dir = temp_dir.path().join("tree-sitter-empty"); + let out_dir = temp_dir.path().join("out"); + init_git_repo(&build_dir); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let language = test_language_build(build_dir.clone(), out_dir); + let err = language.discover_grammars().await.unwrap_err(); + + match err { + Error::Step { name, kind, source } => { + assert_eq!(name.as_ref(), "empty"); + assert!(matches!(kind, error::ParserOp::Discover { dir } if dir == build_dir)); + let message = source.as_error().to_string(); + assert!(message.contains("No grammar.js files found")); + assert!(message.contains("https://example.com/tree-sitter-empty")); + assert!(message.contains("v1.0.0")); + } + other => panic!("expected discovery step error, got {other:?}"), + } + } + #[test] fn test_cache_key_format() { let key = cache::Key::new( From 4120c64954fd6b1c6ea12ea9c09bd550bcf94552 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 01:31:49 +0200 Subject: [PATCH 44/88] tree-sitter-cli: verify on download --- CHANGELOG.md | 1 + src/tree_sitter.rs | 336 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 295 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af290bd..7b07c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ informative and more coherent. - **cache**: Parser builds that track moving refs such as `HEAD` or branches now notice when those refs move and rebuild the affected parsers. The parser cache format changed for this major release and older cache files are not migrated. +- Downloaded `tree-sitter` cli are now verified upon download (no crypto). ### Bug Fixes diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index f1d3d03..777465d 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -5,8 +5,9 @@ use std::path::{Path, PathBuf}; use std::result::Result as StdResult; use async_compression::tokio::bufread::GzipDecoder; +use tempfile::TempPath; use tokio::{fs, io, process::Command}; -use tracing::{debug, info, trace}; +use tracing::{debug, info, trace, warn}; use url::Url; use crate::actors; @@ -14,7 +15,7 @@ use crate::args; use crate::git; use crate::sh::Exec; use crate::shutdown; -use crate::{Result, ResultExt, SafeCanonicalize}; +use crate::{Error, Result, ResultExt, SafeCanonicalize}; #[derive(Debug, Clone)] pub struct PreparedCli { @@ -22,6 +23,13 @@ pub struct PreparedCli { pub tree_sitter: args::TreeSitter, } +#[derive(Debug, PartialEq, Eq)] +enum CliCacheStatus { + Hit, + Missing, + Invalid(String), +} + async fn chmod_x(prog: &Path) -> Result<()> { let metadata = fs::metadata(prog) .await @@ -46,17 +54,31 @@ async fn cli( .join(format!("{cli}-{tag}")) .canon()?; - if res.exists() { - handle.set_outcome_cached().await; - handle.step("cached"); - } else { - handle.set_outcome_built().await; - handle.step("downloading"); - let gz_basename = format!("{cli}.gz"); - let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); - let gz = PathBuf::new().join(build_dir).join(gz_basename); + let gz_basename = format!("{cli}.gz"); + let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); - download_and_extract(&gz, &url, &res).await?; + match check_cached_cli(&res, tag).await? { + CliCacheStatus::Hit => { + info!("Using cached tree-sitter CLI at {}", res.display()); + handle.set_outcome_cached().await; + handle.step("cached"); + } + CliCacheStatus::Missing => { + info!("Tree-sitter CLI cache miss for {tag}; downloading from {url}"); + handle.set_outcome_built().await; + handle.step("downloading"); + download_and_install(&url, &res, tag).await?; + } + CliCacheStatus::Invalid(reason) => { + warn!( + "Cached tree-sitter CLI at {} is invalid ({reason}); re-downloading from {url}", + res.display() + ); + handle.msg("cached CLI invalid; re-downloading"); + handle.set_outcome_built().await; + handle.step("downloading"); + download_and_install(&url, &res, tag).await?; + } } Ok(res) @@ -80,30 +102,180 @@ async fn resolve_release_tag( Ok(tag.into_owned()) } -async fn download(gz: &Path, url: &str) -> Result<()> { - fs::write( - gz, - reqwest::get(url) - .await - .context("fetch")? - .bytes() - .await - .context("fetching bytes")?, - ) - .await - .with_context(|| format!("downloading {url} to {}", gz.display())) +async fn check_cached_cli(path: &Path, tag: &str) -> Result { + let metadata = match fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(CliCacheStatus::Missing) + } + Err(err) => { + return Err(err) + .with_context(|| format!("Inspecting cached tree-sitter CLI {}", path.display())); + } + }; + + let file_type = metadata.file_type(); + if file_type.is_dir() { + return Err(Error::Message { + message: format!( + "Cached tree-sitter CLI path is a directory and cannot be replaced: {}", + path.display() + ), + }); + } + + if file_type.is_symlink() { + return Ok(CliCacheStatus::Invalid( + "cached path is a symbolic link".to_string(), + )); + } + + if !file_type.is_file() { + return Ok(CliCacheStatus::Invalid( + "cached path is not a regular file".to_string(), + )); + } + + if metadata.permissions().mode() & 0o111 == 0 { + return Ok(CliCacheStatus::Invalid( + "cached file is not executable".to_string(), + )); + } + + match verify_cli(path, tag).await { + Ok(()) => Ok(CliCacheStatus::Hit), + Err(Error::Interrupted { signal }) => Err(Error::Interrupted { signal }), + Err(err) => Ok(CliCacheStatus::Invalid(first_line(&err.to_string()))), + } } -async fn download_and_extract(gz: &Path, url: &str, res: &Path) -> Result<()> { - download(gz, url).await?; - gunzip(gz, res).await?; - chmod_x(res).await?; - fs::remove_file(gz) +async fn download(url: &str, gz: &Path) -> Result<()> { + let response = reqwest::get(url) + .await + .with_context(|| format!("Fetching tree-sitter CLI from {url}"))? + .error_for_status() + .with_context(|| format!("Downloading tree-sitter CLI from {url}"))?; + + let bytes = response + .bytes() + .await + .with_context(|| format!("Reading tree-sitter CLI response body from {url}"))?; + + fs::write(gz, bytes) .await - .with_context(|| format!("removing {}", gz.display()))?; + .with_context(|| format!("Writing tree-sitter CLI archive to {}", gz.display())) +} + +async fn download_and_install(url: &str, res: &Path, tag: &str) -> Result<()> { + let gz = temp_path_for(res, ".gz")?; + let cli = temp_path_for(res, ".bin")?; + + info!("Downloading tree-sitter CLI from {url}"); + download(url, gz.as_ref()).await?; + install_downloaded_cli(gz.as_ref(), cli.as_ref(), res, tag).await?; + info!("Installed tree-sitter CLI at {}", res.display()); Ok(()) } +async fn install_downloaded_cli(gz: &Path, tmp_cli: &Path, res: &Path, tag: &str) -> Result<()> { + gunzip(gz, tmp_cli).await?; + chmod_x(tmp_cli).await?; + context_unless_interrupted(verify_cli(tmp_cli, tag).await, || { + format!("Verifying downloaded tree-sitter CLI {}", tmp_cli.display()) + })?; + promote_cli(tmp_cli, res).await +} + +fn temp_path_for(res: &Path, suffix: &str) -> Result { + let parent = res.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not determine parent directory for tree-sitter CLI path {}", + res.display() + ), + })?; + let name = res.file_name().map_or_else( + || Cow::Borrowed("tree-sitter"), + |name| name.to_string_lossy(), + ); + + tempfile::Builder::new() + .prefix(&format!(".{name}.")) + .suffix(suffix) + .tempfile_in(parent) + .map(tempfile::NamedTempFile::into_temp_path) + .with_context(|| { + format!( + "Creating temporary tree-sitter CLI file in {}", + parent.display() + ) + }) +} + +async fn promote_cli(tmp_cli: &Path, res: &Path) -> Result<()> { + if let Err(err) = fs::rename(tmp_cli, res).await { + let _ = fs::remove_file(tmp_cli).await; + return Err(err).with_context(|| { + format!( + "Installing tree-sitter CLI {} to {}", + tmp_cli.display(), + res.display() + ) + }); + } + + Ok(()) +} + +async fn verify_cli(path: &Path, tag: &str) -> Result<()> { + let output = + context_unless_interrupted(Command::new(path).arg("--version").exec().await, || { + format!("Running {} --version", path.display()) + })?; + + let output = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + if let Some(expected) = expected_cli_version(tag) { + if !output.contains(expected) { + return Err(Error::Message { + message: format!( + "tree-sitter CLI version output did not contain expected version {expected:?}: {}", + output.trim() + ), + }); + } + } + + Ok(()) +} + +fn expected_cli_version(tag: &str) -> Option<&str> { + let version = tag.strip_prefix('v').unwrap_or(tag); + is_dotted_numeric_version(version).then_some(version) +} + +fn first_line(message: &str) -> String { + message + .lines() + .next() + .unwrap_or("unknown verification failure") + .to_string() +} + +fn context_unless_interrupted(result: Result, message: impl FnOnce() -> String) -> Result { + match result { + Ok(value) => Ok(value), + Err(err @ Error::Interrupted { .. }) => Err(err), + Err(err) => Err(Error::Context { + message: message(), + source: err.into(), + }), + } +} + fn find_tag( refs: &HashMap, version: &str, @@ -144,7 +316,6 @@ async fn gunzip(gz: &Path, to: &Path) -> Result<()> { .await .with_context(|| format!("opening {}", gz.display()))?; let mut decompressor = GzipDecoder::new(tokio::io::BufReader::new(file)); - // let path = gz.with_extension(""); let mut file = tokio::fs::File::create(to) .await @@ -152,8 +323,11 @@ async fn gunzip(gz: &Path, to: &Path) -> Result<()> { io::copy(&mut decompressor, &mut file) .await - .and(Ok(())) - .with_context(|| format!("decompressing {}", gz.display())) + .map(|_| ()) + .with_context(|| format!("decompressing {}", gz.display()))?; + file.sync_all() + .await + .with_context(|| format!("syncing extracted tree-sitter CLI {}", to.display())) } fn parse_refs(stdout: &str) -> HashMap { @@ -218,15 +392,22 @@ pub async fn prepare( }; info!("Resolved tree-sitter CLI ref {git_ref:?} to release tag {release_tag:?}"); - let cli = match cli( - build_dir, - &progress, - &tree_sitter.platform, - &tree_sitter.repo, - &release_tag, - ) - .await - { + let cli = match context_unless_interrupted( + cli( + build_dir, + &progress, + &tree_sitter.platform, + &tree_sitter.repo, + &release_tag, + ) + .await, + || { + format!( + "Preparing tree-sitter CLI {release_tag} for platform {}", + tree_sitter.platform + ) + }, + ) { Ok(cli) => cli, Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { @@ -267,6 +448,77 @@ pub async fn tag(repo: &str, version: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use std::fs as std_fs; + + fn write_script(path: &Path, body: &str) { + std_fs::write(path, body).unwrap(); + let mut permissions = std_fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + std_fs::set_permissions(path, permissions).unwrap(); + } + + #[tokio::test] + async fn cached_cli_missing_is_miss() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + + assert_eq!( + check_cached_cli(&path, "v1.2.3").await.unwrap(), + CliCacheStatus::Missing + ); + } + + #[tokio::test] + async fn cached_cli_non_executable_is_invalid() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + std_fs::write(&path, b"tree-sitter 1.2.3\n").unwrap(); + + match check_cached_cli(&path, "v1.2.3").await.unwrap() { + CliCacheStatus::Invalid(reason) => assert!(reason.contains("not executable")), + status => panic!("expected invalid cache entry, got {status:?}"), + } + } + + #[tokio::test] + async fn cached_cli_valid_version_hits() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + write_script(&path, "#!/bin/sh\necho 'tree-sitter 1.2.3'\n"); + + assert_eq!( + check_cached_cli(&path, "v1.2.3").await.unwrap(), + CliCacheStatus::Hit + ); + } + + #[tokio::test] + async fn cached_cli_wrong_version_is_invalid() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + write_script(&path, "#!/bin/sh\necho 'tree-sitter 9.9.9'\n"); + + match check_cached_cli(&path, "v1.2.3").await.unwrap() { + CliCacheStatus::Invalid(reason) => assert!(reason.contains("expected version")), + status => panic!("expected invalid cache entry, got {status:?}"), + } + } + + #[tokio::test] + async fn failed_extraction_does_not_create_final_cli_path() { + let temp = tempfile::tempdir().unwrap(); + let gz = temp.path().join("tree-sitter-test.gz"); + let tmp_cli = temp.path().join("tree-sitter-test.tmp"); + let final_cli = temp.path().join("tree-sitter-test"); + std_fs::write(&gz, b"not a gzip archive").unwrap(); + + let err = install_downloaded_cli(&gz, &tmp_cli, &final_cli, "v1.2.3") + .await + .unwrap_err(); + + assert!(err.to_string().contains("decompressing")); + assert!(!final_cli.exists()); + } #[test] fn test_parse_refs_empty() { From 84a11e57bb8cd602237aa4f224bdcd24b9a4f4e7 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 02:06:06 +0200 Subject: [PATCH 45/88] config: don't crash on malformed config files --- CHANGELOG.md | 3 ++ src/app.rs | 127 ++++++++++++++++++++++++++++++++++++------- src/build.rs | 93 +++++++++++++++----------------- src/config.rs | 30 ++++++----- src/logging.rs | 128 ++++++++++++++++++++++++++++++++++---------- src/main.rs | 17 +++--- tests/cmd/build.rs | 20 +++++++ tests/cmd/config.rs | 81 +++++++++++++++++++++++----- 8 files changed, 368 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b07c8e..5ed1c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ informative and more coherent. installed through visible temporary hardlinks. - `--jobs` is a strictly positive number now. - `tsdl` now fails on repos with no `grammar.js`. +- `tsdl config default` and `tsdl selfupdate` no longer : + - crash on malformed configs, + - write any logs _unless_ `--log` is explicitly set. ## [2.0.0] - 2026-02-20 diff --git a/src/app.rs b/src/app.rs index c646606..8a6578c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,44 +1,135 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use clap::ArgMatches; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use crate::{args, config, display, logging, Result}; +use crate::{args, config, display, logging, Result, ResultExt}; -/// Resolved application state, ready to run. -pub struct App { - pub subcommand: args::Command, +/// Resolved build state for commands that actually need build configuration. +pub struct ResolvedBuild { pub command: args::BuildCommand, pub provenance: config::BuildProvenance, +} + +/// The selected command after resolving only the configuration it needs. +pub enum ResolvedCommand { + Build(ResolvedBuild), + ConfigCurrent(ResolvedBuild), + ConfigDefault, + Selfupdate { force: bool, target: String }, +} + +impl ResolvedCommand { + fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { + let implicit = match self { + Self::Build(build) => logging::Implicit::BuildDir { + dir: &build.command.build_dir, + }, + _ => logging::Implicit::None + }; + + logging::Policy { explicit, implicit } + } +} + +/// Resolved application state, ready to run. +pub struct App { + pub command: ResolvedCommand, pub config_path: PathBuf, - pub log_path: PathBuf, + pub logging: logging::Session, pub progress_mode: display::Mode, pub verbose: Verbosity, - pub logging_guard: Option, } pub fn setup() -> Result { let (args, matches) = config::parse_with_matches(); - let build_matches = crate::args::build_matches(&matches); - - let (command, provenance) = config::current_with_provenance(&args.config, build_matches)?; - - let (log_path, logging_guard) = logging::init( - args.log.as_ref(), + let command = resolve_command(&args, &matches)?; + let logging = logging::init( + command.logging_policy(args.log.as_deref()), args.log_color, args.verbose, - &command.build_dir, )?; let progress_mode = display::mode_from_args(&args.progress, &args.verbose); Ok(App { - subcommand: args.command, command, - provenance, config_path: args.config, - log_path, + logging, progress_mode, verbose: args.verbose, - logging_guard: Some(logging_guard), }) } + +fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { + match &args.command { + args::Command::Build => { + resolve_build(&args.config, args::build_matches(matches), "`build`") + .map(ResolvedCommand::Build) + } + + args::Command::Config { + command: args::ConfigCommand::Current, + } => resolve_build(&args.config, None, "`config current`") + .map(ResolvedCommand::ConfigCurrent), + + args::Command::Config { + command: args::ConfigCommand::Default, + } => Ok(ResolvedCommand::ConfigDefault), + + args::Command::Selfupdate { force, target } => Ok(ResolvedCommand::Selfupdate { force: *force, target: target.clone() }), + } +} + +fn resolve_build( + config_path: &Path, + matches: Option<&ArgMatches>, + purpose: &str, +) -> Result { + let (command, provenance) = config::current_with_provenance(config_path, matches) + .with_context(|| format!("Resolving build configuration for {purpose}"))?; + + Ok(ResolvedBuild { + command, + provenance, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_with_config(config: &Path, command: &[&str]) -> (args::Args, ArgMatches) { + let mut argv = vec![ + "tsdl".into(), + "--config".into(), + config.as_os_str().to_owned(), + ]; + argv.extend(command.iter().map(Into::into)); + config::try_parse_from_with_matches(argv).unwrap() + } + + #[test] + fn config_default_does_not_resolve_build_config() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("parsers.toml"); + std::fs::write(&config_path, "not valid toml =").unwrap(); + + let (args, matches) = parse_with_config(&config_path, &["config", "default"]); + let command = resolve_command(&args, &matches).unwrap(); + + assert!(matches!(command, ResolvedCommand::ConfigDefault)); + } + + #[test] + fn selfupdate_does_not_resolve_build_config() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("parsers.toml"); + std::fs::write(&config_path, "not valid toml =").unwrap(); + + let (args, matches) = parse_with_config(&config_path, &["selfupdate"]); + let command = resolve_command(&args, &matches).unwrap(); + + assert!(matches!(command, ResolvedCommand::Selfupdate{ .. })); + } +} diff --git a/src/build.rs b/src/build.rs index 3d54f1c..97a7565 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,7 +1,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, fs, - path::PathBuf, + path::{Path, PathBuf}, result::Result as StdResult, sync::Arc, time::Duration, @@ -38,16 +38,16 @@ pub struct Context { pub overwrite_output: bool, } -pub fn run(app: &app::App) -> Result<()> { - if app.command.show_config { - crate::config::show(&app.command)?; +pub fn run(app: &app::App, command: &args::BuildCommand) -> Result<()> { + if command.show_config { + crate::config::show(command)?; } - let lock = lock::Lock::new(&app.command.build_dir); - let guard = acquire_lock(&lock, Duration::from_secs(app.command.unlock_timeout))?; + let lock = lock::Lock::new(&command.build_dir); + let guard = acquire_lock(&lock, Duration::from_secs(command.unlock_timeout))?; - clear(app, &guard)?; - ignite(app)?; + clear(command, app.logging.path(), &guard)?; + ignite(app, command)?; Ok(()) } @@ -119,18 +119,22 @@ fn handle_locked_by( lock.wait_for_release(owner, unlock_timeout) } -fn clear(app: &app::App, guard: &lock::Guard) -> Result<()> { - if app.command.fresh && app.command.build_dir.exists() { - guard.clear_directory(std::slice::from_ref(&app.log_path))?; +fn clear(command: &args::BuildCommand, log_path: Option<&Path>, guard: &lock::Guard) -> Result<()> { + if command.fresh && command.build_dir.exists() { + let protected_files = log_path + .map(Path::to_path_buf) + .into_iter() + .collect::>(); + guard.clear_directory(&protected_files)?; } - fs::create_dir_all(&app.command.build_dir)?; + fs::create_dir_all(&command.build_dir)?; Ok(()) } -fn collect_languages(app: &app::App) -> Result> { - let results = unique_languages(app); +fn collect_languages(command: &args::BuildCommand) -> Result> { + let results = unique_languages(command); let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); if err.is_empty() { @@ -184,8 +188,8 @@ fn get_language_coords( } } -fn ignite(app: &app::App) -> Result<()> { - fs::create_dir_all(&app.command.out_dir)?; +fn ignite(app: &app::App, command: &args::BuildCommand) -> Result<()> { + fs::create_dir_all(&command.out_dir)?; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -193,25 +197,25 @@ fn ignite(app: &app::App) -> Result<()> { let guard = rt.enter(); - let db = cache::Db::load(&app.command.build_dir)?; - let languages = collect_languages(app)?; + let db = cache::Db::load(&command.build_dir)?; + let languages = collect_languages(command)?; let result = rt.block_on(async move { let shutdown = shutdown::Handle::new(); let _signals = shutdown.spawn_signal_listener()?; - let cache = actors::CacheActor::spawn(db, app.command.force); - let build_dir: Arc = app.command.build_dir.canon()?.into(); - let out_dir: Arc = app.command.out_dir.canon()?.into(); + let cache = actors::CacheActor::spawn(db, command.force); + let build_dir: Arc = command.build_dir.canon()?.into(); + let out_dir: Arc = command.out_dir.canon()?.into(); let display = actors::DisplayActor::spawn(app.progress_mode, build_dir, out_dir); shutdown::scope(shutdown, async move { actors::run( - &app.command.build_dir, + &command.build_dir, cache, display, - app.command.jobs, + command.jobs, languages, - &app.command.tree_sitter, + &command.tree_sitter, ) .await }) @@ -225,9 +229,9 @@ fn ignite(app: &app::App) -> Result<()> { result } -fn unique_languages(app: &app::App) -> Vec> { - let requested_languages = &app.command.languages; - let defined_parsers = app.command.parsers.as_ref(); +fn unique_languages(command: &args::BuildCommand) -> Vec> { + let requested_languages = &command.languages; + let defined_parsers = command.parsers.as_ref(); let final_languages = match requested_languages { Some(langs) if !langs.is_empty() => langs.clone(), @@ -243,27 +247,25 @@ fn unique_languages(app: &app::App) -> Vec> { let result = match get_language_coords(&language, defined_parsers) { Ok((build_script, git_ref, repo)) => Ok(parser::LanguageBuild::new( Context { - overwrite_output: app.command.force, + overwrite_output: command.force, }, Arc::new(Spec { build_script, git_ref, repo, - tree_sitter: app.command.tree_sitter.clone(), - prefix: app.command.prefix.clone(), - target: app.command.target, + tree_sitter: command.tree_sitter.clone(), + prefix: command.prefix.clone(), + target: command.target, }), parser::LanguageName::from(language.clone()), OutputConfig { - build_dir: app - .command + build_dir: command .build_dir .join(format!("tree-sitter-{}", &language)) .canon() .expect("Build dir canonicalization failed") .into(), - out_dir: app - .command + out_dir: command .out_dir .canon() .expect("Out dir canonicalization failed") @@ -284,10 +286,10 @@ fn unique_languages(app: &app::App) -> Vec> { #[cfg(test)] mod tests { use super::*; - use crate::{args::BuildCommand, config::BuildProvenance, display::Mode}; + use crate::args::BuildCommand; - fn app_with_languages(languages: &[&str]) -> app::App { - let command = BuildCommand { + fn command_with_languages(languages: &[&str]) -> BuildCommand { + BuildCommand { languages: Some( languages .iter() @@ -295,25 +297,14 @@ mod tests { .collect(), ), ..BuildCommand::default() - }; - - app::App { - subcommand: crate::args::Command::Build, - command, - config_path: PathBuf::from("parsers.toml"), - log_path: PathBuf::from("tmp/log"), - progress_mode: Mode::Plain, - provenance: BuildProvenance::default(), - verbose: clap_verbosity_flag::Verbosity::default(), - logging_guard: None, } } #[test] fn unique_languages_sorts_and_deduplicates_requested_languages() { - let app = app_with_languages(&["rust", "json", "ruby", "json", "rust"]); + let command = command_with_languages(&["rust", "json", "ruby", "json", "rust"]); - let languages = unique_languages(&app) + let languages = unique_languages(&command) .into_iter() .map(|language| language.unwrap().name.to_string()) .collect::>(); diff --git a/src/config.rs b/src/config.rs index f5c4106..8db7112 100644 --- a/src/config.rs +++ b/src/config.rs @@ -526,19 +526,25 @@ pub fn print_indent(s: &str, indent: &str) { pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { match command { - args::ConfigCommand::Current => { - let cmd: args::BuildCommand = current(config_path, None)?; - println!( - "{}", - toml::to_string(&cmd).context("Generating default TOML config")? - ); - } - args::ConfigCommand::Default => println!( - "{}", - toml::to_string(&args::BuildCommand::default()) - .context("Generating default TOML config")? - ), + args::ConfigCommand::Current => print_current(¤t(config_path, None)?), + args::ConfigCommand::Default => print_default(), } +} + +pub fn print_current(command: &args::BuildCommand) -> Result<()> { + println!( + "{}", + toml::to_string(command).context("Generating current TOML config")? + ); + Ok(()) +} + +pub fn print_default() -> Result<()> { + println!( + "{}", + toml::to_string(&args::BuildCommand::default()) + .context("Generating default TOML config")? + ); Ok(()) } diff --git a/src/logging.rs b/src/logging.rs index 2d31243..60edef9 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -14,32 +14,82 @@ use crate::{absolute_normalize, args, consts, Error, Result, ResultExt}; #[allow(dead_code)] pub struct Guard(WorkerGuard); +pub struct Policy<'a> { + pub explicit: Option<&'a Path>, + pub implicit: Implicit<'a>, +} + +pub enum Implicit<'a> { + BuildDir { dir: &'a Path }, + None, +} + +pub struct Session { + path: Option, + _guard: Option, +} + +impl Session { + #[must_use] + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } +} + pub fn init( - log: Option<&PathBuf>, + policy: Policy<'_>, log_color: args::LogColor, verbose: clap_verbosity_flag::Verbosity, - build_dir: &Path, -) -> Result<(PathBuf, Guard)> { +) -> Result { let color = match log_color { args::LogColor::Auto => atty::is(atty::Stream::Stdout), args::LogColor::No => false, args::LogColor::Yes => true, }; console::set_colors_enabled(color); + let filter = verbose.log_level_filter().as_trace(); - let path = resolve_log_path(log, build_dir)?; - let file = open_log_file(&path)?; - let (writer, guard) = tracing_appender::non_blocking(file); + let path = resolve_log_path(policy)?; + let (writer, guard) = match path.as_ref() { + Some(path) => { + let file = open_log_file(path)?; + let (writer, guard) = tracing_appender::non_blocking(file); + (Some(writer), Some(Guard(guard))) + } + None => (None, None), + }; + init_tracing(writer, color, filter); - Ok((path, Guard(guard))) + Ok(Session { + path, + _guard: guard, + }) } fn init_tracing( - writer: tracing_appender::non_blocking::NonBlocking, + writer: Option, color: bool, filter: LevelFilter, ) { - let stdout_layer = tracing_subscriber::fmt::layer() + let mut layers: Vec + Send + Sync>> = Vec::new(); + + if let Some(writer) = writer { + layers.push(file_layer(writer, color, filter)); + } + + if filter == LevelFilter::DEBUG || filter == LevelFilter::TRACE { + layers.push(stderr_layer(color, filter)); + } + + let subscriber = tracing_subscriber::registry().with(layers); + tracing::subscriber::set_global_default(subscriber).unwrap(); +} + +fn stderr_layer( + color: bool, + filter: LevelFilter, +) -> Box + Send + Sync> { + tracing_subscriber::fmt::layer() .compact() .with_ansi(color) .with_file(true) @@ -49,8 +99,16 @@ fn init_tracing( .with_thread_ids(false) .with_writer(std::io::stderr) .without_time() - .with_filter(filter); - let file_layer = tracing_subscriber::fmt::layer() + .with_filter(filter) + .boxed() +} + +fn file_layer( + writer: tracing_appender::non_blocking::NonBlocking, + color: bool, + filter: LevelFilter, +) -> Box + Send + Sync> { + tracing_subscriber::fmt::layer() .compact() .with_ansi(color) .with_file(true) @@ -59,22 +117,34 @@ fn init_tracing( .with_target(true) .with_thread_ids(true) .with_writer(writer) - .with_filter(filter); - if filter == LevelFilter::DEBUG || filter == LevelFilter::TRACE { - let subscriber = tracing_subscriber::registry() - .with(file_layer) - .with(stdout_layer); - tracing::subscriber::set_global_default(subscriber).unwrap(); - } else { - let subscriber = tracing_subscriber::registry().with(file_layer); - tracing::subscriber::set_global_default(subscriber).unwrap(); + .with_filter(filter) + .boxed() +} + +fn resolve_log_path(policy: Policy<'_>) -> Result> { + match (policy.explicit, policy.implicit) { + (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), + (Some(log), Implicit::None) => validate_standalone_log_path(log).map(Some), + (None, Implicit::BuildDir { dir }) => { + validate_log_path(dir, &dir.join(consts::LOG_FILE)).map(Some) + } + (None, Implicit::None) => Ok(None), } } -fn resolve_log_path(log: Option<&PathBuf>, build_dir: &Path) -> Result { - let log = log.map_or_else(|| build_dir.join(consts::LOG_FILE), Clone::clone); +fn validate_standalone_log_path(log: &Path) -> Result { + let log = absolute_normalize(log)?; - validate_log_path(build_dir, &log) + if log.is_dir() { + return Err(Error::Message { + message: format!( + "--log must be a file path, not a directory: {}", + log.display() + ), + }); + } + + Ok(log) } fn validate_log_path(build_dir: &Path, log: &Path) -> Result { @@ -111,12 +181,12 @@ fn validate_log_path(build_dir: &Path, log: &Path) -> Result { if component_count != 1 { return Err(Error::Message { - message: format!( - "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", - log.display(), - build_dir.display() - ), - }); + message: format!( + "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", + log.display(), + build_dir.display() + ), + }); } let Some(name) = relative.file_name() else { diff --git a/src/main.rs b/src/main.rs index f9dc125..1c0c0b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::{process::ExitCode, time::Instant}; use console::style; use tracing::{error, info}; -use tsdl::{app, args, error::Error, Result}; +use tsdl::{app, error::Error, Result}; fn main() -> ExitCode { set_panic_hook(); @@ -15,7 +15,7 @@ fn main() -> ExitCode { }; info!("Starting"); - match run(app) { + match run(&app) { Err(Error::Interrupted { signal }) => ExitCode::from(signal.shell_exit_code()), Err(e) => { eprintln!("{e}"); @@ -25,10 +25,10 @@ fn main() -> ExitCode { } } -fn run(app: app::App) -> Result<()> { - match app.subcommand { - tsdl::args::Command::Build => { - let (result, duration) = time(|| tsdl::build::run(&app)); +fn run(app: &app::App) -> Result<()> { + match &app.command { + app::ResolvedCommand::Build(build) => { + let (result, duration) = time(|| tsdl::build::run(app, &build.command)); match &result { Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), Err(Error::Interrupted { signal }) => println!( @@ -39,8 +39,9 @@ fn run(app: app::App) -> Result<()> { } result } - args::Command::Config { command } => tsdl::config::run(&app.config_path, &command), - args::Command::Selfupdate{ force, target} => tsdl::selfupdate::run(force, target.as_str()), + app::ResolvedCommand::ConfigCurrent(build) => tsdl::config::print_current(&build.command), + app::ResolvedCommand::ConfigDefault => tsdl::config::print_default(), + app::ResolvedCommand::Selfupdate{ force, target} => tsdl::selfupdate::run(*force, target.as_str()), } } diff --git a/tests/cmd/build.rs b/tests/cmd/build.rs index 66abcd3..349cd2d 100644 --- a/tests/cmd/build.rs +++ b/tests/cmd/build.rs @@ -13,6 +13,26 @@ use tsdl::parser::WASM_EXTENSION; use crate::cmd::Sandbox; +#[rstest] +fn build_rejects_malformed_config_file() { + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); + + sandbox + .cmd + .arg("build") + .assert() + .failure() + .stderr(p::str::contains( + "Resolving build configuration for `build`", + )) + .stderr(p::str::contains("Parsing config file")); +} + #[rstest] fn no_args_should_download_tree_sitter_cli() { let mut sandbox = Sandbox::new(); diff --git a/tests/cmd/config.rs b/tests/cmd/config.rs index be0eeed..c17934a 100644 --- a/tests/cmd/config.rs +++ b/tests/cmd/config.rs @@ -2,7 +2,10 @@ use assert_fs::prelude::*; use indoc::formatdoc; use predicates::{self as p}; -use tsdl::{args::BuildCommand, consts::BUILD_DIR}; +use tsdl::{ + args::BuildCommand, + consts::{BUILD_DIR, CONFIG_FILE}, +}; use crate::cmd::Sandbox; @@ -29,13 +32,61 @@ fn default_is_default_toml() { sandbox.cmd.assert().success().stdout(p::str::contains( toml::to_string(&BuildCommand::default()).unwrap(), )); - assert!(!sandbox.is_empty()); + assert!(sandbox.is_empty()); +} + +#[test] +fn default_uses_explicit_log_file() { + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["--log", "config-default.log", "config", "default"]); + sandbox.cmd.assert().success(); sandbox .tmp - .child(BUILD_DIR) - .child("log") + .child("config-default.log") .assert(p::path::exists()) .assert(p::path::is_file()); + sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); +} + +#[test] +fn default_ignores_malformed_config_file() { + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); + + sandbox + .cmd + .args(["config", "default"]) + .assert() + .success() + .stdout(p::str::contains( + toml::to_string(&BuildCommand::default()).unwrap(), + )); +} + +#[test] +fn current_rejects_malformed_config_file() { + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); + + sandbox + .cmd + .args(["config", "current"]) + .assert() + .failure() + .stderr(p::str::contains( + "Resolving build configuration for `config current`", + )) + .stderr(p::str::contains("Parsing config file")); } #[test] @@ -47,13 +98,22 @@ fn current_uses_default() { .assert() .success() .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); - assert!(!sandbox.is_empty()); + assert!(sandbox.is_empty()); +} + +#[test] +fn current_uses_explicit_log_file() { + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["--log", "config-current.log", "config", "current"]); + sandbox.cmd.assert().success(); sandbox .tmp - .child(BUILD_DIR) - .child("log") + .child("config-current.log") .assert(p::path::exists()) .assert(p::path::is_file()); + sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); } #[test] @@ -75,10 +135,5 @@ fn current_uses_config_file() { .success() .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); assert!(!sandbox.is_empty()); - sandbox - .tmp - .child(build_dir) - .child("log") - .assert(p::path::exists()) - .assert(p::path::is_file()); + sandbox.tmp.child(build_dir).assert(p::path::missing()); } From 511ca2b8d72c58fc34481d1bf9a4f0d1fefb0bc8 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 08:12:54 +0200 Subject: [PATCH 46/88] db: split into store and db --- src/actors/cache.rs | 12 ++- src/build.rs | 5 +- src/cache.rs | 213 ++++++++++++++++++++++++++++++++++---------- tests/cmd/cache.rs | 4 + 4 files changed, 183 insertions(+), 51 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 620b387..42e1efc 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -131,6 +131,7 @@ impl CacheAddr { /// The Cache Actor: Manages cache state and processes messages pub struct CacheActor { db: cache::Db, + store: cache::Store, force: bool, rx: mpsc::Receiver, } @@ -200,7 +201,7 @@ impl CacheActor { tx, kind: ResponseKind::SaveComplete, } - .send(self.db.save().await); + .send(self.store.save(&self.db).await); } CacheMessage::HasCompatibleEntries { language, spec, tx } => { @@ -236,9 +237,14 @@ impl CacheActor { } #[must_use] - pub fn spawn(db: cache::Db, force: bool) -> CacheAddr { + pub fn spawn(db: cache::Db, store: cache::Store, force: bool) -> CacheAddr { let (tx, rx) = mpsc::channel(64); - let actor = Self { db, force, rx }; + let actor = Self { + db, + store, + force, + rx, + }; tokio::spawn(actor.run()); CacheAddr::new(tx) } diff --git a/src/build.rs b/src/build.rs index 97a7565..3bee3c5 100644 --- a/src/build.rs +++ b/src/build.rs @@ -197,13 +197,14 @@ fn ignite(app: &app::App, command: &args::BuildCommand) -> Result<()> { let guard = rt.enter(); - let db = cache::Db::load(&command.build_dir)?; + let cache_store = cache::Store::new(&command.build_dir); + let db = cache_store.load()?; let languages = collect_languages(command)?; let result = rt.block_on(async move { let shutdown = shutdown::Handle::new(); let _signals = shutdown.spawn_signal_listener()?; - let cache = actors::CacheActor::spawn(db, command.force); + let cache = actors::CacheActor::spawn(db, cache_store, command.force); let build_dir: Arc = command.build_dir.canon()?.into(); let out_dir: Arc = command.out_dir.canon()?.into(); let display = actors::DisplayActor::spawn(app.progress_mode, build_dir, out_dir); diff --git a/src/cache.rs b/src/cache.rs index aa4442c..f872f1a 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,6 +1,7 @@ use std::{ collections::BTreeMap, fmt::{self, Write as _}, + io::Write as _, path::{Path, PathBuf}, sync::Arc, }; @@ -10,7 +11,7 @@ use sha1::{Digest, Sha1}; use tokio::io::{AsyncReadExt, ReadBuf}; use tracing::debug; -use crate::{args, build, consts, git, parser, Result, ResultExt}; +use crate::{args, build, consts, git, parser, Error, Result, ResultExt}; #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] @@ -80,11 +81,21 @@ impl From<&str> for GrammarHash { } } -/// The build cache stored in `[build-dir]/[consts::CACHE_FILE]` +/// The logical build cache contents. +/// +/// Persistence details such as the cache file path intentionally live in +/// [`Store`], not in this serializable structure. That keeps the on-disk TOML +/// schema independent from the runtime `--build-dir` location. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { + #[serde(default)] pub parsers: BTreeMap, - pub file: PathBuf, +} + +/// File-backed storage for a [`Db`]. +#[derive(Debug, Clone)] +pub struct Store { + file: PathBuf, } /// Cache entry for a single parser @@ -427,49 +438,81 @@ pub struct Update { pub name: Key, } -impl Db { - /// Clear all entries - pub fn clear(&mut self) { - self.parsers.clear(); - } - - /// Delete the cache file from disk - pub async fn delete(build_dir: &Path) -> Result<()> { - let file = build_dir.join(consts::CACHE_FILE); - if tokio::fs::metadata(&file).await.is_ok() { - tokio::fs::remove_file(&file) - .await - .with_context(|| format!("Deleting cache file at {}", file.display()))?; - debug!("Cache file deleted"); +impl Store { + #[must_use] + pub fn new(build_dir: &Path) -> Self { + Self { + file: build_dir.join(consts::CACHE_FILE), } - Ok(()) } - /// Get cache entry for a parser #[must_use] - pub fn get(&self, name: &Key) -> Option<&Entry> { - self.parsers.get(name) + pub fn path(&self) -> &Path { + &self.file + } + + /// Delete the cache file from disk. + pub async fn delete(&self) -> Result<()> { + match tokio::fs::metadata(&self.file).await { + Ok(_) => { + tokio::fs::remove_file(&self.file) + .await + .with_context(|| format!("Deleting cache file at {}", self.file.display()))?; + debug!("Cache file deleted"); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err).with_context(|| { + format!("Reading cache file metadata at {}", self.file.display()) + }); + } + } + + Ok(()) } - /// Load the cache from disk, or return the empty cache. - pub fn load(build_dir: &Path) -> Result { - let file = build_dir.join(consts::CACHE_FILE); - if !file.exists() { + /// Load the cache from disk, or return an empty cache. + pub fn load(&self) -> Result { + if !self.file.exists() { debug!( "Cache file not found at {}, returning empty cache", - file.display() + self.file.display() ); - return Ok(Db { - parsers: BTreeMap::new(), - file, - }); + return Ok(Db::default()); } - let contents = std::fs::read_to_string(&file) - .with_context(|| format!("Reading cache file at {}", file.display()))?; + let contents = std::fs::read_to_string(&self.file) + .with_context(|| format!("Reading cache file at {}", self.file.display()))?; toml::from_str(&contents) - .with_context(|| format!("Parsing cache file at {}", file.display())) + .with_context(|| format!("Parsing cache file at {}", self.file.display())) + } + + /// Atomically save the cache to disk. + pub async fn save(&self, db: &Db) -> Result<()> { + let contents = toml::to_string_pretty(db).context("Serializing cache to TOML")?; + let file = self.file.clone(); + let result = + tokio::task::spawn_blocking(move || write_cache_file_atomically(&file, &contents)) + .await + .context("Joining cache save task")?; + result?; + + debug!("Cache saved to {}", self.file.display()); + Ok(()) + } +} + +impl Db { + /// Clear all entries. + pub fn clear(&mut self) { + self.parsers.clear(); + } + + /// Get cache entry for a parser. + #[must_use] + pub fn get(&self, name: &Key) -> Option<&Entry> { + self.parsers.get(name) } /// Explain whether a parser cache entry can satisfy the requested build. @@ -502,24 +545,54 @@ impl Db { .needs_rebuild() } - /// Save the cache to disk - pub async fn save(&self) -> Result<()> { - let contents = toml::to_string_pretty(self).context("Serializing cache to TOML")?; - - tokio::fs::write(&self.file, contents) - .await - .with_context(|| format!("Writing cache file to {}", self.file.display()))?; - - debug!("Cache saved to {}", self.file.display()); - Ok(()) - } - - /// Insert or update a parser cache entry + /// Insert or update a parser cache entry. pub fn set(&mut self, name: Key, entry: Entry) { self.parsers.insert(name, entry); } } +fn write_cache_file_atomically(file: &Path, contents: &str) -> Result<()> { + let parent = file.parent().ok_or_else(|| Error::Message { + message: format!( + "Cache file path has no parent directory: {}", + file.display() + ), + })?; + + std::fs::create_dir_all(parent) + .with_context(|| format!("Creating cache directory {}", parent.display()))?; + + let mut temp = tempfile::Builder::new() + .prefix(".cache.toml.") + .suffix(".tmp") + .tempfile_in(parent) + .with_context(|| format!("Creating temporary cache file in {}", parent.display()))?; + + temp.write_all(contents.as_bytes()) + .with_context(|| format!("Writing temporary cache file {}", temp.path().display()))?; + temp.as_file_mut() + .sync_all() + .with_context(|| format!("Syncing temporary cache file {}", temp.path().display()))?; + + temp.persist(file) + .map_err(|err| err.error) + .with_context(|| format!("Installing cache file to {}", file.display()))?; + + if let Err(err) = sync_directory(parent) { + debug!( + "Could not sync cache directory {} after saving {}: {err}", + parent.display(), + file.display() + ); + } + + Ok(()) +} + +fn sync_directory(path: &Path) -> std::io::Result<()> { + std::fs::File::open(path)?.sync_all() +} + /// Hash the contents of a file using SHA-1 and return the hex string. pub async fn hash_file(path: &Path) -> Result { let mut file = tokio::fs::File::open(path) @@ -777,4 +850,52 @@ mod tests { ], ); } + + #[test] + fn store_loads_cache_without_runtime_file_path() { + let temp = tempfile::TempDir::new().unwrap(); + let store = Store::new(temp.path()); + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + let contents = toml::to_string_pretty(&cache).unwrap(); + + std::fs::write(store.path(), contents).unwrap(); + + let loaded = store.load().unwrap(); + assert!(loaded.get(&key()).is_some()); + } + + #[tokio::test] + async fn store_ignores_legacy_file_field_and_saves_to_current_path() { + let current = tempfile::TempDir::new().unwrap(); + let stale = tempfile::TempDir::new().unwrap(); + let store = Store::new(current.path()); + let stale_file = stale.path().join(consts::CACHE_FILE); + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + let contents = toml::to_string_pretty(&cache).unwrap(); + let legacy_contents = format!( + "file = {:?}\n{contents}", + stale_file.to_string_lossy().as_ref() + ); + + std::fs::write(store.path(), legacy_contents).unwrap(); + + let mut loaded = store.load().unwrap(); + assert!(loaded.get(&key()).is_some()); + loaded.clear(); + + store.save(&loaded).await.unwrap(); + + assert!( + !stale_file.exists(), + "cache save should not use a legacy serialized file path" + ); + let saved = std::fs::read_to_string(store.path()).unwrap(); + assert!( + !saved.lines().any(|line| line.starts_with("file")), + "cache TOML should not serialize runtime storage path: {saved}" + ); + assert!(saved.contains("parsers")); + } } diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index 4e3bbac..e1b5595 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -265,4 +265,8 @@ fn cache_file_structure() { cache_content.contains("source"), "Cache should have source identity field" ); + assert!( + !cache_content.lines().any(|line| line.starts_with("file")), + "Cache should not serialize its runtime storage path" + ); } From 3f64c77366bad5b38713b5e8eead927b93067b76 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 10:13:47 +0200 Subject: [PATCH 47/88] cache: correctly identify --target artifacts Fixes a bug where cycling through --target: ```sh tsdl build --target native # cache says native tsdl build --target wasm # cache says wasm tsdl build --target native # cache misses ``` --- CHANGELOG.md | 7 + src/actors/cache.rs | 18 +-- src/actors/mod.rs | 16 +-- src/args.rs | 12 ++ src/cache.rs | 304 ++++++++++++++++++++++++++++++-------------- src/parser.rs | 9 +- tests/cmd/cache.rs | 12 +- 7 files changed, 254 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed1c5c..aa8ee4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,13 @@ informative and more coherent. - `tsdl config default` and `tsdl selfupdate` no longer : - crash on malformed configs, - write any logs _unless_ `--log` is explicitly set. +- Cycling through `--target` values `{all,native,wasm}` would not invalidate the + cache for artifacts built in the previous calls. This used to happen: + ```sh + tsdl build --target native # cache says native + tsdl build --target wasm # cache says wasm + tsdl build --target native # cache misses + ``` ## [2.0.0] - 2026-02-20 diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 42e1efc..0de9280 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -33,7 +33,7 @@ pub enum CacheMessage { NeedsRebuild { hash: cache::GrammarHash, name: cache::Key, - source: cache::Source, + revision: cache::Revision, spec: Arc, artifacts: Vec, tx: oneshot::Sender, @@ -101,13 +101,13 @@ impl CacheAddr { name: cache::Key, hash: cache::GrammarHash, spec: Arc, - source: cache::Source, + revision: cache::Revision, artifacts: Vec, ) -> cache::Decision { self.request(|tx| CacheMessage::NeedsRebuild { name, hash, - source, + revision, spec, artifacts, tx, @@ -164,14 +164,14 @@ impl CacheActor { hash, name, spec, - source, + revision, artifacts, tx, } => { let decision = if self.force { cache::Decision::miss(cache::MissReason::CacheIgnored) } else { - let decision = self.db.rebuild_decision(&name, &hash, &spec, &source); + let decision = self.db.rebuild_decision(&name, &hash, &spec, &revision); if decision.is_hit() { verify_artifacts(artifacts).await } else { @@ -215,13 +215,7 @@ impl CacheActor { !self.force && self .db - .parsers - .iter() - .find(|(key, _)| { - key.as_str() - .starts_with(&cache::Key::language_prefix(&language)) - }) - .is_some_and(|(_, entry)| entry.spec == spec), + .has_compatible_entry_for_language(&language, spec.as_ref()), ); } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index a8418ad..dd357c2 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -132,7 +132,7 @@ async fn run_inner( .collect::>(); let mut errors: Vec = - // 1. crate::cache::Source: Create a stream from the input list + // 1. Create a stream from the input list stream::iter(languages) // 2. Stage: Discovery // Transform Language -> Future>> @@ -217,7 +217,7 @@ async fn discover_grammars( ) .await; - let source = resolve_source(&cache, &language, &progress).await?; + let revision = resolve_revision(&cache, &language, &progress).await?; progress.step("scanning"); let grammars = match language.discover_grammars().await { @@ -251,7 +251,7 @@ async fn discover_grammars( key, hash.clone(), language.spec.clone(), - source.clone(), + revision.clone(), artifacts, ) .await; @@ -274,7 +274,7 @@ async fn discover_grammars( name, output: language.output.clone(), progress, - source: source.clone(), + revision: revision.clone(), spec: language.spec.clone(), ts_cli: ts_cli.clone(), }); @@ -283,11 +283,11 @@ async fn discover_grammars( Ok(builds) } -async fn resolve_source( +async fn resolve_revision( cache: &CacheAddr, language: &parser::LanguageBuild, progress: &ProgressAddr, -) -> Result { +) -> Result { if language.spec.git_ref.is_moving() { info!( "Resolving moving parser git ref for {}: {}", @@ -304,7 +304,7 @@ async fn resolve_source( language.spec.git_ref.requested().as_str(), checkout.commit.as_str() ); - Ok(crate::cache::Source::moving(checkout.commit)) + Ok(crate::cache::Revision::moving(checkout.commit)) } Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { @@ -336,6 +336,6 @@ async fn resolve_source( progress.set_outcome_cached().await; } - Ok(crate::cache::Source::stable()) + Ok(crate::cache::Revision::stable()) } } diff --git a/src/args.rs b/src/args.rs index 65c2289..63fe1fc 100644 --- a/src/args.rs +++ b/src/args.rs @@ -135,6 +135,18 @@ impl Target { ) } + #[must_use] + pub fn union(self, other: Self) -> Self { + match (self, other) { + (Self::Native, Self::Native) => Self::Native, + (Self::Wasm, Self::Wasm) => Self::Wasm, + (Self::All, _) + | (_, Self::All) + | (Self::Native, Self::Wasm) + | (Self::Wasm, Self::Native) => Self::All, + } + } + #[must_use] pub fn native(&self) -> bool { matches!(self, Self::All | Self::Native) diff --git a/src/cache.rs b/src/cache.rs index f872f1a..3090e01 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -98,26 +98,51 @@ pub struct Store { file: PathBuf, } -/// Cache entry for a single parser +/// Cache entry for a single parser. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { - /// Hash of the grammar.js file(s) + /// Hash of the grammar.js file(s). pub hash: GrammarHash, - /// Complete build definition that affects parser output - pub spec: Arc, - /// Parser source identity used by the cache. Moving refs include the + /// Resolved parser revision used by the cache. Moving refs include the /// checked-out commit. - pub source: Source, + pub revision: Revision, + /// Build inputs that affect parser output, excluding the requested output set. + pub recipe: BuildRecipe, + /// Parser outputs known to be available for this entry. + pub outputs: args::Target, +} + +/// Build inputs that affect parser output, excluding the requested output set. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuildRecipe { + pub build_script: Option, + pub git_ref: parser::Ref, + pub prefix: String, + pub repo: url::Url, + pub tree_sitter: args::TreeSitter, +} + +impl BuildRecipe { + #[must_use] + pub fn from_spec(spec: &build::Spec) -> Self { + Self { + build_script: spec.build_script.clone(), + git_ref: spec.git_ref.clone(), + prefix: spec.prefix.clone(), + repo: spec.repo.clone(), + tree_sitter: spec.tree_sitter.clone(), + } + } } #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", tag = "kind")] -pub enum Source { +pub enum Revision { Stable, Moving { commit: git::Sha }, } -impl Source { +impl Revision { #[must_use] pub const fn stable() -> Self { Self::Stable @@ -129,7 +154,7 @@ impl Source { } } -impl fmt::Display for Source { +impl fmt::Display for Revision { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Stable => write!(f, "stable"), @@ -168,9 +193,9 @@ pub enum MissReason { cached: parser::Ref, current: parser::Ref, }, - SourceChanged { - cached: Source, - current: Source, + RevisionChanged { + cached: Revision, + current: Revision, }, TreeSitterChanged { cached: args::TreeSitter, @@ -181,9 +206,9 @@ pub enum MissReason { cached: String, current: String, }, - TargetChanged { - cached: args::Target, - current: args::Target, + OutputsMissing { + available: args::Target, + requested: args::Target, }, ArtifactMissing { path: PathBuf, @@ -281,11 +306,11 @@ impl MissReason { Self::HashChanged { .. } => "hash", Self::RepoChanged { .. } => "repo", Self::RefChanged { .. } => "ref", - Self::SourceChanged { .. } => "source", + Self::RevisionChanged { .. } => "revision", Self::TreeSitterChanged { .. } => "tree-sitter", Self::BuildScriptChanged => "script", Self::PrefixChanged { .. } => "prefix", - Self::TargetChanged { .. } => "target", + Self::OutputsMissing { .. } => "outputs", Self::ArtifactMissing { .. } | Self::ArtifactNotFile { .. } | Self::ArtifactInaccessible { .. } => "artifact", @@ -300,11 +325,11 @@ impl MissReason { Self::HashChanged { .. } => "grammar changed", Self::RepoChanged { .. } => "repo changed", Self::RefChanged { .. } => "git ref changed", - Self::SourceChanged { .. } => "git ref resolved commit changed", + Self::RevisionChanged { .. } => "git ref resolved commit changed", Self::TreeSitterChanged { .. } => "tree-sitter changed", Self::BuildScriptChanged => "build script changed", Self::PrefixChanged { .. } => "prefix changed", - Self::TargetChanged { .. } => "target changed", + Self::OutputsMissing { .. } => "requested output not cached", Self::ArtifactMissing { .. } => "artifact missing", Self::ArtifactNotFile { .. } => "artifact invalid", Self::ArtifactInaccessible { .. } => "artifact inaccessible", @@ -329,8 +354,8 @@ impl fmt::Display for MissReason { cached.requested().as_str(), current.requested().as_str() ), - Self::SourceChanged { cached, current } => { - write!(f, "source changed cached={cached} current={current}") + Self::RevisionChanged { cached, current } => { + write!(f, "revision changed cached={cached} current={current}") } Self::TreeSitterChanged { cached, current } => write!( f, @@ -346,8 +371,14 @@ impl fmt::Display for MissReason { Self::PrefixChanged { cached, current } => { write!(f, "prefix changed cached={cached:?} current={current:?}") } - Self::TargetChanged { cached, current } => { - write!(f, "target changed cached={cached:?} current={current:?}") + Self::OutputsMissing { + available, + requested, + } => { + write!( + f, + "requested output not cached available={available:?} requested={requested:?}" + ) } Self::ArtifactMissing { path } => { write!(f, "artifact missing path={}", path.display()) @@ -368,10 +399,10 @@ impl Entry { &self, hash: &GrammarHash, spec: &build::Spec, - source: &Source, + revision: &Revision, ) -> Decision { let mut reasons = Vec::new(); - let cached = self.spec.as_ref(); + let cached = &self.recipe; if &self.hash != hash { reasons.push(MissReason::HashChanged { @@ -395,10 +426,10 @@ impl Entry { }); } - if !git_ref_changed && self.source != *source { - reasons.push(MissReason::SourceChanged { - cached: self.source.clone(), - current: source.clone(), + if !git_ref_changed && self.revision != *revision { + reasons.push(MissReason::RevisionChanged { + cached: self.revision.clone(), + current: revision.clone(), }); } @@ -420,15 +451,25 @@ impl Entry { }); } - if cached.target != spec.target { - reasons.push(MissReason::TargetChanged { - cached: cached.target, - current: spec.target, + if !self.outputs.covers(spec.target) { + reasons.push(MissReason::OutputsMissing { + available: self.outputs, + requested: spec.target, }); } Decision::from_reasons(reasons) } + + #[must_use] + pub fn covers_request(&self, spec: &build::Spec) -> bool { + self.recipe == BuildRecipe::from_spec(spec) && self.outputs.covers(spec.target) + } + + #[must_use] + pub fn same_subject(&self, other: &Self) -> bool { + self.hash == other.hash && self.revision == other.revision && self.recipe == other.recipe + } } /// Represents a "Delta" to be applied to the cache after a successful build @@ -521,11 +562,11 @@ impl Db { name: &Key, hash: &GrammarHash, spec: &build::Spec, - source: &Source, + revision: &Revision, ) -> Decision { let decision = match self.get(name) { None => Decision::miss(MissReason::MissingEntry), - Some(entry) => entry.rebuild_decision(hash, spec, source), + Some(entry) => entry.rebuild_decision(hash, spec, revision), }; debug!("Cache decision for {name}: {decision}"); @@ -539,14 +580,32 @@ impl Db { name: &Key, hash: &GrammarHash, spec: &build::Spec, - source: &Source, + revision: &Revision, ) -> bool { - self.rebuild_decision(name, hash, spec, source) + self.rebuild_decision(name, hash, spec, revision) .needs_rebuild() } + #[must_use] + pub fn has_compatible_entry_for_language( + &self, + language: &parser::LanguageName, + spec: &build::Spec, + ) -> bool { + let prefix = Key::language_prefix(language); + self.parsers + .iter() + .any(|(key, entry)| key.as_str().starts_with(&prefix) && entry.covers_request(spec)) + } + /// Insert or update a parser cache entry. - pub fn set(&mut self, name: Key, entry: Entry) { + pub fn set(&mut self, name: Key, mut entry: Entry) { + if let Some(existing) = self.parsers.get(&name) { + if existing.same_subject(&entry) { + entry.outputs = existing.outputs.union(entry.outputs); + } + } + self.parsers.insert(name, entry); } } @@ -649,13 +708,13 @@ mod tests { } } - fn stable_source(_spec: &build::Spec) -> Source { - Source::stable() + fn stable_revision() -> Revision { + Revision::stable() } - fn moving_source(spec: &build::Spec, sha: &str) -> Source { + fn moving_revision(spec: &build::Spec, sha: &str) -> Revision { assert!(spec.git_ref.is_moving()); - Source::moving(git::Sha::new(sha).unwrap()) + Revision::moving(git::Sha::new(sha).unwrap()) } fn key() -> Key { @@ -666,20 +725,22 @@ mod tests { GrammarHash::from(value) } + fn entry(hash: &str, spec: &build::Spec, revision: Revision) -> Entry { + Entry { + hash: grammar_hash(hash), + revision, + recipe: BuildRecipe::from_spec(spec), + outputs: spec.target, + } + } + fn cache_with_entry(hash: &str, spec: &build::Spec) -> Db { - cache_with_entry_and_source(hash, spec, stable_source(spec)) + cache_with_entry_and_revision(hash, spec, stable_revision()) } - fn cache_with_entry_and_source(hash: &str, spec: &build::Spec, source: Source) -> Db { + fn cache_with_entry_and_revision(hash: &str, spec: &build::Spec, revision: Revision) -> Db { let mut cache = Db::default(); - cache.set( - key(), - Entry { - hash: grammar_hash(hash), - spec: Arc::new(spec.clone()), - source, - }, - ); + cache.set(key(), entry(hash, spec, revision)); cache } @@ -696,12 +757,7 @@ mod tests { let spec = test_spec(); assert_miss( - cache.rebuild_decision( - &key(), - &grammar_hash("abc123"), - &spec, - &stable_source(&spec), - ), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &stable_revision()), &[MissReason::MissingEntry], ); } @@ -712,12 +768,7 @@ mod tests { let cache = cache_with_entry("abc123", &spec); assert_miss( - cache.rebuild_decision( - &key(), - &grammar_hash("def456"), - &spec, - &stable_source(&spec), - ), + cache.rebuild_decision(&key(), &grammar_hash("def456"), &spec, &stable_revision()), &[MissReason::HashChanged { cached: grammar_hash("abc123"), current: grammar_hash("def456"), @@ -737,7 +788,7 @@ mod tests { &key(), &grammar_hash("abc123"), &requested, - &stable_source(&requested), + &stable_revision(), ), &[MissReason::RefChanged { cached: parser::Ref::parse("v1.0.0").unwrap(), @@ -747,7 +798,7 @@ mod tests { } #[test] - fn test_rebuild_decision_target_changed() { + fn test_rebuild_decision_outputs_must_cover_requested_target() { let cached = test_spec(); let mut requested = cached.clone(); requested.target = args::Target::Wasm; @@ -758,61 +809,72 @@ mod tests { &key(), &grammar_hash("abc123"), &requested, - &stable_source(&requested), + &stable_revision(), ), - &[MissReason::TargetChanged { - cached: args::Target::Native, - current: args::Target::Wasm, + &[MissReason::OutputsMissing { + available: args::Target::Native, + requested: args::Target::Wasm, }], ); } + #[test] + fn test_rebuild_decision_all_outputs_cover_narrower_targets() { + let mut cached = test_spec(); + cached.target = args::Target::All; + let cache = cache_with_entry("abc123", &cached); + + for target in [args::Target::Native, args::Target::Wasm] { + let mut requested = cached.clone(); + requested.target = target; + assert_eq!( + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &requested, + &stable_revision(), + ), + Decision::Hit + ); + } + } + #[test] fn test_rebuild_decision_cache_hit_exact() { let spec = test_spec(); let cache = cache_with_entry("abc123", &spec); assert_eq!( - cache.rebuild_decision( - &key(), - &grammar_hash("abc123"), - &spec, - &stable_source(&spec) - ), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &stable_revision()), Decision::Hit ); - assert!(!cache.needs_rebuild( - &key(), - &grammar_hash("abc123"), - &spec, - &stable_source(&spec) - )); + assert!(!cache.needs_rebuild(&key(), &grammar_hash("abc123"), &spec, &stable_revision())); } #[test] - fn test_rebuild_decision_moving_source_commit_changed() { + fn test_rebuild_decision_moving_revision_commit_changed() { let spec = moving_spec(); - let cached_source = moving_source(&spec, SHA1); - let current_source = moving_source(&spec, SHA2); - let cache = cache_with_entry_and_source("abc123", &spec, cached_source.clone()); + let cached_revision = moving_revision(&spec, SHA1); + let current_revision = moving_revision(&spec, SHA2); + let cache = cache_with_entry_and_revision("abc123", &spec, cached_revision.clone()); assert_miss( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, ¤t_source), - &[MissReason::SourceChanged { - cached: cached_source, - current: current_source, + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, ¤t_revision), + &[MissReason::RevisionChanged { + cached: cached_revision, + current: current_revision, }], ); } #[test] - fn test_rebuild_decision_moving_source_commit_unchanged_hits() { + fn test_rebuild_decision_moving_revision_commit_unchanged_hits() { let spec = moving_spec(); - let source = moving_source(&spec, SHA1); - let cache = cache_with_entry_and_source("abc123", &spec, source.clone()); + let revision = moving_revision(&spec, SHA1); + let cache = cache_with_entry_and_revision("abc123", &spec, revision.clone()); assert_eq!( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &source), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &revision), Decision::Hit ); } @@ -831,7 +893,7 @@ mod tests { &key(), &grammar_hash("def456"), &requested, - &stable_source(&requested), + &stable_revision(), ), &[ MissReason::HashChanged { @@ -843,14 +905,60 @@ mod tests { cached: String::new(), current: "custom-".to_string(), }, - MissReason::TargetChanged { - cached: args::Target::Native, - current: args::Target::All, + MissReason::OutputsMissing { + available: args::Target::Native, + requested: args::Target::All, }, ], ); } + #[test] + fn set_merges_outputs_for_same_entry_subject() { + let native = test_spec(); + let mut wasm = native.clone(); + wasm.target = args::Target::Wasm; + let mut cache = Db::default(); + + cache.set(key(), entry("abc123", &native, stable_revision())); + cache.set(key(), entry("abc123", &wasm, stable_revision())); + + assert_eq!(cache.get(&key()).unwrap().outputs, args::Target::All); + } + + #[test] + fn set_replaces_when_recipe_changes() { + let native = test_spec(); + let mut wasm = native.clone(); + wasm.target = args::Target::Wasm; + wasm.prefix = "other-".to_string(); + let mut cache = Db::default(); + + cache.set(key(), entry("abc123", &native, stable_revision())); + cache.set(key(), entry("abc123", &wasm, stable_revision())); + + let stored = cache.get(&key()).unwrap(); + assert_eq!(stored.outputs, args::Target::Wasm); + assert_eq!(stored.recipe.prefix, "other-"); + } + + #[test] + fn compatible_entries_use_output_coverage() { + let mut cached = test_spec(); + cached.target = args::Target::All; + let mut requested = cached.clone(); + requested.target = args::Target::Native; + let mut cache = Db::default(); + + cache.set( + Key::from("rust/rust"), + entry("abc123", &cached, stable_revision()), + ); + + assert!(cache + .has_compatible_entry_for_language(&parser::LanguageName::from("rust"), &requested)); + } + #[test] fn store_loads_cache_without_runtime_file_path() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/src/parser.rs b/src/parser.rs index fd2243c..ca7bcf6 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -237,7 +237,7 @@ pub struct GrammarBuild { pub name: GrammarName, pub output: build::OutputConfig, pub progress: actors::ProgressAddr, // Use language's handle - pub source: cache::Source, + pub revision: cache::Revision, pub spec: Arc, pub ts_cli: Arc, } @@ -299,8 +299,9 @@ impl GrammarBuild { name: key, entry: cache::Entry { hash: self.hash.clone(), - spec: self.spec.clone(), - source: self.source.clone(), + revision: self.revision.clone(), + recipe: cache::BuildRecipe::from_spec(self.spec.as_ref()), + outputs: self.spec.target, }, }; @@ -1038,7 +1039,7 @@ mod tests { out_dir: out_dir.into(), }, progress, - source: cache::Source::stable(), + revision: cache::Revision::stable(), spec: Arc::new(build::Spec { build_script: None, git_ref: source_ref, diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index e1b5595..e09c8e2 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -262,8 +262,16 @@ fn cache_file_structure() { "Cache should have git_ref field" ); assert!( - cache_content.contains("source"), - "Cache should have source identity field" + cache_content.contains("revision"), + "Cache should have revision identity field" + ); + assert!( + cache_content.contains("recipe"), + "Cache should have build recipe field" + ); + assert!( + cache_content.contains("outputs"), + "Cache should have outputs field" ); assert!( !cache_content.lines().any(|line| line.starts_with("file")), From e5dad88a62b36a7a59839e35a89d1507fa1675f1 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 10:31:14 +0200 Subject: [PATCH 48/88] args: parse --jobs as NonZeroUsize --- src/config.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 8db7112..8575cfe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::{ ffi::OsString, fs, + num::NonZeroUsize, path::{Path, PathBuf}, result::Result as StdResult, }; @@ -274,7 +275,7 @@ pub fn build_cli(defaults: &args::BuildCommand) -> Vec { .long("jobs") .short('j') .env("TSDL_NCPUS") - .value_parser(value_parser!(usize)) + .value_parser(value_parser!(NonZeroUsize)) .help(format!("Number of threads [default: {jobs_default}]")), Arg::new("out-dir") .long("out-dir") From 23ce6bd64f222d5833f1e675a3a5a8ebb05953d7 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 10:56:32 +0200 Subject: [PATCH 49/88] all: never Arc<> a PathBuf --- src/actors/display.rs | 10 +++------ src/actors/mod.rs | 8 ++++---- src/build.rs | 14 ++++++------- src/display.rs | 6 +++--- src/parser.rs | 47 ++++++++++++++++++------------------------- src/walk.rs | 8 +++----- 6 files changed, 39 insertions(+), 54 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index 9ed55dc..eadc078 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -363,11 +363,7 @@ pub struct DisplayActor { impl DisplayActor { #[must_use] - pub fn spawn( - mode: display::Mode, - build_dir: Arc, - out_dir: Arc, - ) -> DisplayAddr { + pub fn spawn(mode: display::Mode, build_dir: PathBuf, out_dir: PathBuf) -> DisplayAddr { let (tx, rx) = mpsc::channel(256); let actor = Self { state: display::State::new(mode, build_dir, out_dir), @@ -1197,8 +1193,8 @@ mod tests { DisplayActor { state: display::State::new( display::Mode::Fancy, - Arc::new(PathBuf::from("build")), - Arc::new(PathBuf::from("out")), + PathBuf::from("build"), + PathBuf::from("out"), ), next_id: display::ItemId::new(NonZeroU64::MIN), plain_name_width: 16, diff --git a/src/actors/mod.rs b/src/actors/mod.rs index dd357c2..4cc88da 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -1,7 +1,7 @@ mod cache; mod display; -use std::{num::NonZeroUsize, path::PathBuf, sync::Arc}; +use std::{num::NonZeroUsize, path::PathBuf}; pub use cache::{CacheActor, CacheAddr}; pub use display::{DisplayActor, DisplayAddr, Message, ProgressAddr}; @@ -125,7 +125,7 @@ async fn run_inner( tree_sitter: &args::TreeSitter, ) -> Result<()> { let prepared = tree_sitter::prepare(build_dir, display.clone(), tree_sitter).await?; - let ts_cli = Arc::new(prepared.path); + let ts_cli = prepared.path; let languages = languages .into_iter() .map(|language| language.with_tree_sitter(prepared.tree_sitter.clone())) @@ -203,7 +203,7 @@ async fn discover_grammars( cache: CacheAddr, display: DisplayAddr, language: parser::LanguageBuild, - ts_cli: Arc, + ts_cli: PathBuf, ) -> Result> { shutdown::test_delay().await; shutdown::check()?; @@ -268,7 +268,7 @@ async fn discover_grammars( builds.push(parser::GrammarBuild { context: language.context.clone(), cache_decision, - dir: dir.into(), + dir, hash, language: language.name.clone(), name, diff --git a/src/build.rs b/src/build.rs index 3bee3c5..45610db 100644 --- a/src/build.rs +++ b/src/build.rs @@ -29,8 +29,8 @@ pub struct Spec { #[derive(Debug, Clone)] pub struct OutputConfig { - pub build_dir: Arc, - pub out_dir: Arc, + pub build_dir: PathBuf, + pub out_dir: PathBuf, } #[derive(Debug, Clone, PartialEq)] @@ -205,8 +205,8 @@ fn ignite(app: &app::App, command: &args::BuildCommand) -> Result<()> { let shutdown = shutdown::Handle::new(); let _signals = shutdown.spawn_signal_listener()?; let cache = actors::CacheActor::spawn(db, cache_store, command.force); - let build_dir: Arc = command.build_dir.canon()?.into(); - let out_dir: Arc = command.out_dir.canon()?.into(); + let build_dir = command.build_dir.canon()?; + let out_dir = command.out_dir.canon()?; let display = actors::DisplayActor::spawn(app.progress_mode, build_dir, out_dir); shutdown::scope(shutdown, async move { @@ -264,13 +264,11 @@ fn unique_languages(command: &args::BuildCommand) -> Vec Err(Error::Language { diff --git a/src/display.rs b/src/display.rs index 3ef6e1f..5d42d04 100644 --- a/src/display.rs +++ b/src/display.rs @@ -508,14 +508,14 @@ pub(crate) struct State { pub mode: Mode, pub repos: HashMap, pub grammars: HashMap, - pub build_dir: Arc, - pub out_dir: Arc, + pub build_dir: PathBuf, + pub out_dir: PathBuf, footer_build: Line<'static>, footer_out: Line<'static>, } impl State { - pub fn new(mode: Mode, build_dir: Arc, out_dir: Arc) -> Self { + pub fn new(mode: Mode, build_dir: PathBuf, out_dir: PathBuf) -> Self { let footer_build = dim_line(format!("build: {}", build_dir.display())); let footer_out = dim_line(format!("out: {}", out_dir.display())); Self { diff --git a/src/parser.rs b/src/parser.rs index ca7bcf6..a35fe62 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -231,7 +231,7 @@ fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { pub struct GrammarBuild { pub context: build::Context, pub cache_decision: cache::Decision, - pub dir: Arc, + pub dir: PathBuf, pub hash: cache::GrammarHash, pub language: LanguageName, // Required for error reporting and cache keys; set from parent LanguageBuild pub name: GrammarName, @@ -239,7 +239,7 @@ pub struct GrammarBuild { pub progress: actors::ProgressAddr, // Use language's handle pub revision: cache::Revision, pub spec: Arc, - pub ts_cli: Arc, + pub ts_cli: PathBuf, } impl GrammarBuild { @@ -366,7 +366,7 @@ impl GrammarBuild { ensure_parent_dir(&artifact).await?; let mut cmd = self.builtin_build_command(kind, &artifact); - cmd.current_dir(self.dir.as_ref()) + cmd.current_dir(self.dir.as_path()) .exec() .await .map_err(|err| self.build_step_error(err))?; @@ -377,7 +377,7 @@ impl GrammarBuild { async fn build_custom_target(&self, kind: ArtifactKind, script: &str) -> Result { let mut cmd = Command::from_str(script); - cmd.current_dir(self.dir.as_ref()) + cmd.current_dir(self.dir.as_path()) .exec() .await .map_err(|err| self.build_step_error(err))?; @@ -393,7 +393,7 @@ impl GrammarBuild { Error::Step { name: self.language.as_arc(), kind: error::ParserOp::Build { - dir: self.dir.to_path_buf(), + dir: self.dir.clone(), }, source: err.into(), } @@ -424,7 +424,7 @@ impl GrammarBuild { async fn brute_force_discover(&self, kind: ArtifactKind) -> Result { let ext = kind.extension(); let expected_name = self.parser_name_and_ext(kind); - let mut files = fs::read_dir(self.dir.as_ref()) + let mut files = fs::read_dir(self.dir.as_path()) .await .with_context(|| format!("Failed to read directory {}", self.dir.display()))?; @@ -480,7 +480,7 @@ impl GrammarBuild { .map_err(|err| Error::Step { name: self.language.as_arc(), kind: error::ParserOp::Generate { - dir: self.dir.to_path_buf(), + dir: self.dir.clone(), }, source: err.into(), }) @@ -680,8 +680,8 @@ impl GrammarBuild { Error::Step { name: self.language.as_arc(), kind: error::ParserOp::Copy { - src: self.output.out_dir.to_path_buf(), - dst: self.output.build_dir.to_path_buf(), + src: self.output.out_dir.clone(), + dst: self.output.build_dir.clone(), }, source: Error::Message { message: format!("Couldn't find any {ext} file"), @@ -694,8 +694,8 @@ impl GrammarBuild { Error::Step { name: self.language.as_arc(), kind: error::ParserOp::Copy { - src: self.output.out_dir.to_path_buf(), - dst: self.output.build_dir.to_path_buf(), + src: self.output.out_dir.clone(), + dst: self.output.build_dir.clone(), }, source: Error::Message { message: format!("Found multiple {ext} files: {candidates:?}"), @@ -736,7 +736,7 @@ impl LanguageBuild { pub async fn discover_grammars( &self, ) -> Result> { - let file_results = collect_grammar_paths(self.output.build_dir.clone()).await?; + let file_results = collect_grammar_paths(&self.output.build_dir).await?; let mut grammars = Vec::new(); for (grammar_path, hash) in file_results { @@ -754,7 +754,7 @@ impl LanguageBuild { return Err(Error::Step { name: self.name.as_arc(), kind: error::ParserOp::Discover { - dir: self.output.build_dir.to_path_buf(), + dir: self.output.build_dir.clone(), }, source: Error::Message { message: format!( @@ -789,7 +789,7 @@ impl LanguageBuild { .map_err(|err| Error::Step { name: self.name.as_arc(), kind: error::ParserOp::Clone { - dir: self.output.build_dir.to_path_buf(), + dir: self.output.build_dir.clone(), }, source: err.into(), }) @@ -991,10 +991,7 @@ mod tests { tree_sitter: TreeSitter::default(), }), LanguageName::from("empty"), - build::OutputConfig { - build_dir: build_dir.into(), - out_dir: out_dir.into(), - }, + build::OutputConfig { build_dir, out_dir }, ) } @@ -1018,11 +1015,7 @@ mod tests { out_dir: PathBuf, overwrite_output: bool, ) -> (GrammarBuild, DisplayAddr) { - let display = DisplayActor::spawn( - Mode::Plain, - Arc::new(grammar_dir.clone()), - Arc::new(out_dir.clone()), - ); + let display = DisplayActor::spawn(Mode::Plain, grammar_dir.clone(), out_dir.clone()); let progress = display .add_grammar(git::Ref::head(), "rust", "rust", 1) .await; @@ -1030,13 +1023,13 @@ mod tests { let build = GrammarBuild { context: build::Context { overwrite_output }, cache_decision: cache::Decision::miss(cache::MissReason::MissingEntry), - dir: grammar_dir.clone().into(), + dir: grammar_dir.clone(), hash: cache::GrammarHash::from("test"), language: "rust".into(), name: "rust".into(), output: build::OutputConfig { - build_dir: grammar_dir.into(), - out_dir: out_dir.into(), + build_dir: grammar_dir, + out_dir, }, progress, revision: cache::Revision::stable(), @@ -1048,7 +1041,7 @@ mod tests { target: Target::Native, tree_sitter: TreeSitter::default(), }), - ts_cli: Arc::new(PathBuf::from("tree-sitter-macos-arm64-v0.26.5")), + ts_cli: PathBuf::from("tree-sitter-macos-arm64-v0.26.5"), }; (build, display) diff --git a/src/walk.rs b/src/walk.rs index 9350803..97e42e2 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -1,12 +1,10 @@ -use std::{path::PathBuf, sync::Arc}; +use std::path::{Path, PathBuf}; use crate::{cache, git, shutdown, Result}; /// Collect grammar.js paths via git ls-files and compute their hashes. -pub async fn collect_grammar_paths( - root: Arc, -) -> Result> { - let files = git::list_grammar_files(root.as_ref()).await?; +pub async fn collect_grammar_paths(root: &Path) -> Result> { + let files = git::list_grammar_files(root).await?; let mut results = Vec::with_capacity(files.len()); for file in files { From 385ab895cbe0af7222ff9017c89943a6c3556920 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 11:03:50 +0200 Subject: [PATCH 50/88] all: use Path instead of PathBuf --- src/actors/mod.rs | 9 ++++++--- src/tree_sitter.rs | 13 +++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 4cc88da..3a5d915 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -1,7 +1,10 @@ mod cache; mod display; -use std::{num::NonZeroUsize, path::PathBuf}; +use std::{ + num::NonZeroUsize, + path::{Path, PathBuf}, +}; pub use cache::{CacheActor, CacheAddr}; pub use display::{DisplayActor, DisplayAddr, Message, ProgressAddr}; @@ -61,7 +64,7 @@ impl Response { /// The entire build pipeline. pub async fn run( - build_dir: &PathBuf, + build_dir: &Path, cache: CacheAddr, display: DisplayAddr, jobs: NonZeroUsize, @@ -117,7 +120,7 @@ pub async fn run( } async fn run_inner( - build_dir: &PathBuf, + build_dir: &Path, cache: CacheAddr, display: DisplayAddr, jobs: NonZeroUsize, diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 777465d..8110923 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -42,17 +42,14 @@ async fn chmod_x(prog: &Path) -> Result<()> { } async fn cli( - build_dir: &PathBuf, + build_dir: &Path, handle: &actors::ProgressAddr, platform: &str, repo: &str, tag: &str, ) -> Result { let cli = format!("tree-sitter-{platform}"); - let res = PathBuf::new() - .join(build_dir) - .join(format!("{cli}-{tag}")) - .canon()?; + let res = build_dir.join(format!("{cli}-{tag}")).canon()?; let gz_basename = format!("{cli}.gz"); let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); @@ -85,7 +82,7 @@ async fn cli( } async fn resolve_release_tag( - build_dir: &PathBuf, + build_dir: &Path, handle: &actors::ProgressAddr, repo: &str, resolved_ref: &git::ResolvedRef, @@ -94,7 +91,7 @@ async fn resolve_release_tag( git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), git::ResolvedRef::Ref(git_ref) => { handle.msg(format!("resolving exact tag for {resolved_ref}")); - let tree_sitter = PathBuf::new().join(build_dir).join("tree-sitter"); + let tree_sitter = build_dir.join("tree-sitter"); git::clone(repo, &tree_sitter).await?; Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) } @@ -347,7 +344,7 @@ fn parse_refs(stdout: &str) -> HashMap { } pub async fn prepare( - build_dir: &PathBuf, + build_dir: &Path, display: actors::DisplayAddr, tree_sitter: &args::TreeSitter, ) -> Result { From dd7ec92eb6caa6f0a50ed22710569f921f909830 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 11:16:38 +0200 Subject: [PATCH 51/88] build-sir: introduce newtype --- src/build.rs | 57 +++++++++++++++++++++++++++----------------- src/build_dir.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ src/cache.rs | 14 ++++++----- src/lib.rs | 1 + src/lock.rs | 11 +++++---- 5 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 src/build_dir.rs diff --git a/src/build.rs b/src/build.rs index 45610db..3133e1a 100644 --- a/src/build.rs +++ b/src/build.rs @@ -13,8 +13,8 @@ use url::Url; use crate::consts::FROM; use crate::{ - actors, app, args, cache, format_duration, lock, parser, prompt_user, shutdown, Error, Result, - ResultExt, SafeCanonicalize, + actors, app, args, build_dir::BuildDir, cache, format_duration, lock, parser, prompt_user, + shutdown, Error, Result, ResultExt, SafeCanonicalize, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -43,11 +43,12 @@ pub fn run(app: &app::App, command: &args::BuildCommand) -> Result<()> { crate::config::show(command)?; } - let lock = lock::Lock::new(&command.build_dir); + let build_dir = BuildDir::new(&command.build_dir)?; + let lock = lock::Lock::new(&build_dir); let guard = acquire_lock(&lock, Duration::from_secs(command.unlock_timeout))?; - clear(command, app.logging.path(), &guard)?; - ignite(app, command)?; + clear(command.fresh, &build_dir, app.logging.path(), &guard)?; + ignite(app, command, &build_dir)?; Ok(()) } @@ -119,8 +120,13 @@ fn handle_locked_by( lock.wait_for_release(owner, unlock_timeout) } -fn clear(command: &args::BuildCommand, log_path: Option<&Path>, guard: &lock::Guard) -> Result<()> { - if command.fresh && command.build_dir.exists() { +fn clear( + fresh: bool, + build_dir: &BuildDir, + log_path: Option<&Path>, + guard: &lock::Guard, +) -> Result<()> { + if fresh && build_dir.as_path().exists() { let protected_files = log_path .map(Path::to_path_buf) .into_iter() @@ -128,13 +134,16 @@ fn clear(command: &args::BuildCommand, log_path: Option<&Path>, guard: &lock::Gu guard.clear_directory(&protected_files)?; } - fs::create_dir_all(&command.build_dir)?; + fs::create_dir_all(build_dir.as_path())?; Ok(()) } -fn collect_languages(command: &args::BuildCommand) -> Result> { - let results = unique_languages(command); +fn collect_languages( + command: &args::BuildCommand, + build_dir: &BuildDir, +) -> Result> { + let results = unique_languages(command, build_dir); let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); if err.is_empty() { @@ -188,7 +197,7 @@ fn get_language_coords( } } -fn ignite(app: &app::App, command: &args::BuildCommand) -> Result<()> { +fn ignite(app: &app::App, command: &args::BuildCommand, build_dir: &BuildDir) -> Result<()> { fs::create_dir_all(&command.out_dir)?; let rt = tokio::runtime::Builder::new_current_thread() @@ -197,21 +206,22 @@ fn ignite(app: &app::App, command: &args::BuildCommand) -> Result<()> { let guard = rt.enter(); - let cache_store = cache::Store::new(&command.build_dir); + let cache_store = cache::Store::new(build_dir); let db = cache_store.load()?; - let languages = collect_languages(command)?; + let languages = collect_languages(command, build_dir)?; let result = rt.block_on(async move { let shutdown = shutdown::Handle::new(); let _signals = shutdown.spawn_signal_listener()?; let cache = actors::CacheActor::spawn(db, cache_store, command.force); - let build_dir = command.build_dir.canon()?; - let out_dir = command.out_dir.canon()?; - let display = actors::DisplayActor::spawn(app.progress_mode, build_dir, out_dir); + let display_build_dir = build_dir.as_path().to_path_buf(); + let display_out_dir = command.out_dir.canon()?; + let display = + actors::DisplayActor::spawn(app.progress_mode, display_build_dir, display_out_dir); shutdown::scope(shutdown, async move { actors::run( - &command.build_dir, + build_dir.as_path(), cache, display, command.jobs, @@ -230,7 +240,10 @@ fn ignite(app: &app::App, command: &args::BuildCommand) -> Result<()> { result } -fn unique_languages(command: &args::BuildCommand) -> Vec> { +fn unique_languages( + command: &args::BuildCommand, + build_dir: &BuildDir, +) -> Vec> { let requested_languages = &command.languages; let defined_parsers = command.parsers.as_ref(); @@ -260,9 +273,8 @@ fn unique_languages(command: &args::BuildCommand) -> Vec>(); diff --git a/src/build_dir.rs b/src/build_dir.rs new file mode 100644 index 0000000..e862505 --- /dev/null +++ b/src/build_dir.rs @@ -0,0 +1,62 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +use crate::{absolute_normalize, consts, Result}; + +/// The root build directory — anchor for all derived paths. +/// +/// Wrapping this in a newtype prevents accidental swaps with other `PathBuf` +/// values (e.g. `out_dir`) and centralises sub-path derivation. +#[derive(Clone, Debug)] +pub struct BuildDir(PathBuf); + +impl BuildDir { + /// Create a `BuildDir`, resolving relative paths against the current + /// working directory and normalising `.` / `..` components. + pub fn new(path: impl Into) -> Result { + let path = absolute_normalize(&path.into())?; + Ok(Self(path)) + } + + /// The absolute, normalised path as a `&Path`. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } + + /// Path to the build cache TOML file inside this build directory. + #[must_use] + pub fn cache_file(&self) -> PathBuf { + self.0.join(consts::CACHE_FILE) + } + + /// Path to the PID lock file inside this build directory. + #[must_use] + pub fn lock_file(&self) -> PathBuf { + self.0.join(consts::LOCK_FILE) + } + + /// Path to the default log file inside this build directory. + #[must_use] + pub fn log_file(&self) -> PathBuf { + self.0.join(consts::LOG_FILE) + } + + /// Per-language checkout directory: `/tree-sitter-`. + #[must_use] + pub fn checkout_dir(&self, language: &str) -> PathBuf { + self.0.join(format!("tree-sitter-{language}")) + } +} + +impl AsRef for BuildDir { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl fmt::Display for BuildDir { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.display()) + } +} diff --git a/src/cache.rs b/src/cache.rs index 3090e01..4a73a73 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -11,7 +11,7 @@ use sha1::{Digest, Sha1}; use tokio::io::{AsyncReadExt, ReadBuf}; use tracing::debug; -use crate::{args, build, consts, git, parser, Error, Result, ResultExt}; +use crate::{args, build, build_dir::BuildDir, git, parser, Error, Result, ResultExt}; #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] @@ -481,9 +481,9 @@ pub struct Update { impl Store { #[must_use] - pub fn new(build_dir: &Path) -> Self { + pub fn new(build_dir: &BuildDir) -> Self { Self { - file: build_dir.join(consts::CACHE_FILE), + file: build_dir.cache_file(), } } @@ -685,7 +685,7 @@ pub async fn hash_file(path: &Path) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::{git, parser}; + use crate::{consts, git, parser}; const SHA1: &str = "636801770eea172d140e64b691815ff11f6b556f"; const SHA2: &str = "736801770eea172d140e64b691815ff11f6b556f"; @@ -962,7 +962,8 @@ mod tests { #[test] fn store_loads_cache_without_runtime_file_path() { let temp = tempfile::TempDir::new().unwrap(); - let store = Store::new(temp.path()); + let build_dir = BuildDir::new(temp.path()).unwrap(); + let store = Store::new(&build_dir); let spec = test_spec(); let cache = cache_with_entry("abc123", &spec); let contents = toml::to_string_pretty(&cache).unwrap(); @@ -977,7 +978,8 @@ mod tests { async fn store_ignores_legacy_file_field_and_saves_to_current_path() { let current = tempfile::TempDir::new().unwrap(); let stale = tempfile::TempDir::new().unwrap(); - let store = Store::new(current.path()); + let build_dir = BuildDir::new(current.path()).unwrap(); + let store = Store::new(&build_dir); let stale_file = stale.path().join(consts::CACHE_FILE); let spec = test_spec(); let cache = cache_with_entry("abc123", &spec); diff --git a/src/lib.rs b/src/lib.rs index 3ac179a..a6f489a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ pub mod actors; pub mod app; pub mod args; pub mod build; +pub mod build_dir; pub mod cache; pub mod columns; pub mod config; diff --git a/src/lock.rs b/src/lock.rs index 76d1bbc..665b9e3 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -15,7 +15,9 @@ use fs2::FileExt; use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, UpdateKind}; use tracing::info; -use crate::{absolute_normalize, consts, format_duration, Error, Result, ResultExt}; +use crate::{ + absolute_normalize, build_dir::BuildDir, consts, format_duration, Error, Result, ResultExt, +}; /// Information about the process currently holding the build lock. #[derive(Debug, Clone)] @@ -293,9 +295,9 @@ pub struct Lock { impl Lock { #[must_use] - pub fn new(build_dir: &Path) -> Self { + pub fn new(build_dir: &BuildDir) -> Self { Self { - lock_path: build_dir.join(consts::LOCK_FILE), + lock_path: build_dir.lock_file(), current_pid: Pid::from(process::id() as usize), } } @@ -565,7 +567,8 @@ mod tests { let lock_file = temp.path().join(consts::LOCK_FILE); fs::write(&lock_file, unused_pid().to_string()).unwrap(); - let lock = Lock::new(temp.path()); + let build_dir = BuildDir::new(temp.path()).unwrap(); + let lock = Lock::new(&build_dir); let status = lock.try_acquire().unwrap(); assert!( From 5eec466c926c6198e275d6bc7366b9dc5afd86c7 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 14:35:51 +0200 Subject: [PATCH 52/88] cache: buffer read files when hashing --- src/cache.rs | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 4a73a73..208d3b0 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,14 +1,16 @@ use std::{ collections::BTreeMap, fmt::{self, Write as _}, - io::Write as _, + io::{self, Write as _}, path::{Path, PathBuf}, + pin::Pin, sync::Arc, + task::{Context, Poll}, }; use serde::{Deserialize, Serialize}; use sha1::{Digest, Sha1}; -use tokio::io::{AsyncReadExt, ReadBuf}; +use tokio::io::AsyncWrite; use tracing::debug; use crate::{args, build, build_dir::BuildDir, git, parser, Error, Result, ResultExt}; @@ -659,20 +661,10 @@ pub async fn hash_file(path: &Path) -> Result { .with_context(|| format!("Opening file for hashing: {}", path.display()))?; let mut hasher = Sha1::new(); - let mut buffer = vec![0u8; 8192]; - loop { - let mut read_buf = ReadBuf::new(&mut buffer); - file.read_buf(&mut read_buf) - .await - .with_context(|| format!("Reading file for hashing: {}", path.display()))?; - - if read_buf.filled().is_empty() { - break; - } - - hasher.update(read_buf.filled()); - } + tokio::io::copy(&mut file, &mut HashWriter(&mut hasher)) + .await + .with_context(|| format!("Reading file for hashing: {}", path.display()))?; let result = hasher.finalize(); let mut hex = String::with_capacity(result.len() * 2); @@ -682,6 +674,27 @@ pub async fn hash_file(path: &Path) -> Result { Ok(GrammarHash::from(hex)) } +struct HashWriter<'a>(&'a mut Sha1); + +impl AsyncWrite for HashWriter<'_> { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.0.update(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + #[cfg(test)] mod tests { use super::*; From 82c8d628e7cdb38540b3bd4b94932e6df4da7ab2 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 15:06:42 +0200 Subject: [PATCH 53/88] config: avoid builder, again --- src/config.rs | 454 +++++++++++++++++++++++--------------------------- 1 file changed, 212 insertions(+), 242 deletions(-) diff --git a/src/config.rs b/src/config.rs index 8575cfe..1df6a24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,14 +6,117 @@ use std::{ result::Result as StdResult, }; -use clap::{ - parser::ValueSource, value_parser, Arg, ArgAction, ArgMatches, CommandFactory, FromArgMatches, -}; +use clap::{parser::ValueSource, ArgMatches, Args, CommandFactory, FromArgMatches}; use serde::Serialize; use tracing::debug; use crate::{args, columns, Result, ResultExt}; +// ── Arg ID constants for provenance tracking ──────────────────────────── +// +// clap derive uses the Rust field name as the arg ID, not the long flag +// name. These constants match the field names so `ArgMatches::value_source` +// can look them up. + +const ARG_BUILD_DIR: &str = "build_dir"; +const ARG_FORCE: &str = "force"; +const ARG_NO_FORCE: &str = "no_force"; +const ARG_FRESH: &str = "fresh"; +const ARG_NO_FRESH: &str = "no_fresh"; +const ARG_LANGUAGES: &str = "languages"; +const ARG_JOBS: &str = "jobs"; +const ARG_OUT_DIR: &str = "out_dir"; +const ARG_PREFIX: &str = "prefix"; +const ARG_SHOW_CONFIG: &str = "show_config"; +const ARG_NO_SHOW_CONFIG: &str = "no_show_config"; +const ARG_TARGET: &str = "target"; +const ARG_TS_VERSION: &str = "version"; +const ARG_TS_PLATFORM: &str = "platform"; +const ARG_TS_REPO: &str = "repo"; +const ARG_UNLOCK_TIMEOUT: &str = "unlock_timeout"; + +// ── Build CLI argument structs ───────────────────────────────────────── + +/// CLI arguments for the `build` subcommand, defined via derive so the arg +/// definitions and value extraction stay in sync. +#[derive(clap::Args, Clone, Debug, Default)] +pub struct BuildArgs { + #[arg(long = "build-dir", short = 'b', env = "BUILD_DIR")] + pub build_dir: Option, + + #[arg( + long = "force", + env = "FORCE", + num_args = 0..=1, + require_equals = true, + default_missing_value = "true" + )] + pub force: Option, + #[arg(long = "no-force")] + pub no_force: bool, + + #[arg( + long = "fresh", + short = 'f', + env = "FRESH", + num_args = 0..=1, + require_equals = true, + default_missing_value = "true" + )] + pub fresh: Option, + #[arg(long = "no-fresh")] + pub no_fresh: bool, + + #[arg(num_args = 0..)] + pub languages: Vec, + + #[arg(long = "jobs", short = 'j', env = "TSDL_NCPUS")] + pub jobs: Option, + + #[arg(long = "out-dir", short = 'o', env = "PARSER_OUT_DIR")] + pub out_dir: Option, + + #[arg(long = "prefix", short = 'p', env = "PREFIX")] + pub prefix: Option, + + #[arg( + long = "show-config", + env = "SHOW_CONFIG", + num_args = 0..=1, + require_equals = true, + default_missing_value = "true" + )] + pub show_config: Option, + #[arg(long = "no-show-config")] + pub no_show_config: bool, + + #[arg(long = "target", short = 't', env = "TSDL_TARGET", value_enum)] + pub target: Option, + + #[command(flatten)] + pub tree_sitter: TreeSitterArgs, + + #[arg( + long = "unlock-timeout", + env = "UNLOCK_TIMEOUT", + value_parser = clap::value_parser!(u64).range(1..) + )] + pub unlock_timeout: Option, +} + +/// Nested CLI arguments for tree-sitter configuration. +#[derive(clap::Args, Clone, Debug, Default)] +pub struct TreeSitterArgs { + #[arg(long = "tree-sitter-version", short = 'V', env = "TSDL_VERSION")] + pub version: Option, + #[arg(long = "tree-sitter-platform", env = "TSDL_PLATFORM")] + pub platform: Option, + #[arg(long = "tree-sitter-repo", short = 'R', env = "TSDL_REPO")] + pub repo: Option, +} + +// ── Source / provenance types ────────────────────────────────────────── + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum Source { @@ -59,6 +162,8 @@ pub struct BuildProvenance { pub unlock_timeout: Source, } +// ── Configuration resolution ─────────────────────────────────────────── + pub fn current(config: &Path, matches: Option<&ArgMatches>) -> Result { let (cmd, _provenance) = current_with_provenance(config, matches)?; Ok(cmd) @@ -73,7 +178,7 @@ pub fn current_with_provenance( let file_provenance = file_provenance_from(&file_overrides); let (cli_overrides, cli_provenance) = if let Some(matches) = matches { - extract_overrides(matches, &defaults) + extract_overrides(matches) } else { ( args::OptionalBuildCommand::default(), @@ -226,195 +331,119 @@ fn merge_source(file: Source, cli: Source) -> Source { } } +// ── CLI override extraction ──────────────────────────────────────────── + +/// Extract CLI overrides from parsed `ArgMatches` into `OptionalBuildCommand` +/// with provenance tracking. #[must_use] -#[allow(clippy::too_many_lines)] -pub fn build_cli(defaults: &args::BuildCommand) -> Vec { - let jobs_default = defaults.jobs.to_string(); - let ut_default = defaults.unlock_timeout.to_string(); - - vec![ - Arg::new("build-dir") - .long("build-dir") - .short('b') - .env("BUILD_DIR") - .value_parser(value_parser!(PathBuf)) - .help(format!( - "Build Directory [default: {}]", - defaults.build_dir.display() - )), - Arg::new("force") - .long("force") - .env("FORCE") - .num_args(0..=1) - .require_equals(true) - .default_missing_value("true") - .value_parser(value_parser!(bool)) - .help("Force clone the repository and rebuild, bypassing cache checks"), - Arg::new("no-force") - .long("no-force") - .action(ArgAction::SetTrue) - .help("Disable --force, overriding config files or environment variables"), - Arg::new("fresh") - .long("fresh") - .short('f') - .env("FRESH") - .num_args(0..=1) - .require_equals(true) - .default_missing_value("true") - .value_parser(value_parser!(bool)) - .help("Clears the build-dir and starts a fresh build"), - Arg::new("no-fresh") - .long("no-fresh") - .action(ArgAction::SetTrue) - .help("Disable --fresh, overriding config files or environment variables"), - Arg::new("languages") - .num_args(0..) - .value_parser(value_parser!(String)) - .help("Parsers to compile"), - Arg::new("jobs") - .long("jobs") - .short('j') - .env("TSDL_NCPUS") - .value_parser(value_parser!(NonZeroUsize)) - .help(format!("Number of threads [default: {jobs_default}]")), - Arg::new("out-dir") - .long("out-dir") - .short('o') - .env("PARSER_OUT_DIR") - .value_parser(value_parser!(PathBuf)) - .help(format!( - "Output Directory [default: {}]", - defaults.out_dir.display() - )), - Arg::new("prefix") - .long("prefix") - .short('p') - .env("PREFIX") - .value_parser(value_parser!(String)) - .help(format!( - "Prefix parser names [default: {}]", - defaults.prefix - )), - Arg::new("show-config") - .long("show-config") - .env("SHOW_CONFIG") - .num_args(0..=1) - .require_equals(true) - .default_missing_value("true") - .value_parser(value_parser!(bool)) - .help("Show Config"), - Arg::new("no-show-config") - .long("no-show-config") - .action(ArgAction::SetTrue) - .help("Disable --show-config, overriding config files or environment variables"), - Arg::new("target") - .long("target") - .short('t') - .env("TSDL_TARGET") - .value_parser([ - clap::builder::PossibleValue::new("native"), - clap::builder::PossibleValue::new("wasm"), - clap::builder::PossibleValue::new("all"), - ]) - .help(format!( - "Build target [default: {}]", - defaults.target.to_lowercase() - )), - Arg::new("tree-sitter-version") - .long("tree-sitter-version") - .short('V') - .env("TSDL_VERSION") - .value_parser(value_parser!(String)) - .help(format!( - "Tree-sitter version [default: {}]", - defaults.tree_sitter.version - )), - Arg::new("tree-sitter-platform") - .long("tree-sitter-platform") - .env("TSDL_PLATFORM") - .value_parser(value_parser!(String)) - .help(format!( - "Tree-sitter platform to build [default: {}]", - defaults.tree_sitter.platform - )), - Arg::new("tree-sitter-repo") - .long("tree-sitter-repo") - .short('R') - .env("TSDL_REPO") - .value_parser(value_parser!(String)) - .help(format!( - "Tree-sitter repo [default: {}]", - defaults.tree_sitter.repo - )), - Arg::new("unlock-timeout") - .long("unlock-timeout") - .env("UNLOCK_TIMEOUT") - .value_parser(value_parser!(u64).range(1..)) - .help(format!( - "Seconds to wait after terminating a lock owner [default: {ut_default}]" - )), - ] +pub fn extract_overrides(matches: &ArgMatches) -> (args::OptionalBuildCommand, BuildProvenance) { + let cli = BuildArgs::from_arg_matches(matches).unwrap_or_else(|err| err.exit()); + overrides_from_build_args(cli, matches) } -#[must_use] -pub fn extract_overrides( +fn overrides_from_build_args( + cli: BuildArgs, matches: &ArgMatches, - _defaults: &args::BuildCommand, ) -> (args::OptionalBuildCommand, BuildProvenance) { let mut o = args::OptionalBuildCommand::default(); let mut p = BuildProvenance::default(); - extract_simple(matches, "build-dir", &mut o.build_dir, &mut p.build_dir); - extract_bool(matches, "force", "no-force", &mut o.force, &mut p.force); - extract_bool(matches, "fresh", "no-fresh", &mut o.fresh, &mut p.fresh); - - if let Some(source) = source_for(matches, "languages") { - let vals: Vec = matches - .get_many::("languages") - .map(|v| v.cloned().collect()) - .unwrap_or_default(); - if !vals.is_empty() { - o.languages = Some(vals); + set_simple( + matches, + ARG_BUILD_DIR, + cli.build_dir, + &mut o.build_dir, + &mut p.build_dir, + ); + resolve_bool( + matches, + cli.force, + ARG_FORCE, + ARG_NO_FORCE, + &mut o.force, + &mut p.force, + ); + resolve_bool( + matches, + cli.fresh, + ARG_FRESH, + ARG_NO_FRESH, + &mut o.fresh, + &mut p.fresh, + ); + + if !cli.languages.is_empty() { + if let Some(source) = source_for(matches, ARG_LANGUAGES) { + o.languages = Some(cli.languages); p.languages = source; } } - extract_simple_usize(matches, "jobs", &mut o.jobs, &mut p.jobs); - extract_simple(matches, "out-dir", &mut o.out_dir, &mut p.out_dir); - extract_simple(matches, "prefix", &mut o.prefix, &mut p.prefix); + if let Some(n) = cli.jobs.and_then(NonZeroUsize::new) { + if let Some(source) = source_for(matches, ARG_JOBS) { + o.jobs = Some(n); + p.jobs = source; + } + } + + set_simple( + matches, + ARG_OUT_DIR, + cli.out_dir, + &mut o.out_dir, + &mut p.out_dir, + ); + set_simple( + matches, + ARG_PREFIX, + cli.prefix, + &mut o.prefix, + &mut p.prefix, + ); - extract_bool( + resolve_bool( matches, - "show-config", - "no-show-config", + cli.show_config, + ARG_SHOW_CONFIG, + ARG_NO_SHOW_CONFIG, &mut o.show_config, &mut p.show_config, ); - extract_target(matches, "target", &mut o.target, &mut p.target); + set_simple( + matches, + ARG_TARGET, + cli.target, + &mut o.target, + &mut p.target, + ); - extract_simple( + set_simple( matches, - "tree-sitter-version", + ARG_TS_VERSION, + cli.tree_sitter.version, &mut o.tree_sitter.version, &mut p.tree_sitter.version, ); - extract_simple( + set_simple( matches, - "tree-sitter-platform", + ARG_TS_PLATFORM, + cli.tree_sitter.platform, &mut o.tree_sitter.platform, &mut p.tree_sitter.platform, ); - extract_simple( + set_simple( matches, - "tree-sitter-repo", + ARG_TS_REPO, + cli.tree_sitter.repo, &mut o.tree_sitter.repo, &mut p.tree_sitter.repo, ); - extract_simple_u64( + set_simple( matches, - "unlock-timeout", + ARG_UNLOCK_TIMEOUT, + cli.unlock_timeout, &mut o.unlock_timeout, &mut p.unlock_timeout, ); @@ -422,95 +451,44 @@ pub fn extract_overrides( (o, p) } -fn extract_simple( +/// Set a simple `Option` override when the arg's `ValueSource` indicates +/// the user supplied it (CLI or env). +fn set_simple( matches: &ArgMatches, id: &str, + value: Option, field: &mut Option, provenance: &mut Source, ) { if let Some(source) = source_for(matches, id) { - if let Some(val) = matches.get_one::(id) { - *field = Some(val.clone()); - *provenance = source; - } - } -} - -fn extract_simple_usize( - matches: &ArgMatches, - id: &str, - field: &mut Option, - provenance: &mut Source, -) { - if let Some(source) = source_for(matches, id) { - if let Some(&n) = matches.get_one::(id) { - if let Some(nz) = std::num::NonZeroUsize::new(n) { - *field = Some(nz); - *provenance = source; - } - } - } -} - -fn extract_simple_u64( - matches: &ArgMatches, - id: &str, - field: &mut Option, - provenance: &mut Source, -) { - if let Some(source) = source_for(matches, id) { - if let Some(&val) = matches.get_one::(id) { + if let Some(val) = value { *field = Some(val); *provenance = source; } } } -fn extract_target( - matches: &ArgMatches, - id: &str, - field: &mut Option, - provenance: &mut Source, -) { - if let Some(source) = source_for(matches, id) { - if let Some(raw) = matches.get_one::(id) { - match raw.to_lowercase().as_str() { - "native" => { - *field = Some(args::Target::Native); - *provenance = source; - } - "wasm" => { - *field = Some(args::Target::Wasm); - *provenance = source; - } - "all" => { - *field = Some(args::Target::All); - *provenance = source; - } - _ => {} - } - } - } -} - -fn extract_bool( +/// Resolve a boolean field that has both a positive (`--force`) and negative +/// (`--no-force`) flag. The negative form only takes effect when passed +/// explicitly on the command line; env-var / config values flow through the +/// positive arg. +fn resolve_bool( matches: &ArgMatches, - positive_id: &str, - negative_id: &str, + positive: Option, + pos_id: &str, + neg_id: &str, field: &mut Option, provenance: &mut Source, ) { - let pos_source = source_for(matches, positive_id); - let neg_source = source_for(matches, negative_id); - - if matches!(neg_source, Some(Source::CommandLine)) { + // --no-* on the command line always wins. + if matches!(source_for(matches, neg_id), Some(Source::CommandLine)) { *field = Some(false); *provenance = Source::CommandLine; return; } - if let Some(source) = pos_source { - if let Some(&val) = matches.get_one::(positive_id) { + if let Some(source) = source_for(matches, pos_id) { + if let Some(val) = positive { *field = Some(val); *provenance = source; } @@ -521,6 +499,8 @@ fn source_for(matches: &ArgMatches, id: &str) -> Option { matches.value_source(id).and_then(Source::from_value_source) } +// ── Display helpers ──────────────────────────────────────────────────── + pub fn print_indent(s: &str, indent: &str) { s.lines().for_each(|line| println!("{indent}{line}")); } @@ -566,17 +546,13 @@ pub fn show(command: &args::BuildCommand) -> Result<()> { Ok(()) } +// ── Parsing ──────────────────────────────────────────────────────────── + #[must_use] pub fn parse_with_matches() -> (args::Args, ArgMatches) { - let defaults = args::BuildCommand::default(); - let build_args = build_cli(&defaults); let mut cmd = args::Args::command(); if let Some(build_sub) = cmd.find_subcommand_mut("build") { - let mut new_sub = build_sub.clone(); - for arg in build_args { - new_sub = new_sub.arg(arg); - } - *build_sub = new_sub; + *build_sub = BuildArgs::augment_args(build_sub.clone()); } let matches = cmd.get_matches(); let args = args::Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); @@ -588,15 +564,9 @@ where I: IntoIterator, T: Into + Clone, { - let defaults = args::BuildCommand::default(); - let build_args = build_cli(&defaults); let mut cmd = args::Args::command(); if let Some(build_sub) = cmd.find_subcommand_mut("build") { - let mut new_sub = build_sub.clone(); - for arg in build_args { - new_sub = new_sub.arg(arg); - } - *build_sub = new_sub; + *build_sub = BuildArgs::augment_args(build_sub.clone()); } let matches = cmd.try_get_matches_from(itr)?; let args = args::Args::from_arg_matches(&matches)?; From 782d3bb849eca45589036ebf06cb54035261178f Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 15:26:09 +0200 Subject: [PATCH 54/88] cache: moved verification from cache to pipeline --- src/actors/cache.rs | 71 ++------------------------------------------- src/actors/mod.rs | 18 +++++++----- src/cache.rs | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 75 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 0de9280..a268d29 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -1,9 +1,6 @@ -use std::{io, path::PathBuf, sync::Arc}; +use std::sync::Arc; -use tokio::{ - fs, - sync::{mpsc, oneshot}, -}; +use tokio::sync::{mpsc, oneshot}; use tracing::info; use crate::{ @@ -35,7 +32,6 @@ pub enum CacheMessage { name: cache::Key, revision: cache::Revision, spec: Arc, - artifacts: Vec, tx: oneshot::Sender, }, /// `cache::Update` a cache entry @@ -102,14 +98,12 @@ impl CacheAddr { hash: cache::GrammarHash, spec: Arc, revision: cache::Revision, - artifacts: Vec, ) -> cache::Decision { self.request(|tx| CacheMessage::NeedsRebuild { name, hash, revision, spec, - artifacts, tx, }) .await @@ -136,26 +130,6 @@ pub struct CacheActor { rx: mpsc::Receiver, } -async fn verify_artifacts(artifacts: Vec) -> cache::Decision { - let mut reasons = Vec::new(); - - for path in artifacts { - match fs::metadata(&path).await { - Ok(metadata) if metadata.is_file() => {} - Ok(_) => reasons.push(cache::MissReason::ArtifactNotFile { path }), - Err(err) if err.kind() == io::ErrorKind::NotFound => { - reasons.push(cache::MissReason::ArtifactMissing { path }); - } - Err(err) => reasons.push(cache::MissReason::ArtifactInaccessible { - path, - error: err.to_string(), - }), - } - } - - cache::Decision::from_reasons(reasons) -} - impl CacheActor { async fn run(mut self) { while let Some(msg) = self.rx.recv().await { @@ -165,18 +139,12 @@ impl CacheActor { name, spec, revision, - artifacts, tx, } => { let decision = if self.force { cache::Decision::miss(cache::MissReason::CacheIgnored) } else { - let decision = self.db.rebuild_decision(&name, &hash, &spec, &revision); - if decision.is_hit() { - verify_artifacts(artifacts).await - } else { - decision - } + self.db.rebuild_decision(&name, &hash, &spec, &revision) }; if decision.needs_rebuild() { info!("Cache miss for {name}: {}", decision.short_message()); @@ -243,36 +211,3 @@ impl CacheActor { CacheAddr::new(tx) } } - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn verify_artifacts_hits_when_all_paths_are_files() { - let temp = TempDir::new().unwrap(); - let artifact = temp.path().join("parser.so"); - fs::write(&artifact, b"parser").await.unwrap(); - - assert_eq!(verify_artifacts(vec![artifact]).await, cache::Decision::Hit); - } - - #[tokio::test] - async fn verify_artifacts_reports_missing_and_non_file_paths() { - let temp = TempDir::new().unwrap(); - let missing = temp.path().join("missing.so"); - let directory = temp.path().join("parser.so"); - fs::create_dir(&directory).await.unwrap(); - - assert_eq!( - verify_artifacts(vec![missing.clone(), directory.clone()]).await, - cache::Decision::Miss(crate::cache::Miss { - reasons: vec![ - cache::MissReason::ArtifactMissing { path: missing }, - cache::MissReason::ArtifactNotFile { path: directory }, - ], - }) - ); - } -} diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 3a5d915..aa423e2 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -250,15 +250,19 @@ async fn discover_grammars( &name, )?; let cache_decision = cache - .needs_rebuild( - key, - hash.clone(), - language.spec.clone(), - revision.clone(), - artifacts, - ) + .needs_rebuild(key, hash.clone(), language.spec.clone(), revision.clone()) .await; + // The cache actor only checks in-memory metadata. When it reports a + // hit we still need to verify that the expected output files actually + // exist on disk — those checks involve filesystem I/O that we perform + // here in the build pipeline so the cache actor never blocks on I/O. + let cache_decision = if cache_decision.is_hit() { + crate::cache::verify_artifacts(artifacts).await + } else { + cache_decision + }; + let progress = display .add_grammar( language.spec.git_ref.requested().clone(), diff --git a/src/cache.rs b/src/cache.rs index 208d3b0..bdae655 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -654,6 +654,30 @@ fn sync_directory(path: &Path) -> std::io::Result<()> { std::fs::File::open(path)?.sync_all() } +/// Verify that every path in `artifacts` exists as a regular file. +/// +/// Returns [`Decision::Hit`] when all artifacts are present, or a +/// [`Decision::Miss`] listing every missing or non-file path. +pub async fn verify_artifacts(artifacts: Vec) -> Decision { + let mut reasons = Vec::new(); + + for path in artifacts { + match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() => {} + Ok(_) => reasons.push(MissReason::ArtifactNotFile { path }), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + reasons.push(MissReason::ArtifactMissing { path }); + } + Err(err) => reasons.push(MissReason::ArtifactInaccessible { + path, + error: err.to_string(), + }), + } + } + + Decision::from_reasons(reasons) +} + /// Hash the contents of a file using SHA-1 and return the hex string. pub async fn hash_file(path: &Path) -> Result { let mut file = tokio::fs::File::open(path) @@ -1021,4 +1045,31 @@ mod tests { ); assert!(saved.contains("parsers")); } + + #[tokio::test] + async fn verify_artifacts_hits_when_all_paths_are_files() { + let temp = tempfile::TempDir::new().unwrap(); + let artifact = temp.path().join("parser.so"); + tokio::fs::write(&artifact, b"parser").await.unwrap(); + + assert_eq!(verify_artifacts(vec![artifact]).await, Decision::Hit); + } + + #[tokio::test] + async fn verify_artifacts_reports_missing_and_non_file_paths() { + let temp = tempfile::TempDir::new().unwrap(); + let missing = temp.path().join("missing.so"); + let directory = temp.path().join("parser.so"); + tokio::fs::create_dir(&directory).await.unwrap(); + + assert_eq!( + verify_artifacts(vec![missing.clone(), directory.clone()]).await, + Decision::Miss(Miss { + reasons: vec![ + MissReason::ArtifactMissing { path: missing }, + MissReason::ArtifactNotFile { path: directory }, + ], + }) + ); + } } From 32638266de2b48ec465e0bccfb90d53dacb632e8 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 18:19:23 +0200 Subject: [PATCH 55/88] pipeline: spawn tree-sitter cli in // to grammar discovery --- src/actors/mod.rs | 93 +++++++++++++++++++++++++++++++++++++---------- src/cache.rs | 20 ++++++---- 2 files changed, 85 insertions(+), 28 deletions(-) diff --git a/src/actors/mod.rs b/src/actors/mod.rs index aa423e2..2bbe64f 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -1,15 +1,12 @@ mod cache; mod display; -use std::{ - num::NonZeroUsize, - path::{Path, PathBuf}, -}; +use std::{num::NonZeroUsize, path::Path}; pub use cache::{CacheActor, CacheAddr}; pub use display::{DisplayActor, DisplayAddr, Message, ProgressAddr}; use futures::{stream, StreamExt}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, watch}; use tracing::{debug, info}; @@ -127,12 +124,20 @@ async fn run_inner( languages: Vec, tree_sitter: &args::TreeSitter, ) -> Result<()> { - let prepared = tree_sitter::prepare(build_dir, display.clone(), tree_sitter).await?; - let ts_cli = prepared.path; - let languages = languages - .into_iter() - .map(|language| language.with_tree_sitter(prepared.tree_sitter.clone())) - .collect::>(); + // Spawn tree-sitter CLI preparation concurrently so its network I/O + // (git ls-remote) overlaps with language discovery work. + let (prepared_tx, prepared_rx) = watch::channel(None::>); + let prepare_build_dir = build_dir.to_path_buf(); + let prepare_display = display.clone(); + let prepare_tree_sitter = tree_sitter.clone(); + tokio::spawn(async move { + let result = + tree_sitter::prepare(&prepare_build_dir, prepare_display, &prepare_tree_sitter).await; + let _ = prepared_tx.send(Some(result)); + }); + + let prepared_rx = prepared_rx; + let languages_empty = languages.is_empty(); let mut errors: Vec = // 1. Create a stream from the input list @@ -140,10 +145,10 @@ async fn run_inner( // 2. Stage: Discovery // Transform Language -> Future>> .map(|language| { - let (cache, display, ts_cli) = (cache.clone(), display.clone(), ts_cli.clone()); + let (cache, display) = (cache.clone(), display.clone()); + let mut rx = prepared_rx.clone(); async move { - // We refactor `discover` to return the list instead of sending messages - discover_grammars(cache, display, language, ts_cli).await + discover_grammars(cache, display, language, &mut rx).await } }) // Run up to `concurrency` discovery tasks at once @@ -189,6 +194,18 @@ async fn run_inner( }) .await; + // Drain the CLI preparation result so we never exit before the binary is + // downloaded. + let mut prepared_rx = prepared_rx; + if let Err(e) = wait_for_prepared(&mut prepared_rx).await { + if languages_empty { + errors.push(e); + } + // else: the pipeline already consumed `prepared_rx` via each + // `discover_grammars` call; a second read finding an error is a + // redundant delivery — suppress it to avoid double-counting. + } + if let Err(e) = cache.save().await { errors.push(e); } @@ -206,7 +223,7 @@ async fn discover_grammars( cache: CacheAddr, display: DisplayAddr, language: parser::LanguageBuild, - ts_cli: PathBuf, + prepared_rx: &mut watch::Receiver>>, ) -> Result> { shutdown::test_delay().await; shutdown::check()?; @@ -220,8 +237,14 @@ async fn discover_grammars( ) .await; + // Phase 1: resolve the parser revision (cache check + possible clone). + // This runs concurrently with tree-sitter CLI preparation — it only + // touches the cache actor and git subprocesses, neither of which depend + // on the resolved tree-sitter version. let revision = resolve_revision(&cache, &language, &progress).await?; + // Phase 2: scan for grammar.js files (git ls-files + hashing). + // Also concurrent with CLI preparation. progress.step("scanning"); let grammars = match language.discover_grammars().await { Ok(grammars) => grammars, @@ -236,7 +259,15 @@ async fn discover_grammars( }; progress.fin("done").await; - // Map the raw discovery data into the Build struct immediately + // Phase 3: wait for the tree-sitter CLI to be ready so we have the + // resolved tree-sitter version for cache comparisons and the binary path + // for computing artifact paths. + let prepared = wait_for_prepared(prepared_rx).await?; + let ts_cli = prepared.path; + let language = language.with_tree_sitter(prepared.tree_sitter); + + // Map the raw discovery data into Build structs, now with the resolved + // tree-sitter version available for accurate cache-key comparison. let mut builds = Vec::new(); for (name, dir, hash) in grammars { shutdown::test_delay().await; @@ -253,10 +284,8 @@ async fn discover_grammars( .needs_rebuild(key, hash.clone(), language.spec.clone(), revision.clone()) .await; - // The cache actor only checks in-memory metadata. When it reports a - // hit we still need to verify that the expected output files actually - // exist on disk — those checks involve filesystem I/O that we perform - // here in the build pipeline so the cache actor never blocks on I/O. + // Cache actor checks in-memory metadata only. When it reports a hit + // we still verify that the expected output files exist on disk. let cache_decision = if cache_decision.is_hit() { crate::cache::verify_artifacts(artifacts).await } else { @@ -290,6 +319,30 @@ async fn discover_grammars( Ok(builds) } +/// Wait for the tree-sitter CLI preparation task to finish, returning the +/// prepared CLI or the error that preparation failed with. +async fn wait_for_prepared( + rx: &mut watch::Receiver>>, +) -> Result { + loop { + if let Some(result) = rx.borrow().as_ref() { + return match result { + Ok(prepared) => Ok(prepared.clone()), + Err(e) => Err(Error::Message { + message: format!("tree-sitter CLI preparation failed: {e}"), + }), + }; + } + if rx.changed().await.is_err() { + return Err(Error::Message { + message: + "tree-sitter CLI preparation failed unexpectedly (task panicked or was dropped)" + .into(), + }); + } + } +} + async fn resolve_revision( cache: &CacheAddr, language: &parser::LanguageBuild, diff --git a/src/cache.rs b/src/cache.rs index bdae655..f34a807 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -463,11 +463,6 @@ impl Entry { Decision::from_reasons(reasons) } - #[must_use] - pub fn covers_request(&self, spec: &build::Spec) -> bool { - self.recipe == BuildRecipe::from_spec(spec) && self.outputs.covers(spec.target) - } - #[must_use] pub fn same_subject(&self, other: &Self) -> bool { self.hash == other.hash && self.revision == other.revision && self.recipe == other.recipe @@ -588,6 +583,13 @@ impl Db { .needs_rebuild() } + /// True when the cache contains at least one entry for `language` + /// that was built from the same repository and git ref. + /// + /// The tree-sitter CLI version, build script, prefix, and target are + /// intentionally excluded — they affect artefact validity (checked by + /// [`needs_rebuild`]) but not whether a prior checkout of the parser + /// source is still usable. #[must_use] pub fn has_compatible_entry_for_language( &self, @@ -595,9 +597,11 @@ impl Db { spec: &build::Spec, ) -> bool { let prefix = Key::language_prefix(language); - self.parsers - .iter() - .any(|(key, entry)| key.as_str().starts_with(&prefix) && entry.covers_request(spec)) + self.parsers.iter().any(|(key, entry)| { + key.as_str().starts_with(&prefix) + && entry.recipe.repo == spec.repo + && entry.recipe.git_ref == spec.git_ref + }) } /// Insert or update a parser cache entry. From 0f22df309a8209430326d7d54da044a1726f86f1 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Thu, 28 May 2026 19:15:01 +0200 Subject: [PATCH 56/88] display: do not double render --- src/actors/display.rs | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index eadc078..b13680c 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -412,7 +412,7 @@ impl DisplayActor { } }; - // Initial render + // One-time bootstrap render so the user sees state immediately. let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); if let Err(err) = draw_lines(&mut terminal, lines) { ratatui::restore(); @@ -425,8 +425,24 @@ impl DisplayActor { let mut tick_interval = time::interval(Duration::from_millis(100)); loop { - // Drain all pending messages before rendering + // ── wait for the next event ───────────────────────────── let mut shutdown = None; + tokio::select! { + msg = self.rx.recv() => { + match msg { + Some(Message::Shutdown { interrupted, tx }) => { + shutdown = Some((interrupted, tx)); + } + Some(other) => self.handle_message(other), + None => break, + } + } + _ = tick_interval.tick() => { + // fall through to drain + render + } + } + + // ── drain any messages that queued up concurrently ───── while let Ok(msg) = self.rx.try_recv() { match msg { Message::Shutdown { interrupted, tx } => { @@ -437,12 +453,13 @@ impl DisplayActor { } } + // ── shutdown after draining ──────────────────────────── if let Some((interrupted, tx)) = shutdown { self.finish_fancy(&mut terminal, interrupted, tx); return; } - // Render current state + // ── render once per event cycle ──────────────────────── let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); if let Err(err) = draw_lines(&mut terminal, lines) { ratatui::restore(); @@ -451,23 +468,6 @@ impl DisplayActor { self.run_plain().await; return; } - - // Wait for next event - tokio::select! { - msg = self.rx.recv() => { - match msg { - Some(Message::Shutdown { interrupted, tx }) => { - self.finish_fancy(&mut terminal, interrupted, tx); - return; - } - Some(other) => self.handle_message(other), - None => break, - } - } - _ = tick_interval.tick() => { - // Next materialize will update live clocks. - } - } } ratatui::restore(); From fe86eddae438642cdfa7c15d6ad43ea85d1b8c1f Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 08:36:16 +0200 Subject: [PATCH 57/88] style: sh: sort --- src/sh.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/sh.rs b/src/sh.rs index 0e2f967..ef6486e 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -19,6 +19,19 @@ pub trait Script { fn from_str(script: &str) -> Command; } +fn signal_display(number: i32) -> Option { + let ptr = unsafe { libc::strsignal(number) }; + if ptr.is_null() { + None + } else { + Some( + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) + } +} + impl Exec for Command { fn display(&self) -> Result { let program = self.as_std().get_program().to_string_lossy(); @@ -178,16 +191,3 @@ impl Script for Command { cmd } } - -fn signal_display(number: i32) -> Option { - let ptr = unsafe { libc::strsignal(number) }; - if ptr.is_null() { - None - } else { - Some( - unsafe { std::ffi::CStr::from_ptr(ptr) } - .to_string_lossy() - .into_owned(), - ) - } -} From b8bc4ee33d85b726c8410f4042bcc9f0ddf6bf61 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 08:50:13 +0200 Subject: [PATCH 58/88] style: cache: sort --- src/actors/cache.rs | 158 ++++++++++++++++++++++++-------------------- 1 file changed, 85 insertions(+), 73 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index a268d29..d35322a 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -8,21 +8,9 @@ use crate::{ build, cache, parser, Result, }; -#[derive(Debug)] -#[allow(dead_code)] -enum ResponseKind<'a> { - CacheGet { - name: &'a cache::Key, - }, - HasCompatibleEntries { - language: &'a parser::LanguageName, - }, - NeedsRebuild { - name: &'a cache::Key, - hash: &'a cache::GrammarHash, - }, - SaveComplete, -} +// ============================================================ +// Enums +// ============================================================ #[derive(Debug)] pub enum CacheMessage { @@ -54,12 +42,44 @@ pub enum CacheMessage { }, } +#[derive(Debug)] +#[allow(dead_code)] +enum ResponseKind<'a> { + CacheGet { + name: &'a cache::Key, + }, + HasCompatibleEntries { + language: &'a parser::LanguageName, + }, + NeedsRebuild { + name: &'a cache::Key, + hash: &'a cache::GrammarHash, + }, + SaveComplete, +} + +// ============================================================ +// Structs +// ============================================================ + +/// The Cache Actor: Manages cache state and processes messages +pub struct CacheActor { + db: cache::Db, + store: cache::Store, + force: bool, + rx: mpsc::Receiver, +} + /// The Cache Handle: Public interface for sending cache operations #[derive(Debug, Clone)] pub struct CacheAddr { tx: mpsc::Sender, } +// ============================================================ +// Impls +// ============================================================ + impl Addr for CacheAddr { type Message = CacheMessage; @@ -72,64 +92,6 @@ impl Addr for CacheAddr { } } -impl CacheAddr { - #[must_use] - pub fn new(tx: mpsc::Sender) -> Self { - Self { tx } - } - - /// Accepts any string type (String, &str, Arc) with minimal cloning - pub async fn get(&self, name: cache::Key) -> Option { - self.request(|tx| CacheMessage::Get { name, tx }).await - } - - pub async fn has_compatible_entries( - &self, - language: parser::LanguageName, - spec: Arc, - ) -> bool { - self.request(|tx| CacheMessage::HasCompatibleEntries { language, spec, tx }) - .await - } - - pub async fn needs_rebuild( - &self, - name: cache::Key, - hash: cache::GrammarHash, - spec: Arc, - revision: cache::Revision, - ) -> cache::Decision { - self.request(|tx| CacheMessage::NeedsRebuild { - name, - hash, - revision, - spec, - tx, - }) - .await - } - - pub async fn save(&self) -> Result<()> { - self.request(|tx| CacheMessage::Save { tx }).await - } - - pub async fn update(&self, update: cache::Update) { - self.fire(CacheMessage::Update { - entry: update.entry, - name: update.name, - }) - .await; - } -} - -/// The Cache Actor: Manages cache state and processes messages -pub struct CacheActor { - db: cache::Db, - store: cache::Store, - force: bool, - rx: mpsc::Receiver, -} - impl CacheActor { async fn run(mut self) { while let Some(msg) = self.rx.recv().await { @@ -211,3 +173,53 @@ impl CacheActor { CacheAddr::new(tx) } } + +impl CacheAddr { + #[must_use] + pub fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } + + /// Accepts any string type (String, &str, Arc) with minimal cloning + pub async fn get(&self, name: cache::Key) -> Option { + self.request(|tx| CacheMessage::Get { name, tx }).await + } + + pub async fn has_compatible_entries( + &self, + language: parser::LanguageName, + spec: Arc, + ) -> bool { + self.request(|tx| CacheMessage::HasCompatibleEntries { language, spec, tx }) + .await + } + + pub async fn needs_rebuild( + &self, + name: cache::Key, + hash: cache::GrammarHash, + spec: Arc, + revision: cache::Revision, + ) -> cache::Decision { + self.request(|tx| CacheMessage::NeedsRebuild { + name, + hash, + revision, + spec, + tx, + }) + .await + } + + pub async fn save(&self) -> Result<()> { + self.request(|tx| CacheMessage::Save { tx }).await + } + + pub async fn update(&self, update: cache::Update) { + self.fire(CacheMessage::Update { + entry: update.entry, + name: update.name, + }) + .await; + } +} From 7b8f2e2ed64aed005cab499c8fc9347e1020c374 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 08:59:25 +0200 Subject: [PATCH 59/88] style: actors/display: sort --- src/actors/display.rs | 1372 ++++++++++++++++++++--------------------- 1 file changed, 686 insertions(+), 686 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index b13680c..2754d19 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -15,9 +15,9 @@ use crate::actors::Addr; use crate::display; use crate::git; -// --------------------------------------------------------------------------- -// Message types -// --------------------------------------------------------------------------- +// ============================================================ +// Enums +// ============================================================ #[derive(Debug)] pub enum Message { @@ -67,10 +67,24 @@ pub enum UpdateKind { Err, } -// --------------------------------------------------------------------------- -// Handles -// --------------------------------------------------------------------------- +// ============================================================ +// Structs +// ============================================================ +pub struct DisplayActor { + state: display::State, + next_id: display::ItemId, + plain_name_width: usize, + plain_progress_started: bool, + grid: display::GridCache, + row_specs: Vec, + rows_dirty: bool, + last_term_width: Option, + rx: mpsc::Receiver, + tx: mpsc::Sender, +} + +/// Handle for updating a specific progress bar (repo or grammar). #[derive(Debug, Clone)] pub struct DisplayAddr { tx: mpsc::Sender, @@ -78,68 +92,11 @@ pub struct DisplayAddr { mode: display::Mode, } -impl Addr for DisplayAddr { - type Message = Message; - - fn name() -> &'static str { - "DisplayAddr" - } - - fn sender(&self) -> &mpsc::Sender { - &self.tx - } -} - -impl DisplayAddr { - #[must_use] - pub fn new(tx: mpsc::Sender, mode: display::Mode) -> Self { - Self { tx, mode } - } - - pub async fn add_language>>( - &self, - git_ref: git::Ref, - name: S, - num_tasks: usize, - ) -> ProgressAddr { - self.request(|tx| Message::RegisterLanguage { - git_ref, - name: name.into(), - num_tasks, - tx, - }) - .await - } - - pub async fn add_grammar>>( - &self, - git_ref: git::Ref, - language: S, - name: S, - num_tasks: usize, - ) -> ProgressAddr { - self.request(|tx| Message::RegisterGrammar { - git_ref, - language: language.into(), - name: name.into(), - num_tasks, - tx, - }) - .await - } - - pub async fn reference>>(&self, git_ref: git::Ref, name: S) { - self.fire(Message::RegisterReference { - git_ref, - name: name.into(), - }) - .await; - } - - pub async fn shutdown(&self, interrupted: bool) { - self.request(|tx| Message::Shutdown { interrupted, tx }) - .await; - } +struct PlainLine { + name: String, + step: usize, + total: usize, + message: String, } /// Handle for updating a specific progress bar (repo or grammar). @@ -149,79 +106,122 @@ pub struct ProgressAddr { tx: mpsc::Sender, } -impl ProgressAddr { - pub fn msg>>(&self, msg: S) { - let _ = self.tx.try_send(Message::Update { - id: self.id, - kind: UpdateKind::Msg, - msg: msg.into(), - }); - } +// ============================================================ +// Free functions +// ============================================================ - pub fn step>>(&self, msg: S) { - let _ = self.tx.try_send(Message::Update { - id: self.id, - kind: UpdateKind::Step, - msg: msg.into(), - }); - } +fn clear_from_cursor_down() -> io::Result<()> { + crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown) + ) +} - async fn send_state_update(&self, kind: UpdateKind, msg: Arc) { - let _ = self - .tx - .send(Message::Update { - id: self.id, - kind, - msg, - }) - .await; - } +fn current_viewport_height( + terminal: &mut ratatui::Terminal, +) -> Result { + terminal.autoresize()?; + Ok(terminal.get_frame().area().height.max(1)) +} - pub async fn set_outcome_cached(&self) { - self.send_state_update(UpdateKind::SetOutcomeCached, Arc::from("")) - .await; - } +fn draw_lines( + terminal: &mut ratatui::Terminal, + lines: Vec>, +) -> Result<(), B::Error> { + terminal + .draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + }) + .map(|_| ()) +} - pub async fn cancel(&self) { - self.send_state_update(UpdateKind::Cancel, Arc::from("cancelled")) - .await; - } +fn draw_lines_in_viewport( + terminal: &mut ratatui::Terminal, + lines: Vec>, +) -> Result<(), B::Error> { + let line_count = lines.len(); - pub async fn set_outcome_built(&self) { - self.send_state_update(UpdateKind::SetOutcomeBuilt, Arc::from("")) - .await; - } + terminal + .draw(move |frame| { + let area = frame.area(); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines), area); - pub async fn cached>>(&self, msg: S) { - self.send_state_update(UpdateKind::Cached, msg.into()).await; - } + let cursor_offset = line_count + .saturating_sub(1) + .min(usize::from(area.height.saturating_sub(1))); + let cursor_y = area.y + u16::try_from(cursor_offset).unwrap_or(0); + frame.set_cursor_position((area.x, cursor_y)); + }) + .map(|_| ()) +} - pub async fn fin>>(&self, msg: S) { - self.send_state_update(UpdateKind::Fin, msg.into()).await; +fn finish_item(state: display::ItemState) -> Result { + match state { + display::ItemState::InProgress(Some(outcome)) => Ok(display::ItemState::Done(outcome)), + display::ItemState::New | display::ItemState::InProgress(None) => invalid_finish( + "finish update received before cached/built path was set", + state, + ), + display::ItemState::Done(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => { + invalid_finish("finish update received for terminal item state", state) + } } +} - pub async fn err>>(&self, msg: S) { - self.send_state_update(UpdateKind::Err, msg.into()).await; +fn insert_lines_before_viewport( + terminal: &mut ratatui::Terminal, + mut lines: Vec>, +) -> Result<(), B::Error> { + while !lines.is_empty() { + let rest = if lines.len() > usize::from(u16::MAX) { + lines.split_off(usize::from(u16::MAX)) + } else { + Vec::new() + }; + let height = u16::try_from(lines.len()).unwrap_or(u16::MAX); + + terminal.insert_before(height, move |buf| { + Paragraph::new(lines).render(buf.area, buf); + })?; + + lines = rest; } + + Ok(()) } -// --------------------------------------------------------------------------- -// Plain output model -// --------------------------------------------------------------------------- +fn invalid_finish(reason: &str, state: display::ItemState) -> Result { + let message = format!("{reason}: {state:?}"); + error!("{message}"); + debug_assert!( + matches!(state, display::ItemState::InProgress(Some(_))), + "{message}" + ); + Err(message) +} -struct PlainLine { - name: String, - step: usize, - total: usize, - message: String, +fn mark_success(state: display::ItemState, outcome: display::SuccessOutcome) -> display::ItemState { + match state { + display::ItemState::New | display::ItemState::InProgress(_) => { + display::ItemState::InProgress(Some(outcome)) + } + display::ItemState::Done(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => state, + } } -fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { +fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { match kind { UpdateKind::Err => format!("failed: {msg}"), UpdateKind::Cached => "cached".to_string(), UpdateKind::Fin => match state { - display::ItemState::Done(_) => "done".to_string(), + display::ItemState::Done(display::SuccessOutcome::Cached) => "cached".to_string(), + display::ItemState::Done(display::SuccessOutcome::Built) => "built".to_string(), display::ItemState::New | display::ItemState::InProgress(_) | display::ItemState::Cancelled @@ -235,13 +235,12 @@ fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> } } -fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { +fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { match kind { UpdateKind::Err => format!("failed: {msg}"), UpdateKind::Cached => "cached".to_string(), UpdateKind::Fin => match state { - display::ItemState::Done(display::SuccessOutcome::Cached) => "cached".to_string(), - display::ItemState::Done(display::SuccessOutcome::Built) => "built".to_string(), + display::ItemState::Done(_) => "done".to_string(), display::ItemState::New | display::ItemState::InProgress(_) | display::ItemState::Cancelled @@ -255,27 +254,18 @@ fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) } } -// --------------------------------------------------------------------------- -// DisplayActor -// --------------------------------------------------------------------------- - -fn draw_lines( +fn render_final_report( terminal: &mut ratatui::Terminal, lines: Vec>, ) -> Result<(), B::Error> { - terminal - .draw(|frame| { - let area = frame.area(); - frame.render_widget(Paragraph::new(lines), area); - }) - .map(|_| ()) + let viewport_height = current_viewport_height(terminal)?; + let (prefix, suffix) = split_lines_for_viewport(lines, viewport_height); + insert_lines_before_viewport(terminal, prefix)?; + draw_lines_in_viewport(terminal, suffix) } -fn current_viewport_height( - terminal: &mut ratatui::Terminal, -) -> Result { - terminal.autoresize()?; - Ok(terminal.get_frame().area().height.max(1)) +fn spacer() -> Span<'static> { + Span::raw(" ") } fn split_lines_for_viewport( @@ -288,190 +278,273 @@ fn split_lines_for_viewport( (lines, suffix) } -fn insert_lines_before_viewport( - terminal: &mut ratatui::Terminal, - mut lines: Vec>, -) -> Result<(), B::Error> { - while !lines.is_empty() { - let rest = if lines.len() > usize::from(u16::MAX) { - lines.split_off(usize::from(u16::MAX)) - } else { - Vec::new() - }; - let height = u16::try_from(lines.len()).unwrap_or(u16::MAX); - - terminal.insert_before(height, move |buf| { - Paragraph::new(lines).render(buf.area, buf); - })?; - - lines = rest; +fn start_item(state: display::ItemState) -> display::ItemState { + match state { + display::ItemState::New => display::ItemState::InProgress(None), + display::ItemState::InProgress(outcome) => display::ItemState::InProgress(outcome), + display::ItemState::Done(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => state, } - - Ok(()) } -fn draw_lines_in_viewport( - terminal: &mut ratatui::Terminal, - lines: Vec>, -) -> Result<(), B::Error> { - let line_count = lines.len(); - - terminal - .draw(move |frame| { - let area = frame.area(); - frame.render_widget(Clear, area); - frame.render_widget(Paragraph::new(lines), area); - - let cursor_offset = line_count - .saturating_sub(1) - .min(usize::from(area.height.saturating_sub(1))); - let cursor_y = area.y + u16::try_from(cursor_offset).unwrap_or(0); - frame.set_cursor_position((area.x, cursor_y)); - }) - .map(|_| ()) -} +// ============================================================ +// Impls +// ============================================================ -fn render_final_report( - terminal: &mut ratatui::Terminal, - lines: Vec>, -) -> Result<(), B::Error> { - let viewport_height = current_viewport_height(terminal)?; - let (prefix, suffix) = split_lines_for_viewport(lines, viewport_height); - insert_lines_before_viewport(terminal, prefix)?; - draw_lines_in_viewport(terminal, suffix) -} +impl Addr for DisplayAddr { + type Message = Message; -fn clear_from_cursor_down() -> io::Result<()> { - crossterm::execute!( - io::stdout(), - crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown) - ) -} + fn name() -> &'static str { + "DisplayAddr" + } -pub struct DisplayActor { - state: display::State, - next_id: display::ItemId, - plain_name_width: usize, - plain_progress_started: bool, - grid: display::GridCache, - row_specs: Vec, - rows_dirty: bool, - last_term_width: Option, - rx: mpsc::Receiver, - tx: mpsc::Sender, + fn sender(&self) -> &mpsc::Sender { + &self.tx + } } impl DisplayActor { - #[must_use] - pub fn spawn(mode: display::Mode, build_dir: PathBuf, out_dir: PathBuf) -> DisplayAddr { - let (tx, rx) = mpsc::channel(256); - let actor = Self { - state: display::State::new(mode, build_dir, out_dir), - next_id: display::ItemId::new(NonZeroU64::MIN), - plain_name_width: 16, - plain_progress_started: false, - grid: display::GridCache::new(), - row_specs: Vec::new(), - rows_dirty: true, - last_term_width: None, - rx, - tx: tx.clone(), - }; + fn aggregate_child_done_outcome( + &self, + repo_id: display::ItemId, + ) -> Option { + let mut saw_cached = false; - tokio::spawn(async move { - actor.run().await; - }); + for grammar in self + .state + .grammars + .values() + .filter(|g| g.repo_id == Some(repo_id)) + { + match grammar.state { + display::ItemState::Done(display::SuccessOutcome::Built) => { + return Some(display::SuccessOutcome::Built) + } + display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, + display::ItemState::New + | display::ItemState::InProgress(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => {} + } + } - DisplayAddr::new(tx, mode) + saw_cached.then_some(display::SuccessOutcome::Cached) } - async fn run(mut self) { - if self.state.mode == display::Mode::Fancy { - self.run_fancy().await; + fn aggregate_child_live_outcome( + &self, + repo_id: display::ItemId, + ) -> Option { + let mut saw_cached = false; + let mut saw_unknown = false; + + for grammar in self + .state + .grammars + .values() + .filter(|g| g.repo_id == Some(repo_id)) + { + match grammar.state { + display::ItemState::InProgress(Some(display::SuccessOutcome::Built)) + | display::ItemState::Done(display::SuccessOutcome::Built) => { + return Some(display::SuccessOutcome::Built) + } + display::ItemState::InProgress(Some(display::SuccessOutcome::Cached)) + | display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, + display::ItemState::New | display::ItemState::InProgress(None) => { + saw_unknown = true; + } + display::ItemState::Cancelled | display::ItemState::Failed => {} + } + } + + if saw_unknown { + None + } else if saw_cached { + Some(display::SuccessOutcome::Cached) } else { - self.run_plain().await; + None } } - // ── Fancy mode ──────────────────────────────────────────────────── - - async fn run_fancy(&mut self) { - let viewport_height = crossterm::terminal::size().map_or(40, |(_, h)| h).min(40); + fn apply_grammar_update(grammar: &mut display::GrammarEntry, kind: UpdateKind, msg: Arc) { + if !grammar.state.is_live() { + return; + } - let mut terminal = match ratatui::Terminal::with_options( - ratatui::backend::CrosstermBackend::new(io::stdout()), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Inline(viewport_height), - }, - ) { - Ok(terminal) => terminal, - Err(err) => { - eprintln!("tsdl: fancy display unavailable; falling back to plain progress: {err}"); - self.run_plain().await; - return; + match kind { + UpdateKind::Msg => { + grammar.msg = msg; } - }; + UpdateKind::Step => { + grammar.state = start_item(grammar.state); + grammar.step += 1; + grammar.msg = msg; + } + UpdateKind::SetOutcomeCached => { + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Cached); + } + UpdateKind::Cancel => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Cancelled; + grammar.step = grammar.total; + grammar.msg = msg; + } + UpdateKind::SetOutcomeBuilt => { + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Built); + } + UpdateKind::Cached => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Done(display::SuccessOutcome::Cached); + grammar.step = grammar.total; + grammar.msg = msg; + } + UpdateKind::Fin => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + match finish_item(grammar.state) { + Ok(state) => { + grammar.state = state; + grammar.msg = msg; + } + Err(message) => { + grammar.state = display::ItemState::Failed; + grammar.msg = message.into(); + } + } + grammar.step = grammar.total; + } + UpdateKind::Err => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Failed; + grammar.msg = msg; + } + } + } - // One-time bootstrap render so the user sees state immediately. - let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - if let Err(err) = draw_lines(&mut terminal, lines) { - ratatui::restore(); - println!(); - eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); - self.run_plain().await; + fn apply_repo_update(repo: &mut display::RepoEntry, kind: UpdateKind, msg: Arc) { + if !repo.state.is_live() { return; } - let mut tick_interval = time::interval(Duration::from_millis(100)); - - loop { - // ── wait for the next event ───────────────────────────── - let mut shutdown = None; - tokio::select! { - msg = self.rx.recv() => { - match msg { - Some(Message::Shutdown { interrupted, tx }) => { - shutdown = Some((interrupted, tx)); - } - Some(other) => self.handle_message(other), - None => break, + match kind { + UpdateKind::Msg => { + repo.msg = msg; + } + UpdateKind::Step => { + repo.state = start_item(repo.state); + repo.step += 1; + repo.msg = msg; + } + UpdateKind::SetOutcomeCached => { + repo.state = mark_success(repo.state, display::SuccessOutcome::Cached); + } + UpdateKind::Cancel => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Cancelled; + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = msg; + } + UpdateKind::SetOutcomeBuilt => { + repo.state = mark_success(repo.state, display::SuccessOutcome::Built); + } + UpdateKind::Cached => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Done(display::SuccessOutcome::Cached); + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = msg; + } + UpdateKind::Fin => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + match finish_item(repo.state) { + Ok(state) => { + repo.state = state; + repo.msg = msg; + } + Err(message) => { + repo.state = display::ItemState::Failed; + repo.msg = message.into(); } } - _ = tick_interval.tick() => { - // fall through to drain + render + if repo.total > 0 { + repo.step = repo.total; } } + UpdateKind::Err => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Failed; + repo.msg = msg; + } + } + } + + fn apply_update(&mut self, id: display::ItemId, kind: UpdateKind, msg: Arc) { + if let Some(repo) = self.state.repos.get_mut(&id) { + Self::apply_repo_update(repo, kind, msg); + self.grid.mark_dirty(id); + return; + } + + let mut maybe_parent_id: Option = None; + if let Some(grammar) = self.state.grammars.get_mut(&id) { + Self::apply_grammar_update(grammar, kind, msg); + self.grid.mark_dirty(id); + if matches!( + kind, + UpdateKind::Cached + | UpdateKind::Err + | UpdateKind::Fin + | UpdateKind::SetOutcomeBuilt + | UpdateKind::SetOutcomeCached + | UpdateKind::Cancel + | UpdateKind::Step + ) { + maybe_parent_id = grammar.repo_id; + } + } - // ── drain any messages that queued up concurrently ───── - while let Ok(msg) = self.rx.try_recv() { - match msg { - Message::Shutdown { interrupted, tx } => { - shutdown = Some((interrupted, tx)); - break; - } - other => self.handle_message(other), + if let Some(repo_id) = maybe_parent_id { + self.sync_parent_repo(repo_id); + } + } + + fn cancel_live_rows(&mut self) { + let mut parent_ids = Vec::new(); + + for (id, grammar) in &mut self.state.grammars { + if grammar.state.is_live() { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Cancelled; + grammar.step = grammar.total; + grammar.msg = Arc::from("cancelled"); + if let Some(repo_id) = grammar.repo_id { + parent_ids.push(repo_id); } + self.grid.mark_dirty(*id); } + } - // ── shutdown after draining ──────────────────────────── - if let Some((interrupted, tx)) = shutdown { - self.finish_fancy(&mut terminal, interrupted, tx); - return; - } + parent_ids.sort_unstable(); + parent_ids.dedup(); + for repo_id in parent_ids { + self.sync_parent_repo(repo_id); + } - // ── render once per event cycle ──────────────────────── - let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - if let Err(err) = draw_lines(&mut terminal, lines) { - ratatui::restore(); - println!(); - eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); - self.run_plain().await; - return; + for (id, repo) in &mut self.state.repos { + if repo.state.is_live() { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Cancelled; + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = Arc::from("cancelled"); + self.grid.mark_dirty(*id); } } - - ratatui::restore(); - println!(); } fn finish_fancy>( @@ -505,6 +578,40 @@ impl DisplayActor { let _ = tx.send(()); } + fn handle_message(&mut self, msg: Message) { + match msg { + Message::RegisterLanguage { + git_ref, + name, + num_tasks, + tx, + } => { + let addr = self.register_repo(name, git_ref, num_tasks); + let _ = tx.send(addr); + } + Message::RegisterGrammar { + git_ref, + language, + name, + num_tasks, + tx, + } => { + let addr = self.register_grammar(language, name, git_ref, num_tasks); + let _ = tx.send(addr); + } + Message::RegisterReference { .. } => {} + Message::Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + } + Message::Shutdown { interrupted, tx } => { + if interrupted { + self.cancel_live_rows(); + } + let _ = tx.send(()); + } + } + } + /// Assemble `Vec` from the grid cache. Rebuilds row order and layout /// only when items have been added. Otherwise reuses the cached row order /// and recomputes only stale cells (dirty items + running clocks). @@ -610,66 +717,22 @@ impl DisplayActor { lines } - // ── Plain mode ──────────────────────────────────────────────────── - - async fn run_plain(&mut self) { - self.print_plain_metadata(); - - while let Some(msg) = self.rx.recv().await { - match msg { - Message::RegisterLanguage { - git_ref, - name, - num_tasks, - tx, - } => { - let addr = self.register_repo(name, git_ref, num_tasks); - let _ = tx.send(addr); - } - Message::RegisterGrammar { - git_ref, - language, - name, - num_tasks, - tx, - } => { - let plain_name = format!("{language}/{name}"); - self.update_plain_name_width(&plain_name); - let addr = self.register_grammar(language, name, git_ref, num_tasks); - let _ = tx.send(addr); - } - Message::RegisterReference { git_ref, name } => { - self.print_plain_ref(&name, git_ref.short()); - } - Message::Update { id, kind, msg } => { - self.apply_update(id, kind, msg); - if matches!( - kind, - UpdateKind::Msg - | UpdateKind::SetOutcomeCached - | UpdateKind::SetOutcomeBuilt - ) { - continue; - } - if let Some(line) = self.plain_progress_line(id, kind) { - self.print_plain_progress(&line); - } - } - Message::Shutdown { interrupted, tx } => { - if interrupted { - self.cancel_live_rows(); - } - self.print_plain_summary(); - let _ = tx.send(()); - break; - } - } + fn plain_progress_line(&self, id: display::ItemId, kind: UpdateKind) -> Option { + if let Some(repo) = self.state.repos.get(&id) { + return Some(PlainLine { + name: repo.name.to_string(), + step: repo.step, + total: repo.total, + message: plain_repo_message(kind, repo.state, &repo.msg), + }); } - } - fn update_plain_name_width(&mut self, name: &str) -> usize { - self.plain_name_width = self.plain_name_width.max(name.chars().count()); - self.plain_name_width + self.state.grammars.get(&id).map(|grammar| PlainLine { + name: format!("{}/{}", grammar.repo, grammar.name), + step: grammar.step, + total: grammar.total, + message: plain_grammar_message(kind, grammar.state, &grammar.msg), + }) } fn print_plain_metadata(&self) { @@ -678,11 +741,6 @@ impl DisplayActor { println!(); } - fn print_plain_ref(&mut self, name: &str, git_ref: &str) { - let width = self.update_plain_name_width(name); - println!("{name: Option { - if let Some(repo) = self.state.repos.get(&id) { - return Some(PlainLine { - name: repo.name.to_string(), - step: repo.step, - total: repo.total, - message: plain_repo_message(kind, repo.state, &repo.msg), - }); - } - - self.state.grammars.get(&id).map(|grammar| PlainLine { - name: format!("{}/{}", grammar.repo, grammar.name), - step: grammar.step, - total: grammar.total, - message: plain_grammar_message(kind, grammar.state, &grammar.msg), - }) - } - - // ── Message handling ────────────────────────────────────────────── - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::RegisterLanguage { - git_ref, - name, - num_tasks, - tx, - } => { - let addr = self.register_repo(name, git_ref, num_tasks); - let _ = tx.send(addr); - } - Message::RegisterGrammar { - git_ref, - language, - name, - num_tasks, - tx, - } => { - let addr = self.register_grammar(language, name, git_ref, num_tasks); - let _ = tx.send(addr); - } - Message::RegisterReference { .. } => {} - Message::Update { id, kind, msg } => { - self.apply_update(id, kind, msg); - } - Message::Shutdown { interrupted, tx } => { - if interrupted { - self.cancel_live_rows(); - } - let _ = tx.send(()); - } - } + fn print_plain_ref(&mut self, name: &str, git_ref: &str) { + let width = self.update_plain_name_width(name); + println!("{name:, - git_ref: git::Ref, - num_tasks: usize, - ) -> ProgressAddr { - let id = self.next_id; - self.next_id = self.next_id.next_after(); - - self.state.repos.insert( - id, - display::RepoEntry { - name, - git_ref, - state: display::ItemState::New, - msg: Arc::from(""), - step: 0, - total: num_tasks, - started_at: Instant::now(), - frozen_elapsed: None, - }, - ); - - self.rows_dirty = true; - - ProgressAddr { - id, - tx: self.tx.clone(), - } + + fn print_plain_summary(&self) { + let (cached, built, _building, failed, cancelled) = self.state.summary_counts(); + println!(); + println!("✓ {cached} cached ✓ {built} built ✗ {cancelled} cancelled ✗ {failed} failed"); } fn register_grammar( @@ -834,185 +812,207 @@ impl DisplayActor { } } - fn cancel_live_rows(&mut self) { - let mut parent_ids = Vec::new(); + fn register_repo( + &mut self, + name: Arc, + git_ref: git::Ref, + num_tasks: usize, + ) -> ProgressAddr { + let id = self.next_id; + self.next_id = self.next_id.next_after(); - for (id, grammar) in &mut self.state.grammars { - if grammar.state.is_live() { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Cancelled; - grammar.step = grammar.total; - grammar.msg = Arc::from("cancelled"); - if let Some(repo_id) = grammar.repo_id { - parent_ids.push(repo_id); - } - self.grid.mark_dirty(*id); - } - } + self.state.repos.insert( + id, + display::RepoEntry { + name, + git_ref, + state: display::ItemState::New, + msg: Arc::from(""), + step: 0, + total: num_tasks, + started_at: Instant::now(), + frozen_elapsed: None, + }, + ); - parent_ids.sort_unstable(); - parent_ids.dedup(); - for repo_id in parent_ids { - self.sync_parent_repo(repo_id); - } + self.rows_dirty = true; - for (id, repo) in &mut self.state.repos { - if repo.state.is_live() { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Cancelled; - if repo.total > 0 { - repo.step = repo.total; - } - repo.msg = Arc::from("cancelled"); - self.grid.mark_dirty(*id); - } + ProgressAddr { + id, + tx: self.tx.clone(), } } - fn apply_update(&mut self, id: display::ItemId, kind: UpdateKind, msg: Arc) { - if let Some(repo) = self.state.repos.get_mut(&id) { - Self::apply_repo_update(repo, kind, msg); - self.grid.mark_dirty(id); - return; + async fn run(mut self) { + if self.state.mode == display::Mode::Fancy { + self.run_fancy().await; + } else { + self.run_plain().await; } + } - let mut maybe_parent_id: Option = None; - if let Some(grammar) = self.state.grammars.get_mut(&id) { - Self::apply_grammar_update(grammar, kind, msg); - self.grid.mark_dirty(id); - if matches!( - kind, - UpdateKind::Cached - | UpdateKind::Err - | UpdateKind::Fin - | UpdateKind::SetOutcomeBuilt - | UpdateKind::SetOutcomeCached - | UpdateKind::Cancel - | UpdateKind::Step - ) { - maybe_parent_id = grammar.repo_id; - } - } + // ── Fancy mode ──────────────────────────────────────────────────── - if let Some(repo_id) = maybe_parent_id { - self.sync_parent_repo(repo_id); - } - } + async fn run_fancy(&mut self) { + let viewport_height = crossterm::terminal::size().map_or(40, |(_, h)| h).min(40); - fn apply_repo_update(repo: &mut display::RepoEntry, kind: UpdateKind, msg: Arc) { - if !repo.state.is_live() { + let mut terminal = match ratatui::Terminal::with_options( + ratatui::backend::CrosstermBackend::new(io::stdout()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Inline(viewport_height), + }, + ) { + Ok(terminal) => terminal, + Err(err) => { + eprintln!("tsdl: fancy display unavailable; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } + }; + + // One-time bootstrap render so the user sees state immediately. + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); + self.run_plain().await; return; } - match kind { - UpdateKind::Msg => { - repo.msg = msg; - } - UpdateKind::Step => { - repo.state = start_item(repo.state); - repo.step += 1; - repo.msg = msg; - } - UpdateKind::SetOutcomeCached => { - repo.state = mark_success(repo.state, display::SuccessOutcome::Cached); - } - UpdateKind::Cancel => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Cancelled; - if repo.total > 0 { - repo.step = repo.total; + let mut tick_interval = time::interval(Duration::from_millis(100)); + + loop { + // ── wait for the next event ───────────────────────────── + let mut shutdown = None; + tokio::select! { + msg = self.rx.recv() => { + match msg { + Some(Message::Shutdown { interrupted, tx }) => { + shutdown = Some((interrupted, tx)); + } + Some(other) => self.handle_message(other), + None => break, + } } - repo.msg = msg; - } - UpdateKind::SetOutcomeBuilt => { - repo.state = mark_success(repo.state, display::SuccessOutcome::Built); - } - UpdateKind::Cached => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Done(display::SuccessOutcome::Cached); - if repo.total > 0 { - repo.step = repo.total; + _ = tick_interval.tick() => { + // fall through to drain + render } - repo.msg = msg; } - UpdateKind::Fin => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - match finish_item(repo.state) { - Ok(state) => { - repo.state = state; - repo.msg = msg; - } - Err(message) => { - repo.state = display::ItemState::Failed; - repo.msg = message.into(); + + // ── drain any messages that queued up concurrently ───── + while let Ok(msg) = self.rx.try_recv() { + match msg { + Message::Shutdown { interrupted, tx } => { + shutdown = Some((interrupted, tx)); + break; } - } - if repo.total > 0 { - repo.step = repo.total; + other => self.handle_message(other), } } - UpdateKind::Err => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Failed; - repo.msg = msg; + + // ── shutdown after draining ──────────────────────────── + if let Some((interrupted, tx)) = shutdown { + self.finish_fancy(&mut terminal, interrupted, tx); + return; + } + + // ── render once per event cycle ──────────────────────── + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); + self.run_plain().await; + return; } } + + // #[allow(unreachable_code)] + // ratatui::restore(); + // println!(); } - fn apply_grammar_update(grammar: &mut display::GrammarEntry, kind: UpdateKind, msg: Arc) { - if !grammar.state.is_live() { - return; - } + // ── Plain mode ──────────────────────────────────────────────────── - match kind { - UpdateKind::Msg => { - grammar.msg = msg; - } - UpdateKind::Step => { - grammar.state = start_item(grammar.state); - grammar.step += 1; - grammar.msg = msg; - } - UpdateKind::SetOutcomeCached => { - grammar.state = mark_success(grammar.state, display::SuccessOutcome::Cached); - } - UpdateKind::Cancel => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Cancelled; - grammar.step = grammar.total; - grammar.msg = msg; - } - UpdateKind::SetOutcomeBuilt => { - grammar.state = mark_success(grammar.state, display::SuccessOutcome::Built); - } - UpdateKind::Cached => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Done(display::SuccessOutcome::Cached); - grammar.step = grammar.total; - grammar.msg = msg; - } - UpdateKind::Fin => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - match finish_item(grammar.state) { - Ok(state) => { - grammar.state = state; - grammar.msg = msg; + async fn run_plain(&mut self) { + self.print_plain_metadata(); + + while let Some(msg) = self.rx.recv().await { + match msg { + Message::RegisterLanguage { + git_ref, + name, + num_tasks, + tx, + } => { + let addr = self.register_repo(name, git_ref, num_tasks); + let _ = tx.send(addr); + } + Message::RegisterGrammar { + git_ref, + language, + name, + num_tasks, + tx, + } => { + let plain_name = format!("{language}/{name}"); + self.update_plain_name_width(&plain_name); + let addr = self.register_grammar(language, name, git_ref, num_tasks); + let _ = tx.send(addr); + } + Message::RegisterReference { git_ref, name } => { + self.print_plain_ref(&name, git_ref.short()); + } + Message::Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + if matches!( + kind, + UpdateKind::Msg + | UpdateKind::SetOutcomeCached + | UpdateKind::SetOutcomeBuilt + ) { + continue; } - Err(message) => { - grammar.state = display::ItemState::Failed; - grammar.msg = message.into(); + if let Some(line) = self.plain_progress_line(id, kind) { + self.print_plain_progress(&line); } } - grammar.step = grammar.total; - } - UpdateKind::Err => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Failed; - grammar.msg = msg; + Message::Shutdown { interrupted, tx } => { + if interrupted { + self.cancel_live_rows(); + } + self.print_plain_summary(); + let _ = tx.send(()); + break; + } } } } + #[must_use] + pub fn spawn(mode: display::Mode, build_dir: PathBuf, out_dir: PathBuf) -> DisplayAddr { + let (tx, rx) = mpsc::channel(256); + let actor = Self { + state: display::State::new(mode, build_dir, out_dir), + next_id: display::ItemId::new(NonZeroU64::MIN), + plain_name_width: 16, + plain_progress_started: false, + grid: display::GridCache::new(), + row_specs: Vec::new(), + rows_dirty: true, + last_term_width: None, + rx, + tx: tx.clone(), + }; + + tokio::spawn(async move { + actor.run().await; + }); + + DisplayAddr::new(tx, mode) + } + fn sync_parent_repo(&mut self, repo_id: display::ItemId) { let has_any = self .state @@ -1070,118 +1070,118 @@ impl DisplayActor { self.grid.mark_dirty(repo_id); } - fn aggregate_child_live_outcome( - &self, - repo_id: display::ItemId, - ) -> Option { - let mut saw_cached = false; - let mut saw_unknown = false; - - for grammar in self - .state - .grammars - .values() - .filter(|g| g.repo_id == Some(repo_id)) - { - match grammar.state { - display::ItemState::InProgress(Some(display::SuccessOutcome::Built)) - | display::ItemState::Done(display::SuccessOutcome::Built) => { - return Some(display::SuccessOutcome::Built) - } - display::ItemState::InProgress(Some(display::SuccessOutcome::Cached)) - | display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, - display::ItemState::New | display::ItemState::InProgress(None) => { - saw_unknown = true; - } - display::ItemState::Cancelled | display::ItemState::Failed => {} - } - } + fn update_plain_name_width(&mut self, name: &str) -> usize { + self.plain_name_width = self.plain_name_width.max(name.chars().count()); + self.plain_name_width + } +} - if saw_unknown { - None - } else if saw_cached { - Some(display::SuccessOutcome::Cached) - } else { - None - } +impl DisplayAddr { + pub async fn add_language>>( + &self, + git_ref: git::Ref, + name: S, + num_tasks: usize, + ) -> ProgressAddr { + self.request(|tx| Message::RegisterLanguage { + git_ref, + name: name.into(), + num_tasks, + tx, + }) + .await } - fn aggregate_child_done_outcome( + pub async fn add_grammar>>( &self, - repo_id: display::ItemId, - ) -> Option { - let mut saw_cached = false; + git_ref: git::Ref, + language: S, + name: S, + num_tasks: usize, + ) -> ProgressAddr { + self.request(|tx| Message::RegisterGrammar { + git_ref, + language: language.into(), + name: name.into(), + num_tasks, + tx, + }) + .await + } - for grammar in self - .state - .grammars - .values() - .filter(|g| g.repo_id == Some(repo_id)) - { - match grammar.state { - display::ItemState::Done(display::SuccessOutcome::Built) => { - return Some(display::SuccessOutcome::Built) - } - display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, - display::ItemState::New - | display::ItemState::InProgress(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => {} - } - } + #[must_use] + pub fn new(tx: mpsc::Sender, mode: display::Mode) -> Self { + Self { tx, mode } + } - saw_cached.then_some(display::SuccessOutcome::Cached) + pub async fn reference>>(&self, git_ref: git::Ref, name: S) { + self.fire(Message::RegisterReference { + git_ref, + name: name.into(), + }) + .await; } -} -fn start_item(state: display::ItemState) -> display::ItemState { - match state { - display::ItemState::New => display::ItemState::InProgress(None), - display::ItemState::InProgress(outcome) => display::ItemState::InProgress(outcome), - display::ItemState::Done(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => state, + pub async fn shutdown(&self, interrupted: bool) { + self.request(|tx| Message::Shutdown { interrupted, tx }) + .await; } } -fn mark_success(state: display::ItemState, outcome: display::SuccessOutcome) -> display::ItemState { - match state { - display::ItemState::New | display::ItemState::InProgress(_) => { - display::ItemState::InProgress(Some(outcome)) - } - display::ItemState::Done(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => state, +impl ProgressAddr { + pub fn msg>>(&self, msg: S) { + let _ = self.tx.try_send(Message::Update { + id: self.id, + kind: UpdateKind::Msg, + msg: msg.into(), + }); } -} -fn finish_item(state: display::ItemState) -> Result { - match state { - display::ItemState::InProgress(Some(outcome)) => Ok(display::ItemState::Done(outcome)), - display::ItemState::New | display::ItemState::InProgress(None) => invalid_finish( - "finish update received before cached/built path was set", - state, - ), - display::ItemState::Done(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => { - invalid_finish("finish update received for terminal item state", state) - } + pub fn step>>(&self, msg: S) { + let _ = self.tx.try_send(Message::Update { + id: self.id, + kind: UpdateKind::Step, + msg: msg.into(), + }); } -} -fn invalid_finish(reason: &str, state: display::ItemState) -> Result { - let message = format!("{reason}: {state:?}"); - error!("{message}"); - debug_assert!( - matches!(state, display::ItemState::InProgress(Some(_))), - "{message}" - ); - Err(message) -} + async fn send_state_update(&self, kind: UpdateKind, msg: Arc) { + let _ = self + .tx + .send(Message::Update { + id: self.id, + kind, + msg, + }) + .await; + } -fn spacer() -> Span<'static> { - Span::raw(" ") + pub async fn set_outcome_cached(&self) { + self.send_state_update(UpdateKind::SetOutcomeCached, Arc::from("")) + .await; + } + + pub async fn cancel(&self) { + self.send_state_update(UpdateKind::Cancel, Arc::from("cancelled")) + .await; + } + + pub async fn set_outcome_built(&self) { + self.send_state_update(UpdateKind::SetOutcomeBuilt, Arc::from("")) + .await; + } + + pub async fn cached>>(&self, msg: S) { + self.send_state_update(UpdateKind::Cached, msg.into()).await; + } + + pub async fn fin>>(&self, msg: S) { + self.send_state_update(UpdateKind::Fin, msg.into()).await; + } + + pub async fn err>>(&self, msg: S) { + self.send_state_update(UpdateKind::Err, msg.into()).await; + } } #[cfg(test)] From 25be4832e4f17a58b9cfc83ee76d4b6e68ed70b8 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 09:03:17 +0200 Subject: [PATCH 60/88] style: actors/mod: sort --- src/actors/mod.rs | 340 ++++++++++++++++++++++++---------------------- 1 file changed, 177 insertions(+), 163 deletions(-) diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 2bbe64f..8df0814 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -12,6 +12,10 @@ use tracing::{debug, info}; use crate::{args, parser, shutdown, tree_sitter, Error, Result}; +// ============================================================ +// Traits +// ============================================================ + pub trait Addr { type Message; @@ -43,19 +47,173 @@ pub trait Addr { } } +// ============================================================ +// Structs +// ============================================================ + pub struct Response { pub kind: K, pub tx: oneshot::Sender, } -impl Response { - /// # Panics - /// - /// Will panic channel is closed. - pub fn send(self, value: T) { - self.tx - .send(value) - .unwrap_or_else(|_| panic!("cannot send response: {:?}", self.kind)); +// ============================================================ +// Free functions +// ============================================================ + +async fn discover_grammars( + cache: CacheAddr, + display: DisplayAddr, + language: parser::LanguageBuild, + prepared_rx: &mut watch::Receiver>>, +) -> Result> { + shutdown::test_delay().await; + shutdown::check()?; + debug!("[discover] lang={}", language.name); + + let progress = display + .add_language( + language.spec.git_ref.requested().clone(), + language.name.as_arc(), + 2, + ) + .await; + + // Phase 1: resolve the parser revision (cache check + possible clone). + // This runs concurrently with tree-sitter CLI preparation — it only + // touches the cache actor and git subprocesses, neither of which depend + // on the resolved tree-sitter version. + let revision = resolve_revision(&cache, &language, &progress).await?; + + // Phase 2: scan for grammar.js files (git ls-files + hashing). + // Also concurrent with CLI preparation. + progress.step("scanning"); + let grammars = match language.discover_grammars().await { + Ok(grammars) => grammars, + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("scan failed").await; + } + return Err(e); + } + }; + progress.fin("done").await; + + // Phase 3: wait for the tree-sitter CLI to be ready so we have the + // resolved tree-sitter version for cache comparisons and the binary path + // for computing artifact paths. + let prepared = wait_for_prepared(prepared_rx).await?; + let ts_cli = prepared.path; + let language = language.with_tree_sitter(prepared.tree_sitter); + + // Map the raw discovery data into Build structs, now with the resolved + // tree-sitter version available for accurate cache-key comparison. + let mut builds = Vec::new(); + for (name, dir, hash) in grammars { + shutdown::test_delay().await; + shutdown::check()?; + + let key = crate::cache::Key::new(&language.name, &name); + let artifacts = parser::GrammarBuild::required_artifacts_for( + &language.output.build_dir, + &ts_cli, + &language.spec, + &name, + )?; + let cache_decision = cache + .needs_rebuild(key, hash.clone(), language.spec.clone(), revision.clone()) + .await; + + // Cache actor checks in-memory metadata only. When it reports a hit + // we still verify that the expected output files exist on disk. + let cache_decision = if cache_decision.is_hit() { + crate::cache::verify_artifacts(artifacts).await + } else { + cache_decision + }; + + let progress = display + .add_grammar( + language.spec.git_ref.requested().clone(), + language.name.as_arc(), + name.as_arc(), + 4, + ) + .await; + + builds.push(parser::GrammarBuild { + context: language.context.clone(), + cache_decision, + dir, + hash, + language: language.name.clone(), + name, + output: language.output.clone(), + progress, + revision: revision.clone(), + spec: language.spec.clone(), + ts_cli: ts_cli.clone(), + }); + } + + Ok(builds) +} + +async fn resolve_revision( + cache: &CacheAddr, + language: &parser::LanguageBuild, + progress: &ProgressAddr, +) -> Result { + if language.spec.git_ref.is_moving() { + info!( + "Resolving moving parser git ref for {}: {}", + language.name, + language.spec.git_ref.requested().as_str() + ); + progress.set_outcome_built().await; + progress.step("cloning"); + match language.checkout().await { + Ok(checkout) => { + info!( + "Resolved parser {} git ref {} to commit {}", + language.name, + language.spec.git_ref.requested().as_str(), + checkout.commit.as_str() + ); + Ok(crate::cache::Revision::moving(checkout.commit)) + } + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("clone failed").await; + } + Err(e) + } + } + } else { + let has_compatible_entries = cache + .has_compatible_entries(language.name.clone(), language.spec.clone()) + .await; + let checkout_needed = !has_compatible_entries || !language.is_checkout_usable().await; + + if checkout_needed { + progress.set_outcome_built().await; + progress.step("cloning"); + if let Err(e) = language.checkout().await { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("clone failed").await; + } + return Err(e); + } + } else { + progress.set_outcome_cached().await; + } + + Ok(crate::cache::Revision::stable()) } } @@ -217,108 +375,6 @@ async fn run_inner( } } -// --- Helper Refactors (Moving logic out of Actor impls) --- - -async fn discover_grammars( - cache: CacheAddr, - display: DisplayAddr, - language: parser::LanguageBuild, - prepared_rx: &mut watch::Receiver>>, -) -> Result> { - shutdown::test_delay().await; - shutdown::check()?; - debug!("[discover] lang={}", language.name); - - let progress = display - .add_language( - language.spec.git_ref.requested().clone(), - language.name.as_arc(), - 2, - ) - .await; - - // Phase 1: resolve the parser revision (cache check + possible clone). - // This runs concurrently with tree-sitter CLI preparation — it only - // touches the cache actor and git subprocesses, neither of which depend - // on the resolved tree-sitter version. - let revision = resolve_revision(&cache, &language, &progress).await?; - - // Phase 2: scan for grammar.js files (git ls-files + hashing). - // Also concurrent with CLI preparation. - progress.step("scanning"); - let grammars = match language.discover_grammars().await { - Ok(grammars) => grammars, - Err(e) => { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("scan failed").await; - } - return Err(e); - } - }; - progress.fin("done").await; - - // Phase 3: wait for the tree-sitter CLI to be ready so we have the - // resolved tree-sitter version for cache comparisons and the binary path - // for computing artifact paths. - let prepared = wait_for_prepared(prepared_rx).await?; - let ts_cli = prepared.path; - let language = language.with_tree_sitter(prepared.tree_sitter); - - // Map the raw discovery data into Build structs, now with the resolved - // tree-sitter version available for accurate cache-key comparison. - let mut builds = Vec::new(); - for (name, dir, hash) in grammars { - shutdown::test_delay().await; - shutdown::check()?; - - let key = crate::cache::Key::new(&language.name, &name); - let artifacts = parser::GrammarBuild::required_artifacts_for( - &language.output.build_dir, - &ts_cli, - &language.spec, - &name, - )?; - let cache_decision = cache - .needs_rebuild(key, hash.clone(), language.spec.clone(), revision.clone()) - .await; - - // Cache actor checks in-memory metadata only. When it reports a hit - // we still verify that the expected output files exist on disk. - let cache_decision = if cache_decision.is_hit() { - crate::cache::verify_artifacts(artifacts).await - } else { - cache_decision - }; - - let progress = display - .add_grammar( - language.spec.git_ref.requested().clone(), - language.name.as_arc(), - name.as_arc(), - 4, - ) - .await; - - builds.push(parser::GrammarBuild { - context: language.context.clone(), - cache_decision, - dir, - hash, - language: language.name.clone(), - name, - output: language.output.clone(), - progress, - revision: revision.clone(), - spec: language.spec.clone(), - ts_cli: ts_cli.clone(), - }); - } - - Ok(builds) -} - /// Wait for the tree-sitter CLI preparation task to finish, returning the /// prepared CLI or the error that preparation failed with. async fn wait_for_prepared( @@ -343,59 +399,17 @@ async fn wait_for_prepared( } } -async fn resolve_revision( - cache: &CacheAddr, - language: &parser::LanguageBuild, - progress: &ProgressAddr, -) -> Result { - if language.spec.git_ref.is_moving() { - info!( - "Resolving moving parser git ref for {}: {}", - language.name, - language.spec.git_ref.requested().as_str() - ); - progress.set_outcome_built().await; - progress.step("cloning"); - match language.checkout().await { - Ok(checkout) => { - info!( - "Resolved parser {} git ref {} to commit {}", - language.name, - language.spec.git_ref.requested().as_str(), - checkout.commit.as_str() - ); - Ok(crate::cache::Revision::moving(checkout.commit)) - } - Err(e) => { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("clone failed").await; - } - Err(e) - } - } - } else { - let has_compatible_entries = cache - .has_compatible_entries(language.name.clone(), language.spec.clone()) - .await; - let checkout_needed = !has_compatible_entries || !language.is_checkout_usable().await; - - if checkout_needed { - progress.set_outcome_built().await; - progress.step("cloning"); - if let Err(e) = language.checkout().await { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("clone failed").await; - } - return Err(e); - } - } else { - progress.set_outcome_cached().await; - } +// ============================================================ +// Impls +// ============================================================ - Ok(crate::cache::Revision::stable()) +impl Response { + /// # Panics + /// + /// Will panic channel is closed. + pub fn send(self, value: T) { + self.tx + .send(value) + .unwrap_or_else(|_| panic!("cannot send response: {:?}", self.kind)); } } From d579abe3971df6f984308b09e635da88ec8ff304 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 09:04:44 +0200 Subject: [PATCH 61/88] style: app: sort --- src/app.rs | 111 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 64 insertions(+), 47 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8a6578c..d442481 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,11 +5,9 @@ use clap_verbosity_flag::{InfoLevel, Verbosity}; use crate::{args, config, display, logging, Result, ResultExt}; -/// Resolved build state for commands that actually need build configuration. -pub struct ResolvedBuild { - pub command: args::BuildCommand, - pub provenance: config::BuildProvenance, -} +// ============================================================ +// Enums +// ============================================================ /// The selected command after resolving only the configuration it needs. pub enum ResolvedCommand { @@ -19,18 +17,9 @@ pub enum ResolvedCommand { Selfupdate { force: bool, target: String }, } -impl ResolvedCommand { - fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { - let implicit = match self { - Self::Build(build) => logging::Implicit::BuildDir { - dir: &build.command.build_dir, - }, - _ => logging::Implicit::None - }; - - logging::Policy { explicit, implicit } - } -} +// ============================================================ +// Structs +// ============================================================ /// Resolved application state, ready to run. pub struct App { @@ -41,6 +30,50 @@ pub struct App { pub verbose: Verbosity, } +/// Resolved build state for commands that actually need build configuration. +pub struct ResolvedBuild { + pub command: args::BuildCommand, + pub provenance: config::BuildProvenance, +} + +// ============================================================ +// Free functions +// ============================================================ + +fn resolve_build( + config_path: &Path, + matches: Option<&ArgMatches>, + purpose: &str, +) -> Result { + let (command, provenance) = config::current_with_provenance(config_path, matches) + .with_context(|| format!("Resolving build configuration for {purpose}"))?; + + Ok(ResolvedBuild { + command, + provenance, + }) +} + + fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { + match &args.command { + args::Command::Build => { + resolve_build(&args.config, args::build_matches(matches), "`build`") + .map(ResolvedCommand::Build) + } + + args::Command::Config { + command: args::ConfigCommand::Current, + } => resolve_build(&args.config, None, "`config current`") + .map(ResolvedCommand::ConfigCurrent), + + args::Command::Config { + command: args::ConfigCommand::Default, + } => Ok(ResolvedCommand::ConfigDefault), + + args::Command::Selfupdate { force, target } => Ok(ResolvedCommand::Selfupdate { force: *force, target: target.clone() }), + } +} + pub fn setup() -> Result { let (args, matches) = config::parse_with_matches(); let command = resolve_command(&args, &matches)?; @@ -61,39 +94,23 @@ pub fn setup() -> Result { }) } -fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { - match &args.command { - args::Command::Build => { - resolve_build(&args.config, args::build_matches(matches), "`build`") - .map(ResolvedCommand::Build) - } - - args::Command::Config { - command: args::ConfigCommand::Current, - } => resolve_build(&args.config, None, "`config current`") - .map(ResolvedCommand::ConfigCurrent), +// ============================================================ +// Impls +// ============================================================ - args::Command::Config { - command: args::ConfigCommand::Default, - } => Ok(ResolvedCommand::ConfigDefault), - args::Command::Selfupdate { force, target } => Ok(ResolvedCommand::Selfupdate { force: *force, target: target.clone() }), - } -} + impl ResolvedCommand { + fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { + let implicit = match self { + Self::Build(build) => logging::Implicit::BuildDir { + dir: &build.command.build_dir, + }, + _ => logging::Implicit::None + }; -fn resolve_build( - config_path: &Path, - matches: Option<&ArgMatches>, - purpose: &str, -) -> Result { - let (command, provenance) = config::current_with_provenance(config_path, matches) - .with_context(|| format!("Resolving build configuration for {purpose}"))?; - - Ok(ResolvedBuild { - command, - provenance, - }) -} + logging::Policy { explicit, implicit } + } + } #[cfg(test)] mod tests { From 0624ebcf178a98d332d2d90da6e2c47871ccaff9 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 09:07:08 +0200 Subject: [PATCH 62/88] style: args: sort --- src/args.rs | 431 +++++++++++++++++++++++++++------------------------- 1 file changed, 222 insertions(+), 209 deletions(-) diff --git a/src/args.rs b/src/args.rs index 63fe1fc..854a11a 100644 --- a/src/args.rs +++ b/src/args.rs @@ -12,56 +12,15 @@ use crate::consts::{ UNLOCK_TIMEOUT, VERSION, }; -const TSDL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/tsdl.version")); - -#[derive(Clone, Debug, clap::Parser)] -#[command(author = crate_authors!("\n"), version = TSDL_VERSION, about, styles=get_styles(), allow_external_subcommands = true)] -#[command(help_template( - "{before-help}{name} {version} -{author-with-newline}{about-with-newline} -{usage-heading} {usage} - -{all-args}{after-help}" -))] -pub struct Args { - #[command(subcommand)] - pub command: Command, - - /// Path to the config file (TOML). - #[arg(short, long, env = "TSDL_CONFIG", default_value = CONFIG_FILE, global = true)] - pub config: PathBuf, - - /// Path to the logging file. If unspecified, it will go to `build-dir/log`. - /// If the path is inside `build-dir`, it must be directly under that directory. - #[arg(short, long, env = "TSDL_LOG", global = true)] - pub log: Option, - - /// Whether to emit colored logs. - #[arg(long, value_enum, default_value_t = LogColor::Auto, global = true)] - pub log_color: LogColor, +// ============================================================ +// Constants +// ============================================================ - /// Progress style. - #[arg(long, value_enum, default_value_t = ProgressStyle::Auto, global = true)] - pub progress: ProgressStyle, - - /// Verbosity level: -v, -vv, or -q, -qq. - #[command(flatten)] - pub verbose: Verbosity, -} - -#[derive(clap::ValueEnum, Clone, Copy, Debug, Deserialize, Serialize)] -pub enum LogColor { - Auto, - No, - Yes, -} +const TSDL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/tsdl.version")); -#[derive(clap::ValueEnum, Clone, Debug, Deserialize, Serialize)] -pub enum ProgressStyle { - Auto, - Fancy, - Plain, -} +// ============================================================ +// Enums +// ============================================================ #[derive(clap::Subcommand, Clone, Debug)] pub enum Command { @@ -76,45 +35,52 @@ pub enum Command { command: ConfigCommand, }, - /// Update tsdl to the latest compatible version. + /// Update tsdl to its latest version. #[command(visible_alias = "u")] Selfupdate { - /// Skip the downgrade confirmation prompt. - #[arg(long, default_value_t = false)] - force: bool, + #[arg(long, short, default_value="false")] + force: bool, - /// Version selector: "patch", "minor", "major", or a semver like "2.5.0". - #[arg(default_value = "minor")] - target: String, + #[arg(long, short, default_value="minor")] + target: String }, } -impl Command { - #[must_use] - pub const fn is_build(&self) -> bool { - matches!(self, Command::Build) - } +#[derive(clap::Subcommand, Clone, Debug, Default)] +pub enum ConfigCommand { + #[default] + Current, + Default, } -#[derive(Clone, Copy, Debug, Default)] -pub enum VersionBump { - /// Update to the latest version, including breaking changes. - Major, - /// Update to the latest minor or patch version (same MAJOR). - #[default] - Minor, - /// Update to the latest patch version (same MAJOR.MINOR). - Patch, +#[derive(clap::ValueEnum, Clone, Copy, Debug, Deserialize, Serialize)] +pub enum LogColor { + Auto, + No, + Yes, } -impl fmt::Display for VersionBump { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Major => write!(f, "major"), - Self::Minor => write!(f, "minor"), - Self::Patch => write!(f, "patch"), - } - } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +#[serde(rename_all = "kebab-case")] +pub enum ParserConfig { + Full { + #[serde(alias = "cmd", alias = "script")] + build_script: Option, + + from: Option, + + #[serde(rename = "ref")] + git_ref: String, + }, + Ref(String), +} + +#[derive(clap::ValueEnum, Clone, Debug, Deserialize, Serialize)] +pub enum ProgressStyle { + Auto, + Fancy, + Plain, } #[derive(clap::ValueEnum, Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] @@ -126,73 +92,73 @@ pub enum Target { All, } -impl Target { - #[must_use] - pub fn covers(&self, other: Target) -> bool { - matches!( - (self, other), - (Target::All, _) | (Target::Native, Target::Native) | (Target::Wasm, Target::Wasm) - ) - } +#[derive(Clone, Copy, Debug, Default)] +pub enum VersionBump { + /// Update to the latest version, including breaking changes. + Major, + /// Update to the latest minor or patch version (same MAJOR). + #[default] + Minor, + /// Update to the latest patch version (same MAJOR.MINOR). + Patch, +} - #[must_use] - pub fn union(self, other: Self) -> Self { - match (self, other) { - (Self::Native, Self::Native) => Self::Native, - (Self::Wasm, Self::Wasm) => Self::Wasm, - (Self::All, _) - | (_, Self::All) - | (Self::Native, Self::Wasm) - | (Self::Wasm, Self::Native) => Self::All, - } - } +// ============================================================ +// Structs +// ============================================================ - #[must_use] - pub fn native(&self) -> bool { - matches!(self, Self::All | Self::Native) - } +#[derive(Clone, Debug, clap::Parser)] +#[command(author = crate_authors!("\n"), version = TSDL_VERSION, about, styles=get_styles(), allow_external_subcommands = true)] +#[command(help_template( + "{before-help}{name} {version} +{author-with-newline}{about-with-newline} +{usage-heading} {usage} - #[must_use] - pub fn wasm(&self) -> bool { - matches!(self, Self::All | Self::Wasm) - } +{all-args}{after-help}" +))] +pub struct Args { + #[command(subcommand)] + pub command: Command, - #[must_use] - pub fn to_lowercase(&self) -> &'static str { - match self { - Target::Native => "native", - Target::Wasm => "wasm", - Target::All => "all", - } - } -} + /// Path to the config file (TOML). + #[arg(short, long, env = "TSDL_CONFIG", default_value = CONFIG_FILE, global = true)] + pub config: PathBuf, -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(untagged)] -#[serde(rename_all = "kebab-case")] -pub enum ParserConfig { - Full { - #[serde(alias = "cmd", alias = "script")] - build_script: Option, + /// Path to the logging file. If unspecified, it will go to `build-dir/log`. + /// If the path is inside `build-dir`, it must be directly under that directory. + #[arg(short, long, env = "TSDL_LOG", global = true)] + pub log: Option, - from: Option, + /// Whether to emit colored logs. + #[arg(long, value_enum, default_value_t = LogColor::Auto, global = true)] + pub log_color: LogColor, - #[serde(rename = "ref")] - git_ref: String, - }, - Ref(String), + /// Progress style. + #[arg(long, value_enum, default_value_t = ProgressStyle::Auto, global = true)] + pub progress: ProgressStyle, + + /// Verbosity level: -v, -vv, or -q, -qq. + #[command(flatten)] + pub verbose: Verbosity, } -/// Fully optional overrides for [`TreeSitter`]. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] -pub struct OptionalTreeSitter { - #[serde(default, alias = "git-ref", alias = "ref")] - pub version: Option, - #[serde(default)] - pub platform: Option, - #[serde(default)] - pub repo: Option, +pub struct BuildCommand { + pub build_dir: PathBuf, + pub force: bool, + pub fresh: bool, + #[serde(skip_serializing)] + pub languages: Option>, + pub jobs: NonZeroUsize, + #[serde(rename = "out-dir", alias = "out")] + pub out_dir: PathBuf, + pub parsers: Option>, + pub prefix: String, + pub show_config: bool, + pub target: Target, + pub tree_sitter: TreeSitter, + pub unlock_timeout: u64, } /// Fully optional overrides for every build configuration field. @@ -229,6 +195,18 @@ pub struct OptionalBuildCommand { pub languages: Option>, } +/// Fully optional overrides for [`TreeSitter`]. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct OptionalTreeSitter { + #[serde(default, alias = "git-ref", alias = "ref")] + pub version: Option, + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub repo: Option, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct TreeSitter { @@ -246,57 +224,19 @@ pub struct TreeSitter { pub repo: String, } -impl Default for TreeSitter { - fn default() -> Self { - Self { - version: VERSION.to_string(), - platform: PLATFORM.to_string(), - repo: REPO.to_string(), - } - } -} - - // #[allow(clippy::struct_excessive_bools)] - // #[derive(clap::Args, Clone, Debug, Deserialize, Diff, PartialEq, Eq, Serialize)] - // #[diff(attr( - // #[derive(Debug, PartialEq)] - // ))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct BuildCommand { - pub build_dir: PathBuf, - pub force: bool, - pub fresh: bool, - #[serde(skip_serializing)] - pub languages: Option>, - pub jobs: NonZeroUsize, - #[serde(rename = "out-dir", alias = "out")] - pub out_dir: PathBuf, - pub parsers: Option>, - pub prefix: String, - pub show_config: bool, - pub target: Target, - pub tree_sitter: TreeSitter, - pub unlock_timeout: u64, -} +// ============================================================ +// Free functions +// ============================================================ -impl Default for BuildCommand { - fn default() -> Self { - Self { - build_dir: PathBuf::from(BUILD_DIR), - force: FORCE, - fresh: FRESH, - languages: None, - jobs: default_jobs(), - out_dir: PathBuf::from(PARSER_OUT_DIR), - parsers: None, - prefix: String::from(PREFIX), - show_config: SHOW_CONFIG, - target: Target::default(), - tree_sitter: TreeSitter::default(), - unlock_timeout: UNLOCK_TIMEOUT, +#[must_use] +pub fn build_matches(matches: &ArgMatches) -> Option<&ArgMatches> { + matches.subcommand().and_then(|(name, sub)| { + if matches!(name, "build" | "b") { + Some(sub) + } else { + None } - } + }) } #[must_use] @@ -304,10 +244,6 @@ pub fn default_jobs() -> NonZeroUsize { NonZeroUsize::new(num_cpus::get()).unwrap_or(NonZeroUsize::MIN) } -fn default_tree_sitter_version() -> String { - VERSION.to_string() -} - fn default_tree_sitter_platform() -> String { PLATFORM.to_string() } @@ -316,28 +252,8 @@ fn default_tree_sitter_repo() -> String { REPO.to_string() } -#[derive(clap::Subcommand, Clone, Debug, Default)] -pub enum ConfigCommand { - #[default] - Current, - Default, -} - -impl fmt::Display for ConfigCommand { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{:?}", format!("{self:?}").to_lowercase()) - } -} - -#[must_use] -pub fn build_matches(matches: &ArgMatches) -> Option<&ArgMatches> { - matches.subcommand().and_then(|(name, sub)| { - if matches!(name, "build" | "b") { - Some(sub) - } else { - None - } - }) +fn default_tree_sitter_version() -> String { + VERSION.to_string() } #[must_use] @@ -371,3 +287,100 @@ const fn get_styles() -> clap::builder::Styles { ) .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::White)))) } + +// ============================================================ +// Impls +// ============================================================ + +impl Command { + #[must_use] + pub const fn is_build(&self) -> bool { + matches!(self, Command::Build) + } +} + +impl Default for BuildCommand { + fn default() -> Self { + Self { + build_dir: PathBuf::from(BUILD_DIR), + force: FORCE, + fresh: FRESH, + languages: None, + jobs: default_jobs(), + out_dir: PathBuf::from(PARSER_OUT_DIR), + parsers: None, + prefix: String::from(PREFIX), + show_config: SHOW_CONFIG, + target: Target::default(), + tree_sitter: TreeSitter::default(), + unlock_timeout: UNLOCK_TIMEOUT, + } + } +} + +impl Default for TreeSitter { + fn default() -> Self { + Self { + version: VERSION.to_string(), + platform: PLATFORM.to_string(), + repo: REPO.to_string(), + } + } +} + +impl fmt::Display for ConfigCommand { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", format!("{self:?}").to_lowercase()) + } +} + +impl Target { + #[must_use] + pub fn covers(&self, other: Target) -> bool { + matches!( + (self, other), + (Target::All, _) | (Target::Native, Target::Native) | (Target::Wasm, Target::Wasm) + ) + } + + #[must_use] + pub fn union(self, other: Self) -> Self { + match (self, other) { + (Self::Native, Self::Native) => Self::Native, + (Self::Wasm, Self::Wasm) => Self::Wasm, + (Self::All, _) + | (_, Self::All) + | (Self::Native, Self::Wasm) + | (Self::Wasm, Self::Native) => Self::All, + } + } + + #[must_use] + pub fn native(&self) -> bool { + matches!(self, Self::All | Self::Native) + } + + #[must_use] + pub fn wasm(&self) -> bool { + matches!(self, Self::All | Self::Wasm) + } + + #[must_use] + pub fn to_lowercase(&self) -> &'static str { + match self { + Target::Native => "native", + Target::Wasm => "wasm", + Target::All => "all", + } + } +} + +impl fmt::Display for VersionBump { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Major => write!(f, "major"), + Self::Minor => write!(f, "minor"), + Self::Patch => write!(f, "patch"), + } + } +} From 84e46e8945285cc64531422d1489dc1474304659 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 09:11:46 +0200 Subject: [PATCH 63/88] style: build: sort --- src/build.rs | 120 ++++++++++++++++++++++++++++----------------------- 1 file changed, 66 insertions(+), 54 deletions(-) diff --git a/src/build.rs b/src/build.rs index 3133e1a..54a68d1 100644 --- a/src/build.rs +++ b/src/build.rs @@ -17,14 +17,13 @@ use crate::{ shutdown, Error, Result, ResultExt, SafeCanonicalize, }; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Spec { - pub build_script: Option, - pub git_ref: parser::Ref, - pub prefix: String, - pub repo: Url, - pub target: args::Target, - pub tree_sitter: args::TreeSitter, +// ============================================================ +// Structs +// ============================================================ + +#[derive(Debug, Clone, PartialEq)] +pub struct Context { + pub overwrite_output: bool, } #[derive(Debug, Clone)] @@ -33,24 +32,19 @@ pub struct OutputConfig { pub out_dir: PathBuf, } -#[derive(Debug, Clone, PartialEq)] -pub struct Context { - pub overwrite_output: bool, +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Spec { + pub build_script: Option, + pub git_ref: parser::Ref, + pub prefix: String, + pub repo: Url, + pub target: args::Target, + pub tree_sitter: args::TreeSitter, } -pub fn run(app: &app::App, command: &args::BuildCommand) -> Result<()> { - if command.show_config { - crate::config::show(command)?; - } - - let build_dir = BuildDir::new(&command.build_dir)?; - let lock = lock::Lock::new(&build_dir); - let guard = acquire_lock(&lock, Duration::from_secs(command.unlock_timeout))?; - - clear(command.fresh, &build_dir, app.logging.path(), &guard)?; - ignite(app, command, &build_dir)?; - Ok(()) -} +// ============================================================ +// Free functions +// ============================================================ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result { // Loop because the lock owner may exit naturally between the prompt and @@ -90,36 +84,6 @@ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result StdResult { - info!("Build directory is locked by another process:"); - info!("{owner}"); - eprintln!( - "If you continue, tsdl will send SIGTERM to PID {} and wait up to {} \ - (--unlock-timeout {}) for the build lock to be released.", - owner.pid, - format_duration(unlock_timeout), - unlock_timeout.as_secs() - ); - - if !prompt_user("Terminate this process and continue?", false)? { - return Err(Error::Message { - message: "lock::Lock acquisition cancelled by user".into(), - } - .into()); - } - - lock.terminate_owner(owner)?; - info!( - "Sent SIGTERM to PID {}. Waiting for the build lock to be released...", - owner.pid - ); - lock.wait_for_release(owner, unlock_timeout) -} - fn clear( fresh: bool, build_dir: &BuildDir, @@ -197,6 +161,36 @@ fn get_language_coords( } } +fn handle_locked_by( + lock: &lock::Lock, + owner: &lock::Owner, + unlock_timeout: Duration, +) -> StdResult { + info!("Build directory is locked by another process:"); + info!("{owner}"); + eprintln!( + "If you continue, tsdl will send SIGTERM to PID {} and wait up to {} \ + (--unlock-timeout {}) for the build lock to be released.", + owner.pid, + format_duration(unlock_timeout), + unlock_timeout.as_secs() + ); + + if !prompt_user("Terminate this process and continue?", false)? { + return Err(Error::Message { + message: "lock::Lock acquisition cancelled by user".into(), + } + .into()); + } + + lock.terminate_owner(owner)?; + info!( + "Sent SIGTERM to PID {}. Waiting for the build lock to be released...", + owner.pid + ); + lock.wait_for_release(owner, unlock_timeout) +} + fn ignite(app: &app::App, command: &args::BuildCommand, build_dir: &BuildDir) -> Result<()> { fs::create_dir_all(&command.out_dir)?; @@ -240,6 +234,20 @@ fn ignite(app: &app::App, command: &args::BuildCommand, build_dir: &BuildDir) -> result } +pub fn run(app: &app::App, command: &args::BuildCommand) -> Result<()> { + if command.show_config { + crate::config::show(command)?; + } + + let build_dir = BuildDir::new(&command.build_dir)?; + let lock = lock::Lock::new(&build_dir); + let guard = acquire_lock(&lock, Duration::from_secs(command.unlock_timeout))?; + + clear(command.fresh, &build_dir, app.logging.path(), &guard)?; + ignite(app, command, &build_dir)?; + Ok(()) +} + fn unique_languages( command: &args::BuildCommand, build_dir: &BuildDir, @@ -294,6 +302,10 @@ fn unique_languages( results } +// ============================================================ +// Tests +// ============================================================ + #[cfg(test)] mod tests { use super::*; From bb1cc6b04b6b090a75037dc184759e8886515fec Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 09:46:13 +0200 Subject: [PATCH 64/88] style: build: fuse build_dir and sort --- src/build.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++--- src/build_dir.rs | 62 --------------------------------------------- src/cache.rs | 2 +- src/lib.rs | 1 - src/lock.rs | 2 +- 5 files changed, 63 insertions(+), 69 deletions(-) delete mode 100644 src/build_dir.rs diff --git a/src/build.rs b/src/build.rs index 54a68d1..9014143 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::{ collections::{BTreeMap, BTreeSet}, fs, @@ -11,16 +12,19 @@ use serde::{Deserialize, Serialize}; use tracing::info; use url::Url; -use crate::consts::FROM; use crate::{ - actors, app, args, build_dir::BuildDir, cache, format_duration, lock, parser, prompt_user, - shutdown, Error, Result, ResultExt, SafeCanonicalize, + absolute_normalize, actors, app, args, cache, consts, format_duration, lock, parser, + prompt_user, shutdown, Error, Result, ResultExt, SafeCanonicalize, }; // ============================================================ // Structs // ============================================================ +/// The root build directory — anchor for all derived paths. +#[derive(Clone, Debug)] +pub struct BuildDir(PathBuf); + #[derive(Debug, Clone, PartialEq)] pub struct Context { pub overwrite_output: bool, @@ -120,6 +124,8 @@ fn collect_languages( } fn default_repo(language: &str) -> Result { + use consts::FROM; + let url = format!("{FROM}{language}"); Url::parse(&url).with_context(|| format!("Creating url {url} for {language}")) } @@ -303,9 +309,60 @@ fn unique_languages( } // ============================================================ -// Tests +// Impls // ============================================================ +impl AsRef for BuildDir { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl BuildDir { + /// The absolute, normalised path as a `&Path`. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } + + /// Path to the build cache TOML file inside this build directory. + #[must_use] + pub fn cache_file(&self) -> PathBuf { + self.0.join(consts::CACHE_FILE) + } + + /// Per-language checkout directory: `/tree-sitter-`. + #[must_use] + pub fn checkout_dir(&self, language: &str) -> PathBuf { + self.0.join(format!("tree-sitter-{language}")) + } + + /// Path to the PID lock file inside this build directory. + #[must_use] + pub fn lock_file(&self) -> PathBuf { + self.0.join(consts::LOCK_FILE) + } + + /// Path to the default log file inside this build directory. + #[must_use] + pub fn log_file(&self) -> PathBuf { + self.0.join(consts::LOG_FILE) + } + + /// Create a `BuildDir`, resolving relative paths against the current + /// working directory and normalising `.` / `..` components. + pub fn new(path: impl Into) -> Result { + let path = absolute_normalize(&path.into())?; + Ok(Self(path)) + } +} + +impl fmt::Display for BuildDir { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.display()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/build_dir.rs b/src/build_dir.rs deleted file mode 100644 index e862505..0000000 --- a/src/build_dir.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::fmt; -use std::path::{Path, PathBuf}; - -use crate::{absolute_normalize, consts, Result}; - -/// The root build directory — anchor for all derived paths. -/// -/// Wrapping this in a newtype prevents accidental swaps with other `PathBuf` -/// values (e.g. `out_dir`) and centralises sub-path derivation. -#[derive(Clone, Debug)] -pub struct BuildDir(PathBuf); - -impl BuildDir { - /// Create a `BuildDir`, resolving relative paths against the current - /// working directory and normalising `.` / `..` components. - pub fn new(path: impl Into) -> Result { - let path = absolute_normalize(&path.into())?; - Ok(Self(path)) - } - - /// The absolute, normalised path as a `&Path`. - #[must_use] - pub fn as_path(&self) -> &Path { - &self.0 - } - - /// Path to the build cache TOML file inside this build directory. - #[must_use] - pub fn cache_file(&self) -> PathBuf { - self.0.join(consts::CACHE_FILE) - } - - /// Path to the PID lock file inside this build directory. - #[must_use] - pub fn lock_file(&self) -> PathBuf { - self.0.join(consts::LOCK_FILE) - } - - /// Path to the default log file inside this build directory. - #[must_use] - pub fn log_file(&self) -> PathBuf { - self.0.join(consts::LOG_FILE) - } - - /// Per-language checkout directory: `/tree-sitter-`. - #[must_use] - pub fn checkout_dir(&self, language: &str) -> PathBuf { - self.0.join(format!("tree-sitter-{language}")) - } -} - -impl AsRef for BuildDir { - fn as_ref(&self) -> &Path { - &self.0 - } -} - -impl fmt::Display for BuildDir { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0.display()) - } -} diff --git a/src/cache.rs b/src/cache.rs index f34a807..ff334d5 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -13,7 +13,7 @@ use sha1::{Digest, Sha1}; use tokio::io::AsyncWrite; use tracing::debug; -use crate::{args, build, build_dir::BuildDir, git, parser, Error, Result, ResultExt}; +use crate::{args, build, build::BuildDir, git, parser, Error, Result, ResultExt}; #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] diff --git a/src/lib.rs b/src/lib.rs index a6f489a..3ac179a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,7 +62,6 @@ pub mod actors; pub mod app; pub mod args; pub mod build; -pub mod build_dir; pub mod cache; pub mod columns; pub mod config; diff --git a/src/lock.rs b/src/lock.rs index 665b9e3..973e431 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -16,7 +16,7 @@ use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, Update use tracing::info; use crate::{ - absolute_normalize, build_dir::BuildDir, consts, format_duration, Error, Result, ResultExt, + absolute_normalize, build::BuildDir, consts, format_duration, Error, Result, ResultExt, }; /// Information about the process currently holding the build lock. From 2fc9962f423d5aecb72e91bff55febae27ae17f5 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 09:48:56 +0200 Subject: [PATCH 65/88] style: cache+column: sort --- src/cache.rs | 1004 ++++++++++++++++++++++++------------------------ src/columns.rs | 130 +++---- 2 files changed, 573 insertions(+), 561 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index ff334d5..a89dcaf 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -15,155 +15,9 @@ use tracing::debug; use crate::{args, build, build::BuildDir, git, parser, Error, Result, ResultExt}; -#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct Key(Arc); - -impl Key { - #[must_use] - pub fn new(language: &parser::LanguageName, grammar: &parser::GrammarName) -> Self { - Self(Arc::from(format!("{language}/{grammar}"))) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - #[must_use] - pub fn language_prefix(language: &parser::LanguageName) -> String { - format!("{language}/") - } -} - -impl fmt::Display for Key { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for Key { - fn from(value: String) -> Self { - Self(value.into()) - } -} - -impl From<&str> for Key { - fn from(value: &str) -> Self { - Self(Arc::from(value)) - } -} - -#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct GrammarHash(Arc); - -impl GrammarHash { - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for GrammarHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for GrammarHash { - fn from(value: String) -> Self { - Self(value.into()) - } -} - -impl From<&str> for GrammarHash { - fn from(value: &str) -> Self { - Self(Arc::from(value)) - } -} - -/// The logical build cache contents. -/// -/// Persistence details such as the cache file path intentionally live in -/// [`Store`], not in this serializable structure. That keeps the on-disk TOML -/// schema independent from the runtime `--build-dir` location. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Db { - #[serde(default)] - pub parsers: BTreeMap, -} - -/// File-backed storage for a [`Db`]. -#[derive(Debug, Clone)] -pub struct Store { - file: PathBuf, -} - -/// Cache entry for a single parser. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Entry { - /// Hash of the grammar.js file(s). - pub hash: GrammarHash, - /// Resolved parser revision used by the cache. Moving refs include the - /// checked-out commit. - pub revision: Revision, - /// Build inputs that affect parser output, excluding the requested output set. - pub recipe: BuildRecipe, - /// Parser outputs known to be available for this entry. - pub outputs: args::Target, -} - -/// Build inputs that affect parser output, excluding the requested output set. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct BuildRecipe { - pub build_script: Option, - pub git_ref: parser::Ref, - pub prefix: String, - pub repo: url::Url, - pub tree_sitter: args::TreeSitter, -} - -impl BuildRecipe { - #[must_use] - pub fn from_spec(spec: &build::Spec) -> Self { - Self { - build_script: spec.build_script.clone(), - git_ref: spec.git_ref.clone(), - prefix: spec.prefix.clone(), - repo: spec.repo.clone(), - tree_sitter: spec.tree_sitter.clone(), - } - } -} - -#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", tag = "kind")] -pub enum Revision { - Stable, - Moving { commit: git::Sha }, -} - -impl Revision { - #[must_use] - pub const fn stable() -> Self { - Self::Stable - } - - #[must_use] - pub const fn moving(commit: git::Sha) -> Self { - Self::Moving { commit } - } -} - -impl fmt::Display for Revision { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Stable => write!(f, "stable"), - Self::Moving { commit } => write!(f, "moving commit={}", commit.as_str()), - } - } -} +// ============================================================ +// Enums +// ============================================================ /// A cache lookup result for a requested parser build. #[derive(Debug, Clone, PartialEq, Eq)] @@ -172,12 +26,6 @@ pub enum Decision { Miss(Miss), } -/// Details explaining why a cache entry cannot satisfy a requested parser build. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Miss { - pub reasons: Vec, -} - /// One reason a cached parser build cannot be reused. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MissReason { @@ -224,173 +72,310 @@ pub enum MissReason { }, } -impl Decision { - #[must_use] - pub fn miss(reason: MissReason) -> Self { - Self::Miss(Miss { - reasons: vec![reason], - }) - } +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum Revision { + Stable, + Moving { commit: git::Sha }, +} - #[must_use] - pub fn from_reasons(reasons: Vec) -> Self { - if reasons.is_empty() { - Self::Hit - } else { - Self::Miss(Miss { reasons }) - } - } +// ============================================================ +// Structs +// ============================================================ - #[must_use] - pub fn is_hit(&self) -> bool { - matches!(self, Self::Hit) - } +/// Build inputs that affect parser output, excluding the requested output set. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuildRecipe { + pub build_script: Option, + pub git_ref: parser::Ref, + pub prefix: String, + pub repo: url::Url, + pub tree_sitter: args::TreeSitter, +} - #[must_use] - pub fn needs_rebuild(&self) -> bool { - !self.is_hit() - } +/// The logical build cache contents. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Db { + #[serde(default)] + pub parsers: BTreeMap, +} - #[must_use] - pub fn short_message(&self) -> String { - match self { - Self::Hit => "cache hit".to_string(), - Self::Miss(miss) => miss.short_message(), - } - } +/// Cache entry for a single parser. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Entry { + /// Hash of the grammar.js file(s). + pub hash: GrammarHash, + /// Resolved parser revision used by the cache. Moving refs include the + /// checked-out commit. + pub revision: Revision, + /// Build inputs that affect parser output, excluding the requested output set. + pub recipe: BuildRecipe, + /// Parser outputs known to be available for this entry. + pub outputs: args::Target, } -impl fmt::Display for Decision { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Decision::Hit => write!(f, "cache hit"), - Decision::Miss(miss) => write!(f, "cache miss: {miss}"), - } - } +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GrammarHash(Arc); + +struct HashWriter<'a>(&'a mut Sha1); + +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Key(Arc); + +/// Details explaining why a cache entry cannot satisfy a requested parser build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Miss { + pub reasons: Vec, } -impl Miss { - #[must_use] - pub fn short_message(&self) -> String { - match self.reasons.as_slice() { - [] => "cache changed".to_string(), - [reason] => reason.short_message().to_string(), - reasons => { - let labels = reasons - .iter() - .map(MissReason::short_label) - .collect::>() - .join(", "); - format!("cache changed: {labels}") - } - } - } +/// File-backed storage for a [`Db`]. +#[derive(Debug, Clone)] +pub struct Store { + file: PathBuf, } -impl fmt::Display for Miss { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for (index, reason) in self.reasons.iter().enumerate() { - if index > 0 { - write!(f, "; ")?; +/// Represents a "Delta" to be applied to the cache after a successful build +#[derive(Debug, Clone)] +pub struct Update { + pub entry: Entry, + pub name: Key, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Hash the contents of a file using SHA-1 and return the hex string. +pub async fn hash_file(path: &Path) -> Result { + let mut file = tokio::fs::File::open(path) + .await + .with_context(|| format!("Opening file for hashing: {}", path.display()))?; + + let mut hasher = Sha1::new(); + + tokio::io::copy(&mut file, &mut HashWriter(&mut hasher)) + .await + .with_context(|| format!("Reading file for hashing: {}", path.display()))?; + + let result = hasher.finalize(); + let mut hex = String::with_capacity(result.len() * 2); + for byte in result { + write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail"); + } + Ok(GrammarHash::from(hex)) +} + +fn sync_directory(path: &Path) -> std::io::Result<()> { + std::fs::File::open(path)?.sync_all() +} + +/// Verify that every path in `artifacts` exists as a regular file. +/// +/// Returns [`Decision::Hit`] when all artifacts are present, or a +/// [`Decision::Miss`] listing every missing or non-file path. +pub async fn verify_artifacts(artifacts: Vec) -> Decision { + let mut reasons = Vec::new(); + + for path in artifacts { + match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() => {} + Ok(_) => reasons.push(MissReason::ArtifactNotFile { path }), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + reasons.push(MissReason::ArtifactMissing { path }); } - write!(f, "{reason}")?; + Err(err) => reasons.push(MissReason::ArtifactInaccessible { + path, + error: err.to_string(), + }), } - Ok(()) } + + Decision::from_reasons(reasons) } -impl MissReason { +fn write_cache_file_atomically(file: &Path, contents: &str) -> Result<()> { + let parent = file.parent().ok_or_else(|| Error::Message { + message: format!( + "Cache file path has no parent directory: {}", + file.display() + ), + })?; + + std::fs::create_dir_all(parent) + .with_context(|| format!("Creating cache directory {}", parent.display()))?; + + let mut temp = tempfile::Builder::new() + .prefix(".cache.toml.") + .suffix(".tmp") + .tempfile_in(parent) + .with_context(|| format!("Creating temporary cache file in {}", parent.display()))?; + + temp.write_all(contents.as_bytes()) + .with_context(|| format!("Writing temporary cache file {}", temp.path().display()))?; + temp.as_file_mut() + .sync_all() + .with_context(|| format!("Syncing temporary cache file {}", temp.path().display()))?; + + temp.persist(file) + .map_err(|err| err.error) + .with_context(|| format!("Installing cache file to {}", file.display()))?; + + if let Err(err) = sync_directory(parent) { + debug!( + "Could not sync cache directory {} after saving {}: {err}", + parent.display(), + file.display() + ); + } + + Ok(()) +} + +// ============================================================ +// Impls +// ============================================================ + +impl AsyncWrite for HashWriter<'_> { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.0.update(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl BuildRecipe { #[must_use] - pub fn short_label(&self) -> &'static str { - match self { - Self::MissingEntry => "missing", - Self::CacheIgnored => "ignored", - Self::HashChanged { .. } => "hash", - Self::RepoChanged { .. } => "repo", - Self::RefChanged { .. } => "ref", - Self::RevisionChanged { .. } => "revision", - Self::TreeSitterChanged { .. } => "tree-sitter", - Self::BuildScriptChanged => "script", - Self::PrefixChanged { .. } => "prefix", - Self::OutputsMissing { .. } => "outputs", - Self::ArtifactMissing { .. } - | Self::ArtifactNotFile { .. } - | Self::ArtifactInaccessible { .. } => "artifact", + pub fn from_spec(spec: &build::Spec) -> Self { + Self { + build_script: spec.build_script.clone(), + git_ref: spec.git_ref.clone(), + prefix: spec.prefix.clone(), + repo: spec.repo.clone(), + tree_sitter: spec.tree_sitter.clone(), } } +} +impl Db { + /// Clear all entries. + pub fn clear(&mut self) { + self.parsers.clear(); + } + + /// Get cache entry for a parser. #[must_use] - pub fn short_message(&self) -> &'static str { - match self { - Self::MissingEntry => "not cached", - Self::CacheIgnored => "cache ignored", - Self::HashChanged { .. } => "grammar changed", - Self::RepoChanged { .. } => "repo changed", - Self::RefChanged { .. } => "git ref changed", - Self::RevisionChanged { .. } => "git ref resolved commit changed", - Self::TreeSitterChanged { .. } => "tree-sitter changed", - Self::BuildScriptChanged => "build script changed", - Self::PrefixChanged { .. } => "prefix changed", - Self::OutputsMissing { .. } => "requested output not cached", - Self::ArtifactMissing { .. } => "artifact missing", - Self::ArtifactNotFile { .. } => "artifact invalid", - Self::ArtifactInaccessible { .. } => "artifact inaccessible", + pub fn get(&self, name: &Key) -> Option<&Entry> { + self.parsers.get(name) + } + + /// True when the cache contains at least one entry for `language` + /// that was built from the same repository and git ref. + /// + /// The tree-sitter CLI version, build script, prefix, and target are + /// intentionally excluded — they affect artefact validity (checked by + /// [`needs_rebuild`]) but not whether a prior checkout of the parser + /// source is still usable. + #[must_use] + pub fn has_compatible_entry_for_language( + &self, + language: &parser::LanguageName, + spec: &build::Spec, + ) -> bool { + let prefix = Key::language_prefix(language); + self.parsers.iter().any(|(key, entry)| { + key.as_str().starts_with(&prefix) + && entry.recipe.repo == spec.repo + && entry.recipe.git_ref == spec.git_ref + }) + } + + /// Check if a parser needs rebuilding by comparing grammar hash and build definition. + #[must_use] + pub fn needs_rebuild( + &self, + name: &Key, + hash: &GrammarHash, + spec: &build::Spec, + revision: &Revision, + ) -> bool { + self.rebuild_decision(name, hash, spec, revision) + .needs_rebuild() + } + + /// Explain whether a parser cache entry can satisfy the requested build. + pub fn rebuild_decision( + &self, + name: &Key, + hash: &GrammarHash, + spec: &build::Spec, + revision: &Revision, + ) -> Decision { + let decision = match self.get(name) { + None => Decision::miss(MissReason::MissingEntry), + Some(entry) => entry.rebuild_decision(hash, spec, revision), + }; + + debug!("Cache decision for {name}: {decision}"); + decision + } + + /// Insert or update a parser cache entry. + pub fn set(&mut self, name: Key, mut entry: Entry) { + if let Some(existing) = self.parsers.get(&name) { + if existing.same_subject(&entry) { + entry.outputs = existing.outputs.union(entry.outputs); + } } + + self.parsers.insert(name, entry); } } -impl fmt::Display for MissReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +impl Decision { + #[must_use] + pub fn from_reasons(reasons: Vec) -> Self { + if reasons.is_empty() { + Self::Hit + } else { + Self::Miss(Miss { reasons }) + } + } + + #[must_use] + pub fn is_hit(&self) -> bool { + matches!(self, Self::Hit) + } + + #[must_use] + pub fn miss(reason: MissReason) -> Self { + Self::Miss(Miss { + reasons: vec![reason], + }) + } + + #[must_use] + pub fn needs_rebuild(&self) -> bool { + !self.is_hit() + } + + #[must_use] + pub fn short_message(&self) -> String { match self { - Self::MissingEntry => write!(f, "missing cache entry"), - Self::CacheIgnored => write!(f, "cache ignored"), - Self::HashChanged { cached, current } => { - write!(f, "grammar hash changed cached={cached} current={current}") - } - Self::RepoChanged { cached, current } => { - write!(f, "repo changed cached={cached} current={current}") - } - Self::RefChanged { cached, current } => write!( - f, - "git ref changed cached={} current={}", - cached.requested().as_str(), - current.requested().as_str() - ), - Self::RevisionChanged { cached, current } => { - write!(f, "revision changed cached={cached} current={current}") - } - Self::TreeSitterChanged { cached, current } => write!( - f, - "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", - cached.version, - cached.repo, - cached.platform, - current.version, - current.repo, - current.platform - ), - Self::BuildScriptChanged => write!(f, "build script changed"), - Self::PrefixChanged { cached, current } => { - write!(f, "prefix changed cached={cached:?} current={current:?}") - } - Self::OutputsMissing { - available, - requested, - } => { - write!( - f, - "requested output not cached available={available:?} requested={requested:?}" - ) - } - Self::ArtifactMissing { path } => { - write!(f, "artifact missing path={}", path.display()) - } - Self::ArtifactNotFile { path } => { - write!(f, "artifact is not a regular file path={}", path.display()) - } - Self::ArtifactInaccessible { path, error } => { - write!(f, "artifact inaccessible path={} error={error}", path.display()) - } + Self::Hit => "cache hit".to_string(), + Self::Miss(miss) => miss.short_message(), } } } @@ -459,36 +444,233 @@ impl Entry { requested: spec.target, }); } - - Decision::from_reasons(reasons) + + Decision::from_reasons(reasons) + } + + #[must_use] + pub fn same_subject(&self, other: &Self) -> bool { + self.hash == other.hash && self.revision == other.revision && self.recipe == other.recipe + } +} + +impl fmt::Display for Decision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Decision::Hit => write!(f, "cache hit"), + Decision::Miss(miss) => write!(f, "cache miss: {miss}"), + } + } +} + +impl fmt::Display for GrammarHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl fmt::Display for Miss { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, reason) in self.reasons.iter().enumerate() { + if index > 0 { + write!(f, "; ")?; + } + write!(f, "{reason}")?; + } + Ok(()) + } +} + +impl fmt::Display for MissReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingEntry => write!(f, "missing cache entry"), + Self::CacheIgnored => write!(f, "cache ignored"), + Self::HashChanged { cached, current } => { + write!(f, "grammar hash changed cached={cached} current={current}") + } + Self::RepoChanged { cached, current } => { + write!(f, "repo changed cached={cached} current={current}") + } + Self::RefChanged { cached, current } => write!( + f, + "git ref changed cached={} current={}", + cached.requested().as_str(), + current.requested().as_str() + ), + Self::RevisionChanged { cached, current } => { + write!(f, "revision changed cached={cached} current={current}") + } + Self::TreeSitterChanged { cached, current } => write!( + f, + "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", + cached.version, + cached.repo, + cached.platform, + current.version, + current.repo, + current.platform + ), + Self::BuildScriptChanged => write!(f, "build script changed"), + Self::PrefixChanged { cached, current } => { + write!(f, "prefix changed cached={cached:?} current={current:?}") + } + Self::OutputsMissing { + available, + requested, + } => { + write!( + f, + "requested output not cached available={available:?} requested={requested:?}" + ) + } + Self::ArtifactMissing { path } => { + write!(f, "artifact missing path={}", path.display()) + } + Self::ArtifactNotFile { path } => { + write!(f, "artifact is not a regular file path={}", path.display()) + } + Self::ArtifactInaccessible { path, error } => { + write!(f, "artifact inaccessible path={} error={error}", path.display()) + } + } + } +} + +impl fmt::Display for Revision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stable => write!(f, "stable"), + Self::Moving { commit } => write!(f, "moving commit={}", commit.as_str()), + } + } +} + +impl From<&str> for GrammarHash { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +impl From<&str> for Key { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +impl From for GrammarHash { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From for Key { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl GrammarHash { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Key { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn language_prefix(language: &parser::LanguageName) -> String { + format!("{language}/") + } + + #[must_use] + pub fn new(language: &parser::LanguageName, grammar: &parser::GrammarName) -> Self { + Self(Arc::from(format!("{language}/{grammar}"))) + } +} + +impl Miss { + #[must_use] + pub fn short_message(&self) -> String { + match self.reasons.as_slice() { + [] => "cache changed".to_string(), + [reason] => reason.short_message().to_string(), + reasons => { + let labels = reasons + .iter() + .map(MissReason::short_label) + .collect::>() + .join(", "); + format!("cache changed: {labels}") + } + } } +} +impl MissReason { #[must_use] - pub fn same_subject(&self, other: &Self) -> bool { - self.hash == other.hash && self.revision == other.revision && self.recipe == other.recipe + pub fn short_label(&self) -> &'static str { + match self { + Self::MissingEntry => "missing", + Self::CacheIgnored => "ignored", + Self::HashChanged { .. } => "hash", + Self::RepoChanged { .. } => "repo", + Self::RefChanged { .. } => "ref", + Self::RevisionChanged { .. } => "revision", + Self::TreeSitterChanged { .. } => "tree-sitter", + Self::BuildScriptChanged => "script", + Self::PrefixChanged { .. } => "prefix", + Self::OutputsMissing { .. } => "outputs", + Self::ArtifactMissing { .. } + | Self::ArtifactNotFile { .. } + | Self::ArtifactInaccessible { .. } => "artifact", + } } -} -/// Represents a "Delta" to be applied to the cache after a successful build -#[derive(Debug, Clone)] -pub struct Update { - pub entry: Entry, - pub name: Key, + #[must_use] + pub fn short_message(&self) -> &'static str { + match self { + Self::MissingEntry => "not cached", + Self::CacheIgnored => "cache ignored", + Self::HashChanged { .. } => "grammar changed", + Self::RepoChanged { .. } => "repo changed", + Self::RefChanged { .. } => "git ref changed", + Self::RevisionChanged { .. } => "git ref resolved commit changed", + Self::TreeSitterChanged { .. } => "tree-sitter changed", + Self::BuildScriptChanged => "build script changed", + Self::PrefixChanged { .. } => "prefix changed", + Self::OutputsMissing { .. } => "requested output not cached", + Self::ArtifactMissing { .. } => "artifact missing", + Self::ArtifactNotFile { .. } => "artifact invalid", + Self::ArtifactInaccessible { .. } => "artifact inaccessible", + } + } } -impl Store { +impl Revision { #[must_use] - pub fn new(build_dir: &BuildDir) -> Self { - Self { - file: build_dir.cache_file(), - } + pub const fn stable() -> Self { + Self::Stable } #[must_use] - pub fn path(&self) -> &Path { - &self.file + pub const fn moving(commit: git::Sha) -> Self { + Self::Moving { commit } } +} +impl Store { /// Delete the cache file from disk. pub async fn delete(&self) -> Result<()> { match tokio::fs::metadata(&self.file).await { @@ -526,6 +708,18 @@ impl Store { .with_context(|| format!("Parsing cache file at {}", self.file.display())) } + #[must_use] + pub fn new(build_dir: &BuildDir) -> Self { + Self { + file: build_dir.cache_file(), + } + } + + #[must_use] + pub fn path(&self) -> &Path { + &self.file + } + /// Atomically save the cache to disk. pub async fn save(&self, db: &Db) -> Result<()> { let contents = toml::to_string_pretty(db).context("Serializing cache to TOML")?; @@ -541,188 +735,6 @@ impl Store { } } -impl Db { - /// Clear all entries. - pub fn clear(&mut self) { - self.parsers.clear(); - } - - /// Get cache entry for a parser. - #[must_use] - pub fn get(&self, name: &Key) -> Option<&Entry> { - self.parsers.get(name) - } - - /// Explain whether a parser cache entry can satisfy the requested build. - pub fn rebuild_decision( - &self, - name: &Key, - hash: &GrammarHash, - spec: &build::Spec, - revision: &Revision, - ) -> Decision { - let decision = match self.get(name) { - None => Decision::miss(MissReason::MissingEntry), - Some(entry) => entry.rebuild_decision(hash, spec, revision), - }; - - debug!("Cache decision for {name}: {decision}"); - decision - } - - /// Check if a parser needs rebuilding by comparing grammar hash and build definition. - #[must_use] - pub fn needs_rebuild( - &self, - name: &Key, - hash: &GrammarHash, - spec: &build::Spec, - revision: &Revision, - ) -> bool { - self.rebuild_decision(name, hash, spec, revision) - .needs_rebuild() - } - - /// True when the cache contains at least one entry for `language` - /// that was built from the same repository and git ref. - /// - /// The tree-sitter CLI version, build script, prefix, and target are - /// intentionally excluded — they affect artefact validity (checked by - /// [`needs_rebuild`]) but not whether a prior checkout of the parser - /// source is still usable. - #[must_use] - pub fn has_compatible_entry_for_language( - &self, - language: &parser::LanguageName, - spec: &build::Spec, - ) -> bool { - let prefix = Key::language_prefix(language); - self.parsers.iter().any(|(key, entry)| { - key.as_str().starts_with(&prefix) - && entry.recipe.repo == spec.repo - && entry.recipe.git_ref == spec.git_ref - }) - } - - /// Insert or update a parser cache entry. - pub fn set(&mut self, name: Key, mut entry: Entry) { - if let Some(existing) = self.parsers.get(&name) { - if existing.same_subject(&entry) { - entry.outputs = existing.outputs.union(entry.outputs); - } - } - - self.parsers.insert(name, entry); - } -} - -fn write_cache_file_atomically(file: &Path, contents: &str) -> Result<()> { - let parent = file.parent().ok_or_else(|| Error::Message { - message: format!( - "Cache file path has no parent directory: {}", - file.display() - ), - })?; - - std::fs::create_dir_all(parent) - .with_context(|| format!("Creating cache directory {}", parent.display()))?; - - let mut temp = tempfile::Builder::new() - .prefix(".cache.toml.") - .suffix(".tmp") - .tempfile_in(parent) - .with_context(|| format!("Creating temporary cache file in {}", parent.display()))?; - - temp.write_all(contents.as_bytes()) - .with_context(|| format!("Writing temporary cache file {}", temp.path().display()))?; - temp.as_file_mut() - .sync_all() - .with_context(|| format!("Syncing temporary cache file {}", temp.path().display()))?; - - temp.persist(file) - .map_err(|err| err.error) - .with_context(|| format!("Installing cache file to {}", file.display()))?; - - if let Err(err) = sync_directory(parent) { - debug!( - "Could not sync cache directory {} after saving {}: {err}", - parent.display(), - file.display() - ); - } - - Ok(()) -} - -fn sync_directory(path: &Path) -> std::io::Result<()> { - std::fs::File::open(path)?.sync_all() -} - -/// Verify that every path in `artifacts` exists as a regular file. -/// -/// Returns [`Decision::Hit`] when all artifacts are present, or a -/// [`Decision::Miss`] listing every missing or non-file path. -pub async fn verify_artifacts(artifacts: Vec) -> Decision { - let mut reasons = Vec::new(); - - for path in artifacts { - match tokio::fs::metadata(&path).await { - Ok(metadata) if metadata.is_file() => {} - Ok(_) => reasons.push(MissReason::ArtifactNotFile { path }), - Err(err) if err.kind() == io::ErrorKind::NotFound => { - reasons.push(MissReason::ArtifactMissing { path }); - } - Err(err) => reasons.push(MissReason::ArtifactInaccessible { - path, - error: err.to_string(), - }), - } - } - - Decision::from_reasons(reasons) -} - -/// Hash the contents of a file using SHA-1 and return the hex string. -pub async fn hash_file(path: &Path) -> Result { - let mut file = tokio::fs::File::open(path) - .await - .with_context(|| format!("Opening file for hashing: {}", path.display()))?; - - let mut hasher = Sha1::new(); - - tokio::io::copy(&mut file, &mut HashWriter(&mut hasher)) - .await - .with_context(|| format!("Reading file for hashing: {}", path.display()))?; - - let result = hasher.finalize(); - let mut hex = String::with_capacity(result.len() * 2); - for byte in result { - write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail"); - } - Ok(GrammarHash::from(hex)) -} - -struct HashWriter<'a>(&'a mut Sha1); - -impl AsyncWrite for HashWriter<'_> { - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - self.0.update(buf); - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/columns.rs b/src/columns.rs index ad5dca8..677fb49 100644 --- a/src/columns.rs +++ b/src/columns.rs @@ -9,6 +9,13 @@ pub enum Layout { Plain, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct DenseLayout { + rows: usize, + cols: usize, + widths: Vec, +} + /// Options controlling [`format`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Options<'a> { @@ -26,19 +33,28 @@ pub struct Options<'a> { pub line_ending: &'a str, } -impl<'a> Options<'a> { - /// Options matching `git column --mode=always --indent= --width=`. - #[must_use] - pub const fn git(indent: &'a str, width: usize) -> Self { - Self { - layout: Layout::Column, - dense: false, - width, - padding: 1, - indent, - line_ending: "\n", +fn compute_column_widths( + item_widths: &[usize], + layout: Layout, + rows: usize, + cols: usize, +) -> Vec { + let mut widths = vec![0; cols]; + + for (x, column_width) in widths.iter_mut().enumerate() { + for y in 0..rows { + let item_index = linear_index(layout, rows, cols, x, y); + if let Some(width) = item_widths.get(item_index) { + *column_width = (*column_width).max(*width); + } } } + + widths +} + +fn display_width(value: &str) -> usize { + console::measure_text_width(value) } /// Format a list of cells using the same row/column rules as `git column`. @@ -97,13 +113,6 @@ pub fn format_git>(items: &[S], indent: &str, width: usize) -> Str format(items, Options::git(indent, width)) } -#[derive(Debug, Clone, PartialEq, Eq)] -struct DenseLayout { - rows: usize, - cols: usize, - widths: Vec, -} - fn format_plain>(items: &[S], options: Options<'_>) -> String { let mut output = String::new(); @@ -157,6 +166,33 @@ fn format_table>( output } +const fn is_last_cell_in_row( + layout: Layout, + item_index: usize, + item_count: usize, + rows: usize, + cols: usize, + x: usize, +) -> bool { + match layout { + Layout::Column => item_index + rows >= item_count, + Layout::Row => x == cols - 1 || item_index == item_count - 1, + Layout::Plain => unreachable!(), + } +} + +const fn linear_index(layout: Layout, rows: usize, cols: usize, x: usize, y: usize) -> usize { + match layout { + Layout::Column => x * rows + y, + Layout::Row => y * cols + x, + Layout::Plain => unreachable!(), + } +} + +fn push_spaces(output: &mut String, count: usize) { + output.extend(std::iter::repeat_n(' ', count)); +} + fn shrink_columns( item_widths: &[usize], options: Options<'_>, @@ -195,55 +231,19 @@ fn shrink_columns( } } -fn compute_column_widths( - item_widths: &[usize], - layout: Layout, - rows: usize, - cols: usize, -) -> Vec { - let mut widths = vec![0; cols]; - - for (x, column_width) in widths.iter_mut().enumerate() { - for y in 0..rows { - let item_index = linear_index(layout, rows, cols, x, y); - if let Some(width) = item_widths.get(item_index) { - *column_width = (*column_width).max(*width); - } +impl<'a> Options<'a> { + /// Options matching `git column --mode=always --indent= --width=`. + #[must_use] + pub const fn git(indent: &'a str, width: usize) -> Self { + Self { + layout: Layout::Column, + dense: false, + width, + padding: 1, + indent, + line_ending: "\n", } } - - widths -} - -const fn linear_index(layout: Layout, rows: usize, cols: usize, x: usize, y: usize) -> usize { - match layout { - Layout::Column => x * rows + y, - Layout::Row => y * cols + x, - Layout::Plain => unreachable!(), - } -} - -const fn is_last_cell_in_row( - layout: Layout, - item_index: usize, - item_count: usize, - rows: usize, - cols: usize, - x: usize, -) -> bool { - match layout { - Layout::Column => item_index + rows >= item_count, - Layout::Row => x == cols - 1 || item_index == item_count - 1, - Layout::Plain => unreachable!(), - } -} - -fn display_width(value: &str) -> usize { - console::measure_text_width(value) -} - -fn push_spaces(output: &mut String, count: usize) { - output.extend(std::iter::repeat_n(' ', count)); } #[cfg(test)] From 70714cdb0029a934b46a691b16dc79a1249e29ec Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 09:55:20 +0200 Subject: [PATCH 66/88] style: config+display: sort --- src/config.rs | 332 ++++++++++----------- src/display.rs | 778 ++++++++++++++++++++++++------------------------- 2 files changed, 545 insertions(+), 565 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1df6a24..674496f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,8 +12,10 @@ use tracing::debug; use crate::{args, columns, Result, ResultExt}; -// ── Arg ID constants for provenance tracking ──────────────────────────── -// +// ============================================================ +// Constants +// ============================================================ + // clap derive uses the Rust field name as the arg ID, not the long flag // name. These constants match the field names so `ArgMatches::value_source` // can look them up. @@ -35,7 +37,23 @@ const ARG_TS_PLATFORM: &str = "platform"; const ARG_TS_REPO: &str = "repo"; const ARG_UNLOCK_TIMEOUT: &str = "unlock_timeout"; -// ── Build CLI argument structs ───────────────────────────────────────── +// ============================================================ +// Enums +// ============================================================ + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Source { + #[default] + BuiltInDefault, + ConfigFile, + Environment, + CommandLine, +} + +// ============================================================ +// Structs +// ============================================================ /// CLI arguments for the `build` subcommand, defined via derive so the arg /// definitions and value extraction stay in sync. @@ -104,6 +122,23 @@ pub struct BuildArgs { pub unlock_timeout: Option, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct BuildProvenance { + pub build_dir: Source, + pub force: Source, + pub fresh: Source, + pub languages: Source, + pub jobs: Source, + pub out_dir: Source, + pub parsers: Source, + pub prefix: Source, + pub show_config: Source, + pub target: Source, + pub tree_sitter: TreeSitterProvenance, + pub unlock_timeout: Source, +} + /// Nested CLI arguments for tree-sitter configuration. #[derive(clap::Args, Clone, Debug, Default)] pub struct TreeSitterArgs { @@ -115,28 +150,6 @@ pub struct TreeSitterArgs { pub repo: Option, } -// ── Source / provenance types ────────────────────────────────────────── - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum Source { - #[default] - BuiltInDefault, - ConfigFile, - Environment, - CommandLine, -} - -impl Source { - fn from_value_source(source: ValueSource) -> Option { - match source { - ValueSource::CommandLine => Some(Self::CommandLine), - ValueSource::EnvVariable => Some(Self::Environment), - _ => None, - } - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct TreeSitterProvenance { @@ -145,24 +158,15 @@ pub struct TreeSitterProvenance { pub repo: Source, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct BuildProvenance { - pub build_dir: Source, - pub force: Source, - pub fresh: Source, - pub languages: Source, - pub jobs: Source, - pub out_dir: Source, - pub parsers: Source, - pub prefix: Source, - pub show_config: Source, - pub target: Source, - pub tree_sitter: TreeSitterProvenance, - pub unlock_timeout: Source, -} +// ============================================================ +// Free functions +// ============================================================ -// ── Configuration resolution ─────────────────────────────────────────── +fn apply_opt(field: &mut T, value: Option) { + if let Some(v) = value { + *field = v; + } +} pub fn current(config: &Path, matches: Option<&ArgMatches>) -> Result { let (cmd, _provenance) = current_with_provenance(config, matches)?; @@ -194,67 +198,12 @@ pub fn current_with_provenance( Ok((command, provenance)) } -fn merge( - defaults: args::BuildCommand, - file: args::OptionalBuildCommand, - cli: args::OptionalBuildCommand, -) -> args::BuildCommand { - let mut cmd = defaults; - apply_opt(&mut cmd.build_dir, file.build_dir); - apply_opt(&mut cmd.build_dir, cli.build_dir); - apply_opt(&mut cmd.force, file.force); - apply_opt(&mut cmd.force, cli.force); - apply_opt(&mut cmd.fresh, file.fresh); - apply_opt(&mut cmd.fresh, cli.fresh); - apply_opt(&mut cmd.jobs, file.jobs); - apply_opt(&mut cmd.jobs, cli.jobs); - apply_opt(&mut cmd.out_dir, file.out_dir); - apply_opt(&mut cmd.out_dir, cli.out_dir); - if let Some(parsers) = file.parsers { - cmd.parsers = Some(parsers); - } - apply_opt(&mut cmd.prefix, file.prefix); - apply_opt(&mut cmd.prefix, cli.prefix); - apply_opt(&mut cmd.show_config, file.show_config); - apply_opt(&mut cmd.show_config, cli.show_config); - apply_opt(&mut cmd.target, file.target); - apply_opt(&mut cmd.target, cli.target); - apply_opt(&mut cmd.unlock_timeout, file.unlock_timeout); - apply_opt(&mut cmd.unlock_timeout, cli.unlock_timeout); - - apply_opt(&mut cmd.tree_sitter.version, file.tree_sitter.version); - apply_opt(&mut cmd.tree_sitter.version, cli.tree_sitter.version); - apply_opt(&mut cmd.tree_sitter.platform, file.tree_sitter.platform); - apply_opt(&mut cmd.tree_sitter.platform, cli.tree_sitter.platform); - apply_opt(&mut cmd.tree_sitter.repo, file.tree_sitter.repo); - apply_opt(&mut cmd.tree_sitter.repo, cli.tree_sitter.repo); - - if let Some(langs) = cli.languages { - cmd.languages = Some(langs); - } - - cmd -} - -fn apply_opt(field: &mut T, value: Option) { - if let Some(v) = value { - *field = v; - } -} - -fn read_file_overrides(config: &Path) -> Result { - if !config.exists() { - return Ok(args::OptionalBuildCommand::default()); - } - - let contents = fs::read_to_string(config) - .with_context(|| format!("Reading config file {}", config.display()))?; - - if contents.trim().is_empty() { - return Ok(args::OptionalBuildCommand::default()); - } - - toml::from_str(&contents).with_context(|| format!("Parsing config file {}", config.display())) +/// Extract CLI overrides from parsed `ArgMatches` into `OptionalBuildCommand` +/// with provenance tracking. +#[must_use] +pub fn extract_overrides(matches: &ArgMatches) -> (args::OptionalBuildCommand, BuildProvenance) { + let cli = BuildArgs::from_arg_matches(matches).unwrap_or_else(|err| err.exit()); + overrides_from_build_args(cli, matches) } fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenance { @@ -302,6 +251,48 @@ fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenan p } +fn merge( + defaults: args::BuildCommand, + file: args::OptionalBuildCommand, + cli: args::OptionalBuildCommand, +) -> args::BuildCommand { + let mut cmd = defaults; + apply_opt(&mut cmd.build_dir, file.build_dir); + apply_opt(&mut cmd.build_dir, cli.build_dir); + apply_opt(&mut cmd.force, file.force); + apply_opt(&mut cmd.force, cli.force); + apply_opt(&mut cmd.fresh, file.fresh); + apply_opt(&mut cmd.fresh, cli.fresh); + apply_opt(&mut cmd.jobs, file.jobs); + apply_opt(&mut cmd.jobs, cli.jobs); + apply_opt(&mut cmd.out_dir, file.out_dir); + apply_opt(&mut cmd.out_dir, cli.out_dir); + if let Some(parsers) = file.parsers { + cmd.parsers = Some(parsers); + } + apply_opt(&mut cmd.prefix, file.prefix); + apply_opt(&mut cmd.prefix, cli.prefix); + apply_opt(&mut cmd.show_config, file.show_config); + apply_opt(&mut cmd.show_config, cli.show_config); + apply_opt(&mut cmd.target, file.target); + apply_opt(&mut cmd.target, cli.target); + apply_opt(&mut cmd.unlock_timeout, file.unlock_timeout); + apply_opt(&mut cmd.unlock_timeout, cli.unlock_timeout); + + apply_opt(&mut cmd.tree_sitter.version, file.tree_sitter.version); + apply_opt(&mut cmd.tree_sitter.version, cli.tree_sitter.version); + apply_opt(&mut cmd.tree_sitter.platform, file.tree_sitter.platform); + apply_opt(&mut cmd.tree_sitter.platform, cli.tree_sitter.platform); + apply_opt(&mut cmd.tree_sitter.repo, file.tree_sitter.repo); + apply_opt(&mut cmd.tree_sitter.repo, cli.tree_sitter.repo); + + if let Some(langs) = cli.languages { + cmd.languages = Some(langs); + } + + cmd +} + fn merge_provenance(file: &BuildProvenance, cli: &BuildProvenance) -> BuildProvenance { BuildProvenance { build_dir: merge_source(file.build_dir, cli.build_dir), @@ -331,16 +322,6 @@ fn merge_source(file: Source, cli: Source) -> Source { } } -// ── CLI override extraction ──────────────────────────────────────────── - -/// Extract CLI overrides from parsed `ArgMatches` into `OptionalBuildCommand` -/// with provenance tracking. -#[must_use] -pub fn extract_overrides(matches: &ArgMatches) -> (args::OptionalBuildCommand, BuildProvenance) { - let cli = BuildArgs::from_arg_matches(matches).unwrap_or_else(|err| err.exit()); - overrides_from_build_args(cli, matches) -} - fn overrides_from_build_args( cli: BuildArgs, matches: &ArgMatches, @@ -451,21 +432,51 @@ fn overrides_from_build_args( (o, p) } -/// Set a simple `Option` override when the arg's `ValueSource` indicates -/// the user supplied it (CLI or env). -fn set_simple( - matches: &ArgMatches, - id: &str, - value: Option, - field: &mut Option, - provenance: &mut Source, -) { - if let Some(source) = source_for(matches, id) { - if let Some(val) = value { - *field = Some(val); - *provenance = source; - } +#[must_use] +pub fn parse_with_matches() -> (args::Args, ArgMatches) { + let mut cmd = args::Args::command(); + if let Some(build_sub) = cmd.find_subcommand_mut("build") { + *build_sub = BuildArgs::augment_args(build_sub.clone()); + } + let matches = cmd.get_matches(); + let args = args::Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); + (args, matches) +} + +pub fn print_current(command: &args::BuildCommand) -> Result<()> { + println!( + "{}", + toml::to_string(command).context("Generating current TOML config")? + ); + Ok(()) +} + +pub fn print_default() -> Result<()> { + println!( + "{}", + toml::to_string(&args::BuildCommand::default()) + .context("Generating default TOML config")? + ); + Ok(()) +} + +pub fn print_indent(s: &str, indent: &str) { + s.lines().for_each(|line| println!("{indent}{line}")); +} + +fn read_file_overrides(config: &Path) -> Result { + if !config.exists() { + return Ok(args::OptionalBuildCommand::default()); + } + + let contents = fs::read_to_string(config) + .with_context(|| format!("Reading config file {}", config.display()))?; + + if contents.trim().is_empty() { + return Ok(args::OptionalBuildCommand::default()); } + + toml::from_str(&contents).with_context(|| format!("Parsing config file {}", config.display())) } /// Resolve a boolean field that has both a positive (`--force`) and negative @@ -495,16 +506,6 @@ fn resolve_bool( } } -fn source_for(matches: &ArgMatches, id: &str) -> Option { - matches.value_source(id).and_then(Source::from_value_source) -} - -// ── Display helpers ──────────────────────────────────────────────────── - -pub fn print_indent(s: &str, indent: &str) { - s.lines().for_each(|line| println!("{indent}{line}")); -} - pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { match command { args::ConfigCommand::Current => print_current(¤t(config_path, None)?), @@ -512,21 +513,21 @@ pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { } } -pub fn print_current(command: &args::BuildCommand) -> Result<()> { - println!( - "{}", - toml::to_string(command).context("Generating current TOML config")? - ); - Ok(()) -} - -pub fn print_default() -> Result<()> { - println!( - "{}", - toml::to_string(&args::BuildCommand::default()) - .context("Generating default TOML config")? - ); - Ok(()) +/// Set a simple `Option` override when the arg's `ValueSource` indicates +/// the user supplied it (CLI or env). +fn set_simple( + matches: &ArgMatches, + id: &str, + value: Option, + field: &mut Option, + provenance: &mut Source, +) { + if let Some(source) = source_for(matches, id) { + if let Some(val) = value { + *field = Some(val); + *provenance = source; + } + } } pub fn show(command: &args::BuildCommand) -> Result<()> { @@ -546,17 +547,8 @@ pub fn show(command: &args::BuildCommand) -> Result<()> { Ok(()) } -// ── Parsing ──────────────────────────────────────────────────────────── - -#[must_use] -pub fn parse_with_matches() -> (args::Args, ArgMatches) { - let mut cmd = args::Args::command(); - if let Some(build_sub) = cmd.find_subcommand_mut("build") { - *build_sub = BuildArgs::augment_args(build_sub.clone()); - } - let matches = cmd.get_matches(); - let args = args::Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); - (args, matches) +fn source_for(matches: &ArgMatches, id: &str) -> Option { + matches.value_source(id).and_then(Source::from_value_source) } pub fn try_parse_from_with_matches(itr: I) -> StdResult<(args::Args, ArgMatches), clap::Error> @@ -572,3 +564,17 @@ where let args = args::Args::from_arg_matches(&matches)?; Ok((args, matches)) } + +// ============================================================ +// Impls +// ============================================================ + +impl Source { + fn from_value_source(source: ValueSource) -> Option { + match source { + ValueSource::CommandLine => Some(Self::CommandLine), + ValueSource::EnvVariable => Some(Self::Environment), + _ => None, + } + } +} diff --git a/src/display.rs b/src/display.rs index 5d42d04..3da4be7 100644 --- a/src/display.rs +++ b/src/display.rs @@ -12,89 +12,45 @@ use ratatui::text::{Line, Span}; use crate::args::ProgressStyle; use crate::git; -// ── Column geometry ────────────────────────────────────────────── -/// Fixed width of the time column (right-aligned, " 0.00s" … "59:59"). -const TIME_COL_WIDTH: usize = 6; +// ============================================================ +// Constants +// ============================================================ + +/// Width of the icon column (●, ✓, ✗ are all single-width). +const ICON_COL_WIDTH: usize = 1; +/// Minimum width for the message column. +const MIN_MSG_WIDTH: usize = 1; +/// Minimum width of the name column (left-aligned). +const MIN_NAME_WIDTH: usize = 4; /// Minimum width of the git-ref column (left-aligned). const MIN_REF_WIDTH: usize = 4; /// Minimum width of the step column (right-aligned, e.g. "[1/4]"). const MIN_STEP_WIDTH: usize = 5; -/// Minimum width of the name column (left-aligned). -const MIN_NAME_WIDTH: usize = 4; -/// Width of the icon column (●, ✓, ✗ are all single-width). -const ICON_COL_WIDTH: usize = 1; +const MSG_STYLE: Style = Style::new(); +const REF_STYLE: Style = Style::new(); /// Width of a spacer between columns (a single space). const SPACER_WIDTH: usize = 1; -/// Minimum width for the message column. -const MIN_MSG_WIDTH: usize = 1; - -/// Pre-compute the fixed (non-message) portion of a row line. -/// -/// Columns in order: Time _ Ref _ Step _ Icon _ Name _ Msg -/// (5 spacers between 6 columns). -const fn fixed_width(time: usize, rf: usize, step: usize, name: usize) -> usize { - time + SPACER_WIDTH - + rf - + SPACER_WIDTH - + step - + SPACER_WIDTH - + ICON_COL_WIDTH - + SPACER_WIDTH - + name - + SPACER_WIDTH -} - -// --------------------------------------------------------------------------- -// Mode -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Mode { - Fancy, - Plain, -} - -/// Determine the display mode from CLI flags and environment. -#[must_use] -pub fn mode_from_args(progress: &ProgressStyle, verbose: &Verbosity) -> Mode { - let mut mode = match progress { - ProgressStyle::Auto => { - if atty::is(atty::Stream::Stdout) { - Mode::Fancy - } else { - Mode::Plain - } - } - ProgressStyle::Fancy => Mode::Fancy, - ProgressStyle::Plain => Mode::Plain, - }; - - if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { - mode = Mode::Plain; - } - - mode -} +/// Fixed width of the time column (right-aligned, " 0.00s" … "59:59"). +const TIME_COL_WIDTH: usize = 6; +const TIME_STYLE: Style = Style::new(); -// --------------------------------------------------------------------------- -// Item lifecycle + outcome -// --------------------------------------------------------------------------- +// ============================================================ +// Enums +// ============================================================ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SuccessOutcome { - /// Cache hit. - Cached, - /// Not cached; work was needed. - Built, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Column { + Time, + Ref, + Step, + Icon, + Name, + Msg, } -impl SuccessOutcome { - fn color(self) -> Color { - match self { - SuccessOutcome::Cached => Color::Yellow, - SuccessOutcome::Built => Color::Blue, - } - } +pub(crate) enum ItemInfo<'a> { + Repo(&'a RepoEntry), + Grammar(&'a GrammarEntry), } /// Complete lifecycle state for a display row. @@ -117,54 +73,51 @@ pub enum ItemState { Failed, } -impl ItemState { - #[must_use] - pub fn is_live(self) -> bool { - matches!(self, Self::New | Self::InProgress(_)) - } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Mode { + Fancy, + Plain, +} - #[must_use] - pub fn success_outcome(self) -> Option { - match self { - Self::InProgress(outcome) => outcome, - Self::Done(outcome) => Some(outcome), - Self::New | Self::Cancelled | Self::Failed => None, - } - } +#[derive(Debug, Clone)] +pub(crate) enum RowKind { + Repo, + Grammar, +} - fn icon(self) -> &'static str { - match self { - Self::New | Self::InProgress(_) => "●", - Self::Done(_) => "✓", - Self::Cancelled | Self::Failed => "✗", - } - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SuccessOutcome { + /// Cache hit. + Cached, + /// Not cached; work was needed. + Built, +} - fn name_color(self) -> Color { - match self { - Self::Failed => Color::Red, - Self::Cancelled => Color::Yellow, - Self::New | Self::InProgress(None) => Color::DarkGray, - Self::InProgress(Some(outcome)) | Self::Done(outcome) => outcome.color(), - } - } +// ============================================================ +// Structs +// ============================================================ - fn indicator_color(self) -> Color { - match self { - Self::Done(_) => Color::Green, - Self::Cancelled => Color::Yellow, - Self::Failed => Color::Red, - Self::New | Self::InProgress(_) => Color::DarkGray, - } - } +#[derive(Debug, Clone)] +pub(crate) struct CachedLayout { + pub time: usize, + pub ref_: usize, + pub step: usize, + pub name: usize, + /// Total width consumed by non-message columns: time + sp + ref + sp + + /// step + sp + icon(1) + sp + name + sp. + pub fixed_width: usize, } -// --------------------------------------------------------------------------- -// State entries -// --------------------------------------------------------------------------- +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct CellKey { + item_id: ItemId, + column: Column, +} #[derive(Debug, Clone)] -pub(crate) struct RepoEntry { +pub(crate) struct GrammarEntry { + pub repo: Arc, + pub repo_id: Option, pub name: Arc, pub git_ref: git::Ref, pub state: ItemState, @@ -177,17 +130,17 @@ pub(crate) struct RepoEntry { pub frozen_elapsed: Option, } -impl RepoEntry { - pub(crate) fn elapsed(&self) -> Duration { - self.frozen_elapsed - .unwrap_or_else(|| self.started_at.elapsed()) - } +pub(crate) struct GridCache { + cells: HashMap>, + pub dirty_items: HashSet, + pub layout: CachedLayout, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ItemId(NonZeroU64); + #[derive(Debug, Clone)] -pub(crate) struct GrammarEntry { - pub repo: Arc, - pub repo_id: Option, +pub(crate) struct RepoEntry { pub name: Arc, pub git_ref: git::Ref, pub state: ItemState, @@ -200,69 +153,253 @@ pub(crate) struct GrammarEntry { pub frozen_elapsed: Option, } -impl GrammarEntry { - pub(crate) fn elapsed(&self) -> Duration { - self.frozen_elapsed - .unwrap_or_else(|| self.started_at.elapsed()) - } +#[derive(Debug, Clone)] +pub(crate) struct RowSpec { + pub id: ItemId, + pub kind: RowKind, + /// The text to display in the name column (already includes indent). + pub display_name: Arc, + /// Indentation prefix for the name column (applied before padding). + pub indent: &'static str, +} + +pub(crate) struct State { + pub mode: Mode, + pub repos: HashMap, + pub grammars: HashMap, + pub build_dir: PathBuf, + pub out_dir: PathBuf, + footer_build: Line<'static>, + footer_out: Line<'static>, } -// --------------------------------------------------------------------------- -// Column identifiers for the grid cache -// --------------------------------------------------------------------------- +// ============================================================ +// Free functions +// ============================================================ -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum Column { - Time, - Ref, - Step, - Icon, - Name, - Msg, +pub(crate) fn compute_icon_cell(info: &ItemInfo<'_>) -> Span<'static> { + let state = info.state(); + Span::styled( + state.icon(), + Style::default() + .fg(state.indicator_color()) + .add_modifier(Modifier::BOLD), + ) } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct CellKey { - item_id: ItemId, - column: Column, +pub(crate) fn compute_msg_cell( + info: &ItemInfo<'_>, + layout: &CachedLayout, + term_width: usize, +) -> Span<'static> { + let msg_width = term_width + .saturating_sub(layout.fixed_width) + .max(MIN_MSG_WIDTH); + Span::styled(truncate_str(info.msg(), msg_width), MSG_STYLE) } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ItemId(NonZeroU64); +pub(crate) fn compute_name_cell( + display_name: &str, + indent: &str, + info: &ItemInfo<'_>, + layout: &CachedLayout, +) -> Span<'static> { + let full = format!("{indent}{display_name}"); + let padded = format!("{: Self { - Self(value) +pub(crate) fn compute_ref_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { + let ref_str = info.git_ref().short(); + let padded = format!("{:, layout: &CachedLayout) -> Span<'static> { + let step_str = if info.total() > 0 { + format!("[{}/{}]", info.step().min(info.total()), info.total()) + } else { + String::new() + }; + // Right-aligned + let padded = format!("{:>width$}", step_str, width = layout.step); + Span::styled(padded, REF_STYLE) +} + +pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { + let time = format_elapsed_duration(info.elapsed()); + // Right-aligned + let padded = format!("{:>width$}", time, width = layout.time); + Span::styled(padded, TIME_STYLE) +} + +fn count_item_state( + state: ItemState, + cached: &mut usize, + built: &mut usize, + building: &mut usize, + failed: &mut usize, + cancelled: &mut usize, +) { + match state { + ItemState::New | ItemState::InProgress(_) => *building += 1, + ItemState::Done(SuccessOutcome::Cached) => *cached += 1, + ItemState::Done(SuccessOutcome::Built) => *built += 1, + ItemState::Cancelled => *cancelled += 1, + ItemState::Failed => *failed += 1, } +} - #[must_use] - pub(crate) fn next_after(self) -> Self { - let next = self - .0 - .get() - .checked_add(1) - .and_then(NonZeroU64::new) - .expect("display item id exhausted"); - Self(next) +/// Pre-computed dimmed footer lines (static; never change). +fn dim_line(text: String) -> Line<'static> { + Line::from(Span::styled( + text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + )) +} + +/// Pre-compute the fixed (non-message) portion of a row line. +/// +/// Columns in order: Time _ Ref _ Step _ Icon _ Name _ Msg +/// (5 spacers between 6 columns). +const fn fixed_width(time: usize, rf: usize, step: usize, name: usize) -> usize { + time + SPACER_WIDTH + + rf + + SPACER_WIDTH + + step + + SPACER_WIDTH + + ICON_COL_WIDTH + + SPACER_WIDTH + + name + + SPACER_WIDTH +} + +fn format_elapsed_duration(dur: Duration) -> String { + let secs = dur.as_secs_f64(); + if secs < 60.0 { + format!("{secs:>5.2}s") + } else { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let mins = secs as u64 / 60; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let remaining = secs as u64 % 60; + format!("{mins:>3}:{remaining:02}") } } -// --------------------------------------------------------------------------- -// Cached layout (column widths) -// --------------------------------------------------------------------------- +/// Determine the display mode from CLI flags and environment. +#[must_use] +pub fn mode_from_args(progress: &ProgressStyle, verbose: &Verbosity) -> Mode { + let mut mode = match progress { + ProgressStyle::Auto => { + if atty::is(atty::Stream::Stdout) { + Mode::Fancy + } else { + Mode::Plain + } + } + ProgressStyle::Fancy => Mode::Fancy, + ProgressStyle::Plain => Mode::Plain, + }; -#[derive(Debug, Clone)] -pub(crate) struct CachedLayout { - pub time: usize, - pub ref_: usize, - pub step: usize, - pub name: usize, - /// Total width consumed by non-message columns: time + sp + ref + sp + - /// step + sp + icon(1) + sp + name + sp. - pub fixed_width: usize, + if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { + mode = Mode::Plain; + } + + mode +} + +fn push_summary_building(spans: &mut Vec>, count: usize) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{count} building"), + Style::default().fg(if count > 0 { + Color::White + } else { + Color::DarkGray + }), + )); +} + +fn push_summary_cancelled(spans: &mut Vec>, count: usize, _leading: bool) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗ {count} cancelled"), + Style::default().fg(if count > 0 { + Color::Yellow + } else { + Color::DarkGray + }), + )); +} + +fn push_summary_failed(spans: &mut Vec>, count: usize) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗ {count} failed"), + Style::default().fg(if count > 0 { + Color::Red + } else { + Color::DarkGray + }), + )); +} + +fn push_summary_success( + spans: &mut Vec>, + count: usize, + label: &'static str, + label_color: Color, + leading: bool, +) { + if !leading { + spans.push(Span::raw(" ")); + } + + if count > 0 { + spans.push(Span::styled( + format!("✓ {count} "), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + label, + Style::default() + .fg(label_color) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!("✓ {count} {label}"), + Style::default().fg(Color::DarkGray), + )); + } } +fn truncate_str(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else if max <= 1 { + "…".to_string() + } else { + let mut truncated: String = s.chars().take(max - 1).collect(); + truncated.push('…'); + truncated + } +} + +// ============================================================ +// Impls +// ============================================================ + impl Default for CachedLayout { fn default() -> Self { Self { @@ -290,14 +427,11 @@ impl CachedLayout { } } -// --------------------------------------------------------------------------- -// Grid cache -// --------------------------------------------------------------------------- - -pub(crate) struct GridCache { - cells: HashMap>, - pub dirty_items: HashSet, - pub layout: CachedLayout, +impl GrammarEntry { + pub(crate) fn elapsed(&self) -> Duration { + self.frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } } impl GridCache { @@ -342,39 +476,28 @@ impl GridCache { } /// Invalidate all cached cells for a given column (e.g. when layout width - /// for that column changes). - pub fn invalidate_column(&mut self, column: Column) { - self.cells.retain(|key, _| key.column != column); - } -} - -// --------------------------------------------------------------------------- -// Row specification -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -pub(crate) enum RowKind { - Repo, - Grammar, -} - -#[derive(Debug, Clone)] -pub(crate) struct RowSpec { - pub id: ItemId, - pub kind: RowKind, - /// The text to display in the name column (already includes indent). - pub display_name: Arc, - /// Indentation prefix for the name column (applied before padding). - pub indent: &'static str, + /// for that column changes). + pub fn invalidate_column(&mut self, column: Column) { + self.cells.retain(|key, _| key.column != column); + } } -// --------------------------------------------------------------------------- -// Unified item info (abstracts over RepoEntry / GrammarEntry) -// --------------------------------------------------------------------------- +impl ItemId { + #[must_use] + pub(crate) fn new(value: NonZeroU64) -> Self { + Self(value) + } -pub(crate) enum ItemInfo<'a> { - Repo(&'a RepoEntry), - Grammar(&'a GrammarEntry), + #[must_use] + pub(crate) fn next_after(self) -> Self { + let next = self + .0 + .get() + .checked_add(1) + .and_then(NonZeroU64::new) + .expect("display item id exhausted"); + Self(next) + } } impl ItemInfo<'_> { @@ -421,97 +544,53 @@ impl ItemInfo<'_> { } } -// --------------------------------------------------------------------------- -// Cell factories — each produces a fully padded, fully styled Span -// --------------------------------------------------------------------------- - -const TIME_STYLE: Style = Style::new(); -const REF_STYLE: Style = Style::new(); -const MSG_STYLE: Style = Style::new(); - -pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { - let time = format_elapsed_duration(info.elapsed()); - // Right-aligned - let padded = format!("{:>width$}", time, width = layout.time); - Span::styled(padded, TIME_STYLE) -} - -pub(crate) fn compute_ref_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { - let ref_str = info.git_ref().short(); - let padded = format!("{:, layout: &CachedLayout) -> Span<'static> { - let step_str = if info.total() > 0 { - format!("[{}/{}]", info.step().min(info.total()), info.total()) - } else { - String::new() - }; - // Right-aligned - let padded = format!("{:>width$}", step_str, width = layout.step); - Span::styled(padded, REF_STYLE) -} - -pub(crate) fn compute_icon_cell(info: &ItemInfo<'_>) -> Span<'static> { - let state = info.state(); - Span::styled( - state.icon(), - Style::default() - .fg(state.indicator_color()) - .add_modifier(Modifier::BOLD), - ) -} +impl ItemState { + #[must_use] + pub fn is_live(self) -> bool { + matches!(self, Self::New | Self::InProgress(_)) + } -pub(crate) fn compute_name_cell( - display_name: &str, - indent: &str, - info: &ItemInfo<'_>, - layout: &CachedLayout, -) -> Span<'static> { - let full = format!("{indent}{display_name}"); - let padded = format!("{: Option { + match self { + Self::InProgress(outcome) => outcome, + Self::Done(outcome) => Some(outcome), + Self::New | Self::Cancelled | Self::Failed => None, + } + } -pub(crate) fn compute_msg_cell( - info: &ItemInfo<'_>, - layout: &CachedLayout, - term_width: usize, -) -> Span<'static> { - let msg_width = term_width - .saturating_sub(layout.fixed_width) - .max(MIN_MSG_WIDTH); - Span::styled(truncate_str(info.msg(), msg_width), MSG_STYLE) -} + fn icon(self) -> &'static str { + match self { + Self::New | Self::InProgress(_) => "●", + Self::Done(_) => "✓", + Self::Cancelled | Self::Failed => "✗", + } + } -// --------------------------------------------------------------------------- -// State — the source of truth -// --------------------------------------------------------------------------- + fn name_color(self) -> Color { + match self { + Self::Failed => Color::Red, + Self::Cancelled => Color::Yellow, + Self::New | Self::InProgress(None) => Color::DarkGray, + Self::InProgress(Some(outcome)) | Self::Done(outcome) => outcome.color(), + } + } -/// Pre-computed dimmed footer lines (static; never change). -fn dim_line(text: String) -> Line<'static> { - Line::from(Span::styled( - text, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::DIM), - )) + fn indicator_color(self) -> Color { + match self { + Self::Done(_) => Color::Green, + Self::Cancelled => Color::Yellow, + Self::Failed => Color::Red, + Self::New | Self::InProgress(_) => Color::DarkGray, + } + } } -pub(crate) struct State { - pub mode: Mode, - pub repos: HashMap, - pub grammars: HashMap, - pub build_dir: PathBuf, - pub out_dir: PathBuf, - footer_build: Line<'static>, - footer_out: Line<'static>, +impl RepoEntry { + pub(crate) fn elapsed(&self) -> Duration { + self.frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } } impl State { @@ -707,116 +786,11 @@ impl State { } } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn count_item_state( - state: ItemState, - cached: &mut usize, - built: &mut usize, - building: &mut usize, - failed: &mut usize, - cancelled: &mut usize, -) { - match state { - ItemState::New | ItemState::InProgress(_) => *building += 1, - ItemState::Done(SuccessOutcome::Cached) => *cached += 1, - ItemState::Done(SuccessOutcome::Built) => *built += 1, - ItemState::Cancelled => *cancelled += 1, - ItemState::Failed => *failed += 1, - } -} - -fn push_summary_success( - spans: &mut Vec>, - count: usize, - label: &'static str, - label_color: Color, - leading: bool, -) { - if !leading { - spans.push(Span::raw(" ")); - } - - if count > 0 { - spans.push(Span::styled( - format!("✓ {count} "), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - label, - Style::default() - .fg(label_color) - .add_modifier(Modifier::BOLD), - )); - } else { - spans.push(Span::styled( - format!("✓ {count} {label}"), - Style::default().fg(Color::DarkGray), - )); - } -} - -fn push_summary_building(spans: &mut Vec>, count: usize) { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("{count} building"), - Style::default().fg(if count > 0 { - Color::White - } else { - Color::DarkGray - }), - )); -} - -fn push_summary_failed(spans: &mut Vec>, count: usize) { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("✗ {count} failed"), - Style::default().fg(if count > 0 { - Color::Red - } else { - Color::DarkGray - }), - )); -} - -fn truncate_str(s: &str, max: usize) -> String { - if s.chars().count() <= max { - s.to_string() - } else if max <= 1 { - "…".to_string() - } else { - let mut truncated: String = s.chars().take(max - 1).collect(); - truncated.push('…'); - truncated - } -} - -fn format_elapsed_duration(dur: Duration) -> String { - let secs = dur.as_secs_f64(); - if secs < 60.0 { - format!("{secs:>5.2}s") - } else { - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let mins = secs as u64 / 60; - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let remaining = secs as u64 % 60; - format!("{mins:>3}:{remaining:02}") +impl SuccessOutcome { + fn color(self) -> Color { + match self { + SuccessOutcome::Cached => Color::Yellow, + SuccessOutcome::Built => Color::Blue, + } } } - -fn push_summary_cancelled(spans: &mut Vec>, count: usize, _leading: bool) { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("✗ {count} cancelled"), - Style::default().fg(if count > 0 { - Color::Yellow - } else { - Color::DarkGray - }), - )); -} From 954e959751a87268825987cba920a45dfb5cdb27 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:18:35 +0200 Subject: [PATCH 67/88] style: error: sort --- src/error.rs | 306 +++++++++++++++++++++++++++------------------------ 1 file changed, 163 insertions(+), 143 deletions(-) diff --git a/src/error.rs b/src/error.rs index de6e773..9e08bbd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,29 +8,9 @@ use crate::shutdown::Signal; pub type Result = std::result::Result; -#[derive(Debug)] -pub struct Cause(Box); - -impl Cause { - #[must_use] - pub fn new(source: impl Into) -> Self { - Self(Box::new(source.into())) - } - - #[must_use] - pub fn as_error(&self) -> &Error { - &self.0 - } -} - -impl From for Cause -where - E: Into, -{ - fn from(source: E) -> Self { - Self::new(source) - } -} +// ============================================================ +// Traits +// ============================================================ pub trait ResultExt { fn context(self, message: impl Into) -> Result; @@ -38,38 +18,9 @@ pub trait ResultExt { fn with_context(self, message: impl FnOnce() -> String) -> Result; } -impl ResultExt for std::result::Result -where - E: Into, -{ - fn context(self, message: impl Into) -> Result { - self.map_err(|source| Error::Context { - message: message.into(), - source: Cause::new(source), - }) - } - - fn with_context(self, message: impl FnOnce() -> String) -> Result { - self.map_err(|source| Error::Context { - message: message(), - source: Cause::new(source), - }) - } -} - -#[derive(Debug, Display)] -pub enum ParserOp { - #[display("Could not build in {}", dir.display())] - Build { dir: PathBuf }, - #[display("Could not clone to {}", dir.display())] - Clone { dir: PathBuf }, - #[display("Could not discover grammars in {}", dir.display())] - Discover { dir: PathBuf }, - #[display("Could not copy {} to {}", src.display(), dst.display())] - Copy { src: PathBuf, dst: PathBuf }, - #[display("Could not generate in {}", dir.display())] - Generate { dir: PathBuf }, -} +// ============================================================ +// Enums +// ============================================================ /// Main error type for tsdl operations #[derive(Debug)] @@ -113,6 +64,43 @@ pub enum Error { }, } +#[derive(Debug, Display)] +pub enum ParserOp { + #[display("Could not build in {}", dir.display())] + Build { dir: PathBuf }, + #[display("Could not clone to {}", dir.display())] + Clone { dir: PathBuf }, + #[display("Could not discover grammars in {}", dir.display())] + Discover { dir: PathBuf }, + #[display("Could not copy {} to {}", src.display(), dst.display())] + Copy { src: PathBuf, dst: PathBuf }, + #[display("Could not generate in {}", dir.display())] + Generate { dir: PathBuf }, +} + +// ============================================================ +// Structs +// ============================================================ + +#[derive(Debug)] +pub struct Cause(Box); + +// ============================================================ +// Free functions +// ============================================================ + +fn format_build_errors(w: &mut impl fmt::Write, errors: &[Error], indent: usize) -> fmt::Result { + let prefix = " ".repeat(indent); + write!(w, "{prefix}Could not build all parsers.")?; + + for error in errors { + write!(w, "\n\n")?; + error.format(w, indent + 2)?; + } + + Ok(()) +} + fn format_command( w: &mut impl fmt::Write, indent: usize, @@ -186,16 +174,20 @@ fn format_language_collection( Ok(()) } -fn format_build_errors(w: &mut impl fmt::Write, errors: &[Error], indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!(w, "{prefix}Could not build all parsers.")?; +// ============================================================ +// Impls +// ============================================================ - for error in errors { - write!(w, "\n\n")?; - error.format(w, indent + 2)?; +impl Cause { + #[must_use] + pub fn new(source: impl Into) -> Self { + Self(Box::new(source.into())) } - Ok(()) + #[must_use] + pub fn as_error(&self) -> &Error { + &self.0 + } } impl fmt::Display for Error { @@ -221,25 +213,66 @@ impl std::error::Error for Error { } } -impl From for Error { - fn from(source: std::io::Error) -> Self { - Error::Io { source } +impl Error { + #[must_use] + pub fn format_indent(&self, indent: usize) -> String { + let mut s = String::new(); + let _ = self.format(&mut s, indent); + s } -} -impl From for Error { - fn from(error: std::fmt::Error) -> Self { - Error::Message { - message: format!("formatting error: {error}"), + fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { + let prefix = " ".repeat(indent); + match self { + Error::Build { errors } => format_build_errors(w, errors, indent), + Error::Command { + msg, + stderr, + stdout, + } => format_command(w, indent, msg, stdout, stderr), + Error::Config { message } => write!(w, "{prefix}Configuration error: {message}"), + Error::Context { message, source } => { + write!( + w, + "{}{}\n{}", + prefix, + message, + source.as_error().format_indent(indent + 2) + ) + } + Error::Io { source } => write!(w, "{prefix}IO error: {source}"), + Error::Interrupted { signal } => write!(w, "{prefix}Interrupted by {signal}"), + Error::Language { name, source } => { + write!( + w, + "{}{}\n{}", + prefix, + name, + source.as_error().format_indent(indent + 2) + ) + } + Error::LanguageCollection { related } => format_language_collection(w, related, indent), + Error::Message { message } => write!(w, "{prefix}{message}"), + Error::Step { name, kind, source } => { + write!( + w, + "{}{}: {}.\n{}", + prefix, + name, + kind, + source.as_error().format_indent(indent + 4) + ) + } } } } -impl From for Error { - fn from(error: std::string::FromUtf8Error) -> Self { - Error::Message { - message: format!("UTF-8 conversion error: {error}"), - } +impl From for Cause +where + E: Into, +{ + fn from(source: E) -> Self { + Self::new(source) } } @@ -251,26 +284,18 @@ impl From for Error { } } -impl From for Error { - fn from(error: url::ParseError) -> Self { - Error::Message { - message: format!("URL parse error: {error}"), - } - } -} - -impl From for Error { - fn from(error: toml::ser::Error) -> Self { +impl From for Error { + fn from(error: reqwest::header::InvalidHeaderValue) -> Self { Error::Message { - message: format!("TOML serialization error: {error}"), + message: format!("Invalid header value: {error}"), } } } -impl From for Error { - fn from(error: toml::de::Error) -> Self { +impl From for Error { + fn from(error: self_update::errors::Error) -> Self { Error::Message { - message: format!("TOML deserialization error: {error}"), + message: format!("Self-update error: {error}"), } } } @@ -283,18 +308,24 @@ impl From for Error { } } -impl From for Error { - fn from(error: self_update::errors::Error) -> Self { +impl From for Error { + fn from(error: std::fmt::Error) -> Self { Error::Message { - message: format!("Self-update error: {error}"), + message: format!("formatting error: {error}"), } } } -impl From for Error { - fn from(error: reqwest::header::InvalidHeaderValue) -> Self { +impl From for Error { + fn from(source: std::io::Error) -> Self { + Error::Io { source } + } +} + +impl From for Error { + fn from(error: std::string::FromUtf8Error) -> Self { Error::Message { - message: format!("Invalid header value: {error}"), + message: format!("UTF-8 conversion error: {error}"), } } } @@ -307,60 +338,49 @@ impl From for Error { } } -impl Error { - #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - let _ = self.format(&mut s, indent); - s +impl From for Error { + fn from(error: toml::de::Error) -> Self { + Error::Message { + message: format!("TOML deserialization error: {error}"), + } } +} - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - match self { - Error::Build { errors } => format_build_errors(w, errors, indent), - Error::Command { - msg, - stderr, - stdout, - } => format_command(w, indent, msg, stdout, stderr), - Error::Config { message } => write!(w, "{prefix}Configuration error: {message}"), - Error::Context { message, source } => { - write!( - w, - "{}{}\n{}", - prefix, - message, - source.as_error().format_indent(indent + 2) - ) - } - Error::Io { source } => write!(w, "{prefix}IO error: {source}"), - Error::Interrupted { signal } => write!(w, "{prefix}Interrupted by {signal}"), - Error::Language { name, source } => { - write!( - w, - "{}{}\n{}", - prefix, - name, - source.as_error().format_indent(indent + 2) - ) - } - Error::LanguageCollection { related } => format_language_collection(w, related, indent), - Error::Message { message } => write!(w, "{prefix}{message}"), - Error::Step { name, kind, source } => { - write!( - w, - "{}{}: {}.\n{}", - prefix, - name, - kind, - source.as_error().format_indent(indent + 4) - ) - } +impl From for Error { + fn from(error: toml::ser::Error) -> Self { + Error::Message { + message: format!("TOML serialization error: {error}"), } } } +impl From for Error { + fn from(error: url::ParseError) -> Self { + Error::Message { + message: format!("URL parse error: {error}"), + } + } +} + +impl ResultExt for std::result::Result +where + E: Into, +{ + fn context(self, message: impl Into) -> Result { + self.map_err(|source| Error::Context { + message: message.into(), + source: Cause::new(source), + }) + } + + fn with_context(self, message: impl FnOnce() -> String) -> Result { + self.map_err(|source| Error::Context { + message: message(), + source: Cause::new(source), + }) + } +} + #[cfg(test)] mod tests { use super::*; From 59c1086b309a109fc5e776afaa6fd0059378faae Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:18:46 +0200 Subject: [PATCH 68/88] style: git: sort --- src/git.rs | 735 +++++++++++++++++++++++++++-------------------------- 1 file changed, 376 insertions(+), 359 deletions(-) diff --git a/src/git.rs b/src/git.rs index 2d5a61b..b9954ca 100644 --- a/src/git.rs +++ b/src/git.rs @@ -11,6 +11,13 @@ use tokio::{fs, process::Command}; use crate::{sh::Exec, Error, Result, ResultExt}; +type RefResult = StdResult; + +// ============================================================ +// Enums +// ============================================================ + +/// Error type for git ref and SHA validation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RefError { EmptyRef, @@ -20,237 +27,257 @@ pub enum RefError { InvalidShaHex { index: usize, character: char }, } -impl fmt::Display for RefError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyRef => write!(f, "git ref cannot be empty"), - Self::InvalidRefCharacter { index, character } => write!( - f, - "git ref contains invalid character at byte {index}: {character:?}" - ), - Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), - Self::InvalidShaLength { actual } => { - write!(f, "git SHA must be exactly 40 hex characters, got {actual}") - } - Self::InvalidShaHex { index, character } => write!( - f, - "git SHA contains non-hex character at byte {index}: {character:?}" - ), - } - } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum ResolvedRef { + Tag { label: String, sha: Sha }, + Ref(Ref), } -impl std::error::Error for RefError {} - -type RefResult = StdResult; +// ============================================================ +// Structs +// ============================================================ -impl From for Error { - fn from(error: RefError) -> Self { - Error::Message { - message: error.to_string(), - } - } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Checkout { + pub commit: Sha, } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Ref(Arc); -impl Ref { - /// Create a validated git ref. - pub fn new(value: impl Into>) -> RefResult { - let value = value.into(); - validate_git_ref(&value)?; - Ok(Self(value)) - } - - /// The default ref used for unpinned parser builds. - #[must_use] - pub fn head() -> Self { - Self(Arc::from("HEAD")) - } - - /// Get the exact git ref string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Sha(Arc); - /// Get a human-oriented representation, shortening full commit SHAs. - #[must_use] - pub fn short(&self) -> &str { - if Sha::is_full_sha(self.as_str()) { - &self.0[..7] - } else { - &self.0 - } - } +// ============================================================ +// Free functions +// ============================================================ - #[must_use] - pub fn is_exact_sha(&self) -> bool { - Sha::is_full_sha(self.as_str()) - } +pub async fn checkout(repo: &str, git_ref: &Ref, cwd: &Path) -> Result { + checkout_with_force(repo, git_ref, cwd, false).await } -impl AsRef for Ref { - fn as_ref(&self) -> &str { - self.as_str() +pub async fn checkout_with_force( + repo: &str, + git_ref: &Ref, + cwd: &Path, + force: bool, +) -> Result { + if force || !is_same_remote(cwd, repo).await { + clean_anyway(cwd).await?; + } + if is_valid_git_dir(cwd).await { + reset_head_hard(cwd, git_ref).await?; + } else { + init_fetch_and_checkout(cwd, repo, git_ref).await?; } + let commit = get_head_sha(cwd) + .await + .with_context(|| format!("Resolving checked out commit for {}", cwd.display()))?; + Ok(Checkout { commit }) } -impl TryFrom<&str> for Ref { - type Error = RefError; - - fn try_from(value: &str) -> StdResult { - Self::new(value) +// TODO: get rid of async fs completely. +async fn clean_anyway(cwd: &Path) -> Result<()> { + if cwd.exists() { + if cwd.is_dir() { + fs::remove_dir_all(cwd).await + } else { + fs::remove_file(cwd).await + }?; } + Ok(()) } -impl TryFrom for Ref { - type Error = RefError; - - fn try_from(value: String) -> StdResult { - Self::new(value) +pub async fn clone(repo: &str, cwd: &Path) -> Result<()> { + if cwd.exists() { + Command::new("git") + .current_dir(cwd) + .args(["pull"]) + .exec() + .await?; + } else { + Command::new("git") + .args(["clone", repo, &format!("{}", cwd.display())]) + .exec() + .await?; } + Ok(()) } -impl std::str::FromStr for Ref { - type Err = RefError; - - fn from_str(value: &str) -> StdResult { - Self::new(value) - } +async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> Result<()> { + Command::new("git") + .env("GIT_TERMINAL_PROMPT", "0") + .current_dir(cwd) + .args(["fetch", "origin", "--depth", "1", git_ref.as_str()]) + .exec() + .await?; + Command::new("git") + .current_dir(cwd) + .args(["reset", "--hard", "FETCH_HEAD"]) + .exec() + .await?; + Ok(()) } -impl fmt::Display for Ref { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.short()) - } +async fn get_head_sha(cwd: &Path) -> Result { + let value = get_head_sha1(cwd).await?; + Sha::new(value.trim()).context("Parsing HEAD commit") } -impl Serialize for Ref { - fn serialize(&self, serializer: S) -> StdResult - where - S: Serializer, - { - serializer.serialize_str(self.as_str()) - } +async fn get_head_sha1(cwd: &Path) -> Result { + String::from_utf8( + Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "HEAD"]) + .exec() + .await? + .stdout, + ) + .context("rev-parse HEAD is not a valid utf-8") } -impl<'de> Deserialize<'de> for Ref { - fn deserialize(deserializer: D) -> StdResult - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(de::Error::custom) - } +async fn get_remote_url(cwd: &Path) -> Result { + String::from_utf8( + Command::new("git") + .current_dir(cwd) + .args(["remote", "get-url", "origin"]) + .exec() + .await? + .stdout, + ) + .context("remote get-url origin did not return a valid utf-8") } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct Sha(Arc); +async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> Result<()> { + clean_anyway(cwd).await?; + fs::create_dir_all(cwd).await?; -impl Sha { - /// Create a validated full 40-character git SHA-1. - pub fn new(value: impl Into>) -> RefResult { - let value = value.into(); - validate_git_sha(&value)?; - Ok(Self(value)) - } + Command::new("git") + .current_dir(cwd) + .arg("init") + .exec() + .await?; - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + Command::new("git") + .current_dir(cwd) + .args(["remote", "add", "origin", repo]) + .exec() + .await?; - #[must_use] - pub fn short(&self) -> &str { - &self.0[..7] - } + fetch_and_checkout(cwd, git_ref).await?; - #[must_use] - pub fn is_full_sha(value: &str) -> bool { - value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) - } + Ok(()) } -impl AsRef for Sha { - fn as_ref(&self) -> &str { - self.as_str() - } +pub async fn is_checkout_usable(repo: &str, cwd: &Path) -> bool { + is_valid_git_dir(cwd).await && is_same_remote(cwd, repo).await } -impl TryFrom<&str> for Sha { - type Error = RefError; - - fn try_from(value: &str) -> StdResult { - Self::new(value) - } +async fn is_same_remote(cwd: &Path, remote: &str) -> bool { + remote == get_remote_url(cwd).await.unwrap_or_default().trim() } -impl TryFrom for Sha { - type Error = RefError; +async fn is_valid_git_dir(cwd: &Path) -> bool { + let is_inside_work_tree = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "--is-inside-work-tree"]) + .exec() + .await + .is_ok(); + let can_parse_head = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "HEAD"]) + .exec() + .await + .is_ok(); - fn try_from(value: String) -> StdResult { - Self::new(value) - } + is_inside_work_tree && can_parse_head } -impl std::str::FromStr for Sha { - type Err = RefError; +pub async fn list_grammar_files(cwd: &Path) -> Result> { + let output = Command::new("git") + .current_dir(cwd) + .args(["ls-files", "--cached", "--others", "--exclude-standard"]) + .exec() + .await?; - fn from_str(value: &str) -> StdResult { - Self::new(value) - } -} + let stdout = + String::from_utf8(output.stdout).context("git ls-files output is not valid utf-8")?; -impl fmt::Display for Sha { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.short()) - } -} + let exclude = [ + ".github", "bindings", "doc", "docs", "examples", "queries", "script", "scripts", "test", + "tests", + ]; -impl Serialize for Sha { - fn serialize(&self, serializer: S) -> StdResult - where - S: Serializer, - { - serializer.serialize_str(self.as_str()) - } -} + let result: Vec = stdout + .lines() + .filter_map(|line| { + if line.is_empty() { + return None; + } -impl<'de> Deserialize<'de> for Sha { - fn deserialize(deserializer: D) -> StdResult - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(de::Error::custom) - } -} + let path = Path::new(line); -impl From for Ref { - fn from(sha: Sha) -> Self { - Self(sha.0) - } -} + // Check if filename is exactly "grammar.js" + if path.file_name() != Some(OsStr::new("grammar.js")) { + return None; + } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub enum ResolvedRef { - Tag { label: String, sha: Sha }, - Ref(Ref), + // Check if any path component is in excluded dirs + let has_excluded = path.components().any(|comp| { + if let Component::Normal(name) = comp { + exclude.contains(&name.to_string_lossy().as_ref()) + } else { + false + } + }); + + if has_excluded { + return None; + } + + Some(PathBuf::from(line)) + }) + .collect(); + + Ok(result) } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct Checkout { - pub commit: Sha, +async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> Result<()> { + if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { + Command::new("git") + .current_dir(cwd) + .args(["reset", "--hard", "HEAD"]) + .exec() + .await?; + fetch_and_checkout(cwd, git_ref).await?; + } + Ok(()) } -impl fmt::Display for ResolvedRef { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Tag { label, .. } => write!(f, "{label}"), - Self::Ref(git_ref) => write!(f, "{git_ref}"), - } +pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> Result { + // Try to find a tag for this ref + let tag = Command::new("git") + .current_dir(cwd) + .args(["describe", "--abbrev=0", "--tags", git_ref.as_str()]) + .exec() + .await; + + if let Ok(output) = tag { + // Found a tag, use it + String::from_utf8(output.stdout) + .context("Failed to parse git tag output as UTF-8") + .map(|s| s.trim().to_string()) + } else { + // No tag found (e.g., ref is a branch), fall back to commit SHA1 + let sha1 = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", git_ref.as_str()]) + .exec() + .await?; + String::from_utf8(sha1.stdout) + .context("Failed to parse git rev-parse output as UTF-8") + .map(|s| s.trim().to_string()) } } @@ -334,232 +361,222 @@ fn validate_git_sha(value: &str) -> RefResult<()> { Ok(()) } -// TODO: get rid of async fs completely. -async fn clean_anyway(cwd: &Path) -> Result<()> { - if cwd.exists() { - if cwd.is_dir() { - fs::remove_dir_all(cwd).await - } else { - fs::remove_file(cwd).await - }?; +// ============================================================ +// Impls +// ============================================================ + +impl AsRef for Ref { + fn as_ref(&self) -> &str { + self.as_str() } - Ok(()) } -pub async fn clone(repo: &str, cwd: &Path) -> Result<()> { - if cwd.exists() { - Command::new("git") - .current_dir(cwd) - .args(["pull"]) - .exec() - .await?; - } else { - Command::new("git") - .args(["clone", repo, &format!("{}", cwd.display())]) - .exec() - .await?; +impl AsRef for Sha { + fn as_ref(&self) -> &str { + self.as_str() } - Ok(()) } -pub async fn checkout(repo: &str, git_ref: &Ref, cwd: &Path) -> Result { - checkout_with_force(repo, git_ref, cwd, false).await +impl<'de> Deserialize<'de> for Ref { + fn deserialize(deserializer: D) -> StdResult + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } } -pub async fn checkout_with_force( - repo: &str, - git_ref: &Ref, - cwd: &Path, - force: bool, -) -> Result { - if force || !is_same_remote(cwd, repo).await { - clean_anyway(cwd).await?; +impl<'de> Deserialize<'de> for Sha { + fn deserialize(deserializer: D) -> StdResult + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) } - if is_valid_git_dir(cwd).await { - reset_head_hard(cwd, git_ref).await?; - } else { - init_fetch_and_checkout(cwd, repo, git_ref).await?; +} + +impl fmt::Display for Ref { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) } - let commit = get_head_sha(cwd) - .await - .with_context(|| format!("Resolving checked out commit for {}", cwd.display()))?; - Ok(Checkout { commit }) } -async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> Result<()> { - Command::new("git") - .env("GIT_TERMINAL_PROMPT", "0") - .current_dir(cwd) - .args(["fetch", "origin", "--depth", "1", git_ref.as_str()]) - .exec() - .await?; - Command::new("git") - .current_dir(cwd) - .args(["reset", "--hard", "FETCH_HEAD"]) - .exec() - .await?; - Ok(()) +impl fmt::Display for RefError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyRef => write!(f, "git ref cannot be empty"), + Self::InvalidRefCharacter { index, character } => write!( + f, + "git ref contains invalid character at byte {index}: {character:?}" + ), + Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), + Self::InvalidShaLength { actual } => { + write!(f, "git SHA must be exactly 40 hex characters, got {actual}") + } + Self::InvalidShaHex { index, character } => write!( + f, + "git SHA contains non-hex character at byte {index}: {character:?}" + ), + } + } } -async fn get_head_sha1(cwd: &Path) -> Result { - String::from_utf8( - Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "HEAD"]) - .exec() - .await? - .stdout, - ) - .context("rev-parse HEAD is not a valid utf-8") +impl fmt::Display for ResolvedRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Tag { label, .. } => write!(f, "{label}"), + Self::Ref(git_ref) => write!(f, "{git_ref}"), + } + } } -async fn get_head_sha(cwd: &Path) -> Result { - let value = get_head_sha1(cwd).await?; - Sha::new(value.trim()).context("Parsing HEAD commit") +impl fmt::Display for Sha { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) + } } -async fn get_remote_url(cwd: &Path) -> Result { - String::from_utf8( - Command::new("git") - .current_dir(cwd) - .args(["remote", "get-url", "origin"]) - .exec() - .await? - .stdout, - ) - .context("remote get-url origin did not return a valid utf-8") +impl From for Error { + fn from(error: RefError) -> Self { + Error::Message { + message: error.to_string(), + } + } } -async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> Result<()> { - clean_anyway(cwd).await?; - fs::create_dir_all(cwd).await?; +impl From for Ref { + fn from(sha: Sha) -> Self { + Self(sha.0) + } +} - Command::new("git") - .current_dir(cwd) - .arg("init") - .exec() - .await?; +impl std::str::FromStr for Ref { + type Err = RefError; - Command::new("git") - .current_dir(cwd) - .args(["remote", "add", "origin", repo]) - .exec() - .await?; + fn from_str(value: &str) -> StdResult { + Self::new(value) + } +} - fetch_and_checkout(cwd, git_ref).await?; +impl std::str::FromStr for Sha { + type Err = RefError; - Ok(()) + fn from_str(value: &str) -> StdResult { + Self::new(value) + } } -async fn is_same_remote(cwd: &Path, remote: &str) -> bool { - remote == get_remote_url(cwd).await.unwrap_or_default().trim() -} +impl Ref { + /// Create a validated git ref. + pub fn new(value: impl Into>) -> RefResult { + let value = value.into(); + validate_git_ref(&value)?; + Ok(Self(value)) + } -pub async fn is_checkout_usable(repo: &str, cwd: &Path) -> bool { - is_valid_git_dir(cwd).await && is_same_remote(cwd, repo).await -} + /// The default ref used for unpinned parser builds. + #[must_use] + pub fn head() -> Self { + Self(Arc::from("HEAD")) + } -async fn is_valid_git_dir(cwd: &Path) -> bool { - let is_inside_work_tree = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "--is-inside-work-tree"]) - .exec() - .await - .is_ok(); - let can_parse_head = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "HEAD"]) - .exec() - .await - .is_ok(); + /// Get the exact git ref string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } - is_inside_work_tree && can_parse_head + /// Get a human-oriented representation, shortening full commit SHAs. + #[must_use] + pub fn short(&self) -> &str { + if Sha::is_full_sha(self.as_str()) { + &self.0[..7] + } else { + &self.0 + } + } + + #[must_use] + pub fn is_exact_sha(&self) -> bool { + Sha::is_full_sha(self.as_str()) + } } -pub async fn list_grammar_files(cwd: &Path) -> Result> { - let output = Command::new("git") - .current_dir(cwd) - .args(["ls-files", "--cached", "--others", "--exclude-standard"]) - .exec() - .await?; +impl Serialize for Ref { + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} - let stdout = - String::from_utf8(output.stdout).context("git ls-files output is not valid utf-8")?; +impl Serialize for Sha { + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} - let exclude = [ - ".github", "bindings", "doc", "docs", "examples", "queries", "script", "scripts", "test", - "tests", - ]; +impl Sha { + /// Create a validated full 40-character git SHA-1. + pub fn new(value: impl Into>) -> RefResult { + let value = value.into(); + validate_git_sha(&value)?; + Ok(Sha(value)) + } - let result: Vec = stdout - .lines() - .filter_map(|line| { - if line.is_empty() { - return None; - } + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } - let path = Path::new(line); + #[must_use] + pub fn short(&self) -> &str { + &self.0[..7] + } - // Check if filename is exactly "grammar.js" - if path.file_name() != Some(OsStr::new("grammar.js")) { - return None; - } + #[must_use] + pub fn is_full_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) + } +} - // Check if any path component is in excluded dirs - let has_excluded = path.components().any(|comp| { - if let Component::Normal(name) = comp { - exclude.contains(&name.to_string_lossy().as_ref()) - } else { - false - } - }); +impl std::error::Error for RefError {} - if has_excluded { - return None; - } +impl TryFrom<&str> for Ref { + type Error = RefError; - Some(PathBuf::from(line)) - }) - .collect(); + fn try_from(value: &str) -> StdResult { + Self::new(value) + } +} - Ok(result) +impl TryFrom<&str> for Sha { + type Error = RefError; + + fn try_from(value: &str) -> StdResult { + Self::new(value) + } } -async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> Result<()> { - if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { - Command::new("git") - .current_dir(cwd) - .args(["reset", "--hard", "HEAD"]) - .exec() - .await?; - fetch_and_checkout(cwd, git_ref).await?; +impl TryFrom for Ref { + type Error = RefError; + + fn try_from(value: String) -> StdResult { + Self::new(value) } - Ok(()) } -pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> Result { - // Try to find a tag for this ref - let tag = Command::new("git") - .current_dir(cwd) - .args(["describe", "--abbrev=0", "--tags", git_ref.as_str()]) - .exec() - .await; +impl TryFrom for Sha { + type Error = RefError; - if let Ok(output) = tag { - // Found a tag, use it - String::from_utf8(output.stdout) - .context("Failed to parse git tag output as UTF-8") - .map(|s| s.trim().to_string()) - } else { - // No tag found (e.g., ref is a branch), fall back to commit SHA1 - let sha1 = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", git_ref.as_str()]) - .exec() - .await?; - String::from_utf8(sha1.stdout) - .context("Failed to parse git rev-parse output as UTF-8") - .map(|s| s.trim().to_string()) + fn try_from(value: String) -> StdResult { + Self::new(value) } } From bfeea5f393583c573ab49da30258fe5eb0b3e358 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:19:10 +0200 Subject: [PATCH 69/88] style: lib: sort --- src/lib.rs | 94 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3ac179a..c9dc0fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,26 +80,17 @@ pub mod shutdown; pub mod tree_sitter; pub mod walk; +// ============================================================ +// Traits +// ============================================================ + pub trait SafeCanonicalize { fn canon(&self) -> Result; } -impl SafeCanonicalize for Path { - fn canon(&self) -> Result { - if self.is_absolute() { - Ok(self.to_path_buf()) - } else { - let current_dir = env::current_dir().context("Failed to get current directory")?; - Ok(current_dir.join(self)) - } - } -} - -impl SafeCanonicalize for PathBuf { - fn canon(&self) -> Result { - self.as_path().canon() - } -} +// ============================================================ +// Free functions +// ============================================================ /// Convert a path to an absolute, lexically-normalized path without requiring /// the final path to exist. @@ -115,24 +106,6 @@ pub fn absolute_normalize(path: &Path) -> Result { Ok(normalize_components(&absolute)) } -fn normalize_components(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - - for component in path.components() { - match component { - Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), - Component::RootDir => normalized.push(component.as_os_str()), - Component::CurDir => {} - Component::ParentDir => { - normalized.pop(); - } - Component::Normal(part) => normalized.push(part), - } - } - - normalized -} - #[must_use] pub fn format_duration(duration: Duration) -> String { let total_seconds = duration.as_secs(); @@ -168,15 +141,22 @@ pub fn format_duration(duration: Duration) -> String { parts.join(" ") } -pub fn relative_to_cwd(dir: &Path) -> PathBuf { - let canon = dir.canon().unwrap_or_else(|_| dir.to_path_buf()); - let cwd = env::current_dir().unwrap_or_else(|_| dir.to_path_buf()); +fn normalize_components(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); - if canon != cwd && canon.starts_with(&cwd) { - dir.strip_prefix(cwd).map_or(canon, Path::to_path_buf) - } else { - canon + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + } } + + normalized } /// Prompt user for confirmation with default behavior @@ -200,3 +180,35 @@ pub fn prompt_user(question: &str, default_yes: bool) -> Result { Ok(input == "y") } + +pub fn relative_to_cwd(dir: &Path) -> PathBuf { + let canon = dir.canon().unwrap_or_else(|_| dir.to_path_buf()); + let cwd = env::current_dir().unwrap_or_else(|_| dir.to_path_buf()); + + if canon != cwd && canon.starts_with(&cwd) { + dir.strip_prefix(cwd).map_or(canon, Path::to_path_buf) + } else { + canon + } +} + +// ============================================================ +// Impls +// ============================================================ + +impl SafeCanonicalize for Path { + fn canon(&self) -> Result { + if self.is_absolute() { + Ok(self.to_path_buf()) + } else { + let current_dir = env::current_dir().context("Failed to get current directory")?; + Ok(current_dir.join(self)) + } + } +} + +impl SafeCanonicalize for PathBuf { + fn canon(&self) -> Result { + self.as_path().canon() + } +} From d441c5b14a44642dec4bc6496a096997f2b529f7 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:19:22 +0200 Subject: [PATCH 70/88] style: lock: sort --- src/lock.rs | 258 ++++++++++++++++++++++++++++------------------------ 1 file changed, 138 insertions(+), 120 deletions(-) diff --git a/src/lock.rs b/src/lock.rs index 973e431..edb33cf 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -19,49 +19,17 @@ use crate::{ absolute_normalize, build::BuildDir, consts, format_duration, Error, Result, ResultExt, }; -/// Information about the process currently holding the build lock. -#[derive(Debug, Clone)] -pub struct Owner { - pub pid: Pid, - pub name: String, - pub command: Option, - pub exe: Option, - pub cwd: Option, - pub status: String, - pub run_time: u64, - pub start_time: u64, -} +// ============================================================ +// Enums +// ============================================================ -impl fmt::Display for Owner { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, " pid: {}", self.pid)?; - writeln!(f, " process: {}", self.name)?; - writeln!( - f, - " command: {}", - self.command.as_deref().unwrap_or("") - )?; - writeln!( - f, - " exe: {}", - self.exe - .as_ref() - .map_or_else(|| "".to_string(), |p| p.display().to_string()) - )?; - writeln!( - f, - " cwd: {}", - self.cwd - .as_ref() - .map_or_else(|| "".to_string(), |p| p.display().to_string()) - )?; - writeln!( - f, - " runtime: {}", - format_duration(Duration::from_secs(self.run_time)) - )?; - write!(f, " status: {}", self.status) - } +/// Last observed state while waiting for a lock takeover to complete. +#[derive(Debug, Clone)] +pub enum Observation { + /// The lock is still held by an identifiable owner. + LockedBy(Box), + /// The lock is still held, but owner metadata could not be resolved. + Unknown { pid: Option, reason: String }, } /// Result of checking lock status. @@ -77,35 +45,6 @@ pub enum Status { Unknown { pid: Option, reason: String }, } -/// Last observed state while waiting for a lock takeover to complete. -#[derive(Debug, Clone)] -pub enum Observation { - /// The lock is still held by an identifiable owner. - LockedBy(Box), - /// The lock is still held, but owner metadata could not be resolved. - Unknown { pid: Option, reason: String }, -} - -impl fmt::Display for Observation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::LockedBy(owner) => { - write!(f, "lock is held by PID {} ({})", owner.pid, owner.name) - } - Self::Unknown { pid, reason } => { - if let Some(pid) = pid { - write!( - f, - "lock is held by PID {pid}, but owner is unknown: {reason}" - ) - } else { - write!(f, "lock is held by an unknown owner: {reason}") - } - } - } - } -} - /// Error returned while terminating a lock owner or waiting for its lock to release. #[derive(Debug)] pub enum TakeoverError { @@ -134,14 +73,118 @@ pub enum TakeoverError { Source(Error), } -impl TakeoverError { - /// Whether the caller should re-check lock status and retry the outer takeover loop. - #[must_use] - pub fn is_retryable(&self) -> bool { - matches!( - self, - Self::OwnerDisappeared { .. } | Self::OwnerChanged { .. } - ) +// ============================================================ +// Structs +// ============================================================ + +/// A guard that holds an exclusive OS lock on the build directory lock file. +/// +/// The lock is released when this guard is dropped. The lock file itself is +/// intentionally left on disk because OS locks are tied to open file handles, +/// not to path existence. +#[derive(Debug)] +pub struct Guard { + file: File, + lock_path: PathBuf, +} + +/// Manages lock configuration and acquisition. +pub struct Lock { + current_pid: Pid, + lock_path: PathBuf, +} + +/// Information about the process currently holding the build lock. +#[derive(Debug, Clone)] +pub struct Owner { + pub pid: Pid, + pub name: String, + pub command: Option, + pub exe: Option, + pub cwd: Option, + pub status: String, + pub run_time: u64, + pub start_time: u64, +} + +// ============================================================ +// Free functions +// ============================================================ + +fn command_line(cmd: &[std::ffi::OsString]) -> Option { + if cmd.is_empty() { + return None; + } + + Some( + cmd.iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "), + ) +} + +fn is_lock_contention(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::WouldBlock +} + +fn same_owner(current: &Owner, previous: &Owner) -> bool { + current.pid == previous.pid && current.start_time == previous.start_time +} + +// ============================================================ +// Impls +// ============================================================ + +impl fmt::Display for Observation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LockedBy(owner) => { + write!(f, "lock is held by PID {} ({})", owner.pid, owner.name) + } + Self::Unknown { pid, reason } => { + if let Some(pid) = pid { + write!( + f, + "lock is held by PID {pid}, but owner is unknown: {reason}" + ) + } else { + write!(f, "lock is held by an unknown owner: {reason}") + } + } + } + } +} + +impl fmt::Display for Owner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, " pid: {}", self.pid)?; + writeln!(f, " process: {}", self.name)?; + writeln!( + f, + " command: {}", + self.command.as_deref().unwrap_or("") + )?; + writeln!( + f, + " exe: {}", + self.exe + .as_ref() + .map_or_else(|| "".to_string(), |p| p.display().to_string()) + )?; + writeln!( + f, + " cwd: {}", + self.cwd + .as_ref() + .map_or_else(|| "".to_string(), |p| p.display().to_string()) + )?; + writeln!( + f, + " runtime: {}", + format_duration(Duration::from_secs(self.run_time)) + )?; + write!(f, " status: {}", self.status) } } @@ -191,7 +234,11 @@ impl fmt::Display for TakeoverError { } } -impl std::error::Error for TakeoverError {} +impl Drop for Guard { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} impl From for TakeoverError { fn from(err: Error) -> Self { @@ -207,23 +254,6 @@ impl From for Error { } } -/// A guard that holds an exclusive OS lock on the build directory lock file. -/// -/// The lock is released when this guard is dropped. The lock file itself is -/// intentionally left on disk because OS locks are tied to open file handles, -/// not to path existence. -#[derive(Debug)] -pub struct Guard { - file: File, - lock_path: PathBuf, -} - -impl Drop for Guard { - fn drop(&mut self) { - let _ = self.file.unlock(); - } -} - impl Guard { /// Delete every entry in the build directory except the lock file itself /// and explicitly protected root-level files, such as the active log file. @@ -287,12 +317,6 @@ impl Guard { } } -/// Manages lock configuration and acquisition. -pub struct Lock { - current_pid: Pid, - lock_path: PathBuf, -} - impl Lock { #[must_use] pub fn new(build_dir: &BuildDir) -> Self { @@ -529,26 +553,20 @@ impl Lock { } } -fn command_line(cmd: &[std::ffi::OsString]) -> Option { - if cmd.is_empty() { - return None; - } - - Some( - cmd.iter() - .map(|arg| arg.to_string_lossy()) - .collect::>() - .join(" "), - ) -} +impl std::error::Error for TakeoverError {} -fn same_owner(current: &Owner, previous: &Owner) -> bool { - current.pid == previous.pid && current.start_time == previous.start_time +impl TakeoverError { + /// Whether the caller should re-check lock status and retry the outer takeover loop. + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::OwnerDisappeared { .. } | Self::OwnerChanged { .. } + ) + } } -fn is_lock_contention(err: &io::Error) -> bool { - err.kind() == io::ErrorKind::WouldBlock -} +// ── Tests ─────────────────────────────────────────────────────── #[cfg(test)] mod tests { From c8a17ac7d9263220e902325890f097562a3719f7 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:27:07 +0200 Subject: [PATCH 71/88] style: logging: sort --- src/logging.rs | 134 +++++++++++++++++++++++++++---------------------- 1 file changed, 75 insertions(+), 59 deletions(-) diff --git a/src/logging.rs b/src/logging.rs index 60edef9..4b71015 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -11,6 +11,19 @@ use tracing_subscriber::{layer::SubscriberExt, Layer}; use crate::{absolute_normalize, args, consts, Error, Result, ResultExt}; +// ============================================================ +// Enums +// ============================================================ + +pub enum Implicit<'a> { + BuildDir { dir: &'a Path }, + None, +} + +// ============================================================ +// Structs +// ============================================================ + #[allow(dead_code)] pub struct Guard(WorkerGuard); @@ -19,21 +32,31 @@ pub struct Policy<'a> { pub implicit: Implicit<'a>, } -pub enum Implicit<'a> { - BuildDir { dir: &'a Path }, - None, -} - pub struct Session { path: Option, _guard: Option, } -impl Session { - #[must_use] - pub fn path(&self) -> Option<&Path> { - self.path.as_deref() - } +// ============================================================ +// Free functions +// ============================================================ + +fn file_layer( + writer: tracing_appender::non_blocking::NonBlocking, + color: bool, + filter: LevelFilter, +) -> Box + Send + Sync> { + tracing_subscriber::fmt::layer() + .compact() + .with_ansi(color) + .with_file(true) + .with_level(true) + .with_line_number(true) + .with_target(true) + .with_thread_ids(true) + .with_writer(writer) + .with_filter(filter) + .boxed() } pub fn init( @@ -85,6 +108,25 @@ fn init_tracing( tracing::subscriber::set_global_default(subscriber).unwrap(); } +fn open_log_file(log: &Path) -> Result { + let parent = log.parent().unwrap_or(Path::new(".")); + if !parent.exists() { + fs::create_dir_all(parent).context("Preparing log directory")?; + } + File::create(log).context("Creating log file") +} + +fn resolve_log_path(policy: Policy<'_>) -> Result> { + match (policy.explicit, policy.implicit) { + (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), + (Some(log), Implicit::None) => validate_standalone_log_path(log).map(Some), + (None, Implicit::BuildDir { dir }) => { + validate_log_path(dir, &dir.join(consts::LOG_FILE)).map(Some) + } + (None, Implicit::None) => Ok(None), + } +} + fn stderr_layer( color: bool, filter: LevelFilter, @@ -103,50 +145,6 @@ fn stderr_layer( .boxed() } -fn file_layer( - writer: tracing_appender::non_blocking::NonBlocking, - color: bool, - filter: LevelFilter, -) -> Box + Send + Sync> { - tracing_subscriber::fmt::layer() - .compact() - .with_ansi(color) - .with_file(true) - .with_level(true) - .with_line_number(true) - .with_target(true) - .with_thread_ids(true) - .with_writer(writer) - .with_filter(filter) - .boxed() -} - -fn resolve_log_path(policy: Policy<'_>) -> Result> { - match (policy.explicit, policy.implicit) { - (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), - (Some(log), Implicit::None) => validate_standalone_log_path(log).map(Some), - (None, Implicit::BuildDir { dir }) => { - validate_log_path(dir, &dir.join(consts::LOG_FILE)).map(Some) - } - (None, Implicit::None) => Ok(None), - } -} - -fn validate_standalone_log_path(log: &Path) -> Result { - let log = absolute_normalize(log)?; - - if log.is_dir() { - return Err(Error::Message { - message: format!( - "--log must be a file path, not a directory: {}", - log.display() - ), - }); - } - - Ok(log) -} - fn validate_log_path(build_dir: &Path, log: &Path) -> Result { let build_dir = absolute_normalize(build_dir)?; let log = absolute_normalize(log)?; @@ -208,10 +206,28 @@ fn validate_log_path(build_dir: &Path, log: &Path) -> Result { Ok(log) } -fn open_log_file(log: &Path) -> Result { - let parent = log.parent().unwrap_or(Path::new(".")); - if !parent.exists() { - fs::create_dir_all(parent).context("Preparing log directory")?; +fn validate_standalone_log_path(log: &Path) -> Result { + let log = absolute_normalize(log)?; + + if log.is_dir() { + return Err(Error::Message { + message: format!( + "--log must be a file path, not a directory: {}", + log.display() + ), + }); + } + + Ok(log) +} + +// ============================================================ +// Impls +// ============================================================ + +impl Session { + #[must_use] + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() } - File::create(log).context("Creating log file") } From 7a1a97ffda448286949758840baaedc6967bba9f Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:30:23 +0200 Subject: [PATCH 72/88] style: parser: sort --- src/parser.rs | 553 ++++++++++++++++++++++++++------------------------ src/sh.rs | 12 ++ 2 files changed, 299 insertions(+), 266 deletions(-) diff --git a/src/parser.rs b/src/parser.rs index a35fe62..246c201 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -25,158 +25,245 @@ use crate::{ Error, Result, ResultExt, }; +// ============================================================ +// Constants +// ============================================================ + pub const WASM_EXTENSION: &str = "wasm"; +// ============================================================ +// Enums +// ============================================================ + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +enum ArtifactKind { + Native, + Wasm, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum Ref { + Stable(git::Ref), + Moving(git::Ref), +} + +// ============================================================ +// Structs +// ============================================================ + +/// A grammar ready to be built, combining definition and cache state +#[derive(Clone, Debug)] +pub struct GrammarBuild { + pub context: build::Context, + pub cache_decision: cache::Decision, + pub dir: PathBuf, + pub hash: cache::GrammarHash, + pub language: LanguageName, // Required for error reporting and cache keys; set from parent LanguageBuild + pub name: GrammarName, + pub output: build::OutputConfig, + pub progress: actors::ProgressAddr, // Use language's handle + pub revision: cache::Revision, + pub spec: Arc, + pub ts_cli: PathBuf, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GrammarName(Arc); + +#[derive(Clone, Debug)] +pub struct LanguageBuild { + pub context: build::Context, + pub spec: Arc, + pub name: LanguageName, + pub output: build::OutputConfig, +} + #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct LanguageName(Arc); -impl LanguageName { - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } +// ============================================================ +// Free functions +// ============================================================ - #[must_use] - pub fn as_arc(&self) -> Arc { - self.0.clone() - } +fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> Result { + let file_name = ts_cli + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Message { + message: format!( + "Could not derive artifact id from tree-sitter CLI path {}", + ts_cli.display() + ), + })?; + let id = file_name.strip_prefix("tree-sitter-").unwrap_or(file_name); + Ok(format!("tsdl-{}", sanitize_path_component(id))) } -impl fmt::Display for LanguageName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } +fn artifact_path_for( + build_dir: &Path, + ts_cli: &Path, + spec: &build::Spec, + grammar_name: &GrammarName, + kind: ArtifactKind, +) -> Result { + Ok(build_dir + .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) + .join(parser_name_and_ext(&spec.prefix, grammar_name, kind))) } -impl From for LanguageName { - fn from(value: String) -> Self { - Self(value.into()) - } +async fn ensure_parent_dir(path: &Path) -> Result<()> { + let parent = path.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not determine parent directory for {}", + path.display() + ), + })?; + + fs::create_dir_all(parent) + .await + .with_context(|| format!("Creating {}", parent.display())) } -impl From<&str> for LanguageName { - fn from(value: &str) -> Self { - Self(Arc::from(value)) - } +fn extract_dir_name(dir: &Path) -> Result { + dir.file_name() + .map(|n| n.to_string_lossy().to_string()) + .ok_or_else(|| Error::Message { + message: format!("Could not get dir name for {}", dir.display()), + }) } -impl From> for LanguageName { - fn from(value: Arc) -> Self { - Self(value) - } +/// Extract grammar name from directory (strips "tree-sitter-" prefix if present) +fn extract_grammar_name(dir: &Path) -> Result { + let dir_name = extract_dir_name(dir)?; + let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); + Ok(GrammarName::from(name)) } -#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct GrammarName(Arc); +fn is_dotted_numeric_version(value: &str) -> bool { + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} -impl GrammarName { - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } +fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { + git_ref.is_exact_sha() + || is_dotted_numeric_version(input) + || is_v_dotted_numeric_version(input) + || git_ref.as_str().starts_with("refs/tags/") +} - #[must_use] - pub fn as_arc(&self) -> Arc { - self.0.clone() - } +fn is_v_dotted_numeric_version(value: &str) -> bool { + value + .strip_prefix('v') + .is_some_and(is_dotted_numeric_version) } -impl fmt::Display for GrammarName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) +fn normalize_source_ref(value: &str) -> String { + if git::Sha::is_full_sha(value) || value.starts_with('v') { + value.to_string() + } else if is_dotted_numeric_version(value) { + format!("v{value}") + } else { + value.to_string() } } -impl From for GrammarName { - fn from(value: String) -> Self { - Self(value.into()) - } +fn parser_name_and_ext(prefix: &str, grammar_name: &GrammarName, kind: ArtifactKind) -> String { + format!("{prefix}{grammar_name}.{}", kind.extension()) } -impl From<&str> for GrammarName { - fn from(value: &str) -> Self { - Self(Arc::from(value)) - } +fn same_file_identity(a: &Metadata, b: &Metadata) -> bool { + a.dev() == b.dev() && a.ino() == b.ino() } -impl From> for GrammarName { - fn from(value: Arc) -> Self { - Self(value) +async fn same_regular_file_contents( + src: &Path, + src_metadata: &Metadata, + dst: &Path, + dst_metadata: &Metadata, +) -> Result { + if src_metadata.len() != dst_metadata.len() { + return Ok(false); } -} -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -enum ArtifactKind { - Native, - Wasm, + let src_hash = cache::hash_file(src).await?; + let dst_hash = cache::hash_file(dst).await?; + Ok(src_hash == dst_hash) } -impl ArtifactKind { - #[must_use] - fn extension(self) -> &'static str { - match self { - Self::Native => DLL_EXTENSION, - Self::Wasm => WASM_EXTENSION, - } - } +fn sanitize_path_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '-' + } + }) + .collect::(); - #[must_use] - const fn is_wasm(self) -> bool { - matches!(self, Self::Wasm) + if sanitized.is_empty() { + "unknown".to_string() + } else { + sanitized } } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub enum Ref { - Stable(git::Ref), - Moving(git::Ref), -} +fn temp_install_path(dst: &Path) -> Result { + let file_name = dst.file_name().ok_or_else(|| Error::Message { + message: format!( + "Could not create temporary install path for {}", + dst.display() + ), + })?; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| Error::Message { + message: format!( + "System clock is before UNIX epoch while creating temporary install path: {e}" + ), + })? + .as_nanos(); + let mut tmp_name = file_name.to_os_string(); + tmp_name.push(format!(".tsdl-{}-{nanos}", process::id())); -impl Ref { - /// The default source ref used for unpinned parser builds. - #[must_use] - pub fn head() -> Self { - Self::Moving(git::Ref::head()) - } + Ok(dst.with_file_name(tmp_name)) +} - /// Create a parser source ref from user input, preserving parser-version - /// normalization while classifying refs for cache semantics. - pub fn parse(value: &str) -> StdResult { - let normalized = normalize_source_ref(value); - let git_ref = git::Ref::new(normalized)?; - if is_stable_source_ref(value, &git_ref) { - Ok(Self::Stable(git_ref)) - } else { - Ok(Self::Moving(git_ref)) - } - } +async fn verify_artifact(path: &Path) -> Result<()> { + let metadata = fs::metadata(path) + .await + .with_context(|| format!("Reading built artifact {}", path.display()))?; - #[must_use] - pub const fn is_moving(&self) -> bool { - matches!(self, Self::Moving(_)) + if metadata.is_file() { + Ok(()) + } else { + Err(Error::Message { + message: format!("Built artifact is not a regular file: {}", path.display()), + }) } +} - #[must_use] - pub const fn is_stable(&self) -> bool { - matches!(self, Self::Stable(_)) - } +// ============================================================ +// Impls +// ============================================================ +impl ArtifactKind { #[must_use] - pub fn requested(&self) -> &git::Ref { + fn extension(self) -> &'static str { match self { - Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, - } - } -} - -impl Serialize for Ref { - fn serialize(&self, serializer: S) -> StdResult - where - S: Serializer, - { - serializer.serialize_str(self.requested().as_str()) + Self::Native => DLL_EXTENSION, + Self::Wasm => WASM_EXTENSION, + } + } + + #[must_use] + const fn is_wasm(self) -> bool { + matches!(self, Self::Wasm) } } @@ -190,56 +277,58 @@ impl<'de> Deserialize<'de> for Ref { } } +impl fmt::Display for GrammarName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl fmt::Display for LanguageName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + impl fmt::Display for Ref { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.requested()) } } -fn is_dotted_numeric_version(value: &str) -> bool { - !value.is_empty() - && value - .split('.') - .all(|part| !part.is_empty() && part.parse::().is_ok()) +impl From<&str> for GrammarName { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } } -fn normalize_source_ref(value: &str) -> String { - if git::Sha::is_full_sha(value) || value.starts_with('v') { - value.to_string() - } else if is_dotted_numeric_version(value) { - format!("v{value}") - } else { - value.to_string() +impl From<&str> for LanguageName { + fn from(value: &str) -> Self { + Self(Arc::from(value)) } } -fn is_v_dotted_numeric_version(value: &str) -> bool { - value - .strip_prefix('v') - .is_some_and(is_dotted_numeric_version) +impl From> for GrammarName { + fn from(value: Arc) -> Self { + Self(value) + } } -fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { - git_ref.is_exact_sha() - || is_dotted_numeric_version(input) - || is_v_dotted_numeric_version(input) - || git_ref.as_str().starts_with("refs/tags/") +impl From> for LanguageName { + fn from(value: Arc) -> Self { + Self(value) + } } -/// A grammar ready to be built, combining definition and cache state -#[derive(Clone, Debug)] -pub struct GrammarBuild { - pub context: build::Context, - pub cache_decision: cache::Decision, - pub dir: PathBuf, - pub hash: cache::GrammarHash, - pub language: LanguageName, // Required for error reporting and cache keys; set from parent LanguageBuild - pub name: GrammarName, - pub output: build::OutputConfig, - pub progress: actors::ProgressAddr, // Use language's handle - pub revision: cache::Revision, - pub spec: Arc, - pub ts_cli: PathBuf, +impl From for GrammarName { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From for LanguageName { + fn from(value: String) -> Self { + Self(value.into()) + } } impl GrammarBuild { @@ -676,6 +765,7 @@ impl GrammarBuild { Ok(()) } + fn missing_parser_error(&self, ext: &str) -> Error { Error::Step { name: self.language.as_arc(), @@ -709,12 +799,16 @@ impl GrammarBuild { } } -#[derive(Clone, Debug)] -pub struct LanguageBuild { - pub context: build::Context, - pub spec: Arc, - pub name: LanguageName, - pub output: build::OutputConfig, +impl GrammarName { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn as_arc(&self) -> Arc { + self.0.clone() + } } impl LanguageBuild { @@ -800,135 +894,62 @@ impl LanguageBuild { } } -fn parser_name_and_ext(prefix: &str, grammar_name: &GrammarName, kind: ArtifactKind) -> String { - format!("{prefix}{grammar_name}.{}", kind.extension()) -} - -fn artifact_path_for( - build_dir: &Path, - ts_cli: &Path, - spec: &build::Spec, - grammar_name: &GrammarName, - kind: ArtifactKind, -) -> Result { - Ok(build_dir - .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) - .join(parser_name_and_ext(&spec.prefix, grammar_name, kind))) -} - -fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> Result { - let file_name = ts_cli - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| Error::Message { - message: format!( - "Could not derive artifact id from tree-sitter CLI path {}", - ts_cli.display() - ), - })?; - let id = file_name.strip_prefix("tree-sitter-").unwrap_or(file_name); - Ok(format!("tsdl-{}", sanitize_path_component(id))) -} - -fn sanitize_path_component(value: &str) -> String { - let sanitized = value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { - ch - } else { - '-' - } - }) - .collect::(); - - if sanitized.is_empty() { - "unknown".to_string() - } else { - sanitized +impl LanguageName { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 } -} - -async fn ensure_parent_dir(path: &Path) -> Result<()> { - let parent = path.parent().ok_or_else(|| Error::Message { - message: format!( - "Could not determine parent directory for {}", - path.display() - ), - })?; - - fs::create_dir_all(parent) - .await - .with_context(|| format!("Creating {}", parent.display())) -} - -async fn verify_artifact(path: &Path) -> Result<()> { - let metadata = fs::metadata(path) - .await - .with_context(|| format!("Reading built artifact {}", path.display()))?; - if metadata.is_file() { - Ok(()) - } else { - Err(Error::Message { - message: format!("Built artifact is not a regular file: {}", path.display()), - }) + #[must_use] + pub fn as_arc(&self) -> Arc { + self.0.clone() } } -fn same_file_identity(a: &Metadata, b: &Metadata) -> bool { - a.dev() == b.dev() && a.ino() == b.ino() -} - -async fn same_regular_file_contents( - src: &Path, - src_metadata: &Metadata, - dst: &Path, - dst_metadata: &Metadata, -) -> Result { - if src_metadata.len() != dst_metadata.len() { - return Ok(false); +impl Ref { + /// The default source ref used for unpinned parser builds. + #[must_use] + pub fn head() -> Self { + Self::Moving(git::Ref::head()) } - let src_hash = cache::hash_file(src).await?; - let dst_hash = cache::hash_file(dst).await?; - Ok(src_hash == dst_hash) -} + /// Create a parser source ref from user input, preserving parser-version + /// normalization while classifying refs for cache semantics. + pub fn parse(value: &str) -> StdResult { + let normalized = normalize_source_ref(value); + let git_ref = git::Ref::new(normalized)?; + if is_stable_source_ref(value, &git_ref) { + Ok(Self::Stable(git_ref)) + } else { + Ok(Self::Moving(git_ref)) + } + } -fn temp_install_path(dst: &Path) -> Result { - let file_name = dst.file_name().ok_or_else(|| Error::Message { - message: format!( - "Could not create temporary install path for {}", - dst.display() - ), - })?; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| Error::Message { - message: format!( - "System clock is before UNIX epoch while creating temporary install path: {e}" - ), - })? - .as_nanos(); - let mut tmp_name = file_name.to_os_string(); - tmp_name.push(format!(".tsdl-{}-{nanos}", process::id())); + #[must_use] + pub const fn is_moving(&self) -> bool { + matches!(self, Self::Moving(_)) + } - Ok(dst.with_file_name(tmp_name)) -} + #[must_use] + pub const fn is_stable(&self) -> bool { + matches!(self, Self::Stable(_)) + } -fn extract_dir_name(dir: &Path) -> Result { - dir.file_name() - .map(|n| n.to_string_lossy().to_string()) - .ok_or_else(|| Error::Message { - message: format!("Could not get dir name for {}", dir.display()), - }) + #[must_use] + pub fn requested(&self) -> &git::Ref { + match self { + Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, + } + } } -/// Extract grammar name from directory (strips "tree-sitter-" prefix if present) -fn extract_grammar_name(dir: &Path) -> Result { - let dir_name = extract_dir_name(dir)?; - let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); - Ok(GrammarName::from(name)) +impl Serialize for Ref { + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.requested().as_str()) + } } #[cfg(test)] diff --git a/src/sh.rs b/src/sh.rs index ef6486e..98045c4 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -9,6 +9,10 @@ use crate::{ Error, Result, ResultExt, }; +// ============================================================ +// Traits +// ============================================================ + pub trait Exec { fn display(&self) -> Result; fn display_full(&self) -> Result; @@ -19,6 +23,10 @@ pub trait Script { fn from_str(script: &str) -> Command; } +// ============================================================ +// Free functions +// ============================================================ + fn signal_display(number: i32) -> Option { let ptr = unsafe { libc::strsignal(number) }; if ptr.is_null() { @@ -32,6 +40,10 @@ fn signal_display(number: i32) -> Option { } } +// ============================================================ +// Impls +// ============================================================ + impl Exec for Command { fn display(&self) -> Result { let program = self.as_std().get_program().to_string_lossy(); From 793f9164143c411f8d8bac9fb19a28e75244418a Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:35:06 +0200 Subject: [PATCH 73/88] style: shutdown: sort --- src/shutdown.rs | 286 +++++++++++++++++++++++++----------------------- 1 file changed, 151 insertions(+), 135 deletions(-) diff --git a/src/shutdown.rs b/src/shutdown.rs index cff0136..2cb15c7 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -16,6 +16,105 @@ tokio::task_local! { static CURRENT_SHUTDOWN: Handle; } +// ============================================================ +// Enums +// ============================================================ + +/// Error returned when constructing a [`PgId`] from a raw value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PgIdError { + /// Process group ID zero would target the current process group. + Zero, + /// The value cannot be represented by the platform's `pid_t` type. + OutOfRange(u32), +} + +// ============================================================ +// Structs +// ============================================================ + +/// Cooperative shutdown signal shared across build tasks. +#[derive(Clone, Debug)] +pub struct Handle { + tx: watch::Sender>, + rx: watch::Receiver>, + /// Active child process group IDs. Build commands are put in their own + /// process groups so a signal can target each command's whole subprocess tree. + active_pgids: Arc>>, +} + +/// A positive Unix process group ID. +/// +/// `PgId` rejects zero and values that do not fit in `libc::pid_t`, so callers +/// cannot accidentally use `killpg(0, ...)`/`kill(-0, ...)` semantics and signal +/// the current process group. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct PgId(NonZeroU32); + +/// Registration guard for an active child process group. +/// +/// Keep this guard alive while the child process group may still contain live +/// processes. Dropping it removes the [`PgId`] from shutdown's active set, so a +/// later second signal will no longer send `SIGKILL` to that process group. +/// Drop it promptly after the child has exited and been reaped; this narrows the +/// window where a stale, OS-reused process group ID could be signalled by a +/// later shutdown escalation. +#[derive(Debug)] +pub struct PgIdGuard { + shutdown: Handle, + pgid: PgId, +} + +/// A Unix signal that requested shutdown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Signal { + pub number: i32, + pub name: &'static str, +} + +// ============================================================ +// Free functions +// ============================================================ + +pub async fn cancelled() -> Signal { + match current() { + Some(shutdown) => shutdown.cancelled().await, + None => std::future::pending::().await, + } +} + +pub fn check() -> Result<()> { + current().map_or(Ok(()), |shutdown| shutdown.check()) +} + +#[must_use] +pub fn current() -> Option { + CURRENT_SHUTDOWN.try_with(Clone::clone).ok() +} + +#[must_use] +pub fn current_or_default() -> Handle { + current().unwrap_or_default() +} + +pub async fn scope(shutdown: Handle, future: F) -> F::Output +where + F: Future, +{ + CURRENT_SHUTDOWN.scope(shutdown, future).await +} + +fn signal_process_group(pgid: PgId, signal: Signal) { + let rc = unsafe { libc::killpg(pgid.as_pid_t(), signal.number) }; + if rc != 0 { + debug!( + "failed to send {} to pgid={pgid}: {}", + signal.name, + std::io::Error::last_os_error() + ); + } +} + /// Test helper: when `TSDL_TEST_DELAY_MS` is set, inserts a sleep at every step /// boundary so signal delivery can be observed deterministically. /// @@ -36,65 +135,19 @@ pub async fn test_delay() { // noop in release } -/// A Unix signal that requested shutdown. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Signal { - pub number: i32, - pub name: &'static str, -} - -impl Signal { - pub const HUP: Self = Self { - number: libc::SIGHUP, - name: "SIGHUP", - }; - pub const INT: Self = Self { - number: libc::SIGINT, - name: "SIGINT", - }; - pub const QUIT: Self = Self { - number: libc::SIGQUIT, - name: "SIGQUIT", - }; - pub const TERM: Self = Self { - number: libc::SIGTERM, - name: "SIGTERM", - }; - pub const KILL: Self = Self { - number: libc::SIGKILL, - name: "SIGKILL", - }; - - /// Conventional shell exit status for commands terminated by signal N. - #[must_use] - pub fn shell_exit_code(self) -> u8 { - u8::try_from(128 + self.number).unwrap_or(255) - } -} +// ============================================================ +// Impls +// ============================================================ -impl fmt::Display for Signal { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.name) +impl Default for Handle { + fn default() -> Self { + Self::new() } } -/// A positive Unix process group ID. -/// -/// `PgId` rejects zero and values that do not fit in `libc::pid_t`, so callers -/// cannot accidentally use `killpg(0, ...)`/`kill(-0, ...)` semantics and signal -/// the current process group. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub struct PgId(NonZeroU32); - -impl PgId { - /// Return the raw positive process group ID value. - #[must_use] - pub fn get(self) -> u32 { - self.0.get() - } - - fn as_pid_t(self) -> libc::pid_t { - ::try_from(self.0.get()).expect("PgId invariant: value fits in libc::pid_t") +impl Drop for PgIdGuard { + fn drop(&mut self) { + self.shutdown.unregister_pgid(self.pgid); } } @@ -104,25 +157,6 @@ impl fmt::Display for PgId { } } -impl TryFrom for PgId { - type Error = PgIdError; - - fn try_from(value: u32) -> StdResult { - let value = NonZeroU32::new(value).ok_or(PgIdError::Zero)?; - ::try_from(value.get()).map_err(|_| PgIdError::OutOfRange(value.get()))?; - Ok(Self(value)) - } -} - -/// Error returned when constructing a [`PgId`] from a raw value. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PgIdError { - /// Process group ID zero would target the current process group. - Zero, - /// The value cannot be represented by the platform's `pid_t` type. - OutOfRange(u32), -} - impl fmt::Display for PgIdError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -134,35 +168,9 @@ impl fmt::Display for PgIdError { } } -impl std::error::Error for PgIdError {} - -/// Cooperative shutdown signal shared across build tasks. -#[derive(Clone, Debug)] -pub struct Handle { - tx: watch::Sender>, - rx: watch::Receiver>, - /// Active child process group IDs. Build commands are put in their own - /// process groups so a signal can target each command's whole subprocess tree. - active_pgids: Arc>>, -} - -/// Registration guard for an active child process group. -/// -/// Keep this guard alive while the child process group may still contain live -/// processes. Dropping it removes the [`PgId`] from shutdown's active set, so a -/// later second signal will no longer send `SIGKILL` to that process group. -/// Drop it promptly after the child has exited and been reaped; this narrows the -/// window where a stale, OS-reused process group ID could be signalled by a -/// later shutdown escalation. -#[derive(Debug)] -pub struct PgIdGuard { - shutdown: Handle, - pgid: PgId, -} - -impl Drop for PgIdGuard { - fn drop(&mut self) { - self.shutdown.unregister_pgid(self.pgid); +impl fmt::Display for Signal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) } } @@ -334,48 +342,56 @@ impl Handle { } } -impl Default for Handle { - fn default() -> Self { - Self::new() +impl PgId { + /// Return the raw positive process group ID value. + #[must_use] + pub fn get(self) -> u32 { + self.0.get() } -} -fn signal_process_group(pgid: PgId, signal: Signal) { - let rc = unsafe { libc::killpg(pgid.as_pid_t(), signal.number) }; - if rc != 0 { - debug!( - "failed to send {} to pgid={pgid}: {}", - signal.name, - std::io::Error::last_os_error() - ); + fn as_pid_t(self) -> libc::pid_t { + ::try_from(self.0.get()).expect("PgId invariant: value fits in libc::pid_t") } } -pub async fn scope(shutdown: Handle, future: F) -> F::Output -where - F: Future, -{ - CURRENT_SHUTDOWN.scope(shutdown, future).await -} +impl Signal { + pub const HUP: Self = Self { + number: libc::SIGHUP, + name: "SIGHUP", + }; + pub const INT: Self = Self { + number: libc::SIGINT, + name: "SIGINT", + }; + pub const QUIT: Self = Self { + number: libc::SIGQUIT, + name: "SIGQUIT", + }; + pub const TERM: Self = Self { + number: libc::SIGTERM, + name: "SIGTERM", + }; + pub const KILL: Self = Self { + number: libc::SIGKILL, + name: "SIGKILL", + }; -#[must_use] -pub fn current() -> Option { - CURRENT_SHUTDOWN.try_with(Clone::clone).ok() + /// Conventional shell exit status for commands terminated by signal N. + #[must_use] + pub fn shell_exit_code(self) -> u8 { + u8::try_from(128 + self.number).unwrap_or(255) + } } -#[must_use] -pub fn current_or_default() -> Handle { - current().unwrap_or_default() -} +impl std::error::Error for PgIdError {} -pub fn check() -> Result<()> { - current().map_or(Ok(()), |shutdown| shutdown.check()) -} +impl TryFrom for PgId { + type Error = PgIdError; -pub async fn cancelled() -> Signal { - match current() { - Some(shutdown) => shutdown.cancelled().await, - None => std::future::pending::().await, + fn try_from(value: u32) -> StdResult { + let value = NonZeroU32::new(value).ok_or(PgIdError::Zero)?; + ::try_from(value.get()).map_err(|_| PgIdError::OutOfRange(value.get()))?; + Ok(Self(value)) } } From 6e8c7126942a176dfe6805c1b6889f219742d36b Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:37:49 +0200 Subject: [PATCH 74/88] style: tree_sitter: sort --- src/tree_sitter.rs | 366 +++++++++++++++++++++++---------------------- 1 file changed, 189 insertions(+), 177 deletions(-) diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 8110923..ffe2d41 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -17,11 +17,9 @@ use crate::sh::Exec; use crate::shutdown; use crate::{Error, Result, ResultExt, SafeCanonicalize}; -#[derive(Debug, Clone)] -pub struct PreparedCli { - pub path: PathBuf, - pub tree_sitter: args::TreeSitter, -} +// ============================================================ +// Enums +// ============================================================ #[derive(Debug, PartialEq, Eq)] enum CliCacheStatus { @@ -30,6 +28,67 @@ enum CliCacheStatus { Invalid(String), } +// ============================================================ +// Structs +// ============================================================ + +#[derive(Debug, Clone)] +pub struct PreparedCli { + pub path: PathBuf, + pub tree_sitter: args::TreeSitter, +} + +// ============================================================ +// Free functions +// ============================================================ + +async fn check_cached_cli(path: &Path, tag: &str) -> Result { + let metadata = match fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(CliCacheStatus::Missing) + } + Err(err) => { + return Err(err) + .with_context(|| format!("Inspecting cached tree-sitter CLI {}", path.display())); + } + }; + + let file_type = metadata.file_type(); + if file_type.is_dir() { + return Err(Error::Message { + message: format!( + "Cached tree-sitter CLI path is a directory and cannot be replaced: {}", + path.display() + ), + }); + } + + if file_type.is_symlink() { + return Ok(CliCacheStatus::Invalid( + "cached path is a symbolic link".to_string(), + )); + } + + if !file_type.is_file() { + return Ok(CliCacheStatus::Invalid( + "cached path is not a regular file".to_string(), + )); + } + + if metadata.permissions().mode() & 0o111 == 0 { + return Ok(CliCacheStatus::Invalid( + "cached file is not executable".to_string(), + )); + } + + match verify_cli(path, tag).await { + Ok(()) => Ok(CliCacheStatus::Hit), + Err(Error::Interrupted { signal }) => Err(Error::Interrupted { signal }), + Err(err) => Ok(CliCacheStatus::Invalid(first_line(&err.to_string()))), + } +} + async fn chmod_x(prog: &Path) -> Result<()> { let metadata = fs::metadata(prog) .await @@ -81,69 +140,19 @@ async fn cli( Ok(res) } -async fn resolve_release_tag( - build_dir: &Path, - handle: &actors::ProgressAddr, - repo: &str, - resolved_ref: &git::ResolvedRef, -) -> Result { - let tag = match resolved_ref { - git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), - git::ResolvedRef::Ref(git_ref) => { - handle.msg(format!("resolving exact tag for {resolved_ref}")); - let tree_sitter = build_dir.join("tree-sitter"); - git::clone(repo, &tree_sitter).await?; - Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) - } - }; - Ok(tag.into_owned()) -} - -async fn check_cached_cli(path: &Path, tag: &str) -> Result { - let metadata = match fs::symlink_metadata(path).await { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(CliCacheStatus::Missing) - } - Err(err) => { - return Err(err) - .with_context(|| format!("Inspecting cached tree-sitter CLI {}", path.display())); - } - }; - - let file_type = metadata.file_type(); - if file_type.is_dir() { - return Err(Error::Message { - message: format!( - "Cached tree-sitter CLI path is a directory and cannot be replaced: {}", - path.display() - ), - }); - } - - if file_type.is_symlink() { - return Ok(CliCacheStatus::Invalid( - "cached path is a symbolic link".to_string(), - )); - } - - if !file_type.is_file() { - return Ok(CliCacheStatus::Invalid( - "cached path is not a regular file".to_string(), - )); - } - - if metadata.permissions().mode() & 0o111 == 0 { - return Ok(CliCacheStatus::Invalid( - "cached file is not executable".to_string(), - )); +fn context_unless_interrupted(result: Result, message: impl FnOnce() -> String) -> Result { + match result { + Ok(value) => Ok(value), + Err(err @ Error::Interrupted { .. }) => Err(err), + Err(err) => Err(Error::Context { + message: message(), + source: err.into(), + }), } +} - match verify_cli(path, tag).await { - Ok(()) => Ok(CliCacheStatus::Hit), - Err(Error::Interrupted { signal }) => Err(Error::Interrupted { signal }), - Err(err) => Ok(CliCacheStatus::Invalid(first_line(&err.to_string()))), - } +pub(crate) fn display_tree_sitter_ref(version: &str) -> StdResult { + git::Ref::new(normalize_release_ref(version)) } async fn download(url: &str, gz: &Path) -> Result<()> { @@ -174,105 +183,11 @@ async fn download_and_install(url: &str, res: &Path, tag: &str) -> Result<()> { Ok(()) } -async fn install_downloaded_cli(gz: &Path, tmp_cli: &Path, res: &Path, tag: &str) -> Result<()> { - gunzip(gz, tmp_cli).await?; - chmod_x(tmp_cli).await?; - context_unless_interrupted(verify_cli(tmp_cli, tag).await, || { - format!("Verifying downloaded tree-sitter CLI {}", tmp_cli.display()) - })?; - promote_cli(tmp_cli, res).await -} - -fn temp_path_for(res: &Path, suffix: &str) -> Result { - let parent = res.parent().ok_or_else(|| Error::Message { - message: format!( - "Could not determine parent directory for tree-sitter CLI path {}", - res.display() - ), - })?; - let name = res.file_name().map_or_else( - || Cow::Borrowed("tree-sitter"), - |name| name.to_string_lossy(), - ); - - tempfile::Builder::new() - .prefix(&format!(".{name}.")) - .suffix(suffix) - .tempfile_in(parent) - .map(tempfile::NamedTempFile::into_temp_path) - .with_context(|| { - format!( - "Creating temporary tree-sitter CLI file in {}", - parent.display() - ) - }) -} - -async fn promote_cli(tmp_cli: &Path, res: &Path) -> Result<()> { - if let Err(err) = fs::rename(tmp_cli, res).await { - let _ = fs::remove_file(tmp_cli).await; - return Err(err).with_context(|| { - format!( - "Installing tree-sitter CLI {} to {}", - tmp_cli.display(), - res.display() - ) - }); - } - - Ok(()) -} - -async fn verify_cli(path: &Path, tag: &str) -> Result<()> { - let output = - context_unless_interrupted(Command::new(path).arg("--version").exec().await, || { - format!("Running {} --version", path.display()) - })?; - - let output = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - if let Some(expected) = expected_cli_version(tag) { - if !output.contains(expected) { - return Err(Error::Message { - message: format!( - "tree-sitter CLI version output did not contain expected version {expected:?}: {}", - output.trim() - ), - }); - } - } - - Ok(()) -} - fn expected_cli_version(tag: &str) -> Option<&str> { let version = tag.strip_prefix('v').unwrap_or(tag); is_dotted_numeric_version(version).then_some(version) } -fn first_line(message: &str) -> String { - message - .lines() - .next() - .unwrap_or("unknown verification failure") - .to_string() -} - -fn context_unless_interrupted(result: Result, message: impl FnOnce() -> String) -> Result { - match result { - Ok(value) => Ok(value), - Err(err @ Error::Interrupted { .. }) => Err(err), - Err(err) => Err(Error::Context { - message: message(), - source: err.into(), - }), - } -} - fn find_tag( refs: &HashMap, version: &str, @@ -291,21 +206,12 @@ fn find_tag( ) } -fn is_dotted_numeric_version(value: &str) -> bool { - !value.is_empty() - && value - .split('.') - .all(|part| !part.is_empty() && part.parse::().is_ok()) -} - -fn normalize_release_ref(value: &str) -> String { - if git::Sha::is_full_sha(value) || value.starts_with('v') { - value.to_string() - } else if is_dotted_numeric_version(value) { - format!("v{value}") - } else { - value.to_string() - } +fn first_line(message: &str) -> String { + message + .lines() + .next() + .unwrap_or("unknown verification failure") + .to_string() } async fn gunzip(gz: &Path, to: &Path) -> Result<()> { @@ -327,6 +233,32 @@ async fn gunzip(gz: &Path, to: &Path) -> Result<()> { .with_context(|| format!("syncing extracted tree-sitter CLI {}", to.display())) } +async fn install_downloaded_cli(gz: &Path, tmp_cli: &Path, res: &Path, tag: &str) -> Result<()> { + gunzip(gz, tmp_cli).await?; + chmod_x(tmp_cli).await?; + context_unless_interrupted(verify_cli(tmp_cli, tag).await, || { + format!("Verifying downloaded tree-sitter CLI {}", tmp_cli.display()) + })?; + promote_cli(tmp_cli, res).await +} + +fn is_dotted_numeric_version(value: &str) -> bool { + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} + +fn normalize_release_ref(value: &str) -> String { + if git::Sha::is_full_sha(value) || value.starts_with('v') { + value.to_string() + } else if is_dotted_numeric_version(value) { + format!("v{value}") + } else { + value.to_string() + } +} + fn parse_refs(stdout: &str) -> HashMap { let mut refs = HashMap::new(); @@ -427,8 +359,37 @@ pub async fn prepare( }) } -pub(crate) fn display_tree_sitter_ref(version: &str) -> StdResult { - git::Ref::new(normalize_release_ref(version)) +async fn promote_cli(tmp_cli: &Path, res: &Path) -> Result<()> { + if let Err(err) = fs::rename(tmp_cli, res).await { + let _ = fs::remove_file(tmp_cli).await; + return Err(err).with_context(|| { + format!( + "Installing tree-sitter CLI {} to {}", + tmp_cli.display(), + res.display() + ) + }); + } + + Ok(()) +} + +async fn resolve_release_tag( + build_dir: &Path, + handle: &actors::ProgressAddr, + repo: &str, + resolved_ref: &git::ResolvedRef, +) -> Result { + let tag = match resolved_ref { + git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), + git::ResolvedRef::Ref(git_ref) => { + handle.msg(format!("resolving exact tag for {resolved_ref}")); + let tree_sitter = build_dir.join("tree-sitter"); + git::clone(repo, &tree_sitter).await?; + Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) + } + }; + Ok(tag.into_owned()) } #[allow(clippy::missing_panics_doc)] @@ -442,6 +403,57 @@ pub async fn tag(repo: &str, version: &str) -> Result { find_tag(&refs, version).with_context(|| format!("Parsing tree-sitter git ref {version:?}")) } +fn temp_path_for(res: &Path, suffix: &str) -> Result { + let parent = res.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not determine parent directory for tree-sitter CLI path {}", + res.display() + ), + })?; + let name = res.file_name().map_or_else( + || Cow::Borrowed("tree-sitter"), + |name| name.to_string_lossy(), + ); + + tempfile::Builder::new() + .prefix(&format!(".{name}.")) + .suffix(suffix) + .tempfile_in(parent) + .map(tempfile::NamedTempFile::into_temp_path) + .with_context(|| { + format!( + "Creating temporary tree-sitter CLI file in {}", + parent.display() + ) + }) +} + +async fn verify_cli(path: &Path, tag: &str) -> Result<()> { + let output = + context_unless_interrupted(Command::new(path).arg("--version").exec().await, || { + format!("Running {} --version", path.display()) + })?; + + let output = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + if let Some(expected) = expected_cli_version(tag) { + if !output.contains(expected) { + return Err(Error::Message { + message: format!( + "tree-sitter CLI version output did not contain expected version {expected:?}: {}", + output.trim() + ), + }); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From 7687e982cfc90e432977376476d2e852e0ff1dec Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 10:44:41 +0200 Subject: [PATCH 75/88] style: tests: sort --- src/walk.rs | 4 + tests/cmd/build.rs | 442 ++++++++++++++++++++++---------------------- tests/cmd/cache.rs | 180 +++++++++--------- tests/cmd/config.rs | 122 ++++++------ tests/cmd/log.rs | 34 ++-- tests/config.rs | 244 ++++++++++++------------ 6 files changed, 514 insertions(+), 512 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index 97e42e2..72b4dde 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -2,6 +2,10 @@ use std::path::{Path, PathBuf}; use crate::{cache, git, shutdown, Result}; +// ============================================================ +// Free functions +// ============================================================ + /// Collect grammar.js paths via git ls-files and compute their hashes. pub async fn collect_grammar_paths(root: &Path) -> Result> { let files = git::list_grammar_files(root).await?; diff --git a/tests/cmd/build.rs b/tests/cmd/build.rs index 349cd2d..8a918d4 100644 --- a/tests/cmd/build.rs +++ b/tests/cmd/build.rs @@ -13,6 +13,111 @@ use tsdl::parser::WASM_EXTENSION; use crate::cmd::Sandbox; +#[rstest] +#[case::pinned_hash_and_from_cobol("cobol", "6a46906")] +#[case::pinned_leading_v_java("java", "v0.21.0")] +#[case::pinned_master_python("python", "master")] +#[case::pinned_no_leading_v_json("json", "v0.21.0")] +#[case::unpinned_rust("rust", "HEAD")] +#[case::pinned::cmd::typescript("typescript", "v0.21.0")] +fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: &str) { + let config = indoc! { + r#" + [parsers] + java = "v0.21.0" + json = "0.21.0" + python = "master" + typescript = { ref = "0.21.0", cmd = "make" } + cobol = { ref = "6a469068cacb5e3955bb16ad8dfff0dd792883c9", from = "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } + "# + }; + let mut sandbox = Sandbox::new(); + sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); + sandbox + .cmd + .args(["build", language]) + .assert() + .success() + .stdout(p::str::contains(format!("{language:<16} @ {version}"))) + .stdout(p::str::contains(format!( + "{:<16} [4/4] built", + format!("{language}/{language}") + ))); + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); +} + +#[rstest] +fn build_implicit_pinned_and_unpinned() { + let parsers = [ + ("cobol", "6a46906"), + ("java", "v0.21.0"), + ("python", "master"), + ("json", "v0.21.0"), + ("typescript", "v0.21.0"), + ]; + let config = indoc! { + r#" + [parsers] + java = "v0.21.0" + json = "0.21.0" + python = "master" + typescript = { ref = "0.21.0", cmd = "make" } + cobol = { ref = "6a469068cacb5e3955bb16ad8dfff0dd792883c9", from = "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } + "# + }; + let mut sandbox = Sandbox::new(); + sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); + let mut out = sandbox.cmd.arg("build").assert().success(); + for (language, _version) in parsers { + out = out + .stdout(p::str::contains(format!("{language}/{language}"))) + .stdout(p::str::contains("[4/4] built")); + } + for (language, _version) in parsers { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } +} + +#[rstest] +fn build_plain_progress_numbered_correctly() { + let mut sandbox = Sandbox::new(); + let output = sandbox + .cmd + .args(["build", "json", "--progress=plain"]) + .output() + .unwrap(); + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Verify that steps are numbered starting from 1, not 0 + assert!(stdout.contains("[1/"), "stdout should contain [1/"); + assert!(stdout.contains("[2/"), "stdout should contain [2/"); + assert!(stdout.contains("[3/"), "stdout should contain [3/"); + + // Verify no [0/ appears (which was the bug) + assert!( + !stdout.contains("[0/"), + "stdout should not contain [0/ (step numbering started at 0)" + ); + + // Verify the output artifact was created + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); +} + #[rstest] fn build_rejects_malformed_config_file() { let mut sandbox = Sandbox::new(); @@ -34,29 +139,84 @@ fn build_rejects_malformed_config_file() { } #[rstest] -fn no_args_should_download_tree_sitter_cli() { +#[case::default(None, &[DLL_EXTENSION])] +#[cfg_attr(enable_wasm_cases, case::all(Some("all"), &[DLL_EXTENSION, WASM_EXTENSION]))] +#[case::native(Some("native"), &[DLL_EXTENSION])] +#[cfg_attr(enable_wasm_cases, case::wasm(Some("wasm"), &[WASM_EXTENSION]))] +fn build_target(#[case] target: Option<&str>, #[case] exts: &[&str]) { + use std::fmt::Write as _; + + let languages = [("json", "0.21.0")]; + let mut config = String::new(); + writeln!(config, "[parsers]").unwrap(); + for (lang, ver) in languages { + writeln!(config, " {lang} = \"{ver}\"").unwrap(); + } + if let Some(target) = target { + config = format!("target = \"{target}\"\n{config}"); + } let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build"); - sandbox + sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); + sandbox.cmd.args(["build"]).assert().success(); + for (lang, _) in languages { + for ext in exts { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{lang}.{ext}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } + } +} + +#[rstest] +fn multi_parsers_cmd() { + let typescript = "typescript"; + let version = "0.21.0"; + let languages = [typescript, "tsx"]; + let mut sandbox = Sandbox::new(); + let config = formatdoc! { + r#" + [parsers] + typescript = {{ ref = "{version}", cmd = "make" }} + "# + }; + sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); + let assert = sandbox.cmd.args(["build", typescript]).assert().success(); + // Check for version in cloning step + // TODO: dig for changes in this test and revert. + _ = assert.stdout(p::str::contains(format!("{typescript:<16} [1/2] cloning"))); + for language in languages { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } +} + +#[rstest] +fn multi_parsers_no_cmd() { + let java = "java"; + let version = "HEAD"; + let languages = [java]; + let mut sandbox = Sandbox::new(); + let mut assert = sandbox .cmd + .args(["build", java]) .assert() .success() - .stdout(p::str::contains(format!("tree-sitter-cli @ v{VERSION}"))); - assert!(!sandbox.is_empty()); - let tree_sitter_cli = sandbox - .tmp - .child(BUILD_DIR) - .child(format!("tree-sitter-{PLATFORM}-v{VERSION}")); - - tree_sitter_cli - .assert(p::path::exists()) - .assert(p::path::is_file()); - - let tree_sitter_cli = tree_sitter_cli.to_path_buf(); - assert!(tree_sitter_cli.metadata().unwrap().permissions().mode() & 0o111 != 0); - - let gz = tree_sitter_cli.with_extension("gz"); - assert!(!gz.exists()); + .stdout(p::str::contains(format!("{java:<16} @ {version}"))); + for language in languages { + assert = assert.stdout(p::str::contains(format!("{language:<16} [1/2] cloning"))); + } + for language in languages { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } } #[rstest] @@ -92,21 +252,47 @@ fn no_args_should_build_tree_sitter_with_specific_version( } #[rstest] -#[case::gringo(vec!["gringo"])] -#[case::gringo_bringo(vec!["gringo", "bringo"])] -fn unknown_parser_should_fail(#[case] languages: Vec<&str>) { +fn no_args_should_download_tree_sitter_cli() { + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build"); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(format!("tree-sitter-cli @ v{VERSION}"))); + assert!(!sandbox.is_empty()); + let tree_sitter_cli = sandbox + .tmp + .child(BUILD_DIR) + .child(format!("tree-sitter-{PLATFORM}-v{VERSION}")); + + tree_sitter_cli + .assert(p::path::exists()) + .assert(p::path::is_file()); + + let tree_sitter_cli = tree_sitter_cli.to_path_buf(); + assert!(tree_sitter_cli.metadata().unwrap().permissions().mode() & 0o111 != 0); + + let gz = tree_sitter_cli.with_extension("gz"); + assert!(!gz.exists()); +} + +#[rstest] +#[case::json(vec!["json"])] +#[case::json_rust(vec!["json", "rust"])] +fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { let mut sandbox = Sandbox::new(); sandbox.cmd.arg("build").args(&languages); - let mut assert = sandbox.cmd.assert().failure(); + let mut assert = sandbox.cmd.assert().success(); for lang in &languages { assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); } - for lang in languages { - sandbox + for lang in &languages { + let dylib = sandbox .tmp .child(PARSER_OUT_DIR) - .child(format!("{lang}.{DLL_EXTENSION}")) - .assert(p::path::missing()); + .child(format!("{PREFIX}{lang}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); } } @@ -178,206 +364,20 @@ Could not build all parsers. } #[rstest] -#[case::json(vec!["json"])] -#[case::json_rust(vec!["json", "rust"])] -fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { +#[case::gringo(vec!["gringo"])] +#[case::gringo_bringo(vec!["gringo", "bringo"])] +fn unknown_parser_should_fail(#[case] languages: Vec<&str>) { let mut sandbox = Sandbox::new(); sandbox.cmd.arg("build").args(&languages); - let mut assert = sandbox.cmd.assert().success(); + let mut assert = sandbox.cmd.assert().failure(); for lang in &languages { assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); } - for lang in &languages { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{lang}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } -} - -#[rstest] -#[case::pinned_hash_and_from_cobol("cobol", "6a46906")] -#[case::pinned_leading_v_java("java", "v0.21.0")] -#[case::pinned_master_python("python", "master")] -#[case::pinned_no_leading_v_json("json", "v0.21.0")] -#[case::unpinned_rust("rust", "HEAD")] -#[case::pinned::cmd::typescript("typescript", "v0.21.0")] -fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: &str) { - let config = indoc! { - r#" - [parsers] - java = "v0.21.0" - json = "0.21.0" - python = "master" - typescript = { ref = "0.21.0", cmd = "make" } - cobol = { ref = "6a469068cacb5e3955bb16ad8dfff0dd792883c9", from = "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } - "# - }; - let mut sandbox = Sandbox::new(); - sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); - sandbox - .cmd - .args(["build", language]) - .assert() - .success() - .stdout(p::str::contains(format!("{language:<16} @ {version}"))) - .stdout(p::str::contains(format!( - "{:<16} [4/4] built", - format!("{language}/{language}") - ))); - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); -} - -#[rstest] -fn build_implicit_pinned_and_unpinned() { - let parsers = [ - ("cobol", "6a46906"), - ("java", "v0.21.0"), - ("python", "master"), - ("json", "v0.21.0"), - ("typescript", "v0.21.0"), - ]; - let config = indoc! { - r#" - [parsers] - java = "v0.21.0" - json = "0.21.0" - python = "master" - typescript = { ref = "0.21.0", cmd = "make" } - cobol = { ref = "6a469068cacb5e3955bb16ad8dfff0dd792883c9", from = "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } - "# - }; - let mut sandbox = Sandbox::new(); - sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); - let mut out = sandbox.cmd.arg("build").assert().success(); - for (language, _version) in parsers { - out = out - .stdout(p::str::contains(format!("{language}/{language}"))) - .stdout(p::str::contains("[4/4] built")); - } - for (language, _version) in parsers { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } -} - -#[rstest] -fn multi_parsers_no_cmd() { - let java = "java"; - let version = "HEAD"; - let languages = [java]; - let mut sandbox = Sandbox::new(); - let mut assert = sandbox - .cmd - .args(["build", java]) - .assert() - .success() - .stdout(p::str::contains(format!("{java:<16} @ {version}"))); - for language in languages { - assert = assert.stdout(p::str::contains(format!("{language:<16} [1/2] cloning"))); - } - for language in languages { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } -} - -#[rstest] -fn multi_parsers_cmd() { - let typescript = "typescript"; - let version = "0.21.0"; - let languages = [typescript, "tsx"]; - let mut sandbox = Sandbox::new(); - let config = formatdoc! { - r#" - [parsers] - typescript = {{ ref = "{version}", cmd = "make" }} - "# - }; - sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); - let assert = sandbox.cmd.args(["build", typescript]).assert().success(); - // Check for version in cloning step - // TODO: dig for changes in this test and revert. - _ = assert.stdout(p::str::contains(format!("{typescript:<16} [1/2] cloning"))); - for language in languages { - let dylib = sandbox + for lang in languages { + sandbox .tmp .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } -} - -#[rstest] -#[case::default(None, &[DLL_EXTENSION])] -#[cfg_attr(enable_wasm_cases, case::all(Some("all"), &[DLL_EXTENSION, WASM_EXTENSION]))] -#[case::native(Some("native"), &[DLL_EXTENSION])] -#[cfg_attr(enable_wasm_cases, case::wasm(Some("wasm"), &[WASM_EXTENSION]))] -fn build_target(#[case] target: Option<&str>, #[case] exts: &[&str]) { - use std::fmt::Write as _; - - let languages = [("json", "0.21.0")]; - let mut config = String::new(); - writeln!(config, "[parsers]").unwrap(); - for (lang, ver) in languages { - writeln!(config, " {lang} = \"{ver}\"").unwrap(); - } - if let Some(target) = target { - config = format!("target = \"{target}\"\n{config}"); - } - let mut sandbox = Sandbox::new(); - sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); - sandbox.cmd.args(["build"]).assert().success(); - for (lang, _) in languages { - for ext in exts { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{lang}.{ext}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } + .child(format!("{lang}.{DLL_EXTENSION}")) + .assert(p::path::missing()); } } - -#[rstest] -fn build_plain_progress_numbered_correctly() { - let mut sandbox = Sandbox::new(); - let output = sandbox - .cmd - .args(["build", "json", "--progress=plain"]) - .output() - .unwrap(); - - assert!(output.status.success()); - - let stdout = String::from_utf8_lossy(&output.stdout); - - // Verify that steps are numbered starting from 1, not 0 - assert!(stdout.contains("[1/"), "stdout should contain [1/"); - assert!(stdout.contains("[2/"), "stdout should contain [2/"); - assert!(stdout.contains("[3/"), "stdout should contain [3/"); - - // Verify no [0/ appears (which was the bug) - assert!( - !stdout.contains("[0/"), - "stdout should not contain [0/ (step numbering started at 0)" - ); - - // Verify the output artifact was created - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}json.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); -} diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index e09c8e2..389f8a2 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -9,6 +9,59 @@ use tsdl::consts::{BUILD_DIR, CONFIG_FILE, PARSER_OUT_DIR, PLATFORM, PREFIX, VER use crate::cmd::Sandbox; +#[rstest] +fn cache_file_structure() { + let mut sandbox = Sandbox::new(); + + // Build two parsers + sandbox + .cmd + .arg("build") + .args(["json", "python"]) + .assert() + .success(); + + // Read and validate cache file + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); + cache_file.assert(p::path::exists()); + + let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); + + // Verify TOML structure contains expected entries + assert!( + cache_content.contains("[parsers.\"json/json\"]"), + "Cache should have json entry" + ); + assert!( + cache_content.contains("[parsers.\"python/python\"]"), + "Cache should have python entry" + ); + assert!( + cache_content.contains("hash"), + "Cache should have hash field" + ); + assert!( + cache_content.contains("git_ref"), + "Cache should have git_ref field" + ); + assert!( + cache_content.contains("revision"), + "Cache should have revision identity field" + ); + assert!( + cache_content.contains("recipe"), + "Cache should have build recipe field" + ); + assert!( + cache_content.contains("outputs"), + "Cache should have outputs field" + ); + assert!( + !cache_content.lines().any(|line| line.starts_with("file")), + "Cache should not serialize its runtime storage path" + ); +} + #[rstest] fn cache_hit_skips_build() { let mut sandbox = Sandbox::new(); @@ -77,43 +130,6 @@ fn cache_miss_on_grammar_modification() { .stdout(p::str::contains("json/json [3/4] building")); } -#[rstest] -fn fresh_flag_clears_build_dir() { - let mut sandbox = Sandbox::new(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - let build_dir = sandbox.tmp.child(BUILD_DIR); - build_dir.assert(p::path::exists()); - - let cache_file = build_dir.child("cache.toml"); - cache_file.assert(p::path::exists()); - - let first_binary = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}json.{DLL_EXTENSION}")); - let first_inode = first_binary.metadata().unwrap().ino(); - - // Second build with --fresh (need --force to overwrite existing binary) - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--fresh", "--force", "json"]) - .assert() - .success(); - - // Cache file should be gone and recreated - cache_file.assert(p::path::exists()); - - let second_inode = first_binary.metadata().unwrap().ino(); - - assert_ne!( - first_inode, second_inode, - "Fresh build should create new binary with different inode" - ); -} - #[rstest] fn force_flag_bypasses_cache() { let mut sandbox = Sandbox::new(); @@ -195,6 +211,43 @@ fn force_flag_reinstalls_hardlink() { ); } +#[rstest] +fn fresh_flag_clears_build_dir() { + let mut sandbox = Sandbox::new(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + let build_dir = sandbox.tmp.child(BUILD_DIR); + build_dir.assert(p::path::exists()); + + let cache_file = build_dir.child("cache.toml"); + cache_file.assert(p::path::exists()); + + let first_binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + let first_inode = first_binary.metadata().unwrap().ino(); + + // Second build with --fresh (need --force to overwrite existing binary) + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd.args(["build", "--fresh", "--force", "json"]) + .assert() + .success(); + + // Cache file should be gone and recreated + cache_file.assert(p::path::exists()); + + let second_inode = first_binary.metadata().unwrap().ino(); + + assert_ne!( + first_inode, second_inode, + "Fresh build should create new binary with different inode" + ); +} + #[rstest] #[case::json_and_python(vec!["json", "python"])] fn multi_parser_independent_cache(#[case] languages: Vec<&str>) { @@ -225,56 +278,3 @@ fn multi_parser_independent_cache(#[case] languages: Vec<&str>) { ))); } } - -#[rstest] -fn cache_file_structure() { - let mut sandbox = Sandbox::new(); - - // Build two parsers - sandbox - .cmd - .arg("build") - .args(["json", "python"]) - .assert() - .success(); - - // Read and validate cache file - let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); - cache_file.assert(p::path::exists()); - - let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); - - // Verify TOML structure contains expected entries - assert!( - cache_content.contains("[parsers.\"json/json\"]"), - "Cache should have json entry" - ); - assert!( - cache_content.contains("[parsers.\"python/python\"]"), - "Cache should have python entry" - ); - assert!( - cache_content.contains("hash"), - "Cache should have hash field" - ); - assert!( - cache_content.contains("git_ref"), - "Cache should have git_ref field" - ); - assert!( - cache_content.contains("revision"), - "Cache should have revision identity field" - ); - assert!( - cache_content.contains("recipe"), - "Cache should have build recipe field" - ); - assert!( - cache_content.contains("outputs"), - "Cache should have outputs field" - ); - assert!( - !cache_content.lines().any(|line| line.starts_with("file")), - "Cache should not serialize its runtime storage path" - ); -} diff --git a/tests/cmd/config.rs b/tests/cmd/config.rs index c17934a..b2f2caf 100644 --- a/tests/cmd/config.rs +++ b/tests/cmd/config.rs @@ -10,41 +10,69 @@ use tsdl::{ use crate::cmd::Sandbox; #[test] -fn no_args_shows_help() { +fn current_rejects_malformed_config_file() { let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); + sandbox .cmd - .args(["config"]) + .args(["config", "current"]) .assert() .failure() - .stderr(p::str::starts_with("Configuration helpers")) - .stderr(p::str::contains(format!( - "Usage: {} config [OPTIONS] ", - env!("CARGO_PKG_NAME") - ))); - assert!(sandbox.is_empty()); + .stderr(p::str::contains( + "Resolving build configuration for `config current`", + )) + .stderr(p::str::contains("Parsing config file")); } #[test] -fn default_is_default_toml() { +fn current_uses_config_file() { + let build_dir = "build-dir"; + let out_dir = "out-dir"; + let config = formatdoc! { + r#" + build-dir = "{build_dir}" + out = "{out_dir}" + "# + }; let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["config", "default"]); - sandbox.cmd.assert().success().stdout(p::str::contains( - toml::to_string(&BuildCommand::default()).unwrap(), - )); + sandbox.config(&config); + sandbox.cmd.args(["config", "current"]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); + assert!(!sandbox.is_empty()); + sandbox.tmp.child(build_dir).assert(p::path::missing()); +} + +#[test] +fn current_uses_default() { + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["config", "current"]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); assert!(sandbox.is_empty()); } #[test] -fn default_uses_explicit_log_file() { +fn current_uses_explicit_log_file() { let mut sandbox = Sandbox::new(); sandbox .cmd - .args(["--log", "config-default.log", "config", "default"]); + .args(["--log", "config-current.log", "config", "current"]); sandbox.cmd.assert().success(); sandbox .tmp - .child("config-default.log") + .child("config-current.log") .assert(p::path::exists()) .assert(p::path::is_file()); sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); @@ -70,70 +98,42 @@ fn default_ignores_malformed_config_file() { } #[test] -fn current_rejects_malformed_config_file() { - let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(CONFIG_FILE) - .write_str("not valid toml =") - .unwrap(); - - sandbox - .cmd - .args(["config", "current"]) - .assert() - .failure() - .stderr(p::str::contains( - "Resolving build configuration for `config current`", - )) - .stderr(p::str::contains("Parsing config file")); -} - -#[test] -fn current_uses_default() { +fn default_is_default_toml() { let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["config", "current"]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); + sandbox.cmd.args(["config", "default"]); + sandbox.cmd.assert().success().stdout(p::str::contains( + toml::to_string(&BuildCommand::default()).unwrap(), + )); assert!(sandbox.is_empty()); } #[test] -fn current_uses_explicit_log_file() { +fn default_uses_explicit_log_file() { let mut sandbox = Sandbox::new(); sandbox .cmd - .args(["--log", "config-current.log", "config", "current"]); + .args(["--log", "config-default.log", "config", "default"]); sandbox.cmd.assert().success(); sandbox .tmp - .child("config-current.log") + .child("config-default.log") .assert(p::path::exists()) .assert(p::path::is_file()); sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); } #[test] -fn current_uses_config_file() { - let build_dir = "build-dir"; - let out_dir = "out-dir"; - let config = formatdoc! { - r#" - build-dir = "{build_dir}" - out = "{out_dir}" - "# - }; +fn no_args_shows_help() { let mut sandbox = Sandbox::new(); - sandbox.config(&config); - sandbox.cmd.args(["config", "current"]); sandbox .cmd + .args(["config"]) .assert() - .success() - .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); - assert!(!sandbox.is_empty()); - sandbox.tmp.child(build_dir).assert(p::path::missing()); + .failure() + .stderr(p::str::starts_with("Configuration helpers")) + .stderr(p::str::contains(format!( + "Usage: {} config [OPTIONS] ", + env!("CARGO_PKG_NAME") + ))); + assert!(sandbox.is_empty()); } diff --git a/tests/cmd/log.rs b/tests/cmd/log.rs index 3644962..889ba84 100644 --- a/tests/cmd/log.rs +++ b/tests/cmd/log.rs @@ -24,6 +24,23 @@ fn build_no_args_should_log_to_default_path() { .assert(p::path::is_file()); } +#[rstest] +#[case::nested_in_build_dir("tmp/logs/tsdl.log", "nested inside --build-dir")] +#[case::lock_file("tmp/tsdl.lock", "conflicts with a tsdl runtime/build file")] +#[case::cache_file("tmp/cache.toml", "conflicts with a tsdl runtime/build file")] +fn build_rejects_log_paths_that_conflict_with_fresh_cleanup( + #[case] log: &str, + #[case] expected: &str, +) { + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["build", "--log", log]); + sandbox + .cmd + .assert() + .failure() + .stderr(p::str::contains(expected)); +} + #[rstest] #[case::cwd("tsdl.log")] #[case::child_dir("here/log")] @@ -45,23 +62,6 @@ fn build_w_specific_log_path(#[case] log: &str) { .assert(p::path::is_file()); } -#[rstest] -#[case::nested_in_build_dir("tmp/logs/tsdl.log", "nested inside --build-dir")] -#[case::lock_file("tmp/tsdl.lock", "conflicts with a tsdl runtime/build file")] -#[case::cache_file("tmp/cache.toml", "conflicts with a tsdl runtime/build file")] -fn build_rejects_log_paths_that_conflict_with_fresh_cleanup( - #[case] log: &str, - #[case] expected: &str, -) { - let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["build", "--log", log]); - sandbox - .cmd - .assert() - .failure() - .stderr(p::str::contains(expected)); -} - #[rstest] fn fresh_preserves_root_level_custom_log_and_removes_build_entries() { let mut sandbox = Sandbox::new(); diff --git a/tests/config.rs b/tests/config.rs index 49305ca..588ee12 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,4 +1,4 @@ -use std::{env, ffi::OsString, sync::Mutex}; +use std::{env, ffi::OsString, path::PathBuf, sync::Mutex}; use anyhow::Result; use assert_fs::prelude::*; @@ -6,8 +6,6 @@ use indoc::{formatdoc, indoc}; #[cfg(test)] use pretty_assertions::{assert_eq, assert_ne}; -use std::path::PathBuf; - use tsdl::{ args::{self, BuildCommand, Target}, config::{self, Source}, @@ -21,14 +19,6 @@ struct EnvVarGuard { previous: Option, } -impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = env::var_os(key); - env::set_var(key, value); - Self { key, previous } - } -} - impl Drop for EnvVarGuard { fn drop(&mut self) { match &self.previous { @@ -38,6 +28,14 @@ impl Drop for EnvVarGuard { } } +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var_os(key); + env::set_var(key, value); + Self { key, previous } + } +} + fn current_with_cli(config: &assert_fs::fixture::ChildPath, argv: &[&str]) -> BuildCommand { let _lock = ENV_LOCK.lock().unwrap(); let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); @@ -56,48 +54,80 @@ fn current_with_cli_provenance( } #[test] -fn current_from_generated_default() -> Result<()> { +fn boolean_env_can_override_config_file() -> Result<()> { + let _lock = ENV_LOCK.lock().unwrap(); + let _force = EnvVarGuard::set("FORCE", "false"); + let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.write_str(&toml::to_string(&def)?)?; - assert_eq!(def, config::current(generated.path(), None).unwrap()); + generated.write_str("force = true\n")?; + + let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + + assert!(!cmd.force); + assert_eq!(prov.force, Source::Environment); Ok(()) } #[test] -fn current_from_empty() -> Result<()> { +fn cli_can_override_config_to_builtin_default_value() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.touch()?; - assert_eq!(def, config::current(generated.path(), None).unwrap()); + generated.write_str("target = \"wasm\"\n")?; + + let resolved = current_with_cli(&generated, &["tsdl", "build", "--target", "native"]); + + assert_eq!(resolved.target, Target::Native); Ok(()) } #[test] -fn current_preserves_cli_languages() -> Result<()> { +fn cli_can_override_tree_sitter_version_to_builtin_default_value() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); - generated.touch()?; - - let def = BuildCommand::default(); - assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); + generated.write_str("[tree-sitter]\nversion = \"0.25.0\"\n")?; - let mut expected = BuildCommand { - languages: Some(vec!["rust".to_string()]), - ..BuildCommand::default() - }; - assert_eq!( - expected, - current_with_cli(&generated, &["tsdl", "build", "rust"]) + let resolved = current_with_cli( + &generated, + &["tsdl", "build", "--tree-sitter-version", VERSION], ); - expected.languages = Some(vec!["rust".to_string(), "ruby".to_string()]); - assert_eq!( - expected, - current_with_cli(&generated, &["tsdl", "build", "rust", "ruby"]) - ); + assert_eq!(resolved.tree_sitter.version, VERSION); + Ok(()) +} + +#[test] +fn cli_explicit_default_value_overrides_config_file() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("build-dir = \"/custom\"\n")?; + + let (cmd, prov) = + current_with_cli_provenance(&generated, &["tsdl", "build", "--build-dir", "tmp"]); + + assert_eq!(cmd.build_dir, PathBuf::from("tmp")); + assert_eq!(prov.build_dir, Source::CommandLine); + Ok(()) +} + +#[test] +fn cli_has_precedence_over_env() -> Result<()> { + let _lock = ENV_LOCK.lock().unwrap(); + let _target = EnvVarGuard::set("TSDL_TARGET", "wasm"); + + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; + + let (_parsed_args, matches) = + config::try_parse_from_with_matches(["tsdl", "build", "--target", "native"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + + assert_eq!(cmd.target, Target::Native); + assert_eq!(prov.target, Source::CommandLine); Ok(()) } @@ -132,6 +162,26 @@ fn current_default_is_default() -> Result<()> { Ok(()) } +#[test] +fn current_from_empty() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.touch()?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + Ok(()) +} + +#[test] +fn current_from_generated_default() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.write_str(&toml::to_string(&def)?)?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + Ok(()) +} + #[test] fn current_overrides_default() -> Result<()> { let config_contents = indoc! { @@ -158,66 +208,28 @@ fn current_overrides_default() -> Result<()> { } #[test] -fn cli_can_override_config_to_builtin_default_value() -> Result<()> { +fn current_preserves_cli_languages() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); - generated.write_str("target = \"wasm\"\n")?; - - let resolved = current_with_cli(&generated, &["tsdl", "build", "--target", "native"]); - - assert_eq!(resolved.target, Target::Native); - Ok(()) -} + generated.touch()?; -#[test] -fn cli_can_override_tree_sitter_version_to_builtin_default_value() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("[tree-sitter]\nversion = \"0.25.0\"\n")?; + let def = BuildCommand::default(); + assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); - let resolved = current_with_cli( - &generated, - &["tsdl", "build", "--tree-sitter-version", VERSION], + let mut expected = BuildCommand { + languages: Some(vec!["rust".to_string()]), + ..BuildCommand::default() + }; + assert_eq!( + expected, + current_with_cli(&generated, &["tsdl", "build", "rust"]) ); - assert_eq!(resolved.tree_sitter.version, VERSION); - Ok(()) -} - -#[test] -fn negative_boolean_flags_override_config_file() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("force = true\nfresh = true\nshow-config = true\n")?; - - let resolved = current_with_cli( - &generated, - &[ - "tsdl", - "build", - "--no-force", - "--no-fresh", - "--no-show-config", - ], + expected.languages = Some(vec!["rust".to_string(), "ruby".to_string()]); + assert_eq!( + expected, + current_with_cli(&generated, &["tsdl", "build", "rust", "ruby"]) ); - - assert!(!resolved.force); - assert!(!resolved.fresh); - assert!(!resolved.show_config); - Ok(()) -} - -#[test] -fn negative_boolean_flag_overrides_positive() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.touch()?; - - let (cmd, prov) = - current_with_cli_provenance(&generated, &["tsdl", "build", "--force=true", "--no-force"]); - - assert!(!cmd.force); - assert_eq!(prov.force, Source::CommandLine); Ok(()) } @@ -240,52 +252,38 @@ fn env_can_override_config_to_builtin_default_value() -> Result<()> { } #[test] -fn boolean_env_can_override_config_file() -> Result<()> { - let _lock = ENV_LOCK.lock().unwrap(); - let _force = EnvVarGuard::set("FORCE", "false"); - - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("force = true\n")?; - - let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); - let build_matches = args::build_matches(&matches); - let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; - - assert!(!cmd.force); - assert_eq!(prov.force, Source::Environment); - Ok(()) -} - -#[test] -fn cli_has_precedence_over_env() -> Result<()> { - let _lock = ENV_LOCK.lock().unwrap(); - let _target = EnvVarGuard::set("TSDL_TARGET", "wasm"); - +fn negative_boolean_flag_overrides_positive() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); generated.touch()?; - let (_parsed_args, matches) = - config::try_parse_from_with_matches(["tsdl", "build", "--target", "native"]).unwrap(); - let build_matches = args::build_matches(&matches); - let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + let (cmd, prov) = + current_with_cli_provenance(&generated, &["tsdl", "build", "--force=true", "--no-force"]); - assert_eq!(cmd.target, Target::Native); - assert_eq!(prov.target, Source::CommandLine); + assert!(!cmd.force); + assert_eq!(prov.force, Source::CommandLine); Ok(()) } #[test] -fn cli_explicit_default_value_overrides_config_file() -> Result<()> { +fn negative_boolean_flags_override_config_file() -> Result<()> { let temp = assert_fs::TempDir::new()?; let generated = temp.child("generated.toml"); - generated.write_str("build-dir = \"/custom\"\n")?; + generated.write_str("force = true\nfresh = true\nshow-config = true\n")?; - let (cmd, prov) = - current_with_cli_provenance(&generated, &["tsdl", "build", "--build-dir", "tmp"]); + let resolved = current_with_cli( + &generated, + &[ + "tsdl", + "build", + "--no-force", + "--no-fresh", + "--no-show-config", + ], + ); - assert_eq!(cmd.build_dir, PathBuf::from("tmp")); - assert_eq!(prov.build_dir, Source::CommandLine); + assert!(!resolved.force); + assert!(!resolved.fresh); + assert!(!resolved.show_config); Ok(()) } From 0bce55dec3394fc12152208c08443b13b15ac048 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 12:49:54 +0200 Subject: [PATCH 76/88] style: structs+enums: sort --- src/actors/display.rs | 18 ++++++++-------- src/columns.rs | 18 ++++++++-------- src/config.rs | 2 +- src/display.rs | 48 +++++++++++++++++++++---------------------- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/actors/display.rs b/src/actors/display.rs index 2754d19..4f3bba4 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -788,16 +788,16 @@ impl DisplayActor { self.state.grammars.insert( id, display::GrammarEntry { + frozen_elapsed: None, + git_ref, + msg: Arc::from(""), + name, repo: language, repo_id, - name, - git_ref, + started_at: Instant::now(), state: display::ItemState::New, - msg: Arc::from(""), step: 0, total: num_tasks, - started_at: Instant::now(), - frozen_elapsed: None, }, ); @@ -824,14 +824,14 @@ impl DisplayActor { self.state.repos.insert( id, display::RepoEntry { - name, + frozen_elapsed: None, git_ref, - state: display::ItemState::New, msg: Arc::from(""), + name, + started_at: Instant::now(), + state: display::ItemState::New, step: 0, total: num_tasks, - started_at: Instant::now(), - frozen_elapsed: None, }, ); diff --git a/src/columns.rs b/src/columns.rs index 677fb49..824dc59 100644 --- a/src/columns.rs +++ b/src/columns.rs @@ -236,12 +236,12 @@ impl<'a> Options<'a> { #[must_use] pub const fn git(indent: &'a str, width: usize) -> Self { Self { - layout: Layout::Column, dense: false, - width, - padding: 1, indent, + layout: Layout::Column, line_ending: "\n", + padding: 1, + width, } } } @@ -256,12 +256,12 @@ mod tests { const fn options(layout: Layout, width: usize) -> Options<'static> { Options { - layout, dense: false, - width, - padding: 1, indent: "", + layout, line_ending: "\n", + padding: 1, + width, } } @@ -273,12 +273,12 @@ mod tests { #[test] fn plain_layout_prints_one_indented_item_per_line() { let opts = Options { - layout: Layout::Plain, dense: false, - width: 80, - padding: 1, indent: "Z", + layout: Layout::Plain, line_ending: "\n", + padding: 1, + width: 80, }; assert_eq!( diff --git a/src/config.rs b/src/config.rs index 674496f..67a788f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -306,9 +306,9 @@ fn merge_provenance(file: &BuildProvenance, cli: &BuildProvenance) -> BuildProve show_config: merge_source(file.show_config, cli.show_config), target: merge_source(file.target, cli.target), tree_sitter: TreeSitterProvenance { - version: merge_source(file.tree_sitter.version, cli.tree_sitter.version), platform: merge_source(file.tree_sitter.platform, cli.tree_sitter.platform), repo: merge_source(file.tree_sitter.repo, cli.tree_sitter.repo), + version: merge_source(file.tree_sitter.version, cli.tree_sitter.version), }, unlock_timeout: merge_source(file.unlock_timeout, cli.unlock_timeout), } diff --git a/src/display.rs b/src/display.rs index 3da4be7..92b5e22 100644 --- a/src/display.rs +++ b/src/display.rs @@ -40,17 +40,17 @@ const TIME_STYLE: Style = Style::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum Column { - Time, - Ref, - Step, Icon, - Name, Msg, + Name, + Ref, + Step, + Time, } pub(crate) enum ItemInfo<'a> { - Repo(&'a RepoEntry), Grammar(&'a GrammarEntry), + Repo(&'a RepoEntry), } /// Complete lifecycle state for a display row. @@ -81,16 +81,16 @@ pub enum Mode { #[derive(Debug, Clone)] pub(crate) enum RowKind { - Repo, Grammar, + Repo, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SuccessOutcome { - /// Cache hit. - Cached, /// Not cached; work was needed. Built, + /// Cache hit. + Cached, } // ============================================================ @@ -503,43 +503,43 @@ impl ItemId { impl ItemInfo<'_> { pub(crate) fn state(&self) -> ItemState { match self { - ItemInfo::Repo(r) => r.state, ItemInfo::Grammar(g) => g.state, + ItemInfo::Repo(r) => r.state, } } pub(crate) fn msg(&self) -> &str { match self { - ItemInfo::Repo(r) => &r.msg, ItemInfo::Grammar(g) => &g.msg, + ItemInfo::Repo(r) => &r.msg, } } pub(crate) fn step(&self) -> usize { match self { - ItemInfo::Repo(r) => r.step, ItemInfo::Grammar(g) => g.step, + ItemInfo::Repo(r) => r.step, } } pub(crate) fn total(&self) -> usize { match self { - ItemInfo::Repo(r) => r.total, ItemInfo::Grammar(g) => g.total, + ItemInfo::Repo(r) => r.total, } } pub(crate) fn git_ref(&self) -> &git::Ref { match self { - ItemInfo::Repo(r) => &r.git_ref, ItemInfo::Grammar(g) => &g.git_ref, + ItemInfo::Repo(r) => &r.git_ref, } } pub(crate) fn elapsed(&self) -> Duration { match self { - ItemInfo::Repo(r) => r.elapsed(), ItemInfo::Grammar(g) => g.elapsed(), + ItemInfo::Repo(r) => r.elapsed(), } } } @@ -598,13 +598,13 @@ impl State { let footer_build = dim_line(format!("build: {}", build_dir.display())); let footer_out = dim_line(format!("out: {}", out_dir.display())); Self { - mode, - repos: HashMap::new(), - grammars: HashMap::new(), build_dir, - out_dir, footer_build, footer_out, + grammars: HashMap::new(), + mode, + out_dir, + repos: HashMap::new(), } } @@ -708,16 +708,16 @@ impl State { pub fn get_item_info(&self, spec: &RowSpec) -> ItemInfo<'_> { match spec.kind { - RowKind::Repo => ItemInfo::Repo( - self.repos - .get(&spec.id) - .expect("repo not found for row spec"), - ), RowKind::Grammar => ItemInfo::Grammar( self.grammars .get(&spec.id) .expect("grammar not found for row spec"), ), + RowKind::Repo => ItemInfo::Repo( + self.repos + .get(&spec.id) + .expect("repo not found for row spec"), + ), } } @@ -789,8 +789,8 @@ impl State { impl SuccessOutcome { fn color(self) -> Color { match self { - SuccessOutcome::Cached => Color::Yellow, SuccessOutcome::Built => Color::Blue, + SuccessOutcome::Cached => Color::Yellow, } } } From c2a19c586384296d2df6b881f5529c800d063b1c Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 13:42:43 +0200 Subject: [PATCH 77/88] style: functions: reorder --- src/actors/cache.rs | 6 +- src/actors/display.rs | 154 +++++++++++++++++++++++++++++------------- src/actors/mod.rs | 16 ++--- src/build.rs | 46 ++++++------- src/cache.rs | 30 ++++---- src/columns.rs | 12 ++-- src/display.rs | 47 ++++++++----- src/git.rs | 6 +- src/main.rs | 2 +- src/parser.rs | 68 +++++++++---------- src/tree_sitter.rs | 2 +- 11 files changed, 231 insertions(+), 158 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index d35322a..2a947fc 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -106,7 +106,7 @@ impl CacheActor { let decision = if self.force { cache::Decision::miss(cache::MissReason::CacheIgnored) } else { - self.db.rebuild_decision(&name, &hash, &spec, &revision) + self.db.rebuild_decision(&name, &hash, &revision, &spec) }; if decision.needs_rebuild() { info!("Cache miss for {name}: {}", decision.short_message()); @@ -161,7 +161,7 @@ impl CacheActor { } #[must_use] - pub fn spawn(db: cache::Db, store: cache::Store, force: bool) -> CacheAddr { + pub fn spawn(db: cache::Db, force: bool, store: cache::Store) -> CacheAddr { let (tx, rx) = mpsc::channel(64); let actor = Self { db, @@ -198,8 +198,8 @@ impl CacheAddr { &self, name: cache::Key, hash: cache::GrammarHash, - spec: Arc, revision: cache::Revision, + spec: Arc, ) -> cache::Decision { self.request(|tx| CacheMessage::NeedsRebuild { name, diff --git a/src/actors/display.rs b/src/actors/display.rs index 4f3bba4..3dab1ad 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -87,9 +87,9 @@ pub struct DisplayActor { /// Handle for updating a specific progress bar (repo or grammar). #[derive(Debug, Clone)] pub struct DisplayAddr { - tx: mpsc::Sender, #[allow(dead_code)] mode: display::Mode, + tx: mpsc::Sender, } struct PlainLine { @@ -656,37 +656,47 @@ impl DisplayActor { // TIME: stale when dirty OR clock is still running let time_stale = is_dirty || info.state().is_live(); - let time = self - .grid - .cell(item_id, display::Column::Time, time_stale, || { - display::compute_time_cell(&info, &layout) - }); - - let gref = self.grid.cell(item_id, display::Column::Ref, is_dirty, || { - display::compute_ref_cell(&info, &layout) - }); - - let step = self - .grid - .cell(item_id, display::Column::Step, is_dirty, || { - display::compute_step_cell(&info, &layout) - }); - - let icon = self - .grid - .cell(item_id, display::Column::Icon, is_dirty, || { - display::compute_icon_cell(&info) - }); - - let name = self - .grid - .cell(item_id, display::Column::Name, is_dirty, || { - display::compute_name_cell(&spec.display_name, spec.indent, &info, &layout) - }); - - let msg = self.grid.cell(item_id, display::Column::Msg, is_dirty, || { - display::compute_msg_cell(&info, &layout, term_w) - }); + let time = self.grid.cell( + item_id, + display::Column::Time, + || display::compute_time_cell(&info, &layout), + time_stale, + ); + + let gref = self.grid.cell( + item_id, + display::Column::Ref, + || display::compute_ref_cell(&info, &layout), + is_dirty, + ); + + let step = self.grid.cell( + item_id, + display::Column::Step, + || display::compute_step_cell(&info, &layout), + is_dirty, + ); + + let icon = self.grid.cell( + item_id, + display::Column::Icon, + || display::compute_icon_cell(&info), + is_dirty, + ); + + let name = self.grid.cell( + item_id, + display::Column::Name, + || display::compute_name_cell(&spec.display_name, spec.indent, &info, &layout), + is_dirty, + ); + + let msg = self.grid.cell( + item_id, + display::Column::Msg, + || display::compute_msg_cell(&info, &layout, term_w), + is_dirty, + ); lines.push(Line::from(vec![ time, @@ -763,9 +773,12 @@ impl DisplayActor { } fn print_plain_summary(&self) { - let (cached, built, _building, failed, cancelled) = self.state.summary_counts(); + let s = self.state.summary(); println!(); - println!("✓ {cached} cached ✓ {built} built ✗ {cancelled} cancelled ✗ {failed} failed"); + println!( + "✓ {} cached ✓ {} built ✗ {} cancelled ✗ {} failed", + s.cached, s.built, s.cancelled, s.failed + ); } fn register_grammar( @@ -991,10 +1004,10 @@ impl DisplayActor { } #[must_use] - pub fn spawn(mode: display::Mode, build_dir: PathBuf, out_dir: PathBuf) -> DisplayAddr { + pub fn spawn(build_dir: PathBuf, mode: display::Mode, out_dir: PathBuf) -> DisplayAddr { let (tx, rx) = mpsc::channel(256); let actor = Self { - state: display::State::new(mode, build_dir, out_dir), + state: display::State::new(build_dir, mode, out_dir), next_id: display::ItemId::new(NonZeroU64::MIN), plain_name_width: 16, plain_progress_started: false, @@ -1010,7 +1023,7 @@ impl DisplayActor { actor.run().await; }); - DisplayAddr::new(tx, mode) + DisplayAddr::new(mode, tx) } fn sync_parent_repo(&mut self, repo_id: display::ItemId) { @@ -1079,8 +1092,8 @@ impl DisplayActor { impl DisplayAddr { pub async fn add_language>>( &self, - git_ref: git::Ref, name: S, + git_ref: git::Ref, num_tasks: usize, ) -> ProgressAddr { self.request(|tx| Message::RegisterLanguage { @@ -1094,9 +1107,9 @@ impl DisplayAddr { pub async fn add_grammar>>( &self, - git_ref: git::Ref, language: S, name: S, + git_ref: git::Ref, num_tasks: usize, ) -> ProgressAddr { self.request(|tx| Message::RegisterGrammar { @@ -1110,11 +1123,11 @@ impl DisplayAddr { } #[must_use] - pub fn new(tx: mpsc::Sender, mode: display::Mode) -> Self { - Self { tx, mode } + pub fn new(mode: display::Mode, tx: mpsc::Sender) -> Self { + Self { mode, tx } } - pub async fn reference>>(&self, git_ref: git::Ref, name: S) { + pub async fn reference>>(&self, name: S, git_ref: git::Ref) { self.fire(Message::RegisterReference { git_ref, name: name.into(), @@ -1192,8 +1205,8 @@ mod tests { let (tx, rx) = mpsc::channel(1); DisplayActor { state: display::State::new( - display::Mode::Fancy, PathBuf::from("build"), + display::Mode::Fancy, PathBuf::from("out"), ), next_id: display::ItemId::new(NonZeroU64::MIN), @@ -1297,7 +1310,16 @@ mod tests { let failed = actor.register_repo("failed".into(), git::Ref::head(), 1); actor.apply_update(failed.id, UpdateKind::Err, Arc::from("failed")); - assert_eq!(actor.state.summary_counts(), (1, 1, 1, 1, 0)); + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 1, + built: 1, + cached: 1, + cancelled: 0, + failed: 1, + } + ); } #[test] @@ -1312,7 +1334,16 @@ mod tests { actor.apply_update(grammar.id, UpdateKind::SetOutcomeCached, Arc::from("")); actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); - assert_eq!(actor.state.summary_counts(), (1, 0, 0, 0, 0)); + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 1, + cancelled: 0, + failed: 0, + } + ); } #[test] @@ -1337,7 +1368,16 @@ mod tests { assert_eq!(repo_entry.msg.as_ref(), "cancelled"); assert!(repo_entry.frozen_elapsed.is_some()); - assert_eq!(actor.state.summary_counts(), (0, 0, 0, 0, 1)); + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 0, + } + ); } #[test] @@ -1394,7 +1434,16 @@ mod tests { assert_eq!(repo_entry.msg.as_ref(), "failed"); assert!(repo_entry.frozen_elapsed.is_some()); - assert_eq!(actor.state.summary_counts(), (0, 0, 0, 1, 1)); + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 1, + } + ); } #[test] @@ -1426,7 +1475,16 @@ mod tests { assert_eq!(repo_entry.state, display::ItemState::Cancelled); assert_eq!(repo_entry.msg.as_ref(), "cancelled"); - assert_eq!(actor.state.summary_counts(), (0, 0, 0, 0, 1)); + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 0, + } + ); } #[test] diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 8df0814..1c9699a 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -72,8 +72,8 @@ async fn discover_grammars( let progress = display .add_language( - language.spec.git_ref.requested().clone(), language.name.as_arc(), + language.spec.git_ref.requested().clone(), 2, ) .await; @@ -110,19 +110,19 @@ async fn discover_grammars( // Map the raw discovery data into Build structs, now with the resolved // tree-sitter version available for accurate cache-key comparison. let mut builds = Vec::new(); - for (name, dir, hash) in grammars { + for (name, hash, dir) in grammars { shutdown::test_delay().await; shutdown::check()?; let key = crate::cache::Key::new(&language.name, &name); let artifacts = parser::GrammarBuild::required_artifacts_for( + &name, &language.output.build_dir, - &ts_cli, &language.spec, - &name, + &ts_cli, )?; let cache_decision = cache - .needs_rebuild(key, hash.clone(), language.spec.clone(), revision.clone()) + .needs_rebuild(key, hash.clone(), revision.clone(), language.spec.clone()) .await; // Cache actor checks in-memory metadata only. When it reports a hit @@ -135,9 +135,9 @@ async fn discover_grammars( let progress = display .add_grammar( - language.spec.git_ref.requested().clone(), language.name.as_arc(), name.as_arc(), + language.spec.git_ref.requested().clone(), 4, ) .await; @@ -237,12 +237,12 @@ pub async fn run( } }; - display.reference(tree_sitter_ref, "tree-sitter-cli").await; + display.reference("tree-sitter-cli", tree_sitter_ref).await; for language in &languages { display .reference( - language.spec.git_ref.requested().clone(), language.name.as_arc(), + language.spec.git_ref.requested().clone(), ) .await; } diff --git a/src/build.rs b/src/build.rs index 9014143..4a92d1c 100644 --- a/src/build.rs +++ b/src/build.rs @@ -89,10 +89,10 @@ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result, guard: &lock::Guard, + fresh: bool, + log_path: Option<&Path>, ) -> Result<()> { if fresh && build_dir.as_path().exists() { let protected_files = log_path @@ -133,15 +133,15 @@ fn default_repo(language: &str) -> Result { fn get_language_coords( language: &str, defined_parsers: Option<&BTreeMap>, -) -> Result<(Option, parser::Ref, Url)> { +) -> Result<(Url, parser::Ref, Option)> { let config = defined_parsers.and_then(|parsers| parsers.get(language)); match config { Some(args::ParserConfig::Ref(git_ref)) => Ok(( - None, + default_repo(language)?, parser::Ref::parse(git_ref) .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, - default_repo(language)?, + None, )), Some(args::ParserConfig::Full { @@ -156,14 +156,14 @@ fn get_language_coords( }; Ok(( - build_script.clone(), + repo, parser::Ref::parse(git_ref) .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, - repo, + build_script.clone(), )) } - None => Ok((None, parser::Ref::head(), default_repo(language)?)), + None => Ok((default_repo(language)?, parser::Ref::head(), None)), } } @@ -197,7 +197,7 @@ fn handle_locked_by( lock.wait_for_release(owner, unlock_timeout) } -fn ignite(app: &app::App, command: &args::BuildCommand, build_dir: &BuildDir) -> Result<()> { +fn ignite(command: &args::BuildCommand, app: &app::App, build_dir: &BuildDir) -> Result<()> { fs::create_dir_all(&command.out_dir)?; let rt = tokio::runtime::Builder::new_current_thread() @@ -213,11 +213,11 @@ fn ignite(app: &app::App, command: &args::BuildCommand, build_dir: &BuildDir) -> let result = rt.block_on(async move { let shutdown = shutdown::Handle::new(); let _signals = shutdown.spawn_signal_listener()?; - let cache = actors::CacheActor::spawn(db, cache_store, command.force); + let cache = actors::CacheActor::spawn(db, command.force, cache_store); let display_build_dir = build_dir.as_path().to_path_buf(); let display_out_dir = command.out_dir.canon()?; let display = - actors::DisplayActor::spawn(app.progress_mode, display_build_dir, display_out_dir); + actors::DisplayActor::spawn(display_build_dir, app.progress_mode, display_out_dir); shutdown::scope(shutdown, async move { actors::run( @@ -240,7 +240,7 @@ fn ignite(app: &app::App, command: &args::BuildCommand, build_dir: &BuildDir) -> result } -pub fn run(app: &app::App, command: &args::BuildCommand) -> Result<()> { +pub fn run(command: &args::BuildCommand, app: &app::App) -> Result<()> { if command.show_config { crate::config::show(command)?; } @@ -249,8 +249,8 @@ pub fn run(app: &app::App, command: &args::BuildCommand) -> Result<()> { let lock = lock::Lock::new(&build_dir); let guard = acquire_lock(&lock, Duration::from_secs(command.unlock_timeout))?; - clear(command.fresh, &build_dir, app.logging.path(), &guard)?; - ignite(app, command, &build_dir)?; + clear(&build_dir, &guard, command.fresh, app.logging.path())?; + ignite(command, app, &build_dir)?; Ok(()) } @@ -273,18 +273,10 @@ fn unique_languages( for language in unique { let result = match get_language_coords(&language, defined_parsers) { - Ok((build_script, git_ref, repo)) => Ok(parser::LanguageBuild::new( + Ok((repo, git_ref, build_script)) => Ok(parser::LanguageBuild::new( Context { overwrite_output: command.force, }, - Arc::new(Spec { - build_script, - git_ref, - repo, - tree_sitter: command.tree_sitter.clone(), - prefix: command.prefix.clone(), - target: command.target, - }), parser::LanguageName::from(language.clone()), OutputConfig { build_dir: build_dir @@ -296,6 +288,14 @@ fn unique_languages( .canon() .expect("Out dir canonicalization failed"), }, + Arc::new(Spec { + build_script, + git_ref, + repo, + tree_sitter: command.tree_sitter.clone(), + prefix: command.prefix.clone(), + target: command.target, + }), )), Err(err) => Err(Error::Language { name: language, diff --git a/src/cache.rs b/src/cache.rs index a89dcaf..93667cd 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -308,10 +308,10 @@ impl Db { &self, name: &Key, hash: &GrammarHash, - spec: &build::Spec, revision: &Revision, + spec: &build::Spec, ) -> bool { - self.rebuild_decision(name, hash, spec, revision) + self.rebuild_decision(name, hash, revision, spec) .needs_rebuild() } @@ -320,12 +320,12 @@ impl Db { &self, name: &Key, hash: &GrammarHash, - spec: &build::Spec, revision: &Revision, + spec: &build::Spec, ) -> Decision { let decision = match self.get(name) { None => Decision::miss(MissReason::MissingEntry), - Some(entry) => entry.rebuild_decision(hash, spec, revision), + Some(entry) => entry.rebuild_decision(hash, revision, spec), }; debug!("Cache decision for {name}: {decision}"); @@ -385,8 +385,8 @@ impl Entry { pub fn rebuild_decision( &self, hash: &GrammarHash, - spec: &build::Spec, revision: &Revision, + spec: &build::Spec, ) -> Decision { let mut reasons = Vec::new(); let cached = &self.recipe; @@ -810,7 +810,7 @@ mod tests { let spec = test_spec(); assert_miss( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &stable_revision()), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &stable_revision(), &spec), &[MissReason::MissingEntry], ); } @@ -821,7 +821,7 @@ mod tests { let cache = cache_with_entry("abc123", &spec); assert_miss( - cache.rebuild_decision(&key(), &grammar_hash("def456"), &spec, &stable_revision()), + cache.rebuild_decision(&key(), &grammar_hash("def456"), &stable_revision(), &spec), &[MissReason::HashChanged { cached: grammar_hash("abc123"), current: grammar_hash("def456"), @@ -840,8 +840,8 @@ mod tests { cache.rebuild_decision( &key(), &grammar_hash("abc123"), - &requested, &stable_revision(), + &requested, ), &[MissReason::RefChanged { cached: parser::Ref::parse("v1.0.0").unwrap(), @@ -861,8 +861,8 @@ mod tests { cache.rebuild_decision( &key(), &grammar_hash("abc123"), - &requested, &stable_revision(), + &requested, ), &[MissReason::OutputsMissing { available: args::Target::Native, @@ -884,8 +884,8 @@ mod tests { cache.rebuild_decision( &key(), &grammar_hash("abc123"), - &requested, &stable_revision(), + &requested, ), Decision::Hit ); @@ -898,10 +898,10 @@ mod tests { let cache = cache_with_entry("abc123", &spec); assert_eq!( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &stable_revision()), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &stable_revision(), &spec), Decision::Hit ); - assert!(!cache.needs_rebuild(&key(), &grammar_hash("abc123"), &spec, &stable_revision())); + assert!(!cache.needs_rebuild(&key(), &grammar_hash("abc123"), &stable_revision(), &spec)); } #[test] @@ -912,7 +912,7 @@ mod tests { let cache = cache_with_entry_and_revision("abc123", &spec, cached_revision.clone()); assert_miss( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, ¤t_revision), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), ¤t_revision, &spec), &[MissReason::RevisionChanged { cached: cached_revision, current: current_revision, @@ -927,7 +927,7 @@ mod tests { let cache = cache_with_entry_and_revision("abc123", &spec, revision.clone()); assert_eq!( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &spec, &revision), + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &revision, &spec), Decision::Hit ); } @@ -945,8 +945,8 @@ mod tests { cache.rebuild_decision( &key(), &grammar_hash("def456"), - &requested, &stable_revision(), + &requested, ), &[ MissReason::HashChanged { diff --git a/src/columns.rs b/src/columns.rs index 824dc59..bc35b65 100644 --- a/src/columns.rs +++ b/src/columns.rs @@ -35,15 +35,15 @@ pub struct Options<'a> { fn compute_column_widths( item_widths: &[usize], + cols: usize, layout: Layout, rows: usize, - cols: usize, ) -> Vec { let mut widths = vec![0; cols]; for (x, column_width) in widths.iter_mut().enumerate() { for y in 0..rows { - let item_index = linear_index(layout, rows, cols, x, y); + let item_index = linear_index(cols, layout, rows, x, y); if let Some(width) = item_widths.get(item_index) { *column_width = (*column_width).max(*width); } @@ -138,7 +138,7 @@ fn format_table>( for y in 0..rows { for x in 0..cols { - let item_index = linear_index(options.layout, rows, cols, x, y); + let item_index = linear_index(cols, options.layout, rows, x, y); if item_index >= items.len() { break; } @@ -181,7 +181,7 @@ const fn is_last_cell_in_row( } } -const fn linear_index(layout: Layout, rows: usize, cols: usize, x: usize, y: usize) -> usize { +const fn linear_index(cols: usize, layout: Layout, rows: usize, x: usize, y: usize) -> usize { match layout { Layout::Column => x * rows + y, Layout::Row => y * cols + x, @@ -212,7 +212,7 @@ fn shrink_columns( .max(1) .min(item_widths.len()); - let candidate_widths = compute_column_widths(item_widths, options.layout, rows, cols); + let candidate_widths = compute_column_widths(item_widths, cols, options.layout, rows); let total_width = indent_width + candidate_widths.iter().sum::() + options.padding.saturating_mul(cols); @@ -227,7 +227,7 @@ fn shrink_columns( DenseLayout { rows, cols, - widths: compute_column_widths(item_widths, options.layout, rows, cols), + widths: compute_column_widths(item_widths, cols, options.layout, rows), } } diff --git a/src/display.rs b/src/display.rs index 92b5e22..878e3d3 100644 --- a/src/display.rs +++ b/src/display.rs @@ -163,14 +163,23 @@ pub(crate) struct RowSpec { pub indent: &'static str, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct BuildSummary { + pub building: usize, + pub built: usize, + pub cached: usize, + pub cancelled: usize, + pub failed: usize, +} + pub(crate) struct State { - pub mode: Mode, - pub repos: HashMap, - pub grammars: HashMap, pub build_dir: PathBuf, - pub out_dir: PathBuf, footer_build: Line<'static>, footer_out: Line<'static>, + pub grammars: HashMap, + pub mode: Mode, + pub out_dir: PathBuf, + pub repos: HashMap, } // ============================================================ @@ -450,8 +459,8 @@ impl GridCache { &mut self, item_id: ItemId, column: Column, - stale: bool, compute: impl FnOnce() -> Span<'static>, + stale: bool, ) -> Span<'static> { let key = CellKey { item_id, column }; if stale { @@ -594,7 +603,7 @@ impl RepoEntry { } impl State { - pub fn new(mode: Mode, build_dir: PathBuf, out_dir: PathBuf) -> Self { + pub fn new(build_dir: PathBuf, mode: Mode, out_dir: PathBuf) -> Self { let footer_build = dim_line(format!("build: {}", build_dir.display())); let footer_out = dim_line(format!("out: {}", out_dir.display())); Self { @@ -619,18 +628,18 @@ impl State { } pub fn format_footer_counts(&self) -> Line<'static> { - let (cached, built, building, failed, cancelled) = self.summary_counts(); + let s = self.summary(); let mut spans: Vec> = Vec::new(); - push_summary_success(&mut spans, cached, "cached", Color::Yellow, true); - push_summary_success(&mut spans, built, "built", Color::Blue, false); - if cancelled > 0 { - push_summary_cancelled(&mut spans, cancelled, false); + push_summary_success(&mut spans, s.cached, "cached", Color::Yellow, true); + push_summary_success(&mut spans, s.built, "built", Color::Blue, false); + if s.cancelled > 0 { + push_summary_cancelled(&mut spans, s.cancelled, false); } - if building > 0 { - push_summary_building(&mut spans, building); + if s.building > 0 { + push_summary_building(&mut spans, s.building); } - push_summary_failed(&mut spans, failed); + push_summary_failed(&mut spans, s.failed); Line::from(spans) } @@ -745,7 +754,7 @@ impl State { // ── Summary counts ──────────────────────────────────────────── - pub(crate) fn summary_counts(&self) -> (usize, usize, usize, usize, usize) { + pub(crate) fn summary(&self) -> BuildSummary { let mut cached = 0; let mut built = 0; let mut building = 0; @@ -782,7 +791,13 @@ impl State { } } - (cached, built, building, failed, cancelled) + BuildSummary { + building, + built, + cached, + cancelled, + failed, + } } } diff --git a/src/git.rs b/src/git.rs index b9954ca..32cee0a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -52,14 +52,14 @@ pub struct Sha(Arc); // Free functions // ============================================================ -pub async fn checkout(repo: &str, git_ref: &Ref, cwd: &Path) -> Result { - checkout_with_force(repo, git_ref, cwd, false).await +pub async fn checkout(repo: &str, cwd: &Path, git_ref: &Ref) -> Result { + checkout_with_force(repo, cwd, git_ref, false).await } pub async fn checkout_with_force( repo: &str, - git_ref: &Ref, cwd: &Path, + git_ref: &Ref, force: bool, ) -> Result { if force || !is_same_remote(cwd, repo).await { diff --git a/src/main.rs b/src/main.rs index 1c0c0b3..218c200 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,7 @@ fn main() -> ExitCode { fn run(app: &app::App) -> Result<()> { match &app.command { app::ResolvedCommand::Build(build) => { - let (result, duration) = time(|| tsdl::build::run(app, &build.command)); + let (result, duration) = time(|| tsdl::build::run(&build.command, app)); match &result { Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), Err(Error::Interrupted { signal }) => println!( diff --git a/src/parser.rs b/src/parser.rs index 246c201..927d049 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -74,9 +74,9 @@ pub struct GrammarName(Arc); #[derive(Clone, Debug)] pub struct LanguageBuild { pub context: build::Context, - pub spec: Arc, pub name: LanguageName, pub output: build::OutputConfig, + pub spec: Arc, } #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -102,15 +102,15 @@ fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> Result { } fn artifact_path_for( - build_dir: &Path, - ts_cli: &Path, - spec: &build::Spec, grammar_name: &GrammarName, + build_dir: &Path, kind: ArtifactKind, + spec: &build::Spec, + ts_cli: &Path, ) -> Result { Ok(build_dir .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) - .join(parser_name_and_ext(&spec.prefix, grammar_name, kind))) + .join(parser_name_and_ext(grammar_name, kind, &spec.prefix))) } async fn ensure_parent_dir(path: &Path) -> Result<()> { @@ -171,7 +171,7 @@ fn normalize_source_ref(value: &str) -> String { } } -fn parser_name_and_ext(prefix: &str, grammar_name: &GrammarName, kind: ArtifactKind) -> String { +fn parser_name_and_ext(grammar_name: &GrammarName, kind: ArtifactKind, prefix: &str) -> String { format!("{prefix}{grammar_name}.{}", kind.extension()) } @@ -180,10 +180,10 @@ fn same_file_identity(a: &Metadata, b: &Metadata) -> bool { } async fn same_regular_file_contents( - src: &Path, - src_metadata: &Metadata, dst: &Path, dst_metadata: &Metadata, + src: &Path, + src_metadata: &Metadata, ) -> Result { if src_metadata.len() != dst_metadata.len() { return Ok(false); @@ -609,16 +609,16 @@ impl GrammarBuild { } }; - self.install_over_existing(&src, &src_metadata, &dst, &dst_link_metadata) + self.install_over_existing(&src, &dst, &dst_link_metadata, &src_metadata) .await } async fn install_over_existing( &self, src: &Path, - src_metadata: &Metadata, dst: &Path, dst_link_metadata: &Metadata, + src_metadata: &Metadata, ) -> Result<()> { let dst_file_type = dst_link_metadata.file_type(); @@ -664,7 +664,7 @@ impl GrammarBuild { } let same_contents = - same_regular_file_contents(src, src_metadata, dst, &dst_metadata).await?; + same_regular_file_contents(dst, &dst_metadata, src, src_metadata).await?; if !same_contents && !self.context.overwrite_output { return Err(Error::Message { @@ -711,30 +711,30 @@ impl GrammarBuild { } pub fn required_artifacts_for( + grammar_name: &GrammarName, build_dir: &Path, - ts_cli: &Path, spec: &build::Spec, - grammar_name: &GrammarName, + ts_cli: &Path, ) -> Result> { let mut artifacts = Vec::new(); if spec.target.native() { artifacts.push(artifact_path_for( - build_dir, - ts_cli, - spec, grammar_name, + build_dir, ArtifactKind::Native, + spec, + ts_cli, )?); } if spec.target.wasm() { artifacts.push(artifact_path_for( - build_dir, - ts_cli, - spec, grammar_name, + build_dir, ArtifactKind::Wasm, + spec, + ts_cli, )?); } @@ -743,11 +743,11 @@ impl GrammarBuild { fn artifact_path(&self, kind: ArtifactKind) -> Result { artifact_path_for( - &self.output.build_dir, - &self.ts_cli, - &self.spec, &self.name, + &self.output.build_dir, kind, + &self.spec, + &self.ts_cli, ) } @@ -795,7 +795,7 @@ impl GrammarBuild { } fn parser_name_and_ext(&self, kind: ArtifactKind) -> String { - parser_name_and_ext(&self.spec.prefix, &self.name, kind) + parser_name_and_ext(&self.name, kind, &self.spec.prefix) } } @@ -815,21 +815,21 @@ impl LanguageBuild { #[must_use] pub fn new( context: build::Context, - spec: Arc, name: LanguageName, output: build::OutputConfig, + spec: Arc, ) -> Self { Self { context, - spec, name, output, + spec, } } pub async fn discover_grammars( &self, - ) -> Result> { + ) -> Result> { let file_results = collect_grammar_paths(&self.output.build_dir).await?; let mut grammars = Vec::new(); @@ -841,7 +841,7 @@ impl LanguageBuild { ), })?; let grammar_name = extract_grammar_name(grammar_dir)?; - grammars.push((grammar_name, grammar_dir.to_path_buf(), hash)); + grammars.push((grammar_name, hash, grammar_dir.to_path_buf())); } if grammars.is_empty() { @@ -876,8 +876,8 @@ impl LanguageBuild { pub async fn checkout(&self) -> Result { git::checkout( self.spec.repo.as_str(), - self.spec.git_ref.requested(), &self.output.build_dir, + self.spec.git_ref.requested(), ) .await .map_err(|err| Error::Step { @@ -1003,6 +1003,8 @@ mod tests { build::Context { overwrite_output: false, }, + LanguageName::from("empty"), + build::OutputConfig { build_dir, out_dir }, Arc::new(build::Spec { build_script: None, git_ref: Ref::parse("v1.0.0").unwrap(), @@ -1011,8 +1013,6 @@ mod tests { target: Target::Native, tree_sitter: TreeSitter::default(), }), - LanguageName::from("empty"), - build::OutputConfig { build_dir, out_dir }, ) } @@ -1036,9 +1036,9 @@ mod tests { out_dir: PathBuf, overwrite_output: bool, ) -> (GrammarBuild, DisplayAddr) { - let display = DisplayActor::spawn(Mode::Plain, grammar_dir.clone(), out_dir.clone()); + let display = DisplayActor::spawn(grammar_dir.clone(), Mode::Plain, out_dir.clone()); let progress = display - .add_grammar(git::Ref::head(), "rust", "rust", 1) + .add_grammar("rust", "rust", git::Ref::head(), 1) .await; let source_ref = Ref::parse("v1.0.0").unwrap(); let build = GrammarBuild { @@ -1158,16 +1158,16 @@ mod tests { #[test] fn test_parser_name_and_ext() { - let name = parser_name_and_ext("", &GrammarName::from("typescript"), ArtifactKind::Native); + let name = parser_name_and_ext(&GrammarName::from("typescript"), ArtifactKind::Native, ""); assert_eq!(name, format!("typescript.{DLL_EXTENSION}")); } #[test] fn test_parser_name_with_prefix() { let name = parser_name_and_ext( - "lib", &GrammarName::from("typescript"), ArtifactKind::Native, + "lib", ); assert_eq!(name, format!("libtypescript.{DLL_EXTENSION}")); } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index ffe2d41..ecf21e4 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -286,8 +286,8 @@ pub async fn prepare( let progress = display .add_language( - display_tree_sitter_ref(&tree_sitter.version)?, "tree-sitter-cli", + display_tree_sitter_ref(&tree_sitter.version)?, 2, ) .await; From 29709d7503a281322161b6b3de18226e4dd43c9b Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 14:09:45 +0200 Subject: [PATCH 78/88] cache: remove BuildSpec and fix linting --- src/cache.rs | 62 ++++++++++++++++------------------------------ src/parser.rs | 2 +- tests/cmd/cache.rs | 4 +-- 3 files changed, 25 insertions(+), 43 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 93667cd..50d3978 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -83,16 +83,6 @@ pub enum Revision { // Structs // ============================================================ -/// Build inputs that affect parser output, excluding the requested output set. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct BuildRecipe { - pub build_script: Option, - pub git_ref: parser::Ref, - pub prefix: String, - pub repo: url::Url, - pub tree_sitter: args::TreeSitter, -} - /// The logical build cache contents. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { @@ -108,8 +98,8 @@ pub struct Entry { /// Resolved parser revision used by the cache. Moving refs include the /// checked-out commit. pub revision: Revision, - /// Build inputs that affect parser output, excluding the requested output set. - pub recipe: BuildRecipe, + /// Build specification used to produce this entry. + pub spec: Arc, /// Parser outputs known to be available for this entry. pub outputs: args::Target, } @@ -256,19 +246,6 @@ impl AsyncWrite for HashWriter<'_> { } } -impl BuildRecipe { - #[must_use] - pub fn from_spec(spec: &build::Spec) -> Self { - Self { - build_script: spec.build_script.clone(), - git_ref: spec.git_ref.clone(), - prefix: spec.prefix.clone(), - repo: spec.repo.clone(), - tree_sitter: spec.tree_sitter.clone(), - } - } -} - impl Db { /// Clear all entries. pub fn clear(&mut self) { @@ -297,8 +274,8 @@ impl Db { let prefix = Key::language_prefix(language); self.parsers.iter().any(|(key, entry)| { key.as_str().starts_with(&prefix) - && entry.recipe.repo == spec.repo - && entry.recipe.git_ref == spec.git_ref + && entry.spec.repo == spec.repo + && entry.spec.git_ref == spec.git_ref }) } @@ -389,7 +366,6 @@ impl Entry { spec: &build::Spec, ) -> Decision { let mut reasons = Vec::new(); - let cached = &self.recipe; if &self.hash != hash { reasons.push(MissReason::HashChanged { @@ -398,17 +374,17 @@ impl Entry { }); } - if cached.repo != spec.repo { + if self.spec.repo != spec.repo { reasons.push(MissReason::RepoChanged { - cached: cached.repo.to_string(), + cached: self.spec.repo.to_string(), current: spec.repo.to_string(), }); } - let git_ref_changed = cached.git_ref != spec.git_ref; + let git_ref_changed = self.spec.git_ref != spec.git_ref; if git_ref_changed { reasons.push(MissReason::RefChanged { - cached: cached.git_ref.clone(), + cached: self.spec.git_ref.clone(), current: spec.git_ref.clone(), }); } @@ -420,20 +396,20 @@ impl Entry { }); } - if cached.tree_sitter != spec.tree_sitter { + if self.spec.tree_sitter != spec.tree_sitter { reasons.push(MissReason::TreeSitterChanged { - cached: cached.tree_sitter.clone(), + cached: self.spec.tree_sitter.clone(), current: spec.tree_sitter.clone(), }); } - if cached.build_script != spec.build_script { + if self.spec.build_script != spec.build_script { reasons.push(MissReason::BuildScriptChanged); } - if cached.prefix != spec.prefix { + if self.spec.prefix != spec.prefix { reasons.push(MissReason::PrefixChanged { - cached: cached.prefix.clone(), + cached: self.spec.prefix.clone(), current: spec.prefix.clone(), }); } @@ -450,7 +426,13 @@ impl Entry { #[must_use] pub fn same_subject(&self, other: &Self) -> bool { - self.hash == other.hash && self.revision == other.revision && self.recipe == other.recipe + self.hash == other.hash + && self.revision == other.revision + && self.spec.build_script == other.spec.build_script + && self.spec.git_ref == other.spec.git_ref + && self.spec.prefix == other.spec.prefix + && self.spec.repo == other.spec.repo + && self.spec.tree_sitter == other.spec.tree_sitter } } @@ -782,7 +764,7 @@ mod tests { Entry { hash: grammar_hash(hash), revision, - recipe: BuildRecipe::from_spec(spec), + spec: Arc::new(spec.clone()), outputs: spec.target, } } @@ -992,7 +974,7 @@ mod tests { let stored = cache.get(&key()).unwrap(); assert_eq!(stored.outputs, args::Target::Wasm); - assert_eq!(stored.recipe.prefix, "other-"); + assert_eq!(stored.spec.prefix, "other-"); } #[test] diff --git a/src/parser.rs b/src/parser.rs index 927d049..f97d58e 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -389,7 +389,7 @@ impl GrammarBuild { entry: cache::Entry { hash: self.hash.clone(), revision: self.revision.clone(), - recipe: cache::BuildRecipe::from_spec(self.spec.as_ref()), + spec: self.spec.clone(), outputs: self.spec.target, }, }; diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index 389f8a2..aa7b60d 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -49,8 +49,8 @@ fn cache_file_structure() { "Cache should have revision identity field" ); assert!( - cache_content.contains("recipe"), - "Cache should have build recipe field" + cache_content.contains("spec"), + "Cache should have build spec field" ); assert!( cache_content.contains("outputs"), From ace50701142dd7c1f6500ef080d01ddbcd51c702 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 14:20:14 +0200 Subject: [PATCH 79/88] docs: all --- src/actors/cache.rs | 22 ++++++++- src/actors/display.rs | 89 +++++++++++++++++++++++++++++++++- src/actors/mod.rs | 12 +++++ src/app.rs | 30 +++++++----- src/args.rs | 54 +++++++++++++++++++++ src/build.rs | 27 +++++++++++ src/cache.rs | 109 ++++++++++++++++++++++++++++-------------- src/columns.rs | 14 +++++- src/config.rs | 52 ++++++++++++++++++++ src/consts.rs | 2 + src/display.rs | 101 ++++++++++++++++++++++++++++++++++++++ src/error.rs | 19 ++++++++ src/git.rs | 39 +++++++++++++++ src/lib.rs | 3 ++ src/lock.rs | 12 +++++ src/logging.rs | 23 +++++++++ src/parser.rs | 81 ++++++++++++++++++++++++++++++- src/sh.rs | 7 +++ src/shutdown.rs | 19 ++++++++ src/tree_sitter.rs | 27 +++++++++++ src/walk.rs | 3 ++ 21 files changed, 693 insertions(+), 52 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 2a947fc..535ba28 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -1,3 +1,6 @@ +//! Cache actor: async message handler for cache reads, rebuild decisions, and +//! updates. + use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; @@ -12,6 +15,11 @@ use crate::{ // Enums // ============================================================ +/// Messages sent to the cache actor. +/// +/// Each variant is a cache operation: query, update, save, or +/// compatibility check. Response-bearing variants carry a +/// [`oneshot::Sender`] for the reply. #[derive(Debug)] pub enum CacheMessage { /// Query if a parser needs rebuild @@ -64,15 +72,20 @@ enum ResponseKind<'a> { /// The Cache Actor: Manages cache state and processes messages pub struct CacheActor { + /// In-memory cache database. db: cache::Db, + /// File-backed storage for persistence. store: cache::Store, + /// When true, every query returns a miss. force: bool, + /// Message receiver channel. rx: mpsc::Receiver, } /// The Cache Handle: Public interface for sending cache operations #[derive(Debug, Clone)] pub struct CacheAddr { + /// Sender half of the channel to the cache actor. tx: mpsc::Sender, } @@ -93,6 +106,7 @@ impl Addr for CacheAddr { } impl CacheActor { + /// Process incoming cache messages until the channel closes. async fn run(mut self) { while let Some(msg) = self.rx.recv().await { match msg { @@ -160,6 +174,7 @@ impl CacheActor { } } + /// Spawn the cache actor in a background task and return an address handle. #[must_use] pub fn spawn(db: cache::Db, force: bool, store: cache::Store) -> CacheAddr { let (tx, rx) = mpsc::channel(64); @@ -175,16 +190,18 @@ impl CacheActor { } impl CacheAddr { + /// Create a new cache address from a sender. #[must_use] pub fn new(tx: mpsc::Sender) -> Self { Self { tx } } - /// Accepts any string type (String, &str, Arc) with minimal cloning + /// Accepts any string type (`String`, `&str`, `Arc`) with minimal cloning pub async fn get(&self, name: cache::Key) -> Option { self.request(|tx| CacheMessage::Get { name, tx }).await } + /// Check if the cache has compatible entries for a language (same repo + git ref). pub async fn has_compatible_entries( &self, language: parser::LanguageName, @@ -194,6 +211,7 @@ impl CacheAddr { .await } + /// Ask the cache actor whether a parser needs rebuilding. pub async fn needs_rebuild( &self, name: cache::Key, @@ -211,10 +229,12 @@ impl CacheAddr { .await } + /// Save the cache to disk. pub async fn save(&self) -> Result<()> { self.request(|tx| CacheMessage::Save { tx }).await } + /// Send a cache update (fire-and-forget). pub async fn update(&self, update: cache::Update) { self.fire(CacheMessage::Update { entry: update.entry, diff --git a/src/actors/display.rs b/src/actors/display.rs index 3dab1ad..ff70774 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -1,3 +1,6 @@ +//! Display actor: ratatui inline render loop (fancy) or plain-text output +//! (plain). Receives progress updates and materializes terminal output. + use std::io; use std::num::NonZeroU64; use std::path::PathBuf; @@ -19,6 +22,10 @@ use crate::git; // Enums // ============================================================ +/// Messages sent to the display actor. +/// +/// Covers registration of new progress rows, per-row updates, and final +/// shutdown. #[derive(Debug)] pub enum Message { /// Register a repo-level progress line. Returns a `ProgressAddr`. @@ -55,15 +62,24 @@ pub enum Message { }, } +/// The kind of update to apply to a progress bar. #[derive(Debug, Clone, Copy)] pub enum UpdateKind { + /// Update the status message only. Msg, + /// Advance to the next step. Step, + /// Mark the outcome as cached (used before clone is skipped). SetOutcomeCached, + /// Mark the outcome as built (used before clone proceeds). SetOutcomeBuilt, + /// Mark the row as cancelled (shutdown). Cancel, + /// Mark the row as cached at completion. Cached, + /// Mark the row as finished (built) at completion. Fin, + /// Mark the row as failed. Err, } @@ -71,24 +87,36 @@ pub enum UpdateKind { // Structs // ============================================================ +/// The display actor: owns the display state and the message receiver. pub struct DisplayActor { + /// Shared display state (repos, grammars, layout). state: display::State, + /// Monotonically increasing ID counter for new rows. next_id: display::ItemId, + /// Maximum name column width for plain-mode alignment. plain_name_width: usize, + /// Whether any progress line has been printed (plain mode). plain_progress_started: bool, + /// Cached rendered cells for the fancy backend. grid: display::GridCache, + /// Ordered list of visible row specs. row_specs: Vec, + /// Whether the row list has changed since last render. rows_dirty: bool, + /// Cached terminal width from the last render cycle. last_term_width: Option, + /// Receiver for incoming display messages. rx: mpsc::Receiver, + /// Cloned sender used to construct child [`ProgressAddr`] handles. tx: mpsc::Sender, } -/// Handle for updating a specific progress bar (repo or grammar). +/// Handle for sending display messages (register languages, update progress). #[derive(Debug, Clone)] pub struct DisplayAddr { #[allow(dead_code)] mode: display::Mode, + /// Sender to the display actor. tx: mpsc::Sender, } @@ -102,7 +130,9 @@ struct PlainLine { /// Handle for updating a specific progress bar (repo or grammar). #[derive(Debug, Clone)] pub struct ProgressAddr { + /// Target row ID for updates. id: display::ItemId, + /// Sender to the display actor. tx: mpsc::Sender, } @@ -110,6 +140,7 @@ pub struct ProgressAddr { // Free functions // ============================================================ +/// Clear from the cursor position to the bottom of the terminal. fn clear_from_cursor_down() -> io::Result<()> { crossterm::execute!( io::stdout(), @@ -117,6 +148,7 @@ fn clear_from_cursor_down() -> io::Result<()> { ) } +/// Measure the current visible height of the terminal viewport. fn current_viewport_height( terminal: &mut ratatui::Terminal, ) -> Result { @@ -124,6 +156,7 @@ fn current_viewport_height( Ok(terminal.get_frame().area().height.max(1)) } +/// Draw a set of lines into the terminal frame. fn draw_lines( terminal: &mut ratatui::Terminal, lines: Vec>, @@ -136,6 +169,8 @@ fn draw_lines( .map(|_| ()) } +/// Draw lines into the viewport, clearing previous content and setting the +/// cursor below the last visible line. fn draw_lines_in_viewport( terminal: &mut ratatui::Terminal, lines: Vec>, @@ -157,6 +192,8 @@ fn draw_lines_in_viewport( .map(|_| ()) } +/// Transition an item state to `Done`. Returns an error if the state is not +/// `InProgress(Some(_))`. fn finish_item(state: display::ItemState) -> Result { match state { display::ItemState::InProgress(Some(outcome)) => Ok(display::ItemState::Done(outcome)), @@ -172,6 +209,7 @@ fn finish_item(state: display::ItemState) -> Result } } +/// Push lines above the current viewport, scrolling content up. fn insert_lines_before_viewport( terminal: &mut ratatui::Terminal, mut lines: Vec>, @@ -194,6 +232,7 @@ fn insert_lines_before_viewport( Ok(()) } +/// Log and return an error for an invalid finish transition. fn invalid_finish(reason: &str, state: display::ItemState) -> Result { let message = format!("{reason}: {state:?}"); error!("{message}"); @@ -204,6 +243,7 @@ fn invalid_finish(reason: &str, state: display::ItemState) -> Result display::ItemState { match state { display::ItemState::New | display::ItemState::InProgress(_) => { @@ -215,6 +255,7 @@ fn mark_success(state: display::ItemState, outcome: display::SuccessOutcome) -> } } +/// Format a plain-text message for a grammar row. fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { match kind { UpdateKind::Err => format!("failed: {msg}"), @@ -235,6 +276,7 @@ fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) } } +/// Format a plain-text message for a repo row. fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { match kind { UpdateKind::Err => format!("failed: {msg}"), @@ -254,6 +296,8 @@ fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> } } +/// Render the final report: insert prefix lines above the viewport, then +/// replace viewport content with the final lines. fn render_final_report( terminal: &mut ratatui::Terminal, lines: Vec>, @@ -268,6 +312,8 @@ fn spacer() -> Span<'static> { Span::raw(" ") } +/// Split a list of lines into a prefix (above viewport) and a suffix that +/// fits within the viewport height. fn split_lines_for_viewport( mut lines: Vec>, viewport_height: u16, @@ -278,6 +324,7 @@ fn split_lines_for_viewport( (lines, suffix) } +/// Transition `New` or `InProgress` into `InProgress(None)`. fn start_item(state: display::ItemState) -> display::ItemState { match state { display::ItemState::New => display::ItemState::InProgress(None), @@ -295,16 +342,21 @@ fn start_item(state: display::ItemState) -> display::ItemState { impl Addr for DisplayAddr { type Message = Message; + /// Return the actor name for tracing. fn name() -> &'static str { "DisplayAddr" } + /// Return a reference to the underlying sender. fn sender(&self) -> &mpsc::Sender { &self.tx } } impl DisplayActor { + /// Aggregate the outcome of finished grammar children for a repo. + /// Returns `Some(Built)` if any child built, `Some(Cached)` if all are + /// cached, or `None` if no children finished. fn aggregate_child_done_outcome( &self, repo_id: display::ItemId, @@ -332,6 +384,7 @@ impl DisplayActor { saw_cached.then_some(display::SuccessOutcome::Cached) } + /// Aggregate the live outcome of still-active grammar children for a repo. fn aggregate_child_live_outcome( &self, repo_id: display::ItemId, @@ -368,6 +421,7 @@ impl DisplayActor { } } + /// Apply a state update to a grammar entry. fn apply_grammar_update(grammar: &mut display::GrammarEntry, kind: UpdateKind, msg: Arc) { if !grammar.state.is_live() { return; @@ -422,6 +476,7 @@ impl DisplayActor { } } + /// Apply a state update to a repo entry. fn apply_repo_update(repo: &mut display::RepoEntry, kind: UpdateKind, msg: Arc) { if !repo.state.is_live() { return; @@ -482,6 +537,8 @@ impl DisplayActor { } } + /// Dispatch an update to the appropriate entry (repo or grammar) and sync + /// the parent repo if needed. fn apply_update(&mut self, id: display::ItemId, kind: UpdateKind, msg: Arc) { if let Some(repo) = self.state.repos.get_mut(&id) { Self::apply_repo_update(repo, kind, msg); @@ -512,6 +569,7 @@ impl DisplayActor { } } + /// Cancel all live rows (used on interrupt). fn cancel_live_rows(&mut self) { let mut parent_ids = Vec::new(); @@ -547,6 +605,7 @@ impl DisplayActor { } } + /// Render the final fancy report and clean up the terminal. fn finish_fancy>( &mut self, terminal: &mut ratatui::Terminal, @@ -578,6 +637,7 @@ impl DisplayActor { let _ = tx.send(()); } + /// Process a single message from the channel. fn handle_message(&mut self, msg: Message) { match msg { Message::RegisterLanguage { @@ -727,6 +787,7 @@ impl DisplayActor { lines } + /// Build a plain-text progress line for an item (repo or grammar). fn plain_progress_line(&self, id: display::ItemId, kind: UpdateKind) -> Option { if let Some(repo) = self.state.repos.get(&id) { return Some(PlainLine { @@ -745,12 +806,14 @@ impl DisplayActor { }) } + /// Print the build and out directory metadata lines (plain mode). fn print_plain_metadata(&self) { println!("build: {}", self.state.build_dir.display()); println!("out: {}", self.state.out_dir.display()); println!(); } + /// Print a plain progress line with name, step/total, and message. fn print_plain_progress(&mut self, line: &PlainLine) { if !self.plain_progress_started { println!(); @@ -767,11 +830,13 @@ impl DisplayActor { ); } + /// Print a reference line (plain mode). fn print_plain_ref(&mut self, name: &str, git_ref: &str) { let width = self.update_plain_name_width(name); println!("{name:, @@ -825,6 +891,7 @@ impl DisplayActor { } } + /// Register a new repo progress row and return its address. fn register_repo( &mut self, name: Arc, @@ -856,6 +923,7 @@ impl DisplayActor { } } + /// Run the actor event loop, dispatching to fancy or plain mode. async fn run(mut self) { if self.state.mode == display::Mode::Fancy { self.run_fancy().await; @@ -866,6 +934,7 @@ impl DisplayActor { // ── Fancy mode ──────────────────────────────────────────────────── + /// Run the fancy (ratatui) render loop. async fn run_fancy(&mut self) { let viewport_height = crossterm::terminal::size().map_or(40, |(_, h)| h).min(40); @@ -948,6 +1017,7 @@ impl DisplayActor { // ── Plain mode ──────────────────────────────────────────────────── + /// Run the plain-text progress loop. async fn run_plain(&mut self) { self.print_plain_metadata(); @@ -1003,6 +1073,7 @@ impl DisplayActor { } } + /// Spawn the display actor in a background task and return an address handle. #[must_use] pub fn spawn(build_dir: PathBuf, mode: display::Mode, out_dir: PathBuf) -> DisplayAddr { let (tx, rx) = mpsc::channel(256); @@ -1026,6 +1097,7 @@ impl DisplayActor { DisplayAddr::new(mode, tx) } + /// Sync a parent repo's state based on its children's aggregated outcomes. fn sync_parent_repo(&mut self, repo_id: display::ItemId) { let has_any = self .state @@ -1083,6 +1155,7 @@ impl DisplayActor { self.grid.mark_dirty(repo_id); } + /// Update the max plain name width and return the current value. fn update_plain_name_width(&mut self, name: &str) -> usize { self.plain_name_width = self.plain_name_width.max(name.chars().count()); self.plain_name_width @@ -1090,6 +1163,7 @@ impl DisplayActor { } impl DisplayAddr { + /// Register a repo-level progress row. pub async fn add_language>>( &self, name: S, @@ -1105,6 +1179,7 @@ impl DisplayAddr { .await } + /// Register a grammar-level progress row. pub async fn add_grammar>>( &self, language: S, @@ -1122,11 +1197,13 @@ impl DisplayAddr { .await } + /// Create a new `DisplayAddr` wrapping a channel sender. #[must_use] pub fn new(mode: display::Mode, tx: mpsc::Sender) -> Self { Self { mode, tx } } + /// Send a reference line (plain mode; ignored in fancy mode). pub async fn reference>>(&self, name: S, git_ref: git::Ref) { self.fire(Message::RegisterReference { git_ref, @@ -1135,6 +1212,7 @@ impl DisplayAddr { .await; } + /// Shut down the display actor, waiting for cleanup to complete. pub async fn shutdown(&self, interrupted: bool) { self.request(|tx| Message::Shutdown { interrupted, tx }) .await; @@ -1142,6 +1220,7 @@ impl DisplayAddr { } impl ProgressAddr { + /// Send a message update (fire-and-forget via `try_send`). pub fn msg>>(&self, msg: S) { let _ = self.tx.try_send(Message::Update { id: self.id, @@ -1150,6 +1229,7 @@ impl ProgressAddr { }); } + /// Advance to the next step (fire-and-forget via `try_send`). pub fn step>>(&self, msg: S) { let _ = self.tx.try_send(Message::Update { id: self.id, @@ -1158,6 +1238,7 @@ impl ProgressAddr { }); } + /// Send a state update message, awaiting capacity. async fn send_state_update(&self, kind: UpdateKind, msg: Arc) { let _ = self .tx @@ -1169,29 +1250,35 @@ impl ProgressAddr { .await; } + /// Mark the outcome as cached (async). pub async fn set_outcome_cached(&self) { self.send_state_update(UpdateKind::SetOutcomeCached, Arc::from("")) .await; } + /// Cancel the progress row. pub async fn cancel(&self) { self.send_state_update(UpdateKind::Cancel, Arc::from("cancelled")) .await; } + /// Mark the outcome as built (async). pub async fn set_outcome_built(&self) { self.send_state_update(UpdateKind::SetOutcomeBuilt, Arc::from("")) .await; } + /// Complete as cached (async). pub async fn cached>>(&self, msg: S) { self.send_state_update(UpdateKind::Cached, msg.into()).await; } + /// Complete as built (async). pub async fn fin>>(&self, msg: S) { self.send_state_update(UpdateKind::Fin, msg.into()).await; } + /// Complete as failed (async). pub async fn err>>(&self, msg: S) { self.send_state_update(UpdateKind::Err, msg.into()).await; } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 1c9699a..da31f92 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -1,3 +1,6 @@ +//! Actor system: stream-based build pipeline with `Addr` trait, cache actor, +//! and display actor. + mod cache; mod display; @@ -51,8 +54,11 @@ pub trait Addr { // Structs // ============================================================ +/// Wraps a [`oneshot::Sender`] with a debug-friendly kind label for logging. pub struct Response { + /// Debug label identifying which variant generated this response. pub kind: K, + /// Channel to send the response value back. pub tx: oneshot::Sender, } @@ -60,6 +66,8 @@ pub struct Response { // Free functions // ============================================================ +/// Clone a parser repo, scan it for grammar.js files, and produce a +/// `GrammarBuild` for each one after resolving the tree-sitter CLI. async fn discover_grammars( cache: CacheAddr, display: DisplayAddr, @@ -160,6 +168,7 @@ async fn discover_grammars( Ok(builds) } +/// Determine the revision (stable or moving commit) for a parser and clone it if needed. async fn resolve_revision( cache: &CacheAddr, language: &parser::LanguageBuild, @@ -274,6 +283,8 @@ pub async fn run( result } +/// Core pipeline: prepare tree-sitter CLI concurrently, discover grammars, build +/// them, and accumulate results. async fn run_inner( build_dir: &Path, cache: CacheAddr, @@ -404,6 +415,7 @@ async fn wait_for_prepared( // ============================================================ impl Response { + /// Send a value through the response channel. /// # Panics /// /// Will panic channel is closed. diff --git a/src/app.rs b/src/app.rs index d442481..f6c2823 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,6 @@ +//! Application entry point: resolves the build command, acquires locks, and +//! orchestrates the builder. + use std::path::{Path, PathBuf}; use clap::ArgMatches; @@ -40,6 +43,8 @@ pub struct ResolvedBuild { // Free functions // ============================================================ +/// Resolve the build configuration for a given purpose (e.g. "build", "config +/// current") and return both the resolved command and its provenance. fn resolve_build( config_path: &Path, matches: Option<&ArgMatches>, @@ -54,7 +59,8 @@ fn resolve_build( }) } - fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { +/// map cli args + matches into a [`resolvedcommand`] (build, config, or self-update). +fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { match &args.command { args::Command::Build => { resolve_build(&args.config, args::build_matches(matches), "`build`") @@ -74,6 +80,7 @@ fn resolve_build( } } +/// Parse CLI args, resolve the command, and initialise logging. pub fn setup() -> Result { let (args, matches) = config::parse_with_matches(); let command = resolve_command(&args, &matches)?; @@ -100,16 +107,17 @@ pub fn setup() -> Result { impl ResolvedCommand { - fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { - let implicit = match self { - Self::Build(build) => logging::Implicit::BuildDir { - dir: &build.command.build_dir, - }, - _ => logging::Implicit::None - }; - - logging::Policy { explicit, implicit } - } + /// Return a logging policy based on the command type. + fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { + let implicit = match self { + Self::Build(build) => logging::Implicit::BuildDir { + dir: &build.command.build_dir, + }, + _ => logging::Implicit::None + }; + + logging::Policy { explicit, implicit } + } } #[cfg(test)] diff --git a/src/args.rs b/src/args.rs index 854a11a..5b9a241 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,3 +1,5 @@ +//! CLI argument definitions using clap. + use std::{collections::BTreeMap, fmt, num::NonZeroUsize, path::PathBuf}; use clap::{ @@ -22,6 +24,7 @@ const TSDL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/tsdl.version" // Enums // ============================================================ +/// CLI commands: build parsers, manage config, or self-update. #[derive(clap::Subcommand, Clone, Debug)] pub enum Command { /// Build one or many parsers. @@ -46,49 +49,71 @@ pub enum Command { }, } +/// Subcommands for the `config` command. #[derive(clap::Subcommand, Clone, Debug, Default)] pub enum ConfigCommand { + /// Show the currently active (merged) configuration. #[default] Current, + /// Show the built-in default configuration. Default, } +/// Whether to emit colored stderr logs (auto, on, off). #[derive(clap::ValueEnum, Clone, Copy, Debug, Deserialize, Serialize)] pub enum LogColor { + /// Respect terminal capabilities. Auto, + /// Force plain text (no ANSI escapes). No, + /// Force colored output. Yes, } +/// A single parser override, either a bare git ref string or a full block +/// with optional build script and custom repo URL. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(untagged)] #[serde(rename_all = "kebab-case")] pub enum ParserConfig { + /// Full parser configuration block. Full { + /// Custom build script (e.g. "make" or a shell command). #[serde(alias = "cmd", alias = "script")] build_script: Option, + /// Custom repository URL for this parser. from: Option, + /// Git ref to check out (tag, branch, or commit SHA). #[serde(rename = "ref")] git_ref: String, }, + /// Short form: a bare git ref string. Ref(String), } +/// Terminal progress display style: auto-detect, force fancy (ratatui), or force plain. #[derive(clap::ValueEnum, Clone, Debug, Deserialize, Serialize)] pub enum ProgressStyle { + /// Respect terminal TTY detection. Auto, + /// Force ratatui inline rendering. Fancy, + /// Force plain line-by-line output. Plain, } +/// Build target: native shared library, WebAssembly, or both. #[derive(clap::ValueEnum, Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum Target { + /// Build a native `.so`/`.dylib`. #[default] Native, + /// Build a `.wasm` binary. Wasm, + /// Build both targets. All, } @@ -107,6 +132,7 @@ pub enum VersionBump { // Structs // ============================================================ +/// Top-level CLI arguments. #[derive(Clone, Debug, clap::Parser)] #[command(author = crate_authors!("\n"), version = TSDL_VERSION, about, styles=get_styles(), allow_external_subcommands = true)] #[command(help_template( @@ -145,19 +171,31 @@ pub struct Args { #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct BuildCommand { + /// Checkout and build directory root. pub build_dir: PathBuf, + /// Ignore cache, force a full rebuild. pub force: bool, + /// Clear build directory before starting. pub fresh: bool, + /// Specific languages (positional CLI args); `None` means "all configured". #[serde(skip_serializing)] pub languages: Option>, + /// Max concurrent build jobs. pub jobs: NonZeroUsize, + /// Directory for installed parser binaries. #[serde(rename = "out-dir", alias = "out")] pub out_dir: PathBuf, + /// Per-parser configuration overrides from config file. pub parsers: Option>, + /// Output filename prefix for installed binaries. pub prefix: String, + /// Print resolved config and exit. pub show_config: bool, + /// Build output kind (native, wasm, or all). pub target: Target, + /// Tree-sitter CLI version, platform, and repo. pub tree_sitter: TreeSitter, + /// Seconds to wait for a stale lock to be released. pub unlock_timeout: u64, } @@ -207,9 +245,11 @@ pub struct OptionalTreeSitter { pub repo: Option, } +/// The tree-sitter CLI binary version, platform, and download repo. #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct TreeSitter { + /// Git tag (or ref) of the tree-sitter CLI release. #[serde( default = "default_tree_sitter_version", alias = "git-ref", @@ -217,9 +257,11 @@ pub struct TreeSitter { )] pub version: String, + /// Target platform string used to select the correct release asset. #[serde(default = "default_tree_sitter_platform")] pub platform: String, + /// GitHub repository to download the CLI from (owner/name). #[serde(default = "default_tree_sitter_repo")] pub repo: String, } @@ -228,6 +270,7 @@ pub struct TreeSitter { // Free functions // ============================================================ +/// Get the build subcommand matches, if any. #[must_use] pub fn build_matches(matches: &ArgMatches) -> Option<&ArgMatches> { matches.subcommand().and_then(|(name, sub)| { @@ -239,24 +282,29 @@ pub fn build_matches(matches: &ArgMatches) -> Option<&ArgMatches> { }) } +/// Return the number of available CPUs as the default job count. #[must_use] pub fn default_jobs() -> NonZeroUsize { NonZeroUsize::new(num_cpus::get()).unwrap_or(NonZeroUsize::MIN) } +/// Get the default tree-sitter platform from compile-time constants. fn default_tree_sitter_platform() -> String { PLATFORM.to_string() } +/// Get the default tree-sitter download repo from compile-time constants. fn default_tree_sitter_repo() -> String { REPO.to_string() } +/// Get the default tree-sitter version from compile-time constants. fn default_tree_sitter_version() -> String { VERSION.to_string() } #[must_use] +/// Define the CLI colour scheme. const fn get_styles() -> clap::builder::Styles { clap::builder::Styles::styled() .usage( @@ -293,6 +341,7 @@ const fn get_styles() -> clap::builder::Styles { // ============================================================ impl Command { + /// Check whether this command is a build. #[must_use] pub const fn is_build(&self) -> bool { matches!(self, Command::Build) @@ -335,6 +384,7 @@ impl fmt::Display for ConfigCommand { } impl Target { + /// Check whether `self` covers the requested target (e.g. All covers everything). #[must_use] pub fn covers(&self, other: Target) -> bool { matches!( @@ -343,6 +393,7 @@ impl Target { ) } + /// Combine two targets into the broadest coverage. #[must_use] pub fn union(self, other: Self) -> Self { match (self, other) { @@ -355,16 +406,19 @@ impl Target { } } + /// Check whether the target includes native builds. #[must_use] pub fn native(&self) -> bool { matches!(self, Self::All | Self::Native) } + /// Check whether the target includes wasm builds. #[must_use] pub fn wasm(&self) -> bool { matches!(self, Self::All | Self::Wasm) } + /// Return a lowercased string representation. #[must_use] pub fn to_lowercase(&self) -> &'static str { match self { diff --git a/src/build.rs b/src/build.rs index 4a92d1c..b940e37 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,3 +1,6 @@ +//! Build orchestrator: resolves languages, acquires the lock, and drives the +//! per-language build pipeline. + use std::fmt; use std::{ collections::{BTreeMap, BTreeSet}, @@ -25,24 +28,37 @@ use crate::{ #[derive(Clone, Debug)] pub struct BuildDir(PathBuf); +/// Per-language build context flags. #[derive(Debug, Clone, PartialEq)] pub struct Context { + /// Whether to overwrite existing output binaries. pub overwrite_output: bool, } +/// Paths for the checkout and output directories of a parser build. #[derive(Debug, Clone)] pub struct OutputConfig { + /// Directory where the parser source is checked out. pub build_dir: PathBuf, + /// Directory where built binaries are installed. pub out_dir: PathBuf, } +/// Full build specification for a parser: source location, git ref, prefix, +/// target, and tree-sitter CLI version. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Spec { + /// Optional custom build script (e.g. "make"). pub build_script: Option, + /// Git ref to check out (tag, branch, or commit SHA). pub git_ref: parser::Ref, + /// Filename prefix for installed binaries. pub prefix: String, + /// Repository URL to clone. pub repo: Url, + /// Build output kind. pub target: args::Target, + /// Tree-sitter CLI configuration. pub tree_sitter: args::TreeSitter, } @@ -50,6 +66,7 @@ pub struct Spec { // Free functions // ============================================================ +/// Try to acquire the build lock, prompting the user if another process holds it. fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result { // Loop because the lock owner may exit naturally between the prompt and // SIGTERM, or another process may replace it. Each iteration re-checks @@ -88,6 +105,7 @@ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result Result { use consts::FROM; @@ -130,6 +151,7 @@ fn default_repo(language: &str) -> Result { Url::parse(&url).with_context(|| format!("Creating url {url} for {language}")) } +/// Look up the coordinates (repo URL, git ref, build script) for a language. fn get_language_coords( language: &str, defined_parsers: Option<&BTreeMap>, @@ -167,6 +189,7 @@ fn get_language_coords( } } +/// Prompt the user to terminate the lock-holding process, then wait for release. fn handle_locked_by( lock: &lock::Lock, owner: &lock::Owner, @@ -197,6 +220,7 @@ fn handle_locked_by( lock.wait_for_release(owner, unlock_timeout) } +/// Set up the runtime and spawn the build pipeline. fn ignite(command: &args::BuildCommand, app: &app::App, build_dir: &BuildDir) -> Result<()> { fs::create_dir_all(&command.out_dir)?; @@ -240,6 +264,7 @@ fn ignite(command: &args::BuildCommand, app: &app::App, build_dir: &BuildDir) -> result } +/// Acquire the build lock, clear if requested, and start the build. pub fn run(command: &args::BuildCommand, app: &app::App) -> Result<()> { if command.show_config { crate::config::show(command)?; @@ -254,6 +279,8 @@ pub fn run(command: &args::BuildCommand, app: &app::App) -> Result<()> { Ok(()) } +/// Produce a sorted, deduplicated list of language build definitions from the +/// command's requested languages or defined parsers. fn unique_languages( command: &args::BuildCommand, build_dir: &BuildDir, diff --git a/src/cache.rs b/src/cache.rs index 50d3978..a05394d 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,3 +1,7 @@ +//! Build cache: TOML-backed key-value store indexed by `{language}/{grammar}`. +//! Entries store grammar hashes and build specs to decide when rebuilds are +//! needed. + use std::{ collections::BTreeMap, fmt::{self, Write as _}, @@ -22,60 +26,63 @@ use crate::{args, build, build::BuildDir, git, parser, Error, Result, ResultExt} /// A cache lookup result for a requested parser build. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Decision { + /// Entry exists and is fully valid. Hit, + /// Entry is missing or something changed. Miss(Miss), } /// One reason a cached parser build cannot be reused. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MissReason { + /// No cache entry at all. MissingEntry, + /// Cache was explicitly bypassed (--force). CacheIgnored, + /// Hash of grammar.js changed. HashChanged { cached: GrammarHash, current: GrammarHash, }, - RepoChanged { - cached: String, - current: String, - }, + /// Parser repo URL changed. + RepoChanged { cached: String, current: String }, + /// Git ref changed (different tag or branch). RefChanged { cached: parser::Ref, current: parser::Ref, }, - RevisionChanged { - cached: Revision, - current: Revision, - }, + /// Git ref resolved to a different commit. + RevisionChanged { cached: Revision, current: Revision }, + /// Tree-sitter CLI version changed. TreeSitterChanged { cached: args::TreeSitter, current: args::TreeSitter, }, + /// Build script changed. BuildScriptChanged, - PrefixChanged { - cached: String, - current: String, - }, + /// Output filename prefix changed. + PrefixChanged { cached: String, current: String }, + /// Requested output target not covered by cached entry. OutputsMissing { available: args::Target, requested: args::Target, }, - ArtifactMissing { - path: PathBuf, - }, - ArtifactNotFile { - path: PathBuf, - }, - ArtifactInaccessible { - path: PathBuf, - error: String, - }, + /// Expected output file is missing from disk. + ArtifactMissing { path: PathBuf }, + /// Expected output path exists but is not a regular file. + ArtifactNotFile { path: PathBuf }, + /// Expected output path exists but cannot be read. + ArtifactInaccessible { path: PathBuf, error: String }, } +/// Parser revision identity: stable (tag/commit) or moving (branch with +/// pinned commit SHA). #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", tag = "kind")] pub enum Revision { + /// Stable ref — no per-build commit tracking needed. Stable, + /// Moving ref — the specific commit SHA checked out this build. Moving { commit: git::Sha }, } @@ -86,6 +93,7 @@ pub enum Revision { /// The logical build cache contents. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { + /// Per-key cache entries (keyed by `"{language}/{grammar}"`). #[serde(default)] pub parsers: BTreeMap, } @@ -104,12 +112,14 @@ pub struct Entry { pub outputs: args::Target, } +/// SHA-1 hash of a grammar.js file. #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct GrammarHash(Arc); struct HashWriter<'a>(&'a mut Sha1); +/// Cache lookup key: `"{language}/{grammar}"`. #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct Key(Arc); @@ -117,19 +127,23 @@ pub struct Key(Arc); /// Details explaining why a cache entry cannot satisfy a requested parser build. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Miss { + /// Individual reasons for the cache miss. pub reasons: Vec, } /// File-backed storage for a [`Db`]. #[derive(Debug, Clone)] pub struct Store { + /// Path to the cache file on disk. file: PathBuf, } /// Represents a "Delta" to be applied to the cache after a successful build #[derive(Debug, Clone)] pub struct Update { + /// The cache entry to store. pub entry: Entry, + /// The key to store it under. pub name: Key, } @@ -157,6 +171,7 @@ pub async fn hash_file(path: &Path) -> Result { Ok(GrammarHash::from(hex)) } +/// Sync the directory metadata to disk (best-effort for atomic write safety). fn sync_directory(path: &Path) -> std::io::Result<()> { std::fs::File::open(path)?.sync_all() } @@ -185,6 +200,7 @@ pub async fn verify_artifacts(artifacts: Vec) -> Decision { Decision::from_reasons(reasons) } +/// Atomically write cache contents to a file using a temporary file + rename. fn write_cache_file_atomically(file: &Path, contents: &str) -> Result<()> { let parent = file.parent().ok_or_else(|| Error::Message { message: format!( @@ -228,6 +244,7 @@ fn write_cache_file_atomically(file: &Path, contents: &str) -> Result<()> { // ============================================================ impl AsyncWrite for HashWriter<'_> { + /// Write bytes into the SHA-1 hasher. fn poll_write( mut self: Pin<&mut Self>, _cx: &mut Context<'_>, @@ -237,10 +254,12 @@ impl AsyncWrite for HashWriter<'_> { Poll::Ready(Ok(buf.len())) } + /// Flush is a no-op for the hasher. fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) } + /// Shutdown is a no-op for the hasher. fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) } @@ -263,7 +282,7 @@ impl Db { /// /// The tree-sitter CLI version, build script, prefix, and target are /// intentionally excluded — they affect artefact validity (checked by - /// [`needs_rebuild`]) but not whether a prior checkout of the parser + /// `needs_rebuild`) but not whether a prior checkout of the parser /// source is still usable. #[must_use] pub fn has_compatible_entry_for_language( @@ -322,6 +341,7 @@ impl Db { } impl Decision { + /// Create a Decision from a list of miss reasons (empty list → Hit). #[must_use] pub fn from_reasons(reasons: Vec) -> Self { if reasons.is_empty() { @@ -331,11 +351,13 @@ impl Decision { } } + /// Check whether this decision indicates a cache hit. #[must_use] pub fn is_hit(&self) -> bool { matches!(self, Self::Hit) } + /// Create a Miss decision for a single reason. #[must_use] pub fn miss(reason: MissReason) -> Self { Self::Miss(Miss { @@ -343,11 +365,13 @@ impl Decision { }) } + /// Check whether the decision requires a rebuild. #[must_use] pub fn needs_rebuild(&self) -> bool { !self.is_hit() } + /// Return a short human-readable summary of the decision. #[must_use] pub fn short_message(&self) -> String { match self { @@ -358,6 +382,8 @@ impl Decision { } impl Entry { + /// Compare the current build parameters against this entry and decide + /// whether the cached output is still valid. #[must_use] pub fn rebuild_decision( &self, @@ -424,6 +450,8 @@ impl Entry { Decision::from_reasons(reasons) } + /// Check whether two entries describe the same build subject (ignoring + /// output coverage). #[must_use] pub fn same_subject(&self, other: &Self) -> bool { self.hash == other.hash @@ -559,6 +587,7 @@ impl From for Key { } impl GrammarHash { + /// Return the hash as a string slice. #[must_use] pub fn as_str(&self) -> &str { &self.0 @@ -566,16 +595,19 @@ impl GrammarHash { } impl Key { + /// Return the key as a string slice. #[must_use] pub fn as_str(&self) -> &str { &self.0 } + /// Build the prefix for all keys belonging to a given language. #[must_use] pub fn language_prefix(language: &parser::LanguageName) -> String { format!("{language}/") } + /// Create a cache key from a language name and grammar name. #[must_use] pub fn new(language: &parser::LanguageName, grammar: &parser::GrammarName) -> Self { Self(Arc::from(format!("{language}/{grammar}"))) @@ -583,6 +615,7 @@ impl Key { } impl Miss { + /// Return a short human-readable summary of the miss reasons. #[must_use] pub fn short_message(&self) -> String { match self.reasons.as_slice() { @@ -601,6 +634,7 @@ impl Miss { } impl MissReason { + /// Return a compact label for this miss reason (suitable for lists). #[must_use] pub fn short_label(&self) -> &'static str { match self { @@ -620,6 +654,7 @@ impl MissReason { } } + /// Return a one-line message describing this miss reason. #[must_use] pub fn short_message(&self) -> &'static str { match self { @@ -641,11 +676,13 @@ impl MissReason { } impl Revision { + /// Create a stable (non-moving) revision. #[must_use] pub const fn stable() -> Self { Self::Stable } + /// Create a moving revision pinned to a specific commit. #[must_use] pub const fn moving(commit: git::Sha) -> Self { Self::Moving { commit } @@ -653,6 +690,20 @@ impl Revision { } impl Store { + /// Create a store backed by the build directory's cache file. + #[must_use] + pub fn new(build_dir: &BuildDir) -> Self { + Self { + file: build_dir.cache_file(), + } + } + + /// Return the path to the cache file on disk. + #[must_use] + pub fn path(&self) -> &Path { + &self.file + } + /// Delete the cache file from disk. pub async fn delete(&self) -> Result<()> { match tokio::fs::metadata(&self.file).await { @@ -690,18 +741,6 @@ impl Store { .with_context(|| format!("Parsing cache file at {}", self.file.display())) } - #[must_use] - pub fn new(build_dir: &BuildDir) -> Self { - Self { - file: build_dir.cache_file(), - } - } - - #[must_use] - pub fn path(&self) -> &Path { - &self.file - } - /// Atomically save the cache to disk. pub async fn save(&self, db: &Db) -> Result<()> { let contents = toml::to_string_pretty(db).context("Serializing cache to TOML")?; diff --git a/src/columns.rs b/src/columns.rs index bc35b65..0bfb7ea 100644 --- a/src/columns.rs +++ b/src/columns.rs @@ -1,4 +1,6 @@ -/// Layout direction for [`format`]. +//! Column-layout algorithm for terminal output (multi-column, dense, plain). + +/// Layout direction for [`format()`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Layout { /// Fill down each column before moving right. @@ -16,7 +18,7 @@ struct DenseLayout { widths: Vec, } -/// Options controlling [`format`]. +/// Options controlling [`format()`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Options<'a> { /// Layout direction. @@ -33,6 +35,7 @@ pub struct Options<'a> { pub line_ending: &'a str, } +/// Compute the maximum width of each column from the item widths. fn compute_column_widths( item_widths: &[usize], cols: usize, @@ -53,6 +56,7 @@ fn compute_column_widths( widths } +/// Return the visible width of a string (accounting for CJK, emoji, etc.). fn display_width(value: &str) -> usize { console::measure_text_width(value) } @@ -113,6 +117,7 @@ pub fn format_git>(items: &[S], indent: &str, width: usize) -> Str format(items, Options::git(indent, width)) } +/// Format items one per line (indented). fn format_plain>(items: &[S], options: Options<'_>) -> String { let mut output = String::new(); @@ -125,6 +130,7 @@ fn format_plain>(items: &[S], options: Options<'_>) -> String { output } +/// Render items into a row/column grid with optional per-column widths. fn format_table>( items: &[S], item_widths: &[usize], @@ -166,6 +172,7 @@ fn format_table>( output } +/// Check whether `item_index` is the last cell in its row. const fn is_last_cell_in_row( layout: Layout, item_index: usize, @@ -181,6 +188,7 @@ const fn is_last_cell_in_row( } } +/// Convert grid coordinates to a linear index based on the layout direction. const fn linear_index(cols: usize, layout: Layout, rows: usize, x: usize, y: usize) -> usize { match layout { Layout::Column => x * rows + y, @@ -189,10 +197,12 @@ const fn linear_index(cols: usize, layout: Layout, rows: usize, x: usize, y: usi } } +/// Append `count` space characters to the output string. fn push_spaces(output: &mut String, count: usize) { output.extend(std::iter::repeat_n(' ', count)); } +/// Try to reduce row count while keeping total width within the limit. fn shrink_columns( item_widths: &[usize], options: Options<'_>, diff --git a/src/config.rs b/src/config.rs index 67a788f..ca576e0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,6 @@ +//! Configuration merging: CLI flags ⊕ `parsers.toml` ⊕ defaults, resolved via +//! figment and diff-struct. + use std::{ ffi::OsString, fs, @@ -41,13 +44,18 @@ const ARG_UNLOCK_TIMEOUT: &str = "unlock_timeout"; // Enums // ============================================================ +/// Provenance source for a single configuration value. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum Source { + /// Compiled-in default. #[default] BuiltInDefault, + /// From the parser TOML config file. ConfigFile, + /// From an environment variable. Environment, + /// From a CLI flag. CommandLine, } @@ -59,9 +67,11 @@ pub enum Source { /// definitions and value extraction stay in sync. #[derive(clap::Args, Clone, Debug, Default)] pub struct BuildArgs { + /// Override build directory (`--build-dir`, `-b`). #[arg(long = "build-dir", short = 'b', env = "BUILD_DIR")] pub build_dir: Option, + /// Force rebuild (`--force`). #[arg( long = "force", env = "FORCE", @@ -70,9 +80,11 @@ pub struct BuildArgs { default_missing_value = "true" )] pub force: Option, + /// Negate --force (`--no-force`). #[arg(long = "no-force")] pub no_force: bool, + /// Fresh build, clear build directory (`--fresh`, `-f`). #[arg( long = "fresh", short = 'f', @@ -82,21 +94,27 @@ pub struct BuildArgs { default_missing_value = "true" )] pub fresh: Option, + /// Negate --fresh (`--no-fresh`). #[arg(long = "no-fresh")] pub no_fresh: bool, + /// Languages to build (positional args, zero or more). #[arg(num_args = 0..)] pub languages: Vec, + /// Max concurrent jobs (`--jobs`, `-j`). #[arg(long = "jobs", short = 'j', env = "TSDL_NCPUS")] pub jobs: Option, + /// Output directory for installed binaries (`--out-dir`, `-o`). #[arg(long = "out-dir", short = 'o', env = "PARSER_OUT_DIR")] pub out_dir: Option, + /// Output filename prefix (`--prefix`, `-p`). #[arg(long = "prefix", short = 'p', env = "PREFIX")] pub prefix: Option, + /// Print resolved config and exit (`--show-config`). #[arg( long = "show-config", env = "SHOW_CONFIG", @@ -105,15 +123,18 @@ pub struct BuildArgs { default_missing_value = "true" )] pub show_config: Option, + /// Negate --show-config (`--no-show-config`). #[arg(long = "no-show-config")] pub no_show_config: bool, + /// Build target (`--target`, `-t`). #[arg(long = "target", short = 't', env = "TSDL_TARGET", value_enum)] pub target: Option, #[command(flatten)] pub tree_sitter: TreeSitterArgs, + /// Lock wait timeout in seconds (`--unlock-timeout`). #[arg( long = "unlock-timeout", env = "UNLOCK_TIMEOUT", @@ -122,6 +143,11 @@ pub struct BuildArgs { pub unlock_timeout: Option, } +/// Tracks the source provenance for every field in [`args::BuildCommand`]. +/// +/// Each field records whether the corresponding [`args::BuildCommand`] +/// value came from the built-in default, the config file, an env var, or +/// the CLI. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct BuildProvenance { @@ -142,14 +168,22 @@ pub struct BuildProvenance { /// Nested CLI arguments for tree-sitter configuration. #[derive(clap::Args, Clone, Debug, Default)] pub struct TreeSitterArgs { + /// CLI flag: `--tree-sitter-version` / `-V`. #[arg(long = "tree-sitter-version", short = 'V', env = "TSDL_VERSION")] pub version: Option, + /// CLI flag: `--tree-sitter-platform`. #[arg(long = "tree-sitter-platform", env = "TSDL_PLATFORM")] pub platform: Option, + /// CLI flag: `--tree-sitter-repo` / `-R`. #[arg(long = "tree-sitter-repo", short = 'R', env = "TSDL_REPO")] pub repo: Option, } +/// Tracks the source provenance for every field in [`args::TreeSitter`]. +/// +/// Each field records whether the corresponding [`args::TreeSitter`] +/// value came from the built-in default, the config file, an env var, or +/// the CLI. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct TreeSitterProvenance { @@ -162,17 +196,20 @@ pub struct TreeSitterProvenance { // Free functions // ============================================================ +/// Apply an Option value to a mutable field if present. fn apply_opt(field: &mut T, value: Option) { if let Some(v) = value { *field = v; } } +/// Resolve the current merged configuration from defaults, config file, and CLI. pub fn current(config: &Path, matches: Option<&ArgMatches>) -> Result { let (cmd, _provenance) = current_with_provenance(config, matches)?; Ok(cmd) } +/// Resolve the current merged configuration with full provenance tracking. pub fn current_with_provenance( config: &Path, matches: Option<&ArgMatches>, @@ -206,6 +243,7 @@ pub fn extract_overrides(matches: &ArgMatches) -> (args::OptionalBuildCommand, B overrides_from_build_args(cli, matches) } +/// Build provenance for each config file override. fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenance { let mut p = BuildProvenance::default(); if overrides.build_dir.is_some() { @@ -251,6 +289,7 @@ fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenan p } +/// Merge defaults + file overrides + CLI overrides into a final `BuildCommand`. fn merge( defaults: args::BuildCommand, file: args::OptionalBuildCommand, @@ -293,6 +332,7 @@ fn merge( cmd } +/// Merge file and CLI provenance into a single resolved provenance. fn merge_provenance(file: &BuildProvenance, cli: &BuildProvenance) -> BuildProvenance { BuildProvenance { build_dir: merge_source(file.build_dir, cli.build_dir), @@ -314,6 +354,7 @@ fn merge_provenance(file: &BuildProvenance, cli: &BuildProvenance) -> BuildProve } } +/// Pick the CLI source if set, otherwise fall back to file source. fn merge_source(file: Source, cli: Source) -> Source { if cli == Source::default() { file @@ -322,6 +363,7 @@ fn merge_source(file: Source, cli: Source) -> Source { } } +/// Convert CLI `BuildArgs` into `OptionalBuildCommand` + `BuildProvenance`. fn overrides_from_build_args( cli: BuildArgs, matches: &ArgMatches, @@ -432,6 +474,7 @@ fn overrides_from_build_args( (o, p) } +/// Parse CLI args using the augmented command definitions. #[must_use] pub fn parse_with_matches() -> (args::Args, ArgMatches) { let mut cmd = args::Args::command(); @@ -443,6 +486,7 @@ pub fn parse_with_matches() -> (args::Args, ArgMatches) { (args, matches) } +/// Print the current merged configuration as TOML. pub fn print_current(command: &args::BuildCommand) -> Result<()> { println!( "{}", @@ -451,6 +495,7 @@ pub fn print_current(command: &args::BuildCommand) -> Result<()> { Ok(()) } +/// Print the built-in default configuration as TOML. pub fn print_default() -> Result<()> { println!( "{}", @@ -460,10 +505,12 @@ pub fn print_default() -> Result<()> { Ok(()) } +/// Print a string with a per-line indent prefix. pub fn print_indent(s: &str, indent: &str) { s.lines().for_each(|line| println!("{indent}{line}")); } +/// Read parser config file overrides from the TOML file at `config`. fn read_file_overrides(config: &Path) -> Result { if !config.exists() { return Ok(args::OptionalBuildCommand::default()); @@ -506,6 +553,7 @@ fn resolve_bool( } } +/// Run a config command (current or default). pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { match command { args::ConfigCommand::Current => print_current(¤t(config_path, None)?), @@ -530,6 +578,7 @@ fn set_simple( } } +/// Print a human-readable summary of the resolved configuration. pub fn show(command: &args::BuildCommand) -> Result<()> { if let Some(langs) = &command.languages { println!("Building the following languages:"); @@ -547,10 +596,12 @@ pub fn show(command: &args::BuildCommand) -> Result<()> { Ok(()) } +/// Look up the `ValueSource` for a given arg ID and convert it to a Source. fn source_for(matches: &ArgMatches, id: &str) -> Option { matches.value_source(id).and_then(Source::from_value_source) } +/// Parse CLI args from an iterator (used in tests). pub fn try_parse_from_with_matches(itr: I) -> StdResult<(args::Args, ArgMatches), clap::Error> where I: IntoIterator, @@ -570,6 +621,7 @@ where // ============================================================ impl Source { + /// Convert a clap `ValueSource` to a config Source. fn from_value_source(source: ValueSource) -> Option { match source { ValueSource::CommandLine => Some(Self::CommandLine), diff --git a/src/consts.rs b/src/consts.rs index 8fc0fd3..97376dc 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,3 +1,5 @@ +//! Build-time constants generated from `Cargo.toml` metadata. + // Include the generated constants. // See build.rs . include!(concat!(env!("OUT_DIR"), "/tree_sitter_consts.rs")); diff --git a/src/display.rs b/src/display.rs index 878e3d3..04897e9 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,3 +1,6 @@ +//! Display state model: repos, grammars, grid cache, layout computation, and +//! summary counts. + use std::collections::{HashMap, HashSet}; use std::num::NonZeroU64; use std::path::PathBuf; @@ -40,16 +43,24 @@ const TIME_STYLE: Style = Style::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum Column { + /// Icon (status indicator). Icon, + /// Message text. Msg, + /// Parser/grammar name. Name, + /// Git ref string. Ref, + /// Step counter (e.g. "[1/4]"). Step, + /// Elapsed time. Time, } pub(crate) enum ItemInfo<'a> { + /// References a grammar-level entry. Grammar(&'a GrammarEntry), + /// References a repo-level entry. Repo(&'a RepoEntry), } @@ -73,18 +84,29 @@ pub enum ItemState { Failed, } +/// The selected display backend. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Mode { + /// Ratatui inline terminal rendering. Fancy, + /// Deterministic line-by-line output. Plain, } +// ============================================================ +// Structs +// ============================================================ + +/// The kind of rows: collapsed single-grammar repos and multi-grammar repos. #[derive(Debug, Clone)] pub(crate) enum RowKind { + /// A grammar-level progress row. Grammar, + /// A repo-level progress row. Repo, } +/// Whether a completed build needed actual work or was served from cache. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SuccessOutcome { /// Not cached; work was needed. @@ -99,9 +121,13 @@ pub enum SuccessOutcome { #[derive(Debug, Clone)] pub(crate) struct CachedLayout { + /// Width of the time column. pub time: usize, + /// Width of the git-ref column. pub ref_: usize, + /// Width of the step column. pub step: usize, + /// Width of the name column. pub name: usize, /// Total width consumed by non-message columns: time + sp + ref + sp + /// step + sp + icon(1) + sp + name + sp. @@ -116,13 +142,21 @@ struct CellKey { #[derive(Debug, Clone)] pub(crate) struct GrammarEntry { + /// Parent repo name. pub repo: Arc, + /// Parent repo's item ID (for batch updates). pub repo_id: Option, + /// Grammar name within the repo. pub name: Arc, + /// Git ref for display. pub git_ref: git::Ref, + /// Current lifecycle state. pub state: ItemState, + /// Current status message. pub msg: Arc, + /// Current step index (0-based). pub step: usize, + /// Total number of steps. pub total: usize, /// Creation time for the whole item. This is not reset between steps. pub started_at: Instant, @@ -131,21 +165,31 @@ pub(crate) struct GrammarEntry { } pub(crate) struct GridCache { + /// Cached rendered cells, keyed by (`item_id`, column). cells: HashMap>, + /// Item IDs whose cells need recomputation. pub dirty_items: HashSet, + /// Current column layout (widths). pub layout: CachedLayout, } +/// Unique identifier for a display row (repo or grammar). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ItemId(NonZeroU64); #[derive(Debug, Clone)] pub(crate) struct RepoEntry { + /// Repo name. pub name: Arc, + /// Git ref for display. pub git_ref: git::Ref, + /// Current lifecycle state. pub state: ItemState, + /// Current status message. pub msg: Arc, + /// Current step index (0-based). pub step: usize, + /// Total number of steps. pub total: usize, /// Creation time for the whole item. This is not reset between steps. pub started_at: Instant, @@ -155,7 +199,9 @@ pub(crate) struct RepoEntry { #[derive(Debug, Clone)] pub(crate) struct RowSpec { + /// Unique row identifier. pub id: ItemId, + /// Whether this row is a repo or a grammar. pub kind: RowKind, /// The text to display in the name column (already includes indent). pub display_name: Arc, @@ -163,22 +209,33 @@ pub(crate) struct RowSpec { pub indent: &'static str, } +/// Tallies of terminal row states for the summary footer. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) struct BuildSummary { + /// Rows still building. pub building: usize, + /// Rows that were built (not cached). pub built: usize, + /// Rows served from cache. pub cached: usize, + /// Rows cancelled by shutdown. pub cancelled: usize, + /// Rows that failed. pub failed: usize, } pub(crate) struct State { + /// Build directory root path. pub build_dir: PathBuf, footer_build: Line<'static>, footer_out: Line<'static>, + /// Grammar-level entries keyed by item ID. pub grammars: HashMap, + /// Display mode. pub mode: Mode, + /// Output directory path. pub out_dir: PathBuf, + /// Repo-level entries keyed by item ID. pub repos: HashMap, } @@ -186,6 +243,7 @@ pub(crate) struct State { // Free functions // ============================================================ +/// Render the status icon cell (●, ✓, ✗) with indicator color. pub(crate) fn compute_icon_cell(info: &ItemInfo<'_>) -> Span<'static> { let state = info.state(); Span::styled( @@ -196,6 +254,7 @@ pub(crate) fn compute_icon_cell(info: &ItemInfo<'_>) -> Span<'static> { ) } +/// Render the message cell, truncating to fit the available width. pub(crate) fn compute_msg_cell( info: &ItemInfo<'_>, layout: &CachedLayout, @@ -207,6 +266,7 @@ pub(crate) fn compute_msg_cell( Span::styled(truncate_str(info.msg(), msg_width), MSG_STYLE) } +/// Render the name cell with indentation and outcome-based colour. pub(crate) fn compute_name_cell( display_name: &str, indent: &str, @@ -223,12 +283,14 @@ pub(crate) fn compute_name_cell( ) } +/// Render the git ref cell, left-aligned. pub(crate) fn compute_ref_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { let ref_str = info.git_ref().short(); let padded = format!("{:, layout: &CachedLayout) -> Span<'static> { let step_str = if info.total() > 0 { format!("[{}/{}]", info.step().min(info.total()), info.total()) @@ -240,6 +302,7 @@ pub(crate) fn compute_step_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> S Span::styled(padded, REF_STYLE) } +/// Render the elapsed time cell, right-aligned. pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { let time = format_elapsed_duration(info.elapsed()); // Right-aligned @@ -247,6 +310,8 @@ pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> S Span::styled(padded, TIME_STYLE) } +/// Accumulate a single item state into the summary counters. +/// Accumulate a single item state into the summary counters. fn count_item_state( state: ItemState, cached: &mut usize, @@ -265,6 +330,7 @@ fn count_item_state( } /// Pre-computed dimmed footer lines (static; never change). +/// Build a dimmed footer line. fn dim_line(text: String) -> Line<'static> { Line::from(Span::styled( text, @@ -278,6 +344,7 @@ fn dim_line(text: String) -> Line<'static> { /// /// Columns in order: Time _ Ref _ Step _ Icon _ Name _ Msg /// (5 spacers between 6 columns). +/// Compute the total width consumed by all non-message columns. const fn fixed_width(time: usize, rf: usize, step: usize, name: usize) -> usize { time + SPACER_WIDTH + rf @@ -290,6 +357,8 @@ const fn fixed_width(time: usize, rf: usize, step: usize, name: usize) -> usize + SPACER_WIDTH } +/// Format a duration as " 0.00s" or " 3:45". +/// Format a duration as " 0.00s" or " 3:45". fn format_elapsed_duration(dur: Duration) -> String { let secs = dur.as_secs_f64(); if secs < 60.0 { @@ -325,6 +394,7 @@ pub fn mode_from_args(progress: &ProgressStyle, verbose: &Verbosity) mode } +/// Append a "building" counter span to the summary line. fn push_summary_building(spans: &mut Vec>, count: usize) { spans.push(Span::raw(" ")); spans.push(Span::styled( @@ -337,6 +407,7 @@ fn push_summary_building(spans: &mut Vec>, count: usize) { )); } +/// Append a "cancelled" counter span to the summary line. fn push_summary_cancelled(spans: &mut Vec>, count: usize, _leading: bool) { spans.push(Span::raw(" ")); spans.push(Span::styled( @@ -349,6 +420,7 @@ fn push_summary_cancelled(spans: &mut Vec>, count: usize, _leading )); } +/// Append a "failed" counter span to the summary line. fn push_summary_failed(spans: &mut Vec>, count: usize) { spans.push(Span::raw(" ")); spans.push(Span::styled( @@ -361,6 +433,7 @@ fn push_summary_failed(spans: &mut Vec>, count: usize) { )); } +/// Append a "cached"/"built" success counter span to the summary line. fn push_summary_success( spans: &mut Vec>, count: usize, @@ -393,6 +466,7 @@ fn push_summary_success( } } +/// Truncate a string with an ellipsis if it exceeds `max` characters. fn truncate_str(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_string() @@ -427,6 +501,7 @@ impl Default for CachedLayout { } impl CachedLayout { + /// Apply minimum width constraints and recompute `fixed_width`. fn finalize(&mut self) { self.time = TIME_COL_WIDTH; self.ref_ = self.ref_.max(MIN_REF_WIDTH); @@ -437,6 +512,7 @@ impl CachedLayout { } impl GrammarEntry { + /// Return the elapsed time (frozen or live). pub(crate) fn elapsed(&self) -> Duration { self.frozen_elapsed .unwrap_or_else(|| self.started_at.elapsed()) @@ -444,6 +520,7 @@ impl GrammarEntry { } impl GridCache { + /// Create an empty grid cache. pub fn new() -> Self { Self { cells: HashMap::new(), @@ -476,10 +553,12 @@ impl GridCache { } } + /// Mark all cells for an item as needing recomputation. pub fn mark_dirty(&mut self, item_id: ItemId) { self.dirty_items.insert(item_id); } + /// Clear the dirty set after recomputation. pub fn clear_dirty(&mut self) { self.dirty_items.clear(); } @@ -492,11 +571,13 @@ impl GridCache { } impl ItemId { + /// Create an `ItemId` from a non-zero u64. #[must_use] pub(crate) fn new(value: NonZeroU64) -> Self { Self(value) } + /// Return the next sequential `ItemId`. #[must_use] pub(crate) fn next_after(self) -> Self { let next = self @@ -510,6 +591,7 @@ impl ItemId { } impl ItemInfo<'_> { + /// Get the current item state. pub(crate) fn state(&self) -> ItemState { match self { ItemInfo::Grammar(g) => g.state, @@ -517,6 +599,7 @@ impl ItemInfo<'_> { } } + /// Get the current message string. pub(crate) fn msg(&self) -> &str { match self { ItemInfo::Grammar(g) => &g.msg, @@ -524,6 +607,7 @@ impl ItemInfo<'_> { } } + /// Get the current step index (0-based). pub(crate) fn step(&self) -> usize { match self { ItemInfo::Grammar(g) => g.step, @@ -531,6 +615,7 @@ impl ItemInfo<'_> { } } + /// Get the total number of steps. pub(crate) fn total(&self) -> usize { match self { ItemInfo::Grammar(g) => g.total, @@ -538,6 +623,8 @@ impl ItemInfo<'_> { } } + /// Get the git ref for this item. + /// Get the git ref for this item. pub(crate) fn git_ref(&self) -> &git::Ref { match self { ItemInfo::Grammar(g) => &g.git_ref, @@ -545,6 +632,7 @@ impl ItemInfo<'_> { } } + /// Get the elapsed time for this item. pub(crate) fn elapsed(&self) -> Duration { match self { ItemInfo::Grammar(g) => g.elapsed(), @@ -554,11 +642,13 @@ impl ItemInfo<'_> { } impl ItemState { + /// Check whether the item is still live (new or in progress). #[must_use] pub fn is_live(self) -> bool { matches!(self, Self::New | Self::InProgress(_)) } + /// Get the success outcome, if the item has reached a terminal outcome state. #[must_use] pub fn success_outcome(self) -> Option { match self { @@ -568,6 +658,7 @@ impl ItemState { } } + /// Return the status icon character. fn icon(self) -> &'static str { match self { Self::New | Self::InProgress(_) => "●", @@ -576,6 +667,7 @@ impl ItemState { } } + /// Return the colour for the name text. fn name_color(self) -> Color { match self { Self::Failed => Color::Red, @@ -585,6 +677,7 @@ impl ItemState { } } + /// Return the colour for the status indicator. fn indicator_color(self) -> Color { match self { Self::Done(_) => Color::Green, @@ -596,6 +689,7 @@ impl ItemState { } impl RepoEntry { + /// Return the elapsed time (frozen or live). pub(crate) fn elapsed(&self) -> Duration { self.frozen_elapsed .unwrap_or_else(|| self.started_at.elapsed()) @@ -603,6 +697,7 @@ impl RepoEntry { } impl State { + /// Create a new display state with the given build dir, mode, and output dir. pub fn new(build_dir: PathBuf, mode: Mode, out_dir: PathBuf) -> Self { let footer_build = dim_line(format!("build: {}", build_dir.display())); let footer_out = dim_line(format!("out: {}", out_dir.display())); @@ -619,14 +714,17 @@ impl State { // ── Footer lines ────────────────────────────────────────────── + /// Return the pre-computed "build:" footer line. pub fn footer_build_line(&self) -> Line<'static> { self.footer_build.clone() } + /// Return the pre-computed "out:" footer line. pub fn footer_out_line(&self) -> Line<'static> { self.footer_out.clone() } + /// Build the summary counts footer line. pub fn format_footer_counts(&self) -> Line<'static> { let s = self.summary(); let mut spans: Vec> = Vec::new(); @@ -715,6 +813,7 @@ impl State { // ── Item access ─────────────────────────────────────────────── + /// Get the item info for a row spec. pub fn get_item_info(&self, spec: &RowSpec) -> ItemInfo<'_> { match spec.kind { RowKind::Grammar => ItemInfo::Grammar( @@ -754,6 +853,7 @@ impl State { // ── Summary counts ──────────────────────────────────────────── + /// Compute build summary counts from all item states. pub(crate) fn summary(&self) -> BuildSummary { let mut cached = 0; let mut built = 0; @@ -802,6 +902,7 @@ impl State { } impl SuccessOutcome { + /// Return the display colour for entries with this outcome. fn color(self) -> Color { match self { SuccessOutcome::Built => Color::Blue, diff --git a/src/error.rs b/src/error.rs index 9e08bbd..051e587 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,6 @@ +//! Error type hierarchy: `TsdlError` with nested `Step`, `Parser`, +//! `Language`, `Command`, and `Build` variants. + use std::fmt; use std::path::PathBuf; use std::sync::Arc; @@ -13,8 +16,10 @@ pub type Result = std::result::Result; // ============================================================ pub trait ResultExt { + /// Wrap the error value with a context message. fn context(self, message: impl Into) -> Result; + /// Wrap the error value with a lazily-evaluated context message. fn with_context(self, message: impl FnOnce() -> String) -> Result; } @@ -64,16 +69,22 @@ pub enum Error { }, } +/// The specific parser operation that failed. #[derive(Debug, Display)] pub enum ParserOp { + /// Compilation step inside the checked-out grammar directory. #[display("Could not build in {}", dir.display())] Build { dir: PathBuf }, + /// Git clone into the checkout directory. #[display("Could not clone to {}", dir.display())] Clone { dir: PathBuf }, + /// Scanning the checkout for grammar.js files. #[display("Could not discover grammars in {}", dir.display())] Discover { dir: PathBuf }, + /// Copying or hard-linking the built artifact. #[display("Could not copy {} to {}", src.display(), dst.display())] Copy { src: PathBuf, dst: PathBuf }, + /// Running tree-sitter generate. #[display("Could not generate in {}", dir.display())] Generate { dir: PathBuf }, } @@ -82,6 +93,7 @@ pub enum ParserOp { // Structs // ============================================================ +/// Boxed wrapper around [`Error`] to break the recursive type. #[derive(Debug)] pub struct Cause(Box); @@ -89,6 +101,7 @@ pub struct Cause(Box); // Free functions // ============================================================ +/// Format a build error list with indentation. fn format_build_errors(w: &mut impl fmt::Write, errors: &[Error], indent: usize) -> fmt::Result { let prefix = " ".repeat(indent); write!(w, "{prefix}Could not build all parsers.")?; @@ -101,6 +114,7 @@ fn format_build_errors(w: &mut impl fmt::Write, errors: &[Error], indent: usize) Ok(()) } +/// Format a command execution error with stdout/stderr and indentation. fn format_command( w: &mut impl fmt::Write, indent: usize, @@ -153,6 +167,7 @@ fn format_command( Ok(()) } +/// Format language collection errors as a comma-separated list. fn format_language_collection( w: &mut impl fmt::Write, related: &[Error], @@ -179,11 +194,13 @@ fn format_language_collection( // ============================================================ impl Cause { + /// Wrap an error source into a boxed Cause. #[must_use] pub fn new(source: impl Into) -> Self { Self(Box::new(source.into())) } + /// Borrow the inner error. #[must_use] pub fn as_error(&self) -> &Error { &self.0 @@ -214,6 +231,7 @@ impl std::error::Error for Error { } impl Error { + /// Format the error with a given indentation level, returning a String. #[must_use] pub fn format_indent(&self, indent: usize) -> String { let mut s = String::new(); @@ -221,6 +239,7 @@ impl Error { s } + /// Recursively format the error tree with per-level indentation. fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { let prefix = " ".repeat(indent); match self { diff --git a/src/git.rs b/src/git.rs index 32cee0a..6d19a25 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,3 +1,5 @@ +//! Git operations: clone, fetch, checkout, ls-files, tag resolution. + use std::{ ffi::OsStr, fmt, @@ -20,16 +22,24 @@ type RefResult = StdResult; /// Error type for git ref and SHA validation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RefError { + /// Ref string is empty. EmptyRef, + /// Ref contains an invalid character. InvalidRefCharacter { index: usize, character: char }, + /// Ref has invalid syntax. InvalidRefSyntax { reason: &'static str }, + /// SHA string is not 40 characters long. InvalidShaLength { actual: usize }, + /// SHA string contains non-hex characters. InvalidShaHex { index: usize, character: char }, } +/// A resolved git ref after tag lookup or branch checkout. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum ResolvedRef { + /// A tag with its resolved commit SHA. Tag { label: String, sha: Sha }, + /// A bare git ref (no tag resolution). Ref(Ref), } @@ -37,14 +47,18 @@ pub enum ResolvedRef { // Structs // ============================================================ +/// The commit SHA checked out for a given git ref. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Checkout { + /// The checked-out commit hash. pub commit: Sha, } +/// A validated git ref string (branch name, tag, or commit SHA). #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Ref(Arc); +/// A validated 40-character hex SHA-1 commit hash. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Sha(Arc); @@ -52,10 +66,14 @@ pub struct Sha(Arc); // Free functions // ============================================================ +/// Check out a git ref in the given directory (with force flag to handle +/// remote URL changes). pub async fn checkout(repo: &str, cwd: &Path, git_ref: &Ref) -> Result { checkout_with_force(repo, cwd, git_ref, false).await } +/// Like [`checkout`], but optionally force-clean the working directory even +/// when the remote URL matches. pub async fn checkout_with_force( repo: &str, cwd: &Path, @@ -77,6 +95,7 @@ pub async fn checkout_with_force( } // TODO: get rid of async fs completely. +/// Remove a directory or file at `cwd` if it exists. async fn clean_anyway(cwd: &Path) -> Result<()> { if cwd.exists() { if cwd.is_dir() { @@ -88,6 +107,7 @@ async fn clean_anyway(cwd: &Path) -> Result<()> { Ok(()) } +/// Clone a repository and pull if it already exists. pub async fn clone(repo: &str, cwd: &Path) -> Result<()> { if cwd.exists() { Command::new("git") @@ -104,6 +124,7 @@ pub async fn clone(repo: &str, cwd: &Path) -> Result<()> { Ok(()) } +/// Fetch from origin and reset to `FETCH_HEAD`. async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> Result<()> { Command::new("git") .env("GIT_TERMINAL_PROMPT", "0") @@ -119,11 +140,13 @@ async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> Result<()> { Ok(()) } +/// Get the full SHA of HEAD in the given repo. async fn get_head_sha(cwd: &Path) -> Result { let value = get_head_sha1(cwd).await?; Sha::new(value.trim()).context("Parsing HEAD commit") } +/// Get the raw SHA-1 string of HEAD. async fn get_head_sha1(cwd: &Path) -> Result { String::from_utf8( Command::new("git") @@ -136,6 +159,7 @@ async fn get_head_sha1(cwd: &Path) -> Result { .context("rev-parse HEAD is not a valid utf-8") } +/// Get the remote URL for origin. async fn get_remote_url(cwd: &Path) -> Result { String::from_utf8( Command::new("git") @@ -148,6 +172,7 @@ async fn get_remote_url(cwd: &Path) -> Result { .context("remote get-url origin did not return a valid utf-8") } +/// Initialise a new repo, add a remote, and fetch+checkout. async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> Result<()> { clean_anyway(cwd).await?; fs::create_dir_all(cwd).await?; @@ -169,14 +194,18 @@ async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> Resul Ok(()) } +/// Check whether the checkout directory contains a usable git repo with the +/// expected remote URL. pub async fn is_checkout_usable(repo: &str, cwd: &Path) -> bool { is_valid_git_dir(cwd).await && is_same_remote(cwd, repo).await } +/// Check whether a directory has the same origin remote URL as `remote`. async fn is_same_remote(cwd: &Path, remote: &str) -> bool { remote == get_remote_url(cwd).await.unwrap_or_default().trim() } +/// Check whether `cwd` is inside a valid git work tree with a parseable HEAD. async fn is_valid_git_dir(cwd: &Path) -> bool { let is_inside_work_tree = Command::new("git") .current_dir(cwd) @@ -194,6 +223,8 @@ async fn is_valid_git_dir(cwd: &Path) -> bool { is_inside_work_tree && can_parse_head } +/// List all grammar.js files tracked by git (excluding common non-source +/// directories). pub async fn list_grammar_files(cwd: &Path) -> Result> { let output = Command::new("git") .current_dir(cwd) @@ -243,6 +274,7 @@ pub async fn list_grammar_files(cwd: &Path) -> Result> { Ok(result) } +/// Reset HEAD hard to the given ref, fetching first if needed. async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> Result<()> { if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { Command::new("git") @@ -255,6 +287,7 @@ async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> Result<()> { Ok(()) } +/// Resolve a git ref to a tag name or fall back to commit SHA. pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> Result { // Try to find a tag for this ref let tag = Command::new("git") @@ -281,6 +314,7 @@ pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> Result { } } +/// Validate a git ref string against Git's refname rules. fn validate_git_ref(value: &str) -> RefResult<()> { if value.is_empty() { return Err(RefError::EmptyRef); @@ -347,6 +381,7 @@ fn validate_git_ref(value: &str) -> RefResult<()> { Ok(()) } +/// Validate a 40-character hex SHA-1 string. fn validate_git_sha(value: &str) -> RefResult<()> { if value.len() != 40 { return Err(RefError::InvalidShaLength { @@ -498,6 +533,7 @@ impl Ref { } } + /// Check whether the ref is an exact 40-character SHA. #[must_use] pub fn is_exact_sha(&self) -> bool { Sha::is_full_sha(self.as_str()) @@ -530,16 +566,19 @@ impl Sha { Ok(Sha(value)) } + /// Return the SHA as a string slice. #[must_use] pub fn as_str(&self) -> &str { &self.0 } + /// Return the first 7 hex characters of the SHA. #[must_use] pub fn short(&self) -> &str { &self.0[..7] } + /// Check whether a string looks like a full 40-character hex SHA. #[must_use] pub fn is_full_sha(value: &str) -> bool { value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) diff --git a/src/lib.rs b/src/lib.rs index c9dc0fb..0318271 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,6 +106,7 @@ pub fn absolute_normalize(path: &Path) -> Result { Ok(normalize_components(&absolute)) } +/// Convert a duration to a human-readable string (e.g. "0.50s", "1mn 30s"). #[must_use] pub fn format_duration(duration: Duration) -> String { let total_seconds = duration.as_secs(); @@ -141,6 +142,7 @@ pub fn format_duration(duration: Duration) -> String { parts.join(" ") } +/// Resolve `.` and `..` components without requiring the path to exist. fn normalize_components(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); @@ -181,6 +183,7 @@ pub fn prompt_user(question: &str, default_yes: bool) -> Result { Ok(input == "y") } +/// Strip the current working directory prefix from a path when possible. pub fn relative_to_cwd(dir: &Path) -> PathBuf { let canon = dir.canon().unwrap_or_else(|_| dir.to_path_buf()); let cwd = env::current_dir().unwrap_or_else(|_| dir.to_path_buf()); diff --git a/src/lock.rs b/src/lock.rs index edb33cf..94ddd15 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -1,3 +1,5 @@ +//! PID-based filesystem lock to prevent concurrent builds. + use std::{ collections::HashSet, ffi::OsString, @@ -111,6 +113,7 @@ pub struct Owner { // Free functions // ============================================================ +/// Join OS command arguments into a single display string. fn command_line(cmd: &[std::ffi::OsString]) -> Option { if cmd.is_empty() { return None; @@ -124,10 +127,12 @@ fn command_line(cmd: &[std::ffi::OsString]) -> Option { ) } +/// Check if an IO error is caused by lock contention (`WouldBlock`). fn is_lock_contention(err: &io::Error) -> bool { err.kind() == io::ErrorKind::WouldBlock } +/// Check if two owner records refer to the same process instance (same PID and start time). fn same_owner(current: &Owner, previous: &Owner) -> bool { current.pid == previous.pid && current.start_time == previous.start_time } @@ -318,6 +323,7 @@ impl Guard { } impl Lock { + /// Create a lock manager for the given build directory. #[must_use] pub fn new(build_dir: &BuildDir) -> Self { Self { @@ -441,6 +447,7 @@ impl Lock { delay = delay.saturating_mul(2).min(Duration::from_millis(500)); } } + /// Write PID metadata and construct a Guard after acquiring the OS lock. fn activate(&self, mut file: File) -> Result { self.write_metadata(&mut file)?; info!("Acquired lock on build directory"); @@ -450,6 +457,7 @@ impl Lock { }) } + /// Open (or create) the lock file for exclusive access. fn open_lock_file(&self) -> Result { if let Some(parent) = self.lock_path.parent() { fs::create_dir_all(parent) @@ -491,6 +499,7 @@ impl Lock { } } + /// Read process metadata for a given PID (name, command, cwd, etc.). fn owner_for_pid(pid: Pid) -> Option { let system = Self::system_for_pid(pid); let process = system.process(pid)?; @@ -507,6 +516,7 @@ impl Lock { }) } + /// Read the PID value stored in the lock file. fn read_pid(&self) -> Result { let content = fs::read_to_string(&self.lock_path) .with_context(|| format!("Reading lock file {}", self.lock_path.display()))?; @@ -522,6 +532,7 @@ impl Lock { Ok(Pid::from(pid)) } + /// Create a System snapshot with process info for a single PID. fn system_for_pid(pid: Pid) -> System { let mut system = System::new(); system.refresh_processes_specifics( @@ -536,6 +547,7 @@ impl Lock { system } + /// Write the current PID into the lock file. fn write_metadata(&self, file: &mut File) -> Result<()> { file.set_len(0) .with_context(|| format!("Truncating lock file {}", self.lock_path.display()))?; diff --git a/src/logging.rs b/src/logging.rs index 4b71015..1c40548 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,3 +1,5 @@ +//! Logging/tracing setup: dual stderr+file output, configurable levels. + use std::{ ffi::OsStr, fs::{self, File}, @@ -15,8 +17,12 @@ use crate::{absolute_normalize, args, consts, Error, Result, ResultExt}; // Enums // ============================================================ +/// Describes how the log path is determined: explicitly via `--log` or +/// implicitly derived from the build directory. pub enum Implicit<'a> { + /// Derive from build directory. BuildDir { dir: &'a Path }, + /// No implicit path — log is disabled unless `--log` is given. None, } @@ -24,16 +30,24 @@ pub enum Implicit<'a> { // Structs // ============================================================ +/// Wrapper around the non-blocking writer guard to keep the log writer alive. #[allow(dead_code)] pub struct Guard(WorkerGuard); +/// Log path policy: an explicitly provided path or a fallback to the build +/// directory. pub struct Policy<'a> { + /// Path provided via `--log` flag. pub explicit: Option<&'a Path>, + /// Fallback policy when no explicit path is given. pub implicit: Implicit<'a>, } +/// Active logging session: owns the resolved log path and the writer guard. pub struct Session { + /// Resolved log file path on disk. path: Option, + /// Non-blocking writer guard (keeps writer alive). _guard: Option, } @@ -41,6 +55,7 @@ pub struct Session { // Free functions // ============================================================ +/// Build a tracing layer that writes formatted log output to a writer. fn file_layer( writer: tracing_appender::non_blocking::NonBlocking, color: bool, @@ -59,6 +74,7 @@ fn file_layer( .boxed() } +/// Initialize the logging system from a policy, color setting, and verbosity. pub fn init( policy: Policy<'_>, log_color: args::LogColor, @@ -89,6 +105,7 @@ pub fn init( }) } +/// Register the global tracing subscriber with optional file and stderr layers. fn init_tracing( writer: Option, color: bool, @@ -108,6 +125,7 @@ fn init_tracing( tracing::subscriber::set_global_default(subscriber).unwrap(); } +/// Open or create the log file. fn open_log_file(log: &Path) -> Result { let parent = log.parent().unwrap_or(Path::new(".")); if !parent.exists() { @@ -116,6 +134,7 @@ fn open_log_file(log: &Path) -> Result { File::create(log).context("Creating log file") } +/// Resolve the log path from a policy (explicit + implicit fallback). fn resolve_log_path(policy: Policy<'_>) -> Result> { match (policy.explicit, policy.implicit) { (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), @@ -127,6 +146,7 @@ fn resolve_log_path(policy: Policy<'_>) -> Result> { } } +/// Build a tracing layer for stderr output (used at DEBUG/TRACE levels). fn stderr_layer( color: bool, filter: LevelFilter, @@ -145,6 +165,7 @@ fn stderr_layer( .boxed() } +/// Validate that a log path relative to the build directory is acceptable. fn validate_log_path(build_dir: &Path, log: &Path) -> Result { let build_dir = absolute_normalize(build_dir)?; let log = absolute_normalize(log)?; @@ -206,6 +227,7 @@ fn validate_log_path(build_dir: &Path, log: &Path) -> Result { Ok(log) } +/// Validate a standalone log path (no build directory context). fn validate_standalone_log_path(log: &Path) -> Result { let log = absolute_normalize(log)?; @@ -226,6 +248,7 @@ fn validate_standalone_log_path(log: &Path) -> Result { // ============================================================ impl Session { + /// Return the resolved log file path, if any. #[must_use] pub fn path(&self) -> Option<&Path> { self.path.as_deref() diff --git a/src/parser.rs b/src/parser.rs index f97d58e..d7bbb76 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,3 +1,6 @@ +//! Per-language and per-grammar build pipeline: clone, discover grammars, +//! build, install. + use std::{ env::consts::DLL_EXTENSION, fmt, @@ -41,9 +44,13 @@ enum ArtifactKind { Wasm, } +/// A parser git ref: either a stable tag/commit or a moving branch whose +/// checked-out commit gets resolved and pinned at clone time. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum Ref { + /// Stable ref (tag or commit SHA): revision is always `Stable`. Stable(git::Ref), + /// Moving ref (branch): revision changes on each build. Moving(git::Ref), } @@ -54,31 +61,51 @@ pub enum Ref { /// A grammar ready to be built, combining definition and cache state #[derive(Clone, Debug)] pub struct GrammarBuild { + /// Overwrite and other per-build flags. pub context: build::Context, + /// Cache lookup result for this grammar. pub cache_decision: cache::Decision, + /// Directory containing the checked-out grammar source. pub dir: PathBuf, + /// SHA-1 hash of the grammar.js source. pub hash: cache::GrammarHash, - pub language: LanguageName, // Required for error reporting and cache keys; set from parent LanguageBuild + /// Parent language name (for cache keys and error reporting). + pub language: LanguageName, + /// Grammar name within the parser repo. pub name: GrammarName, + /// Build and output directory paths. pub output: build::OutputConfig, - pub progress: actors::ProgressAddr, // Use language's handle + /// Handle for updating the progress display. + pub progress: actors::ProgressAddr, + /// Resolved parser revision (commit SHA for moving refs). pub revision: cache::Revision, + /// Full build spec (source, ref, tree-sitter version, etc.). pub spec: Arc, + /// Path to the tree-sitter CLI binary. pub ts_cli: PathBuf, } +/// A validated grammar name string, used for cache keys and display. #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct GrammarName(Arc); +/// A language ready for grammar discovery: has a name, context flags, output +/// paths, and a resolved build spec. #[derive(Clone, Debug)] pub struct LanguageBuild { + /// Overwrite and other per-build flags. pub context: build::Context, + /// Language name. pub name: LanguageName, + /// Build and output directory paths. pub output: build::OutputConfig, + /// Full build spec (source, ref, tree-sitter version, etc.). pub spec: Arc, } +/// A validated language name string, used for cache keys, display, and error +/// reporting. #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct LanguageName(Arc); @@ -87,6 +114,7 @@ pub struct LanguageName(Arc); // Free functions // ============================================================ +/// Derive an artifact subdirectory name from the tree-sitter CLI filename. fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> Result { let file_name = ts_cli .file_name() @@ -101,6 +129,7 @@ fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> Result { Ok(format!("tsdl-{}", sanitize_path_component(id))) } +/// Compute the path to a built artifact for a grammar. fn artifact_path_for( grammar_name: &GrammarName, build_dir: &Path, @@ -113,6 +142,7 @@ fn artifact_path_for( .join(parser_name_and_ext(grammar_name, kind, &spec.prefix))) } +/// Create the parent directory for a path if it doesn't exist. async fn ensure_parent_dir(path: &Path) -> Result<()> { let parent = path.parent().ok_or_else(|| Error::Message { message: format!( @@ -126,6 +156,7 @@ async fn ensure_parent_dir(path: &Path) -> Result<()> { .with_context(|| format!("Creating {}", parent.display())) } +/// Get the last path component as a string. fn extract_dir_name(dir: &Path) -> Result { dir.file_name() .map(|n| n.to_string_lossy().to_string()) @@ -141,6 +172,7 @@ fn extract_grammar_name(dir: &Path) -> Result { Ok(GrammarName::from(name)) } +/// Check whether a string looks like a dotted-numeric version (e.g. "0.21.0"). fn is_dotted_numeric_version(value: &str) -> bool { !value.is_empty() && value @@ -148,6 +180,7 @@ fn is_dotted_numeric_version(value: &str) -> bool { .all(|part| !part.is_empty() && part.parse::().is_ok()) } +/// Determine whether a source ref is "stable" (tag, SHA, version number). fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { git_ref.is_exact_sha() || is_dotted_numeric_version(input) @@ -155,12 +188,14 @@ fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { || git_ref.as_str().starts_with("refs/tags/") } +/// Check if a string matches "v" + dotted numeric version. fn is_v_dotted_numeric_version(value: &str) -> bool { value .strip_prefix('v') .is_some_and(is_dotted_numeric_version) } +/// Normalize a user-provided source ref: adds "v" prefix to bare version numbers. fn normalize_source_ref(value: &str) -> String { if git::Sha::is_full_sha(value) || value.starts_with('v') { value.to_string() @@ -171,14 +206,17 @@ fn normalize_source_ref(value: &str) -> String { } } +/// Build the output filename for a grammar artifact. fn parser_name_and_ext(grammar_name: &GrammarName, kind: ArtifactKind, prefix: &str) -> String { format!("{prefix}{grammar_name}.{}", kind.extension()) } +/// Check whether two metadata structs refer to the same file (same device + inode). fn same_file_identity(a: &Metadata, b: &Metadata) -> bool { a.dev() == b.dev() && a.ino() == b.ino() } +/// Check if two files have identical contents (via SHA-1 hash). async fn same_regular_file_contents( dst: &Path, dst_metadata: &Metadata, @@ -194,6 +232,7 @@ async fn same_regular_file_contents( Ok(src_hash == dst_hash) } +/// Sanitise a string for use as a filesystem path component. fn sanitize_path_component(value: &str) -> String { let sanitized = value .chars() @@ -213,6 +252,7 @@ fn sanitize_path_component(value: &str) -> String { } } +/// Create a temporary path next to the destination for atomic install. fn temp_install_path(dst: &Path) -> Result { let file_name = dst.file_name().ok_or_else(|| Error::Message { message: format!( @@ -234,6 +274,7 @@ fn temp_install_path(dst: &Path) -> Result { Ok(dst.with_file_name(tmp_name)) } +/// Check that a path exists and is a regular file. async fn verify_artifact(path: &Path) -> Result<()> { let metadata = fs::metadata(path) .await @@ -253,6 +294,7 @@ async fn verify_artifact(path: &Path) -> Result<()> { // ============================================================ impl ArtifactKind { + /// Get the file extension for this artifact kind (e.g. "so", "dylib", "wasm"). #[must_use] fn extension(self) -> &'static str { match self { @@ -261,6 +303,7 @@ impl ArtifactKind { } } + /// Check whether this is a wasm target. #[must_use] const fn is_wasm(self) -> bool { matches!(self, Self::Wasm) @@ -399,6 +442,7 @@ impl GrammarBuild { Ok(Some(update)) } + /// Build the tree-sitter CLI command line for a given target. fn builtin_build_command(&self, kind: ArtifactKind, output_path: &Path) -> Command { let mut cmd = Command::new(self.ts_cli.as_os_str()); cmd.arg("build"); @@ -411,6 +455,7 @@ impl GrammarBuild { cmd } + /// Run the full build pipeline for this grammar (generate, build, install). async fn build_grammar(&self) -> Result<()> { shutdown::test_delay().await; shutdown::check()?; @@ -434,6 +479,7 @@ impl GrammarBuild { Ok(()) } + /// Build a single target (native or wasm) and return the artifact path. async fn build_target(&self, kind: ArtifactKind) -> Result { shutdown::test_delay().await; shutdown::check()?; @@ -450,6 +496,7 @@ impl GrammarBuild { self.build_builtin_target(kind).await } + /// Build a target using the built-in tree-sitter CLI. async fn build_builtin_target(&self, kind: ArtifactKind) -> Result { let artifact = self.artifact_path(kind)?; ensure_parent_dir(&artifact).await?; @@ -464,6 +511,7 @@ impl GrammarBuild { Ok(artifact) } + /// Build a target using a custom build script. async fn build_custom_target(&self, kind: ArtifactKind, script: &str) -> Result { let mut cmd = Command::from_str(script); cmd.current_dir(self.dir.as_path()) @@ -478,6 +526,7 @@ impl GrammarBuild { Ok(artifact) } + /// Create a Step error indicating a build failure. fn build_step_error(&self, err: Error) -> Error { Error::Step { name: self.language.as_arc(), @@ -488,6 +537,7 @@ impl GrammarBuild { } } + /// Build every target (native and/or wasm) requested by the spec. async fn build_targets(&self) -> Result<()> { if self.spec.target.native() { self.build_target(ArtifactKind::Native).await?; @@ -500,6 +550,7 @@ impl GrammarBuild { Ok(()) } + /// Create a hardlink from src to dst. async fn create_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { fs::hard_link(src, dst).await.with_context(|| { format!( @@ -510,6 +561,7 @@ impl GrammarBuild { }) } + /// Brute-force discover a built artifact by searching the grammar directory. async fn brute_force_discover(&self, kind: ArtifactKind) -> Result { let ext = kind.extension(); let expected_name = self.parser_name_and_ext(kind); @@ -559,6 +611,7 @@ impl GrammarBuild { } } + /// Run tree-sitter generate in the grammar directory. async fn generate(&self) -> Result<()> { Command::new(self.ts_cli.as_os_str()) .current_dir(self.dir.as_path()) @@ -575,6 +628,7 @@ impl GrammarBuild { }) } + /// Install built binary artifacts to the output directory. async fn install(&self) -> Result<()> { // Find and install parser binary for each extension if self.spec.target.native() { @@ -588,6 +642,7 @@ impl GrammarBuild { Ok(()) } + /// Install a single binary artifact to the output directory. async fn install_binary(&self, kind: ArtifactKind) -> Result<()> { let src = self.artifact_path(kind)?; let dst = self.output.out_dir.join(self.parser_name_and_ext(kind)); @@ -613,6 +668,7 @@ impl GrammarBuild { .await } + /// Handle the case where a destination output file already exists. async fn install_over_existing( &self, src: &Path, @@ -680,6 +736,7 @@ impl GrammarBuild { Ok(()) } + /// Copy a custom-built artifact to its canonical build-dir location. async fn stage_artifact(&self, src: &Path, dst: &Path) -> Result<()> { ensure_parent_dir(dst).await?; let src_metadata = fs::metadata(src) @@ -710,6 +767,7 @@ impl GrammarBuild { self.replace_with_hardlink(src, dst).await } + /// Return the list of artifact paths that must exist on disk for a cache hit. pub fn required_artifacts_for( grammar_name: &GrammarName, build_dir: &Path, @@ -741,6 +799,7 @@ impl GrammarBuild { Ok(artifacts) } + /// Compute the artifact path for a given target kind. fn artifact_path(&self, kind: ArtifactKind) -> Result { artifact_path_for( &self.name, @@ -751,6 +810,7 @@ impl GrammarBuild { ) } + /// Atomically replace dst with a hardlink to src (via a temp file + rename). async fn replace_with_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { let tmp = temp_install_path(dst)?; self.create_hardlink(src, &tmp).await?; @@ -766,6 +826,7 @@ impl GrammarBuild { Ok(()) } + /// Create an error reporting a missing parser binary. fn missing_parser_error(&self, ext: &str) -> Error { Error::Step { name: self.language.as_arc(), @@ -780,6 +841,7 @@ impl GrammarBuild { } } + /// Create an error reporting multiple candidate parser binaries. fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> Error { Error::Step { name: self.language.as_arc(), @@ -794,17 +856,20 @@ impl GrammarBuild { } } + /// Build the output filename for a grammar artifact (with prefix). fn parser_name_and_ext(&self, kind: ArtifactKind) -> String { parser_name_and_ext(&self.name, kind, &self.spec.prefix) } } impl GrammarName { + /// Return the grammar name as a string slice. #[must_use] pub fn as_str(&self) -> &str { &self.0 } + /// Return the grammar name as a cloned `Arc`. #[must_use] pub fn as_arc(&self) -> Arc { self.0.clone() @@ -812,6 +877,7 @@ impl GrammarName { } impl LanguageBuild { + /// Create a new `LanguageBuild`. #[must_use] pub fn new( context: build::Context, @@ -827,6 +893,8 @@ impl LanguageBuild { } } + /// Scan the checkout directory for grammar.js files and return their + /// names, hashes, and parent directories. pub async fn discover_grammars( &self, ) -> Result> { @@ -865,6 +933,8 @@ impl LanguageBuild { Ok(grammars) } + /// Return a new `LanguageBuild` with the tree-sitter version replaced + /// (once the CLI has been resolved). #[must_use] pub fn with_tree_sitter(mut self, tree_sitter: TreeSitter) -> Self { let mut spec = self.spec.as_ref().clone(); @@ -873,6 +943,7 @@ impl LanguageBuild { self } + /// Clone/checkout the parser repository at the configured ref. pub async fn checkout(&self) -> Result { git::checkout( self.spec.repo.as_str(), @@ -889,17 +960,20 @@ impl LanguageBuild { }) } + /// Check whether the existing checkout directory is usable for this language. pub async fn is_checkout_usable(&self) -> bool { git::is_checkout_usable(self.spec.repo.as_str(), &self.output.build_dir).await } } impl LanguageName { + /// Return the language name as a string slice. #[must_use] pub fn as_str(&self) -> &str { &self.0 } + /// Return the language name as a cloned `Arc`. #[must_use] pub fn as_arc(&self) -> Arc { self.0.clone() @@ -925,16 +999,19 @@ impl Ref { } } + /// Check whether this is a moving ref (branch). #[must_use] pub const fn is_moving(&self) -> bool { matches!(self, Self::Moving(_)) } + /// Check whether this is a stable ref (tag or SHA). #[must_use] pub const fn is_stable(&self) -> bool { matches!(self, Self::Stable(_)) } + /// Return the underlying git ref, regardless of moving/stable classification. #[must_use] pub fn requested(&self) -> &git::Ref { match self { diff --git a/src/sh.rs b/src/sh.rs index 98045c4..d882882 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -1,3 +1,5 @@ +//! Command execution trait (`Exec`) with error formatting. + use std::os::unix::process::CommandExt as _; use std::{env, fmt::Write, os::unix::process::ExitStatusExt, process::Output, time::Duration}; @@ -27,6 +29,7 @@ pub trait Script { // Free functions // ============================================================ +/// Look up the human-readable name of a Unix signal number. fn signal_display(number: i32) -> Option { let ptr = unsafe { libc::strsignal(number) }; if ptr.is_null() { @@ -45,6 +48,7 @@ fn signal_display(number: i32) -> Option { // ============================================================ impl Exec for Command { + /// Format the command as a one-line string (program + args). fn display(&self) -> Result { let program = self.as_std().get_program().to_string_lossy(); let args = self.as_std().get_args(); @@ -60,6 +64,7 @@ impl Exec for Command { Ok(res.trim_end().to_string()) } + /// Format the command including the working directory prefix. fn display_full(&self) -> Result { let cwd = self.as_std().get_current_dir(); let base = self.display()?; @@ -70,6 +75,7 @@ impl Exec for Command { } } + /// Execute the command, handle shutdown signals, and return the output. #[tracing::instrument(skip(self))] async fn exec(&mut self) -> Result { let cmd_full = self.display_full()?; @@ -196,6 +202,7 @@ impl Exec for Command { } impl Script for Command { + /// Create a shell command to run a script string. fn from_str(script: &str) -> Command { let shell = env::var("SHELL").unwrap_or_else(|_| String::from("sh")); let mut cmd = Command::new(shell); diff --git a/src/shutdown.rs b/src/shutdown.rs index 2cb15c7..4e488af 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -1,3 +1,6 @@ +//! Graceful shutdown: signal handling, process group management, and test +//! delay hooks. + use std::{ collections::HashSet, fmt, @@ -76,6 +79,7 @@ pub struct Signal { // Free functions // ============================================================ +/// Wait for the shutdown signal from the current task-local handle. pub async fn cancelled() -> Signal { match current() { Some(shutdown) => shutdown.cancelled().await, @@ -83,20 +87,24 @@ pub async fn cancelled() -> Signal { } } +/// Check if shutdown was requested by the current task-local handle. pub fn check() -> Result<()> { current().map_or(Ok(()), |shutdown| shutdown.check()) } +/// Get the shutdown handle scoped to the current task, if any. #[must_use] pub fn current() -> Option { CURRENT_SHUTDOWN.try_with(Clone::clone).ok() } +/// Get the current task-local handle or a no-op default. #[must_use] pub fn current_or_default() -> Handle { current().unwrap_or_default() } +/// Install a shutdown handle for the duration of the given future. pub async fn scope(shutdown: Handle, future: F) -> F::Output where F: Future, @@ -104,6 +112,7 @@ where CURRENT_SHUTDOWN.scope(shutdown, future).await } +/// Send a Unix signal to an entire process group via `killpg`. fn signal_process_group(pgid: PgId, signal: Signal) { let rc = unsafe { libc::killpg(pgid.as_pid_t(), signal.number) }; if rc != 0 { @@ -130,6 +139,8 @@ pub async fn test_delay() { } } +/// Test helper that inserts a sleep at every step boundary when +/// `TSDL_TEST_DELAY_MS` is set. This is a noop in release builds. #[cfg(not(debug_assertions))] pub async fn test_delay() { // noop in release @@ -175,6 +186,7 @@ impl fmt::Display for Signal { } impl Handle { + /// Create a new shutdown handle with no signal set. #[must_use] pub fn new() -> Self { let (tx, rx) = watch::channel(None); @@ -225,16 +237,19 @@ impl Handle { active.remove(&pgid); } + /// Check whether a shutdown signal has been recorded. #[must_use] pub fn is_cancelled(&self) -> bool { self.reason().is_some() } + /// Return the signal that triggered shutdown, if any. #[must_use] pub fn reason(&self) -> Option { *self.rx.borrow() } + /// Wait until a shutdown signal is set, then return it. pub async fn cancelled(&self) -> Signal { if let Some(signal) = self.reason() { return signal; @@ -251,6 +266,7 @@ impl Handle { } } + /// Return `Err(Interrupted)` if shutdown was signalled, or `Ok(())` otherwise. pub fn check(&self) -> Result<()> { if let Some(signal) = self.reason() { Err(Error::Interrupted { signal }) @@ -285,6 +301,7 @@ impl Handle { signal_process_group(pgid, Signal::KILL); } + /// Collect all currently registered child process group IDs. fn active_pgids(&self) -> Vec { self.active_pgids .lock() @@ -294,6 +311,7 @@ impl Handle { .collect() } + /// Spawn a background task that listens for OS signals and triggers graceful then forceful shutdown. #[cfg(unix)] pub fn spawn_signal_listener(&self) -> Result> { use std::process; @@ -349,6 +367,7 @@ impl PgId { self.0.get() } + /// Convert to the platform `pid_t` type (panics if the invariant is violated). fn as_pid_t(self) -> libc::pid_t { ::try_from(self.0.get()).expect("PgId invariant: value fits in libc::pid_t") } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index ecf21e4..7deb42f 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -1,3 +1,5 @@ +//! Download, prepare, and cache the tree-sitter CLI binary. + use std::borrow::Cow; use std::collections::HashMap; use std::os::unix::fs::PermissionsExt; @@ -32,9 +34,12 @@ enum CliCacheStatus { // Structs // ============================================================ +/// A downloaded and cached tree-sitter CLI binary, ready to use. #[derive(Debug, Clone)] pub struct PreparedCli { + /// Path to the CLI binary on disk. pub path: PathBuf, + /// The tree-sitter version, platform, and repo used to fetch it. pub tree_sitter: args::TreeSitter, } @@ -42,6 +47,7 @@ pub struct PreparedCli { // Free functions // ============================================================ +/// Check whether a cached tree-sitter CLI binary is still valid. async fn check_cached_cli(path: &Path, tag: &str) -> Result { let metadata = match fs::symlink_metadata(path).await { Ok(metadata) => metadata, @@ -89,6 +95,7 @@ async fn check_cached_cli(path: &Path, tag: &str) -> Result { } } +/// Make a binary executable (chmod +x). async fn chmod_x(prog: &Path) -> Result<()> { let metadata = fs::metadata(prog) .await @@ -100,6 +107,7 @@ async fn chmod_x(prog: &Path) -> Result<()> { .with_context(|| format!("chmod +x {}", prog.display())) } +/// Download and/or cache the tree-sitter CLI binary for the given tag. async fn cli( build_dir: &Path, handle: &actors::ProgressAddr, @@ -140,6 +148,7 @@ async fn cli( Ok(res) } +/// Wrap a result, adding context unless it's an Interrupted error. fn context_unless_interrupted(result: Result, message: impl FnOnce() -> String) -> Result { match result { Ok(value) => Ok(value), @@ -151,10 +160,12 @@ fn context_unless_interrupted(result: Result, message: impl FnOnce() -> St } } +/// Format a tree-sitter version string as a git ref for display purposes. pub(crate) fn display_tree_sitter_ref(version: &str) -> StdResult { git::Ref::new(normalize_release_ref(version)) } +/// Download a file via HTTP GET. async fn download(url: &str, gz: &Path) -> Result<()> { let response = reqwest::get(url) .await @@ -172,6 +183,7 @@ async fn download(url: &str, gz: &Path) -> Result<()> { .with_context(|| format!("Writing tree-sitter CLI archive to {}", gz.display())) } +/// Download a gzipped CLI binary and install it to the final path. async fn download_and_install(url: &str, res: &Path, tag: &str) -> Result<()> { let gz = temp_path_for(res, ".gz")?; let cli = temp_path_for(res, ".bin")?; @@ -183,11 +195,13 @@ async fn download_and_install(url: &str, res: &Path, tag: &str) -> Result<()> { Ok(()) } +/// Get the expected CLI version string from a tag (strips "v" prefix). fn expected_cli_version(tag: &str) -> Option<&str> { let version = tag.strip_prefix('v').unwrap_or(tag); is_dotted_numeric_version(version).then_some(version) } +/// Find a release tag matching the requested version string. fn find_tag( refs: &HashMap, version: &str, @@ -206,6 +220,7 @@ fn find_tag( ) } +/// Get the first line of a multi-line string (for error summaries). fn first_line(message: &str) -> String { message .lines() @@ -214,6 +229,7 @@ fn first_line(message: &str) -> String { .to_string() } +/// Decompress a gzip file to the target path. async fn gunzip(gz: &Path, to: &Path) -> Result<()> { let file = fs::File::open(gz) .await @@ -233,6 +249,7 @@ async fn gunzip(gz: &Path, to: &Path) -> Result<()> { .with_context(|| format!("syncing extracted tree-sitter CLI {}", to.display())) } +/// Decompress, verify, and install a downloaded CLI binary. async fn install_downloaded_cli(gz: &Path, tmp_cli: &Path, res: &Path, tag: &str) -> Result<()> { gunzip(gz, tmp_cli).await?; chmod_x(tmp_cli).await?; @@ -242,6 +259,7 @@ async fn install_downloaded_cli(gz: &Path, tmp_cli: &Path, res: &Path, tag: &str promote_cli(tmp_cli, res).await } +/// Check if a string looks like a dotted-numeric version. fn is_dotted_numeric_version(value: &str) -> bool { !value.is_empty() && value @@ -249,6 +267,7 @@ fn is_dotted_numeric_version(value: &str) -> bool { .all(|part| !part.is_empty() && part.parse::().is_ok()) } +/// Normalize a release ref by adding "v" prefix to bare semver. fn normalize_release_ref(value: &str) -> String { if git::Sha::is_full_sha(value) || value.starts_with('v') { value.to_string() @@ -259,6 +278,7 @@ fn normalize_release_ref(value: &str) -> String { } } +/// Parse `git ls-remote --refs --tags` output into a tag→sha map. fn parse_refs(stdout: &str) -> HashMap { let mut refs = HashMap::new(); @@ -275,6 +295,8 @@ fn parse_refs(stdout: &str) -> HashMap { refs } +/// Download and prepare the tree-sitter CLI binary for the requested version. +/// Returns the path to the prepared CLI binary and metadata. pub async fn prepare( build_dir: &Path, display: actors::DisplayAddr, @@ -359,6 +381,7 @@ pub async fn prepare( }) } +/// Move a temp file to its final destination. async fn promote_cli(tmp_cli: &Path, res: &Path) -> Result<()> { if let Err(err) = fs::rename(tmp_cli, res).await { let _ = fs::remove_file(tmp_cli).await; @@ -374,6 +397,7 @@ async fn promote_cli(tmp_cli: &Path, res: &Path) -> Result<()> { Ok(()) } +/// Resolve a user-requested tree-sitter ref into a concrete release tag. async fn resolve_release_tag( build_dir: &Path, handle: &actors::ProgressAddr, @@ -393,6 +417,7 @@ async fn resolve_release_tag( } #[allow(clippy::missing_panics_doc)] +/// Look up the git ref for a tree-sitter release tag. pub async fn tag(repo: &str, version: &str) -> Result { let output = Command::new("git") .args(["ls-remote", "--refs", "--tags", repo]) @@ -403,6 +428,7 @@ pub async fn tag(repo: &str, version: &str) -> Result { find_tag(&refs, version).with_context(|| format!("Parsing tree-sitter git ref {version:?}")) } +/// Create a temporary file path adjacent to the final path. fn temp_path_for(res: &Path, suffix: &str) -> Result { let parent = res.parent().ok_or_else(|| Error::Message { message: format!( @@ -428,6 +454,7 @@ fn temp_path_for(res: &Path, suffix: &str) -> Result { }) } +/// Verify that a tree-sitter CLI binary works and matches the expected version. async fn verify_cli(path: &Path, tag: &str) -> Result<()> { let output = context_unless_interrupted(Command::new(path).arg("--version").exec().await, || { diff --git a/src/walk.rs b/src/walk.rs index 72b4dde..8172f6d 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -1,3 +1,6 @@ +//! File-system walker for discovering `grammar.js` files (also used as a git +//! ls-files path). + use std::path::{Path, PathBuf}; use crate::{cache, git, shutdown, Result}; From fbc55b52091859627f262511f67c87218df0fdf9 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 16:10:44 +0200 Subject: [PATCH 80/88] bump: rust: 2024 --- CHANGELOG.md | 4 ++++ Cargo.toml | 2 +- TODO.md | 5 ----- src/actors/cache.rs | 3 ++- src/actors/display.rs | 4 ++-- src/actors/mod.rs | 12 +++++------ src/app.rs | 2 +- src/args.rs | 3 ++- src/build.rs | 12 +++++++---- src/cache.rs | 22 ++++++++++++------- src/config.rs | 50 ++++++++++++++++++++----------------------- src/git.rs | 4 ++-- src/lock.rs | 10 ++++----- src/logging.rs | 4 ++-- src/main.rs | 4 ++-- src/parser.rs | 15 ++++++------- src/sh.rs | 2 +- src/shutdown.rs | 14 ++++++------ src/tree_sitter.rs | 20 ++++++++--------- src/walk.rs | 2 +- tests/cmd/mod.rs | 2 +- tests/config.rs | 10 ++++++--- 22 files changed, 108 insertions(+), 98 deletions(-) delete mode 100644 TODO.md diff --git a/CHANGELOG.md b/CHANGELOG.md index aa8ee4c..0256cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ informative and more coherent. tsdl build --target native # cache misses ``` +### Maintenance + +- Bumped to rust 2024 + ## [2.0.0] - 2026-02-20 This is a major rewrite, moving to a single-threaded async runtime, with actors. diff --git a/Cargo.toml b/Cargo.toml index 5d2ffdd..009fe13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [] authors = ["Firas al-Khalil "] build = "build.rs" description = "A downloader/builder of many tree-sitter parsers" -edition = "2021" +edition = "2024" name = "tsdl" version = "2.0.0" # managed by release.sh license = "MIT" diff --git a/TODO.md b/TODO.md deleted file mode 100644 index cc22b7e..0000000 --- a/TODO.md +++ /dev/null @@ -1,5 +0,0 @@ -# TODO - -## Tests - -- [ ] changing log file destination from command line, apparently it's not working. diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 535ba28..f027c61 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -7,8 +7,9 @@ use tokio::sync::{mpsc, oneshot}; use tracing::info; use crate::{ + Result, actors::{Addr, Response}, - build, cache, parser, Result, + build, cache, parser, }; // ============================================================ diff --git a/src/actors/display.rs b/src/actors/display.rs index ff70774..0058222 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -371,7 +371,7 @@ impl DisplayActor { { match grammar.state { display::ItemState::Done(display::SuccessOutcome::Built) => { - return Some(display::SuccessOutcome::Built) + return Some(display::SuccessOutcome::Built); } display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, display::ItemState::New @@ -401,7 +401,7 @@ impl DisplayActor { match grammar.state { display::ItemState::InProgress(Some(display::SuccessOutcome::Built)) | display::ItemState::Done(display::SuccessOutcome::Built) => { - return Some(display::SuccessOutcome::Built) + return Some(display::SuccessOutcome::Built); } display::ItemState::InProgress(Some(display::SuccessOutcome::Cached)) | display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, diff --git a/src/actors/mod.rs b/src/actors/mod.rs index da31f92..8340106 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -8,12 +8,12 @@ use std::{num::NonZeroUsize, path::Path}; pub use cache::{CacheActor, CacheAddr}; pub use display::{DisplayActor, DisplayAddr, Message, ProgressAddr}; -use futures::{stream, StreamExt}; +use futures::{StreamExt, stream}; use tokio::sync::{mpsc, oneshot, watch}; use tracing::{debug, info}; -use crate::{args, parser, shutdown, tree_sitter, Error, Result}; +use crate::{Error, Result, args, parser, shutdown, tree_sitter}; // ============================================================ // Traits @@ -366,10 +366,10 @@ async fn run_inner( // Drain the CLI preparation result so we never exit before the binary is // downloaded. let mut prepared_rx = prepared_rx; - if let Err(e) = wait_for_prepared(&mut prepared_rx).await { - if languages_empty { - errors.push(e); - } + if let Err(e) = wait_for_prepared(&mut prepared_rx).await + && languages_empty + { + errors.push(e); // else: the pipeline already consumed `prepared_rx` via each // `discover_grammars` call; a second read finding an error is a // redundant delivery — suppress it to avoid double-counting. diff --git a/src/app.rs b/src/app.rs index f6c2823..11b9cfa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use clap::ArgMatches; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use crate::{args, config, display, logging, Result, ResultExt}; +use crate::{Result, ResultExt, args, config, display, logging}; // ============================================================ // Enums diff --git a/src/args.rs b/src/args.rs index 5b9a241..e0e9084 100644 --- a/src/args.rs +++ b/src/args.rs @@ -3,8 +3,9 @@ use std::{collections::BTreeMap, fmt, num::NonZeroUsize, path::PathBuf}; use clap::{ + ArgMatches, builder::styling::{AnsiColor, Color, Style}, - crate_authors, ArgMatches, + crate_authors, }; use clap_verbosity_flag::{InfoLevel, Verbosity}; use serde::{Deserialize, Serialize}; diff --git a/src/build.rs b/src/build.rs index b940e37..da271f3 100644 --- a/src/build.rs +++ b/src/build.rs @@ -16,8 +16,8 @@ use tracing::info; use url::Url; use crate::{ - absolute_normalize, actors, app, args, cache, consts, format_duration, lock, parser, - prompt_user, shutdown, Error, Result, ResultExt, SafeCanonicalize, + Error, Result, ResultExt, SafeCanonicalize, absolute_normalize, actors, app, args, cache, + consts, format_duration, lock, parser, prompt_user, shutdown, }; // ============================================================ @@ -93,9 +93,13 @@ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result { if let Some(pid) = pid { - info!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"); + info!( + "Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}" + ); } else { - info!("Build directory is locked, but tsdl could not identify the owner: {reason}"); + info!( + "Build directory is locked, but tsdl could not identify the owner: {reason}" + ); } return Err(Error::Message { message: format!("Could not identify build lock owner: {reason}"), diff --git a/src/cache.rs b/src/cache.rs index a05394d..9ce65c9 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -17,7 +17,7 @@ use sha1::{Digest, Sha1}; use tokio::io::AsyncWrite; use tracing::debug; -use crate::{args, build, build::BuildDir, git, parser, Error, Result, ResultExt}; +use crate::{Error, Result, ResultExt, args, build, build::BuildDir, git, parser}; // ============================================================ // Enums @@ -330,10 +330,10 @@ impl Db { /// Insert or update a parser cache entry. pub fn set(&mut self, name: Key, mut entry: Entry) { - if let Some(existing) = self.parsers.get(&name) { - if existing.same_subject(&entry) { - entry.outputs = existing.outputs.union(entry.outputs); - } + if let Some(existing) = self.parsers.get(&name) + && existing.same_subject(&entry) + { + entry.outputs = existing.outputs.union(entry.outputs); } self.parsers.insert(name, entry); @@ -547,7 +547,11 @@ impl fmt::Display for MissReason { write!(f, "artifact is not a regular file path={}", path.display()) } Self::ArtifactInaccessible { path, error } => { - write!(f, "artifact inaccessible path={} error={error}", path.display()) + write!( + f, + "artifact inaccessible path={} error={error}", + path.display() + ) } } } @@ -1029,8 +1033,10 @@ mod tests { entry("abc123", &cached, stable_revision()), ); - assert!(cache - .has_compatible_entry_for_language(&parser::LanguageName::from("rust"), &requested)); + assert!( + cache + .has_compatible_entry_for_language(&parser::LanguageName::from("rust"), &requested) + ); } #[test] diff --git a/src/config.rs b/src/config.rs index ca576e0..a96f3c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,11 +9,11 @@ use std::{ result::Result as StdResult, }; -use clap::{parser::ValueSource, ArgMatches, Args, CommandFactory, FromArgMatches}; +use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, parser::ValueSource}; use serde::Serialize; use tracing::debug; -use crate::{args, columns, Result, ResultExt}; +use crate::{Result, ResultExt, args, columns}; // ============================================================ // Constants @@ -356,11 +356,7 @@ fn merge_provenance(file: &BuildProvenance, cli: &BuildProvenance) -> BuildProve /// Pick the CLI source if set, otherwise fall back to file source. fn merge_source(file: Source, cli: Source) -> Source { - if cli == Source::default() { - file - } else { - cli - } + if cli == Source::default() { file } else { cli } } /// Convert CLI `BuildArgs` into `OptionalBuildCommand` + `BuildProvenance`. @@ -395,18 +391,18 @@ fn overrides_from_build_args( &mut p.fresh, ); - if !cli.languages.is_empty() { - if let Some(source) = source_for(matches, ARG_LANGUAGES) { - o.languages = Some(cli.languages); - p.languages = source; - } + if !cli.languages.is_empty() + && let Some(source) = source_for(matches, ARG_LANGUAGES) + { + o.languages = Some(cli.languages); + p.languages = source; } - if let Some(n) = cli.jobs.and_then(NonZeroUsize::new) { - if let Some(source) = source_for(matches, ARG_JOBS) { - o.jobs = Some(n); - p.jobs = source; - } + if let Some(n) = cli.jobs.and_then(NonZeroUsize::new) + && let Some(source) = source_for(matches, ARG_JOBS) + { + o.jobs = Some(n); + p.jobs = source; } set_simple( @@ -545,11 +541,11 @@ fn resolve_bool( return; } - if let Some(source) = source_for(matches, pos_id) { - if let Some(val) = positive { - *field = Some(val); - *provenance = source; - } + if let Some(source) = source_for(matches, pos_id) + && let Some(val) = positive + { + *field = Some(val); + *provenance = source; } } @@ -570,11 +566,11 @@ fn set_simple( field: &mut Option, provenance: &mut Source, ) { - if let Some(source) = source_for(matches, id) { - if let Some(val) = value { - *field = Some(val); - *provenance = source; - } + if let Some(source) = source_for(matches, id) + && let Some(val) = value + { + *field = Some(val); + *provenance = source; } } diff --git a/src/git.rs b/src/git.rs index 6d19a25..d6cafa8 100644 --- a/src/git.rs +++ b/src/git.rs @@ -8,10 +8,10 @@ use std::{ sync::Arc, }; -use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use tokio::{fs, process::Command}; -use crate::{sh::Exec, Error, Result, ResultExt}; +use crate::{Error, Result, ResultExt, sh::Exec}; type RefResult = StdResult; diff --git a/src/lock.rs b/src/lock.rs index 94ddd15..f8d5e97 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -18,7 +18,7 @@ use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, Update use tracing::info; use crate::{ - absolute_normalize, build::BuildDir, consts, format_duration, Error, Result, ResultExt, + Error, Result, ResultExt, absolute_normalize, build::BuildDir, consts, format_duration, }; // ============================================================ @@ -284,10 +284,10 @@ impl Guard { for protected in protected_files { let protected_abs = absolute_normalize(protected)?; - if protected_abs.parent() == Some(build_dir_abs.as_path()) { - if let Some(name) = protected_abs.file_name() { - protected_names.insert(name.to_os_string()); - } + if protected_abs.parent() == Some(build_dir_abs.as_path()) + && let Some(name) = protected_abs.file_name() + { + protected_names.insert(name.to_os_string()); } } diff --git a/src/logging.rs b/src/logging.rs index 1c40548..afebc6a 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -9,9 +9,9 @@ use std::{ use tracing::level_filters::LevelFilter; use tracing_appender::non_blocking::WorkerGuard; use tracing_log::AsTrace; -use tracing_subscriber::{layer::SubscriberExt, Layer}; +use tracing_subscriber::{Layer, layer::SubscriberExt}; -use crate::{absolute_normalize, args, consts, Error, Result, ResultExt}; +use crate::{Error, Result, ResultExt, absolute_normalize, args, consts}; // ============================================================ // Enums diff --git a/src/main.rs b/src/main.rs index 218c200..2a83bb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::{process::ExitCode, time::Instant}; use console::style; use tracing::{error, info}; -use tsdl::{app, error::Error, Result}; +use tsdl::{Error, Result, app}; fn main() -> ExitCode { set_panic_hook(); @@ -49,7 +49,7 @@ pub fn set_panic_hook() { std::panic::set_hook(Box::new(move |info| { #[cfg(not(debug_assertions))] { - use human_panic::{handle_dump, print_msg, Metadata}; + use human_panic::{Metadata, handle_dump, print_msg}; let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) .authors(env!("CARGO_PKG_AUTHORS").replace(':', ", ")) .homepage(env!("CARGO_PKG_HOMEPAGE")); diff --git a/src/parser.rs b/src/parser.rs index d7bbb76..48ca6c2 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -18,14 +18,13 @@ use tokio::{fs, process::Command}; use tracing::{debug, warn}; use crate::args::TreeSitter; -use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use crate::{ - actors, build, cache, error, git, + Error, Result, ResultExt, actors, build, cache, error, git, sh::{Exec, Script}, shutdown, walk::collect_grammar_paths, - Error, Result, ResultExt, }; // ============================================================ @@ -724,11 +723,11 @@ impl GrammarBuild { if !same_contents && !self.context.overwrite_output { return Err(Error::Message { - message: format!( - "Output already exists and differs from the built parser: {}. Use --force to replace it.", - dst.display() - ), - }); + message: format!( + "Output already exists and differs from the built parser: {}. Use --force to replace it.", + dst.display() + ), + }); } self.replace_with_hardlink(src, dst).await?; diff --git a/src/sh.rs b/src/sh.rs index d882882..8142924 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -7,8 +7,8 @@ use tokio::{process::Command, time}; use tracing::{debug, error, info, trace, warn}; use crate::{ - shutdown::{self, PgId}, Error, Result, ResultExt, + shutdown::{self, PgId}, }; // ============================================================ diff --git a/src/shutdown.rs b/src/shutdown.rs index 4e488af..6a41c4a 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -130,12 +130,12 @@ fn signal_process_group(pgid: PgId, signal: Signal) { /// Only active in debug/test builds; a noop in release. #[cfg(debug_assertions)] pub async fn test_delay() { - if let Ok(ms) = std::env::var("TSDL_TEST_DELAY_MS") { - if let Ok(ms) = ms.parse::() { - let d = std::time::Duration::from_millis(ms); - debug!("[tsdl:test_delay] sleeping {ms}ms"); - tokio::time::sleep(d).await; - } + if let Ok(ms) = std::env::var("TSDL_TEST_DELAY_MS") + && let Ok(ms) = ms.parse::() + { + let d = std::time::Duration::from_millis(ms); + debug!("[tsdl:test_delay] sleeping {ms}ms"); + tokio::time::sleep(d).await; } } @@ -316,7 +316,7 @@ impl Handle { pub fn spawn_signal_listener(&self) -> Result> { use std::process; - use tokio::signal::unix::{signal, SignalKind}; + use tokio::signal::unix::{SignalKind, signal}; let mut sighup = signal(SignalKind::from_raw(Signal::HUP.number)) .context("Installing SIGHUP handler")?; diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 7deb42f..aab9cb9 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -52,7 +52,7 @@ async fn check_cached_cli(path: &Path, tag: &str) -> Result { let metadata = match fs::symlink_metadata(path).await { Ok(metadata) => metadata, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(CliCacheStatus::Missing) + return Ok(CliCacheStatus::Missing); } Err(err) => { return Err(err) @@ -467,15 +467,15 @@ async fn verify_cli(path: &Path, tag: &str) -> Result<()> { String::from_utf8_lossy(&output.stderr) ); - if let Some(expected) = expected_cli_version(tag) { - if !output.contains(expected) { - return Err(Error::Message { - message: format!( - "tree-sitter CLI version output did not contain expected version {expected:?}: {}", - output.trim() - ), - }); - } + if let Some(expected) = expected_cli_version(tag) + && !output.contains(expected) + { + return Err(Error::Message { + message: format!( + "tree-sitter CLI version output did not contain expected version {expected:?}: {}", + output.trim() + ), + }); } Ok(()) diff --git a/src/walk.rs b/src/walk.rs index 8172f6d..762904c 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; -use crate::{cache, git, shutdown, Result}; +use crate::{Result, cache, git, shutdown}; // ============================================================ // Free functions diff --git a/tests/cmd/mod.rs b/tests/cmd/mod.rs index e7cbe9b..ef96e4e 100644 --- a/tests/cmd/mod.rs +++ b/tests/cmd/mod.rs @@ -9,7 +9,7 @@ mod log; #[cfg(test)] use std::{env, fs, path::Path}; -use assert_cmd::{cargo::cargo_bin_cmd, Command}; +use assert_cmd::{Command, cargo::cargo_bin_cmd}; use assert_fs::TempDir; use tsdl::{args::BuildCommand, config as tsdl_config, consts::CONFIG_FILE}; diff --git a/tests/config.rs b/tests/config.rs index 588ee12..815bc9c 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -21,9 +21,11 @@ struct EnvVarGuard { impl Drop for EnvVarGuard { fn drop(&mut self) { + // SAFETY: The caller holds ENV_LOCK, serializing all env access + // within these tests. match &self.previous { - Some(value) => env::set_var(self.key, value), - None => env::remove_var(self.key), + Some(value) => unsafe { env::set_var(self.key, value) }, + None => unsafe { env::remove_var(self.key) }, } } } @@ -31,7 +33,9 @@ impl Drop for EnvVarGuard { impl EnvVarGuard { fn set(key: &'static str, value: &str) -> Self { let previous = env::var_os(key); - env::set_var(key, value); + // SAFETY: The caller holds ENV_LOCK, serializing all env access + // within these tests. + unsafe { env::set_var(key, value) }; Self { key, previous } } } From 9b4b3b2f8f9d0938b0b94cc296f92f7d4aff347a Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 16:39:30 +0200 Subject: [PATCH 81/88] release: get rid of git cliff --- .github/workflows/release.yml | 5 +-- CHANGELOG.md | 2 - cliff.toml | 85 ----------------------------------- docs/release.md | 5 ++- justfile | 2 +- scripts/changelog.sh | 20 +++++++++ scripts/release.sh | 82 ++++++++++++++++++++++++--------- 7 files changed, 86 insertions(+), 115 deletions(-) delete mode 100644 cliff.toml create mode 100755 scripts/changelog.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9da79dd..3b6682b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,8 @@ jobs: rm -rf artifacts ls -l target/ - - name: Install git-cliff - uses: taiki-e/install-action@git-cliff - - name: Generate changelog - run: git-cliff -vv --latest --strip header --output CHANGES.md + run: scripts/changelog.sh "${GITHUB_REF_NAME#v}" > CHANGES.md - name: Create release uses: softprops/action-gh-release@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0256cff..78d3c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -187,5 +187,3 @@ Some people call it fun, believe it or not … ### Features - **tsdl**: Working implementation - - diff --git a/cliff.toml b/cliff.toml deleted file mode 100644 index 5a32143..0000000 --- a/cliff.toml +++ /dev/null @@ -1,85 +0,0 @@ -[changelog] -# changelog header -header = """ -# Changelog\n -""" -# template for the changelog body -# https://keats.github.io/tera/docs/#introduction -body = """ -{%- macro remote_url() -%} - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} -{%- endmacro -%} - -{% macro print_commit(commit) -%} - - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\ - {% if commit.breaking %}[*breaking*] {% endif %}\ - {{ commit.message | upper_first }} - \ - ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ -{% endmacro -%} - -{% if version -%}\ - ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} -{% else -%}\ - ## [unreleased] -{% endif -%}\ -{% for group, commits in commits | group_by(attribute="group") %} - ### {{ group | striptags | trim | upper_first }} - {% for commit in commits | filter(attribute="scope") | sort(attribute="scope") %} - {{ self::print_commit(commit=commit) }} - {%- endfor %} - {% for commit in commits %} - {%- if not commit.scope -%} - {{ self::print_commit(commit=commit) }} - {% endif -%} - {% endfor -%} -{% endfor %} -""" -# template for the changelog footer -footer = """ -{% for release in releases -%} - {% if release.version -%} - {% if release.previous.version -%} - [{{ release.version | trim_start_matches(pat="v") }}]: \ - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\ - /compare/{{ release.previous.version }}..{{ release.version }} - {% endif -%} - {% else -%} - [unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\ - /compare/{{ release.previous.version }}..HEAD - {% endif -%} -{% endfor %} - -""" -# remove the leading and trailing whitespace from the templates -trim = true - -[git] -# parse the commits based on https://www.conventionalcommits.org -conventional_commits = true -# filter out the commits that are not conventional -filter_unconventional = false -# process each line of a commit as an individual commit -split_commits = false -# regex for parsing and grouping commits -commit_parsers = [ - { message = "!:", group = "Breaking" }, - { message = "^feat", group = "Features" }, - { message = "^fix", group = "Bug Fixes" }, - { message = "^perf", group = "Performance" }, - { message = "^doc", group = "Documentation" }, - { message = "^refactor", skip = true }, - { message = "^test", skip = true }, - { message = "^style", skip = true }, - { message = "^chore", skip = true }, - { message = ".*", group = "Other" }, -] -# filter out the commits that are not matched by commit parsers -filter_commits = true -# sort the tags topologically -topo_order = false -# sort the commits inside sections by oldest/newest order -sort_commits = "oldest" - -[remote.github] -owner = "stackmystack" -repo = "tsdl" diff --git a/docs/release.md b/docs/release.md index 90b6308..f9e84a7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,8 +6,9 @@ releases are automated via [GitHub actions](./.github/workflows/release.yml) and triggered by pushing a tag. -1. Run the [release script](./scripts/release.sh): `scripts/release.sh`. - The current version will be computed automatically if no version `v[X.Y.Z]` was passed. +1. Run the [release script](./scripts/release.sh): + `scripts/release.sh v`. + The version is required. 2. Push the changes: `git push` 3. Check if [Continuous Integration](https://github.com/stackmystack/tsdl/actions) workflow is completed successfully. diff --git a/justfile b/justfile index 8475921..43f6f3d 100644 --- a/justfile +++ b/justfile @@ -34,7 +34,7 @@ fmt-check: lint: clippy fmt-check typos setup: - cargo install git-cliff cargo-nextest typos-cli + cargo install cargo-nextest typos-cli # cmd::build::build_implicit_pinned_and_unpinned is flaky. test *args="--retries 0": diff --git a/scripts/changelog.sh b/scripts/changelog.sh new file mode 100755 index 0000000..dd111ac --- /dev/null +++ b/scripts/changelog.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +query=${1:?"Usage: $0 "} + +# Strip leading v, lowercase for case-insensitive matching. +query_lower=$(echo "${query#v}" | tr '[:upper:]' '[:lower:]') + +awk -v q="$query_lower" ' + /^## \[/ { + if (found) exit + idx = index($0, "[") + end = index($0, "]") + if (idx > 0 && end > idx) { + heading = substr($0, idx + 1, end - idx - 1) + if (tolower(heading) == q) found = 1 + } + } + found +' CHANGELOG.md diff --git a/scripts/release.sh b/scripts/release.sh index 7da6710..1f91b63 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,31 +1,71 @@ #!/usr/bin/env bash +set -euo pipefail -set -ex - -version=${1:-"$(git cliff --bumped-version)"} +version=${1:?"Usage: $0 vX.Y.Z"} +date=$(date +%Y-%m-%d) +version_bare=${version#v} echo "Preparing $version..." -# lint and test first + +# Lint and test first. just lint just test -# update the version + +# Update Cargo.toml version. msg="# managed by release.sh" -sed -E "s/^version = .*[[:space:]]+$msg$/version = \"${version#v}\" $msg/" Cargo.toml > Cargo.toml.tmp +sed -E "s/^version = .*[[:space:]]+$msg$/version = \"${version_bare}\" $msg/" \ + Cargo.toml > Cargo.toml.tmp mv Cargo.toml.tmp Cargo.toml -# update the changelog -git cliff --unreleased --tag "$version" --prepend CHANGELOG.md -git add -A && git commit -m "chore(release): $version" + +# Move [Unreleased] content under a new version heading in CHANGELOG.md. +# If [Unreleased] is empty, no section is created. +awk -v ver="$version_bare" -v date="$date" ' +/^## \[Unreleased\]/ { + header = $0 + in_unreleased = 1 + next +} +in_unreleased && /^## \[/ { + print header + print "" + if (content != "") { + printf "## [%s] - %s\n\n", ver, date + printf "%s", content + } + in_unreleased = 0 + print + next +} +in_unreleased { + if (!started && $0 ~ /^[[:space:]]*$/) next + started = 1 + content = content $0 "\n" + next +} +{ print } +END { + if (in_unreleased) { + print header + print "" + if (content != "") { + printf "## [%s] - %s\n\n", ver, date + printf "%s", content + } + } +} +' CHANGELOG.md > CHANGELOG.md.tmp +mv CHANGELOG.md.tmp CHANGELOG.md + +# Commit. +git add -A +git commit -m "chore(release): $version" + +# Extract the new section for the tag message. +tag_body=$(scripts/changelog.sh "$version_bare") + +git tag -a "$version" -m "Release $version" -m "$tag_body" + git show -# generate a changelog for the tag message -export GIT_CLIFF_TEMPLATE="\ - {% for group, commits in commits | group_by(attribute=\"group\") %} - {{ group | upper_first }}\ - {% for commit in commits %} - - {% if commit.breaking %}(breaking) {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end=\"\") }})\ - {% endfor %} - {% endfor %}" -changelog=$(git cliff --unreleased --strip all) -# create a tag -git tag -a "$version" -m "Release $version" -m "$changelog" + echo "Done!" -echo "Now push the commit (git push origin master) and the tag (git push origin refs/tags/$version)." +echo "Now push the commit and tag." From c02ffaa9788633382bb3624f7cd6847a8dc3fe1b Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 18:52:12 +0200 Subject: [PATCH 82/88] ci: upgrade actions and match tsdl artifact exactly --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b6682b..4bea30f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: fetch-depth: 0 - name: Download build artifacts - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v5 with: path: artifacts @@ -33,7 +33,7 @@ jobs: run: | mkdir -p target for platform in $(cd artifacts; ls | sed 's/^tsdl\.//'); do - exe=$(ls artifacts/tsdl.$platform/tsdl*) + exe="artifacts/tsdl.$platform/tsdl" gzip --stdout --name $exe > target/tsdl-$platform.gz done rm -rf artifacts From f80e8b0e042635d94776e8dc5d34db3f74a780c7 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 19:09:34 +0200 Subject: [PATCH 83/88] fmt --- src/git.rs | 26 +++++++++++++++----------- src/lock.rs | 22 +++++++++++++--------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/git.rs b/src/git.rs index d6cafa8..2b1d191 100644 --- a/src/git.rs +++ b/src/git.rs @@ -442,18 +442,22 @@ impl fmt::Display for RefError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::EmptyRef => write!(f, "git ref cannot be empty"), - Self::InvalidRefCharacter { index, character } => write!( - f, - "git ref contains invalid character at byte {index}: {character:?}" - ), + Self::InvalidRefCharacter { index, character } => { + write!( + f, + "git ref contains invalid character at byte {index}: {character:?}" + ) + } Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), Self::InvalidShaLength { actual } => { write!(f, "git SHA must be exactly 40 hex characters, got {actual}") } - Self::InvalidShaHex { index, character } => write!( - f, - "git SHA contains non-hex character at byte {index}: {character:?}" - ), + Self::InvalidShaHex { index, character } => { + write!( + f, + "git SHA contains non-hex character at byte {index}: {character:?}" + ) + } } } } @@ -636,7 +640,7 @@ mod tests { Ref::new("feature branch"), Err(RefError::InvalidRefCharacter { index: 7, - character: ' ', + character: ' ' }) ); } @@ -646,7 +650,7 @@ mod tests { assert_eq!( Ref::new("feature..branch"), Err(RefError::InvalidRefSyntax { - reason: "refs cannot contain ..", + reason: "refs cannot contain .." }) ); assert_eq!( @@ -688,7 +692,7 @@ mod tests { Sha::new("636801770eea172d140e64b691815ff11f6b556x"), Err(RefError::InvalidShaHex { index: 39, - character: 'x', + character: 'x' }) ); } diff --git a/src/lock.rs b/src/lock.rs index f8d5e97..9a0b917 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -214,15 +214,19 @@ impl fmt::Display for TakeoverError { write!(f, "Could not identify build lock owner: {reason}") } } - Self::SignalFailed { owner } => write!( - f, - "Failed to send SIGTERM to lock owner PID {} ({})", - owner.pid, owner.name - ), - Self::SignalUnsupported { .. } => write!( - f, - "SIGTERM is not supported on this platform; cannot terminate lock owner" - ), + Self::SignalFailed { owner } => { + write!( + f, + "Failed to send SIGTERM to lock owner PID {} ({})", + owner.pid, owner.name + ) + } + Self::SignalUnsupported { .. } => { + write!( + f, + "SIGTERM is not supported on this platform; cannot terminate lock owner" + ) + } Self::Timeout { previous, timeout, From 85c42a62f2ff050e7bc1e113eeb177a07351b254 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 19:12:15 +0200 Subject: [PATCH 84/88] fmd: tab width = 2 --- build.rs | 196 +-- rustfmt.toml | 6 +- src/actors/cache.rs | 373 +++--- src/actors/display.rs | 2906 ++++++++++++++++++++--------------------- src/actors/mod.rs | 582 +++++---- src/app.rs | 205 ++- src/args.rs | 551 ++++---- src/build.rs | 615 +++++---- src/cache.rs | 1869 +++++++++++++------------- src/columns.rs | 655 +++++----- src/config.rs | 797 ++++++----- src/display.rs | 1331 +++++++++---------- src/error.rs | 562 ++++---- src/git.rs | 1002 +++++++------- src/lib.rs | 160 +-- src/lock.rs | 969 +++++++------- src/logging.rs | 326 ++--- src/main.rs | 117 +- src/parser.rs | 2318 ++++++++++++++++---------------- src/selfupdate.rs | 283 ++-- src/sh.rs | 317 ++--- src/shutdown.rs | 573 ++++---- src/tree_sitter.rs | 907 ++++++------- src/walk.rs | 18 +- tests/cli.rs | 24 +- tests/cmd/build.rs | 524 ++++---- tests/cmd/cache.rs | 471 +++---- tests/cmd/config.rs | 188 +-- tests/cmd/log.rs | 98 +- tests/cmd/mod.rs | 46 +- tests/config.rs | 350 ++--- 31 files changed, 9675 insertions(+), 9664 deletions(-) diff --git a/build.rs b/build.rs index f6d0536..41ee3de 100644 --- a/build.rs +++ b/build.rs @@ -6,23 +6,23 @@ use std::{env, fs}; /// Maps targets to Tree Sitter platform strings. const TARGETS: &[(&str, &str)] = &[ - ("linux-arm", "arm-unknown-linux-gnueabi"), - ("linux-arm64", "aarch64-unknown-linux-gnu"), - ("linux-x64", "x86_64-unknown-linux-gnu"), - ("linux-x86", "i686-unknown-linux-gnu"), - ("macos-arm64", "aarch64-apple-darwin"), - ("macos-x64", "x86_64-apple-darwin"), + ("linux-arm", "arm-unknown-linux-gnueabi"), + ("linux-arm64", "aarch64-unknown-linux-gnu"), + ("linux-x64", "x86_64-unknown-linux-gnu"), + ("linux-x86", "i686-unknown-linux-gnu"), + ("macos-arm64", "aarch64-apple-darwin"), + ("macos-x64", "x86_64-apple-darwin"), ]; const fn platform_for_target(target: &str) -> &str { - let mut i = 0; - while i < TARGETS.len() { - if const_str::equal!(TARGETS[i].1, target) { - return TARGETS[i].0; - } - i += 1; + let mut i = 0; + while i < TARGETS.len() { + if const_str::equal!(TARGETS[i].1, target) { + return TARGETS[i].0; } - target + i += 1; + } + target } /// Generates a Rust file with `pub const` definitions. @@ -69,91 +69,91 @@ macro_rules! generate_consts { } fn main() { - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=Cargo.toml"); - - let out_dir = env::var_os("OUT_DIR").unwrap(); - let build_target = env::var("TARGET").unwrap(); - - // 1. Get Metadata - let metadata = MetadataCommand::new().exec().unwrap(); - let meta = metadata - .root_package() - .unwrap() - .metadata - .as_object() - .unwrap(); - - // 2. Prep dynamic values - let tsdl_bin_build_dir = PathBuf::from(file!()) - .parent() - .unwrap() - .join("src") - .canonicalize() - .unwrap() - .join(""); // Ensure trailing slash logic if needed, or handle in string - - // Note: User original code added a trailing slash via format string, - // we convert to string here for the macro. - let tsdl_bin_str = format!("{}/", tsdl_bin_build_dir.to_str().unwrap()); - - let ts_platform = platform_for_target(&build_target); - - // 3. Generate TSDL Consts - let tsdl = meta.get("tsdl").expect("missing [metadata.tsdl]"); - generate_consts!( - Path::new(&out_dir).join("tsdl_consts.rs"), - BIN_BUILD_DIR : str = expr(tsdl_bin_str), - BUILD_DIR : str = json(tsdl, "build-dir"), - CACHE_FILE : str = json(tsdl, "cache-file"), - CONFIG_FILE : str = json(tsdl, "config-file"), - FORCE : bool = json(tsdl, "force"), - FRESH : bool = json(tsdl, "fresh"), - FROM : str = json(tsdl, "from"), - LOCK_FILE : str = json(tsdl, "lock-file"), - LOG_FILE : str = json(tsdl, "log-file"), - PARSER_OUT_DIR : str = json(tsdl, "out-dir"), - UNLOCK_TIMEOUT: u64 = json(tsdl, "unlock-timeout"), - PREFIX : str = json(tsdl, "prefix"), - REF : str = json(tsdl, "ref"), - SHOW_CONFIG : bool = json(tsdl, "show-config"), - ); - - // 4. Generate Tree Sitter Consts - let tree_sitter = meta - .get("tree-sitter") - .expect("missing [metadata.tree-sitter]"); - generate_consts!( - Path::new(&out_dir).join("tree_sitter_consts.rs"), - PLATFORM : str = expr(ts_platform), - REPO : str = json(tree_sitter, "repo"), - VERSION : str = json(tree_sitter, "version"), - ); - - // 5. Generate Version/SHA - let sha1 = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| format!(" ({})", s.trim())) - .unwrap_or_default(); - - fs::write( - Path::new(&out_dir).join("tsdl.version"), - format!("{}{}", env!("CARGO_PKG_VERSION"), sha1), - ) + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=Cargo.toml"); + + let out_dir = env::var_os("OUT_DIR").unwrap(); + let build_target = env::var("TARGET").unwrap(); + + // 1. Get Metadata + let metadata = MetadataCommand::new().exec().unwrap(); + let meta = metadata + .root_package() + .unwrap() + .metadata + .as_object() .unwrap(); - // 6. FIXME: Control tests on CI (for some reason, wasm is not passing) - let is_macos = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos"); - let is_linux = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux"); - let in_github = std::env::var("GITHUB_ACTIONS").is_ok(); - - if is_macos || (is_linux && !in_github) { - println!("cargo:rustc-cfg=enable_wasm_cases"); - } - - // Register the cfg so check-cfg is happy - println!("cargo:rustc-check-cfg=cfg(enable_wasm_cases)"); + // 2. Prep dynamic values + let tsdl_bin_build_dir = PathBuf::from(file!()) + .parent() + .unwrap() + .join("src") + .canonicalize() + .unwrap() + .join(""); // Ensure trailing slash logic if needed, or handle in string + + // Note: User original code added a trailing slash via format string, + // we convert to string here for the macro. + let tsdl_bin_str = format!("{}/", tsdl_bin_build_dir.to_str().unwrap()); + + let ts_platform = platform_for_target(&build_target); + + // 3. Generate TSDL Consts + let tsdl = meta.get("tsdl").expect("missing [metadata.tsdl]"); + generate_consts!( + Path::new(&out_dir).join("tsdl_consts.rs"), + BIN_BUILD_DIR : str = expr(tsdl_bin_str), + BUILD_DIR : str = json(tsdl, "build-dir"), + CACHE_FILE : str = json(tsdl, "cache-file"), + CONFIG_FILE : str = json(tsdl, "config-file"), + FORCE : bool = json(tsdl, "force"), + FRESH : bool = json(tsdl, "fresh"), + FROM : str = json(tsdl, "from"), + LOCK_FILE : str = json(tsdl, "lock-file"), + LOG_FILE : str = json(tsdl, "log-file"), + PARSER_OUT_DIR : str = json(tsdl, "out-dir"), + UNLOCK_TIMEOUT: u64 = json(tsdl, "unlock-timeout"), + PREFIX : str = json(tsdl, "prefix"), + REF : str = json(tsdl, "ref"), + SHOW_CONFIG : bool = json(tsdl, "show-config"), + ); + + // 4. Generate Tree Sitter Consts + let tree_sitter = meta + .get("tree-sitter") + .expect("missing [metadata.tree-sitter]"); + generate_consts!( + Path::new(&out_dir).join("tree_sitter_consts.rs"), + PLATFORM : str = expr(ts_platform), + REPO : str = json(tree_sitter, "repo"), + VERSION : str = json(tree_sitter, "version"), + ); + + // 5. Generate Version/SHA + let sha1 = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| format!(" ({})", s.trim())) + .unwrap_or_default(); + + fs::write( + Path::new(&out_dir).join("tsdl.version"), + format!("{}{}", env!("CARGO_PKG_VERSION"), sha1), + ) + .unwrap(); + + // 6. FIXME: Control tests on CI (for some reason, wasm is not passing) + let is_macos = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos"); + let is_linux = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux"); + let in_github = std::env::var("GITHUB_ACTIONS").is_ok(); + + if is_macos || (is_linux && !in_github) { + println!("cargo:rustc-cfg=enable_wasm_cases"); + } + + // Register the cfg so check-cfg is happy + println!("cargo:rustc-check-cfg=cfg(enable_wasm_cases)"); } diff --git a/rustfmt.toml b/rustfmt.toml index b1483ac..b196eaa 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,5 +1 @@ -# This file intentionally left almost blank -# -# The empty `rustfmt.toml` makes rustfmt use the default configuration, -# overriding any which may be found in the contributor's home or parent -# folders. +tab_spaces = 2 diff --git a/src/actors/cache.rs b/src/actors/cache.rs index f027c61..1dade2d 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -7,9 +7,9 @@ use tokio::sync::{mpsc, oneshot}; use tracing::info; use crate::{ - Result, - actors::{Addr, Response}, - build, cache, parser, + Result, + actors::{Addr, Response}, + build, cache, parser, }; // ============================================================ @@ -23,48 +23,48 @@ use crate::{ /// [`oneshot::Sender`] for the reply. #[derive(Debug)] pub enum CacheMessage { - /// Query if a parser needs rebuild - NeedsRebuild { - hash: cache::GrammarHash, - name: cache::Key, - revision: cache::Revision, - spec: Arc, - tx: oneshot::Sender, - }, - /// `cache::Update` a cache entry - Update { - entry: cache::Entry, - name: cache::Key, - }, - /// Save cache to disk - Save { tx: oneshot::Sender> }, - /// Check if cache contains entries compatible with a language spec. - HasCompatibleEntries { - language: parser::LanguageName, - spec: Arc, - tx: oneshot::Sender, - }, - /// Get a cache entry - Get { - name: cache::Key, - tx: oneshot::Sender>, - }, + /// Query if a parser needs rebuild + NeedsRebuild { + hash: cache::GrammarHash, + name: cache::Key, + revision: cache::Revision, + spec: Arc, + tx: oneshot::Sender, + }, + /// `cache::Update` a cache entry + Update { + entry: cache::Entry, + name: cache::Key, + }, + /// Save cache to disk + Save { tx: oneshot::Sender> }, + /// Check if cache contains entries compatible with a language spec. + HasCompatibleEntries { + language: parser::LanguageName, + spec: Arc, + tx: oneshot::Sender, + }, + /// Get a cache entry + Get { + name: cache::Key, + tx: oneshot::Sender>, + }, } #[derive(Debug)] #[allow(dead_code)] enum ResponseKind<'a> { - CacheGet { - name: &'a cache::Key, - }, - HasCompatibleEntries { - language: &'a parser::LanguageName, - }, - NeedsRebuild { - name: &'a cache::Key, - hash: &'a cache::GrammarHash, - }, - SaveComplete, + CacheGet { + name: &'a cache::Key, + }, + HasCompatibleEntries { + language: &'a parser::LanguageName, + }, + NeedsRebuild { + name: &'a cache::Key, + hash: &'a cache::GrammarHash, + }, + SaveComplete, } // ============================================================ @@ -73,21 +73,21 @@ enum ResponseKind<'a> { /// The Cache Actor: Manages cache state and processes messages pub struct CacheActor { - /// In-memory cache database. - db: cache::Db, - /// File-backed storage for persistence. - store: cache::Store, - /// When true, every query returns a miss. - force: bool, - /// Message receiver channel. - rx: mpsc::Receiver, + /// In-memory cache database. + db: cache::Db, + /// File-backed storage for persistence. + store: cache::Store, + /// When true, every query returns a miss. + force: bool, + /// Message receiver channel. + rx: mpsc::Receiver, } /// The Cache Handle: Public interface for sending cache operations #[derive(Debug, Clone)] pub struct CacheAddr { - /// Sender half of the channel to the cache actor. - tx: mpsc::Sender, + /// Sender half of the channel to the cache actor. + tx: mpsc::Sender, } // ============================================================ @@ -95,152 +95,155 @@ pub struct CacheAddr { // ============================================================ impl Addr for CacheAddr { - type Message = CacheMessage; + type Message = CacheMessage; - fn name() -> &'static str { - "CacheAddr" - } + fn name() -> &'static str { + "CacheAddr" + } - fn sender(&self) -> &mpsc::Sender { - &self.tx - } + fn sender(&self) -> &mpsc::Sender { + &self.tx + } } impl CacheActor { - /// Process incoming cache messages until the channel closes. - async fn run(mut self) { - while let Some(msg) = self.rx.recv().await { - match msg { - CacheMessage::NeedsRebuild { - hash, - name, - spec, - revision, - tx, - } => { - let decision = if self.force { - cache::Decision::miss(cache::MissReason::CacheIgnored) - } else { - self.db.rebuild_decision(&name, &hash, &revision, &spec) - }; - if decision.needs_rebuild() { - info!("Cache miss for {name}: {}", decision.short_message()); - } - - Response { - tx, - kind: ResponseKind::NeedsRebuild { - name: &name, - hash: &hash, - }, - } - .send(decision); - } - - CacheMessage::Update { entry, name } => { - self.db.set(name, entry); - } - - CacheMessage::Save { tx } => { - Response { - tx, - kind: ResponseKind::SaveComplete, - } - .send(self.store.save(&self.db).await); - } - - CacheMessage::HasCompatibleEntries { language, spec, tx } => { - Response { - tx, - kind: ResponseKind::HasCompatibleEntries { - language: &language, - }, - } - .send( - !self.force - && self - .db - .has_compatible_entry_for_language(&language, spec.as_ref()), - ); - } - - CacheMessage::Get { name, tx } => { - Response { - tx, - kind: ResponseKind::CacheGet { name: &name }, - } - .send(self.db.get(&name).cloned()); - } - } + /// Process incoming cache messages until the channel closes. + async fn run(mut self) { + while let Some(msg) = self.rx.recv().await { + match msg { + CacheMessage::NeedsRebuild { + hash, + name, + spec, + revision, + tx, + } => { + let decision = if self.force { + cache::Decision::miss(cache::MissReason::CacheIgnored) + } else { + self.db.rebuild_decision(&name, &hash, &revision, &spec) + }; + if decision.needs_rebuild() { + info!("Cache miss for {name}: {}", decision.short_message()); + } + + Response { + tx, + kind: ResponseKind::NeedsRebuild { + name: &name, + hash: &hash, + }, + } + .send(decision); } - } - /// Spawn the cache actor in a background task and return an address handle. - #[must_use] - pub fn spawn(db: cache::Db, force: bool, store: cache::Store) -> CacheAddr { - let (tx, rx) = mpsc::channel(64); - let actor = Self { - db, - store, - force, - rx, - }; - tokio::spawn(actor.run()); - CacheAddr::new(tx) - } -} - -impl CacheAddr { - /// Create a new cache address from a sender. - #[must_use] - pub fn new(tx: mpsc::Sender) -> Self { - Self { tx } - } - - /// Accepts any string type (`String`, `&str`, `Arc`) with minimal cloning - pub async fn get(&self, name: cache::Key) -> Option { - self.request(|tx| CacheMessage::Get { name, tx }).await - } + CacheMessage::Update { entry, name } => { + self.db.set(name, entry); + } - /// Check if the cache has compatible entries for a language (same repo + git ref). - pub async fn has_compatible_entries( - &self, - language: parser::LanguageName, - spec: Arc, - ) -> bool { - self.request(|tx| CacheMessage::HasCompatibleEntries { language, spec, tx }) - .await - } + CacheMessage::Save { tx } => { + Response { + tx, + kind: ResponseKind::SaveComplete, + } + .send(self.store.save(&self.db).await); + } - /// Ask the cache actor whether a parser needs rebuilding. - pub async fn needs_rebuild( - &self, - name: cache::Key, - hash: cache::GrammarHash, - revision: cache::Revision, - spec: Arc, - ) -> cache::Decision { - self.request(|tx| CacheMessage::NeedsRebuild { - name, - hash, - revision, - spec, + CacheMessage::HasCompatibleEntries { language, spec, tx } => { + Response { tx, - }) - .await - } + kind: ResponseKind::HasCompatibleEntries { + language: &language, + }, + } + .send( + !self.force + && self + .db + .has_compatible_entry_for_language(&language, spec.as_ref()), + ); + } - /// Save the cache to disk. - pub async fn save(&self) -> Result<()> { - self.request(|tx| CacheMessage::Save { tx }).await + CacheMessage::Get { name, tx } => { + Response { + tx, + kind: ResponseKind::CacheGet { name: &name }, + } + .send(self.db.get(&name).cloned()); + } + } } + } + + /// Spawn the cache actor in a background task and return an address handle. + #[must_use] + pub fn spawn(db: cache::Db, force: bool, store: cache::Store) -> CacheAddr { + let (tx, rx) = mpsc::channel(64); + let actor = Self { + db, + store, + force, + rx, + }; + tokio::spawn(actor.run()); + CacheAddr::new(tx) + } +} - /// Send a cache update (fire-and-forget). - pub async fn update(&self, update: cache::Update) { - self.fire(CacheMessage::Update { - entry: update.entry, - name: update.name, - }) - .await; - } +impl CacheAddr { + /// Create a new cache address from a sender. + #[must_use] + pub fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } + + /// Accepts any string type (`String`, `&str`, `Arc`) with minimal cloning + pub async fn get(&self, name: cache::Key) -> Option { + self.request(|tx| CacheMessage::Get { name, tx }).await + } + + /// Check if the cache has compatible entries for a language (same repo + git ref). + pub async fn has_compatible_entries( + &self, + language: parser::LanguageName, + spec: Arc, + ) -> bool { + self + .request(|tx| CacheMessage::HasCompatibleEntries { language, spec, tx }) + .await + } + + /// Ask the cache actor whether a parser needs rebuilding. + pub async fn needs_rebuild( + &self, + name: cache::Key, + hash: cache::GrammarHash, + revision: cache::Revision, + spec: Arc, + ) -> cache::Decision { + self + .request(|tx| CacheMessage::NeedsRebuild { + name, + hash, + revision, + spec, + tx, + }) + .await + } + + /// Save the cache to disk. + pub async fn save(&self) -> Result<()> { + self.request(|tx| CacheMessage::Save { tx }).await + } + + /// Send a cache update (fire-and-forget). + pub async fn update(&self, update: cache::Update) { + self + .fire(CacheMessage::Update { + entry: update.entry, + name: update.name, + }) + .await; + } } diff --git a/src/actors/display.rs b/src/actors/display.rs index 0058222..bf4309c 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -28,59 +28,59 @@ use crate::git; /// shutdown. #[derive(Debug)] pub enum Message { - /// Register a repo-level progress line. Returns a `ProgressAddr`. - RegisterLanguage { - git_ref: git::Ref, - name: Arc, - num_tasks: usize, - tx: oneshot::Sender, - }, - - /// Register a grammar-level progress line. Returns a `ProgressAddr`. - RegisterGrammar { - git_ref: git::Ref, - language: Arc, - name: Arc, - num_tasks: usize, - tx: oneshot::Sender, - }, - - /// Plain-mode reference line. Fancy mode ignores this because refs are rendered per row. - RegisterReference { git_ref: git::Ref, name: Arc }, - - /// Update a specific bar. - Update { - id: display::ItemId, - kind: UpdateKind, - msg: Arc, - }, - - /// Flush and close the display actor. The response is sent after cleanup. - Shutdown { - interrupted: bool, - tx: oneshot::Sender<()>, - }, + /// Register a repo-level progress line. Returns a `ProgressAddr`. + RegisterLanguage { + git_ref: git::Ref, + name: Arc, + num_tasks: usize, + tx: oneshot::Sender, + }, + + /// Register a grammar-level progress line. Returns a `ProgressAddr`. + RegisterGrammar { + git_ref: git::Ref, + language: Arc, + name: Arc, + num_tasks: usize, + tx: oneshot::Sender, + }, + + /// Plain-mode reference line. Fancy mode ignores this because refs are rendered per row. + RegisterReference { git_ref: git::Ref, name: Arc }, + + /// Update a specific bar. + Update { + id: display::ItemId, + kind: UpdateKind, + msg: Arc, + }, + + /// Flush and close the display actor. The response is sent after cleanup. + Shutdown { + interrupted: bool, + tx: oneshot::Sender<()>, + }, } /// The kind of update to apply to a progress bar. #[derive(Debug, Clone, Copy)] pub enum UpdateKind { - /// Update the status message only. - Msg, - /// Advance to the next step. - Step, - /// Mark the outcome as cached (used before clone is skipped). - SetOutcomeCached, - /// Mark the outcome as built (used before clone proceeds). - SetOutcomeBuilt, - /// Mark the row as cancelled (shutdown). - Cancel, - /// Mark the row as cached at completion. - Cached, - /// Mark the row as finished (built) at completion. - Fin, - /// Mark the row as failed. - Err, + /// Update the status message only. + Msg, + /// Advance to the next step. + Step, + /// Mark the outcome as cached (used before clone is skipped). + SetOutcomeCached, + /// Mark the outcome as built (used before clone proceeds). + SetOutcomeBuilt, + /// Mark the row as cancelled (shutdown). + Cancel, + /// Mark the row as cached at completion. + Cached, + /// Mark the row as finished (built) at completion. + Fin, + /// Mark the row as failed. + Err, } // ============================================================ @@ -89,51 +89,51 @@ pub enum UpdateKind { /// The display actor: owns the display state and the message receiver. pub struct DisplayActor { - /// Shared display state (repos, grammars, layout). - state: display::State, - /// Monotonically increasing ID counter for new rows. - next_id: display::ItemId, - /// Maximum name column width for plain-mode alignment. - plain_name_width: usize, - /// Whether any progress line has been printed (plain mode). - plain_progress_started: bool, - /// Cached rendered cells for the fancy backend. - grid: display::GridCache, - /// Ordered list of visible row specs. - row_specs: Vec, - /// Whether the row list has changed since last render. - rows_dirty: bool, - /// Cached terminal width from the last render cycle. - last_term_width: Option, - /// Receiver for incoming display messages. - rx: mpsc::Receiver, - /// Cloned sender used to construct child [`ProgressAddr`] handles. - tx: mpsc::Sender, + /// Shared display state (repos, grammars, layout). + state: display::State, + /// Monotonically increasing ID counter for new rows. + next_id: display::ItemId, + /// Maximum name column width for plain-mode alignment. + plain_name_width: usize, + /// Whether any progress line has been printed (plain mode). + plain_progress_started: bool, + /// Cached rendered cells for the fancy backend. + grid: display::GridCache, + /// Ordered list of visible row specs. + row_specs: Vec, + /// Whether the row list has changed since last render. + rows_dirty: bool, + /// Cached terminal width from the last render cycle. + last_term_width: Option, + /// Receiver for incoming display messages. + rx: mpsc::Receiver, + /// Cloned sender used to construct child [`ProgressAddr`] handles. + tx: mpsc::Sender, } /// Handle for sending display messages (register languages, update progress). #[derive(Debug, Clone)] pub struct DisplayAddr { - #[allow(dead_code)] - mode: display::Mode, - /// Sender to the display actor. - tx: mpsc::Sender, + #[allow(dead_code)] + mode: display::Mode, + /// Sender to the display actor. + tx: mpsc::Sender, } struct PlainLine { - name: String, - step: usize, - total: usize, - message: String, + name: String, + step: usize, + total: usize, + message: String, } /// Handle for updating a specific progress bar (repo or grammar). #[derive(Debug, Clone)] pub struct ProgressAddr { - /// Target row ID for updates. - id: display::ItemId, - /// Sender to the display actor. - tx: mpsc::Sender, + /// Target row ID for updates. + id: display::ItemId, + /// Sender to the display actor. + tx: mpsc::Sender, } // ============================================================ @@ -142,197 +142,195 @@ pub struct ProgressAddr { /// Clear from the cursor position to the bottom of the terminal. fn clear_from_cursor_down() -> io::Result<()> { - crossterm::execute!( - io::stdout(), - crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown) - ) + crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown) + ) } /// Measure the current visible height of the terminal viewport. fn current_viewport_height( - terminal: &mut ratatui::Terminal, + terminal: &mut ratatui::Terminal, ) -> Result { - terminal.autoresize()?; - Ok(terminal.get_frame().area().height.max(1)) + terminal.autoresize()?; + Ok(terminal.get_frame().area().height.max(1)) } /// Draw a set of lines into the terminal frame. fn draw_lines( - terminal: &mut ratatui::Terminal, - lines: Vec>, + terminal: &mut ratatui::Terminal, + lines: Vec>, ) -> Result<(), B::Error> { - terminal - .draw(|frame| { - let area = frame.area(); - frame.render_widget(Paragraph::new(lines), area); - }) - .map(|_| ()) + terminal + .draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + }) + .map(|_| ()) } /// Draw lines into the viewport, clearing previous content and setting the /// cursor below the last visible line. fn draw_lines_in_viewport( - terminal: &mut ratatui::Terminal, - lines: Vec>, + terminal: &mut ratatui::Terminal, + lines: Vec>, ) -> Result<(), B::Error> { - let line_count = lines.len(); - - terminal - .draw(move |frame| { - let area = frame.area(); - frame.render_widget(Clear, area); - frame.render_widget(Paragraph::new(lines), area); - - let cursor_offset = line_count - .saturating_sub(1) - .min(usize::from(area.height.saturating_sub(1))); - let cursor_y = area.y + u16::try_from(cursor_offset).unwrap_or(0); - frame.set_cursor_position((area.x, cursor_y)); - }) - .map(|_| ()) + let line_count = lines.len(); + + terminal + .draw(move |frame| { + let area = frame.area(); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines), area); + + let cursor_offset = line_count + .saturating_sub(1) + .min(usize::from(area.height.saturating_sub(1))); + let cursor_y = area.y + u16::try_from(cursor_offset).unwrap_or(0); + frame.set_cursor_position((area.x, cursor_y)); + }) + .map(|_| ()) } /// Transition an item state to `Done`. Returns an error if the state is not /// `InProgress(Some(_))`. fn finish_item(state: display::ItemState) -> Result { - match state { - display::ItemState::InProgress(Some(outcome)) => Ok(display::ItemState::Done(outcome)), - display::ItemState::New | display::ItemState::InProgress(None) => invalid_finish( - "finish update received before cached/built path was set", - state, - ), - display::ItemState::Done(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => { - invalid_finish("finish update received for terminal item state", state) - } + match state { + display::ItemState::InProgress(Some(outcome)) => Ok(display::ItemState::Done(outcome)), + display::ItemState::New | display::ItemState::InProgress(None) => invalid_finish( + "finish update received before cached/built path was set", + state, + ), + display::ItemState::Done(_) | display::ItemState::Cancelled | display::ItemState::Failed => { + invalid_finish("finish update received for terminal item state", state) } + } } /// Push lines above the current viewport, scrolling content up. fn insert_lines_before_viewport( - terminal: &mut ratatui::Terminal, - mut lines: Vec>, + terminal: &mut ratatui::Terminal, + mut lines: Vec>, ) -> Result<(), B::Error> { - while !lines.is_empty() { - let rest = if lines.len() > usize::from(u16::MAX) { - lines.split_off(usize::from(u16::MAX)) - } else { - Vec::new() - }; - let height = u16::try_from(lines.len()).unwrap_or(u16::MAX); - - terminal.insert_before(height, move |buf| { - Paragraph::new(lines).render(buf.area, buf); - })?; - - lines = rest; - } - - Ok(()) + while !lines.is_empty() { + let rest = if lines.len() > usize::from(u16::MAX) { + lines.split_off(usize::from(u16::MAX)) + } else { + Vec::new() + }; + let height = u16::try_from(lines.len()).unwrap_or(u16::MAX); + + terminal.insert_before(height, move |buf| { + Paragraph::new(lines).render(buf.area, buf); + })?; + + lines = rest; + } + + Ok(()) } /// Log and return an error for an invalid finish transition. fn invalid_finish(reason: &str, state: display::ItemState) -> Result { - let message = format!("{reason}: {state:?}"); - error!("{message}"); - debug_assert!( - matches!(state, display::ItemState::InProgress(Some(_))), - "{message}" - ); - Err(message) + let message = format!("{reason}: {state:?}"); + error!("{message}"); + debug_assert!( + matches!(state, display::ItemState::InProgress(Some(_))), + "{message}" + ); + Err(message) } /// Set the success outcome on an item state without changing its progress phase. fn mark_success(state: display::ItemState, outcome: display::SuccessOutcome) -> display::ItemState { - match state { - display::ItemState::New | display::ItemState::InProgress(_) => { - display::ItemState::InProgress(Some(outcome)) - } - display::ItemState::Done(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => state, + match state { + display::ItemState::New | display::ItemState::InProgress(_) => { + display::ItemState::InProgress(Some(outcome)) } + display::ItemState::Done(_) | display::ItemState::Cancelled | display::ItemState::Failed => { + state + } + } } /// Format a plain-text message for a grammar row. fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { - match kind { - UpdateKind::Err => format!("failed: {msg}"), - UpdateKind::Cached => "cached".to_string(), - UpdateKind::Fin => match state { - display::ItemState::Done(display::SuccessOutcome::Cached) => "cached".to_string(), - display::ItemState::Done(display::SuccessOutcome::Built) => "built".to_string(), - display::ItemState::New - | display::ItemState::InProgress(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => msg.to_string(), - }, - UpdateKind::SetOutcomeBuilt - | UpdateKind::SetOutcomeCached - | UpdateKind::Cancel - | UpdateKind::Msg - | UpdateKind::Step => msg.to_string(), - } + match kind { + UpdateKind::Err => format!("failed: {msg}"), + UpdateKind::Cached => "cached".to_string(), + UpdateKind::Fin => match state { + display::ItemState::Done(display::SuccessOutcome::Cached) => "cached".to_string(), + display::ItemState::Done(display::SuccessOutcome::Built) => "built".to_string(), + display::ItemState::New + | display::ItemState::InProgress(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => msg.to_string(), + }, + UpdateKind::SetOutcomeBuilt + | UpdateKind::SetOutcomeCached + | UpdateKind::Cancel + | UpdateKind::Msg + | UpdateKind::Step => msg.to_string(), + } } /// Format a plain-text message for a repo row. fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { - match kind { - UpdateKind::Err => format!("failed: {msg}"), - UpdateKind::Cached => "cached".to_string(), - UpdateKind::Fin => match state { - display::ItemState::Done(_) => "done".to_string(), - display::ItemState::New - | display::ItemState::InProgress(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => msg.to_string(), - }, - UpdateKind::SetOutcomeBuilt - | UpdateKind::SetOutcomeCached - | UpdateKind::Cancel - | UpdateKind::Msg - | UpdateKind::Step => msg.to_string(), - } + match kind { + UpdateKind::Err => format!("failed: {msg}"), + UpdateKind::Cached => "cached".to_string(), + UpdateKind::Fin => match state { + display::ItemState::Done(_) => "done".to_string(), + display::ItemState::New + | display::ItemState::InProgress(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => msg.to_string(), + }, + UpdateKind::SetOutcomeBuilt + | UpdateKind::SetOutcomeCached + | UpdateKind::Cancel + | UpdateKind::Msg + | UpdateKind::Step => msg.to_string(), + } } /// Render the final report: insert prefix lines above the viewport, then /// replace viewport content with the final lines. fn render_final_report( - terminal: &mut ratatui::Terminal, - lines: Vec>, + terminal: &mut ratatui::Terminal, + lines: Vec>, ) -> Result<(), B::Error> { - let viewport_height = current_viewport_height(terminal)?; - let (prefix, suffix) = split_lines_for_viewport(lines, viewport_height); - insert_lines_before_viewport(terminal, prefix)?; - draw_lines_in_viewport(terminal, suffix) + let viewport_height = current_viewport_height(terminal)?; + let (prefix, suffix) = split_lines_for_viewport(lines, viewport_height); + insert_lines_before_viewport(terminal, prefix)?; + draw_lines_in_viewport(terminal, suffix) } fn spacer() -> Span<'static> { - Span::raw(" ") + Span::raw(" ") } /// Split a list of lines into a prefix (above viewport) and a suffix that /// fits within the viewport height. fn split_lines_for_viewport( - mut lines: Vec>, - viewport_height: u16, + mut lines: Vec>, + viewport_height: u16, ) -> (Vec>, Vec>) { - let suffix_len = usize::from(viewport_height).min(lines.len()); - let split_at = lines.len() - suffix_len; - let suffix = lines.split_off(split_at); - (lines, suffix) + let suffix_len = usize::from(viewport_height).min(lines.len()); + let split_at = lines.len() - suffix_len; + let suffix = lines.split_off(split_at); + (lines, suffix) } /// Transition `New` or `InProgress` into `InProgress(None)`. fn start_item(state: display::ItemState) -> display::ItemState { - match state { - display::ItemState::New => display::ItemState::InProgress(None), - display::ItemState::InProgress(outcome) => display::ItemState::InProgress(outcome), - display::ItemState::Done(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => state, + match state { + display::ItemState::New => display::ItemState::InProgress(None), + display::ItemState::InProgress(outcome) => display::ItemState::InProgress(outcome), + display::ItemState::Done(_) | display::ItemState::Cancelled | display::ItemState::Failed => { + state } + } } // ============================================================ @@ -340,1335 +338,1335 @@ fn start_item(state: display::ItemState) -> display::ItemState { // ============================================================ impl Addr for DisplayAddr { - type Message = Message; + type Message = Message; - /// Return the actor name for tracing. - fn name() -> &'static str { - "DisplayAddr" - } + /// Return the actor name for tracing. + fn name() -> &'static str { + "DisplayAddr" + } - /// Return a reference to the underlying sender. - fn sender(&self) -> &mpsc::Sender { - &self.tx - } + /// Return a reference to the underlying sender. + fn sender(&self) -> &mpsc::Sender { + &self.tx + } } impl DisplayActor { - /// Aggregate the outcome of finished grammar children for a repo. - /// Returns `Some(Built)` if any child built, `Some(Cached)` if all are - /// cached, or `None` if no children finished. - fn aggregate_child_done_outcome( - &self, - repo_id: display::ItemId, - ) -> Option { - let mut saw_cached = false; - - for grammar in self - .state - .grammars - .values() - .filter(|g| g.repo_id == Some(repo_id)) - { - match grammar.state { - display::ItemState::Done(display::SuccessOutcome::Built) => { - return Some(display::SuccessOutcome::Built); - } - display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, - display::ItemState::New - | display::ItemState::InProgress(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => {} - } - } - - saw_cached.then_some(display::SuccessOutcome::Cached) - } - - /// Aggregate the live outcome of still-active grammar children for a repo. - fn aggregate_child_live_outcome( - &self, - repo_id: display::ItemId, - ) -> Option { - let mut saw_cached = false; - let mut saw_unknown = false; - - for grammar in self - .state - .grammars - .values() - .filter(|g| g.repo_id == Some(repo_id)) - { - match grammar.state { - display::ItemState::InProgress(Some(display::SuccessOutcome::Built)) - | display::ItemState::Done(display::SuccessOutcome::Built) => { - return Some(display::SuccessOutcome::Built); - } - display::ItemState::InProgress(Some(display::SuccessOutcome::Cached)) - | display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, - display::ItemState::New | display::ItemState::InProgress(None) => { - saw_unknown = true; - } - display::ItemState::Cancelled | display::ItemState::Failed => {} - } - } - - if saw_unknown { - None - } else if saw_cached { - Some(display::SuccessOutcome::Cached) - } else { - None - } - } - - /// Apply a state update to a grammar entry. - fn apply_grammar_update(grammar: &mut display::GrammarEntry, kind: UpdateKind, msg: Arc) { - if !grammar.state.is_live() { - return; - } - - match kind { - UpdateKind::Msg => { - grammar.msg = msg; - } - UpdateKind::Step => { - grammar.state = start_item(grammar.state); - grammar.step += 1; - grammar.msg = msg; - } - UpdateKind::SetOutcomeCached => { - grammar.state = mark_success(grammar.state, display::SuccessOutcome::Cached); - } - UpdateKind::Cancel => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Cancelled; - grammar.step = grammar.total; - grammar.msg = msg; - } - UpdateKind::SetOutcomeBuilt => { - grammar.state = mark_success(grammar.state, display::SuccessOutcome::Built); - } - UpdateKind::Cached => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Done(display::SuccessOutcome::Cached); - grammar.step = grammar.total; - grammar.msg = msg; - } - UpdateKind::Fin => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - match finish_item(grammar.state) { - Ok(state) => { - grammar.state = state; - grammar.msg = msg; - } - Err(message) => { - grammar.state = display::ItemState::Failed; - grammar.msg = message.into(); - } - } - grammar.step = grammar.total; - } - UpdateKind::Err => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Failed; - grammar.msg = msg; - } + /// Aggregate the outcome of finished grammar children for a repo. + /// Returns `Some(Built)` if any child built, `Some(Cached)` if all are + /// cached, or `None` if no children finished. + fn aggregate_child_done_outcome( + &self, + repo_id: display::ItemId, + ) -> Option { + let mut saw_cached = false; + + for grammar in self + .state + .grammars + .values() + .filter(|g| g.repo_id == Some(repo_id)) + { + match grammar.state { + display::ItemState::Done(display::SuccessOutcome::Built) => { + return Some(display::SuccessOutcome::Built); } + display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, + display::ItemState::New + | display::ItemState::InProgress(_) + | display::ItemState::Cancelled + | display::ItemState::Failed => {} + } } - /// Apply a state update to a repo entry. - fn apply_repo_update(repo: &mut display::RepoEntry, kind: UpdateKind, msg: Arc) { - if !repo.state.is_live() { - return; + saw_cached.then_some(display::SuccessOutcome::Cached) + } + + /// Aggregate the live outcome of still-active grammar children for a repo. + fn aggregate_child_live_outcome( + &self, + repo_id: display::ItemId, + ) -> Option { + let mut saw_cached = false; + let mut saw_unknown = false; + + for grammar in self + .state + .grammars + .values() + .filter(|g| g.repo_id == Some(repo_id)) + { + match grammar.state { + display::ItemState::InProgress(Some(display::SuccessOutcome::Built)) + | display::ItemState::Done(display::SuccessOutcome::Built) => { + return Some(display::SuccessOutcome::Built); } - - match kind { - UpdateKind::Msg => { - repo.msg = msg; - } - UpdateKind::Step => { - repo.state = start_item(repo.state); - repo.step += 1; - repo.msg = msg; - } - UpdateKind::SetOutcomeCached => { - repo.state = mark_success(repo.state, display::SuccessOutcome::Cached); - } - UpdateKind::Cancel => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Cancelled; - if repo.total > 0 { - repo.step = repo.total; - } - repo.msg = msg; - } - UpdateKind::SetOutcomeBuilt => { - repo.state = mark_success(repo.state, display::SuccessOutcome::Built); - } - UpdateKind::Cached => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Done(display::SuccessOutcome::Cached); - if repo.total > 0 { - repo.step = repo.total; - } - repo.msg = msg; - } - UpdateKind::Fin => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - match finish_item(repo.state) { - Ok(state) => { - repo.state = state; - repo.msg = msg; - } - Err(message) => { - repo.state = display::ItemState::Failed; - repo.msg = message.into(); - } - } - if repo.total > 0 { - repo.step = repo.total; - } - } - UpdateKind::Err => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Failed; - repo.msg = msg; - } + display::ItemState::InProgress(Some(display::SuccessOutcome::Cached)) + | display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, + display::ItemState::New | display::ItemState::InProgress(None) => { + saw_unknown = true; } + display::ItemState::Cancelled | display::ItemState::Failed => {} + } } - /// Dispatch an update to the appropriate entry (repo or grammar) and sync - /// the parent repo if needed. - fn apply_update(&mut self, id: display::ItemId, kind: UpdateKind, msg: Arc) { - if let Some(repo) = self.state.repos.get_mut(&id) { - Self::apply_repo_update(repo, kind, msg); - self.grid.mark_dirty(id); - return; - } - - let mut maybe_parent_id: Option = None; - if let Some(grammar) = self.state.grammars.get_mut(&id) { - Self::apply_grammar_update(grammar, kind, msg); - self.grid.mark_dirty(id); - if matches!( - kind, - UpdateKind::Cached - | UpdateKind::Err - | UpdateKind::Fin - | UpdateKind::SetOutcomeBuilt - | UpdateKind::SetOutcomeCached - | UpdateKind::Cancel - | UpdateKind::Step - ) { - maybe_parent_id = grammar.repo_id; - } - } - - if let Some(repo_id) = maybe_parent_id { - self.sync_parent_repo(repo_id); - } + if saw_unknown { + None + } else if saw_cached { + Some(display::SuccessOutcome::Cached) + } else { + None } + } - /// Cancel all live rows (used on interrupt). - fn cancel_live_rows(&mut self) { - let mut parent_ids = Vec::new(); - - for (id, grammar) in &mut self.state.grammars { - if grammar.state.is_live() { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Cancelled; - grammar.step = grammar.total; - grammar.msg = Arc::from("cancelled"); - if let Some(repo_id) = grammar.repo_id { - parent_ids.push(repo_id); - } - self.grid.mark_dirty(*id); - } - } - - parent_ids.sort_unstable(); - parent_ids.dedup(); - for repo_id in parent_ids { - self.sync_parent_repo(repo_id); - } - - for (id, repo) in &mut self.state.repos { - if repo.state.is_live() { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Cancelled; - if repo.total > 0 { - repo.step = repo.total; - } - repo.msg = Arc::from("cancelled"); - self.grid.mark_dirty(*id); - } - } + /// Apply a state update to a grammar entry. + fn apply_grammar_update(grammar: &mut display::GrammarEntry, kind: UpdateKind, msg: Arc) { + if !grammar.state.is_live() { + return; } - /// Render the final fancy report and clean up the terminal. - fn finish_fancy>( - &mut self, - terminal: &mut ratatui::Terminal, - interrupted: bool, - tx: oneshot::Sender<()>, - ) { - if interrupted { - self.cancel_live_rows(); - } - let term_width = terminal.size().map_or(80, |s| s.width); - let mut final_lines = self.materialize(term_width); - final_lines.push(Line::from("")); - - match render_final_report(terminal, final_lines) { - Ok(()) => { - ratatui::restore(); - if let Err(err) = clear_from_cursor_down() { - println!(); - eprintln!("tsdl: fancy display cleanup failed: {err}"); - } - } - Err(err) => { - ratatui::restore(); - println!(); - eprintln!("tsdl: fancy display final report failed: {err}"); - } + match kind { + UpdateKind::Msg => { + grammar.msg = msg; + } + UpdateKind::Step => { + grammar.state = start_item(grammar.state); + grammar.step += 1; + grammar.msg = msg; + } + UpdateKind::SetOutcomeCached => { + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Cached); + } + UpdateKind::Cancel => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Cancelled; + grammar.step = grammar.total; + grammar.msg = msg; + } + UpdateKind::SetOutcomeBuilt => { + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Built); + } + UpdateKind::Cached => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Done(display::SuccessOutcome::Cached); + grammar.step = grammar.total; + grammar.msg = msg; + } + UpdateKind::Fin => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + match finish_item(grammar.state) { + Ok(state) => { + grammar.state = state; + grammar.msg = msg; + } + Err(message) => { + grammar.state = display::ItemState::Failed; + grammar.msg = message.into(); + } } - - let _ = tx.send(()); + grammar.step = grammar.total; + } + UpdateKind::Err => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Failed; + grammar.msg = msg; + } } + } - /// Process a single message from the channel. - fn handle_message(&mut self, msg: Message) { - match msg { - Message::RegisterLanguage { - git_ref, - name, - num_tasks, - tx, - } => { - let addr = self.register_repo(name, git_ref, num_tasks); - let _ = tx.send(addr); - } - Message::RegisterGrammar { - git_ref, - language, - name, - num_tasks, - tx, - } => { - let addr = self.register_grammar(language, name, git_ref, num_tasks); - let _ = tx.send(addr); - } - Message::RegisterReference { .. } => {} - Message::Update { id, kind, msg } => { - self.apply_update(id, kind, msg); - } - Message::Shutdown { interrupted, tx } => { - if interrupted { - self.cancel_live_rows(); - } - let _ = tx.send(()); - } - } + /// Apply a state update to a repo entry. + fn apply_repo_update(repo: &mut display::RepoEntry, kind: UpdateKind, msg: Arc) { + if !repo.state.is_live() { + return; } - /// Assemble `Vec` from the grid cache. Rebuilds row order and layout - /// only when items have been added. Otherwise reuses the cached row order - /// and recomputes only stale cells (dirty items + running clocks). - fn materialize(&mut self, term_width: u16) -> Vec> { - let term_w = term_width as usize; - - if self.last_term_width != Some(term_w) { - self.grid.invalidate_column(display::Column::Msg); - self.last_term_width = Some(term_w); + match kind { + UpdateKind::Msg => { + repo.msg = msg; + } + UpdateKind::Step => { + repo.state = start_item(repo.state); + repo.step += 1; + repo.msg = msg; + } + UpdateKind::SetOutcomeCached => { + repo.state = mark_success(repo.state, display::SuccessOutcome::Cached); + } + UpdateKind::Cancel => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Cancelled; + if repo.total > 0 { + repo.step = repo.total; } - - // Rebuild row order if items were added - if self.rows_dirty { - self.row_specs = self.state.compute_row_order(); - let new_layout = self.state.compute_layout(&self.row_specs); - - // Invalidate columns whose width changed - if new_layout.ref_ != self.grid.layout.ref_ { - self.grid.invalidate_column(display::Column::Ref); - } - if new_layout.step != self.grid.layout.step { - self.grid.invalidate_column(display::Column::Step); - } - if new_layout.name != self.grid.layout.name { - self.grid.invalidate_column(display::Column::Name); - } - if new_layout.fixed_width != self.grid.layout.fixed_width { - self.grid.invalidate_column(display::Column::Msg); - } - self.grid.layout = new_layout; - self.rows_dirty = false; + repo.msg = msg; + } + UpdateKind::SetOutcomeBuilt => { + repo.state = mark_success(repo.state, display::SuccessOutcome::Built); + } + UpdateKind::Cached => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Done(display::SuccessOutcome::Cached); + if repo.total > 0 { + repo.step = repo.total; } - - // Assemble rows from the grid cache - let layout = self.grid.layout.clone(); - let mut lines: Vec> = Vec::with_capacity(self.row_specs.len()); - - for spec in &self.row_specs { - let info = self.state.get_item_info(spec); - let item_id = spec.id; - let is_dirty = self.grid.dirty_items.contains(&item_id); - - // TIME: stale when dirty OR clock is still running - let time_stale = is_dirty || info.state().is_live(); - let time = self.grid.cell( - item_id, - display::Column::Time, - || display::compute_time_cell(&info, &layout), - time_stale, - ); - - let gref = self.grid.cell( - item_id, - display::Column::Ref, - || display::compute_ref_cell(&info, &layout), - is_dirty, - ); - - let step = self.grid.cell( - item_id, - display::Column::Step, - || display::compute_step_cell(&info, &layout), - is_dirty, - ); - - let icon = self.grid.cell( - item_id, - display::Column::Icon, - || display::compute_icon_cell(&info), - is_dirty, - ); - - let name = self.grid.cell( - item_id, - display::Column::Name, - || display::compute_name_cell(&spec.display_name, spec.indent, &info, &layout), - is_dirty, - ); - - let msg = self.grid.cell( - item_id, - display::Column::Msg, - || display::compute_msg_cell(&info, &layout, term_w), - is_dirty, - ); - - lines.push(Line::from(vec![ - time, - spacer(), - gref, - spacer(), - step, - spacer(), - icon, - spacer(), - name, - spacer(), - msg, - ])); + repo.msg = msg; + } + UpdateKind::Fin => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + match finish_item(repo.state) { + Ok(state) => { + repo.state = state; + repo.msg = msg; + } + Err(message) => { + repo.state = display::ItemState::Failed; + repo.msg = message.into(); + } } - - // Footer - if !lines.is_empty() { - lines.push(Line::from("")); + if repo.total > 0 { + repo.step = repo.total; } - lines.push(self.state.footer_build_line()); - lines.push(self.state.footer_out_line()); - lines.push(Line::from("")); - lines.push(self.state.format_footer_counts()); - - self.grid.clear_dirty(); - - lines + } + UpdateKind::Err => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Failed; + repo.msg = msg; + } } - - /// Build a plain-text progress line for an item (repo or grammar). - fn plain_progress_line(&self, id: display::ItemId, kind: UpdateKind) -> Option { - if let Some(repo) = self.state.repos.get(&id) { - return Some(PlainLine { - name: repo.name.to_string(), - step: repo.step, - total: repo.total, - message: plain_repo_message(kind, repo.state, &repo.msg), - }); - } - - self.state.grammars.get(&id).map(|grammar| PlainLine { - name: format!("{}/{}", grammar.repo, grammar.name), - step: grammar.step, - total: grammar.total, - message: plain_grammar_message(kind, grammar.state, &grammar.msg), - }) + } + + /// Dispatch an update to the appropriate entry (repo or grammar) and sync + /// the parent repo if needed. + fn apply_update(&mut self, id: display::ItemId, kind: UpdateKind, msg: Arc) { + if let Some(repo) = self.state.repos.get_mut(&id) { + Self::apply_repo_update(repo, kind, msg); + self.grid.mark_dirty(id); + return; } - /// Print the build and out directory metadata lines (plain mode). - fn print_plain_metadata(&self) { - println!("build: {}", self.state.build_dir.display()); - println!("out: {}", self.state.out_dir.display()); - println!(); - } - - /// Print a plain progress line with name, step/total, and message. - fn print_plain_progress(&mut self, line: &PlainLine) { - if !self.plain_progress_started { - println!(); - self.plain_progress_started = true; - } - - let width = self.update_plain_name_width(&line.name); - println!( - "{name: = None; + if let Some(grammar) = self.state.grammars.get_mut(&id) { + Self::apply_grammar_update(grammar, kind, msg); + self.grid.mark_dirty(id); + if matches!( + kind, + UpdateKind::Cached + | UpdateKind::Err + | UpdateKind::Fin + | UpdateKind::SetOutcomeBuilt + | UpdateKind::SetOutcomeCached + | UpdateKind::Cancel + | UpdateKind::Step + ) { + maybe_parent_id = grammar.repo_id; + } } - /// Print a reference line (plain mode). - fn print_plain_ref(&mut self, name: &str, git_ref: &str) { - let width = self.update_plain_name_width(name); - println!("{name:, - name: Arc, - git_ref: git::Ref, - num_tasks: usize, - ) -> ProgressAddr { - let id = self.next_id; - self.next_id = self.next_id.next_after(); - - let repo_id = self - .state - .repos - .iter() - .find(|(_, r)| r.name == language) - .map(|(id, _)| *id); - - self.state.grammars.insert( - id, - display::GrammarEntry { - frozen_elapsed: None, - git_ref, - msg: Arc::from(""), - name, - repo: language, - repo_id, - started_at: Instant::now(), - state: display::ItemState::New, - step: 0, - total: num_tasks, - }, - ); - - self.rows_dirty = true; - if let Some(repo_id) = repo_id { - self.sync_parent_repo(repo_id); - } - - ProgressAddr { - id, - tx: self.tx.clone(), + } + + /// Cancel all live rows (used on interrupt). + fn cancel_live_rows(&mut self) { + let mut parent_ids = Vec::new(); + + for (id, grammar) in &mut self.state.grammars { + if grammar.state.is_live() { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Cancelled; + grammar.step = grammar.total; + grammar.msg = Arc::from("cancelled"); + if let Some(repo_id) = grammar.repo_id { + parent_ids.push(repo_id); } + self.grid.mark_dirty(*id); + } } - /// Register a new repo progress row and return its address. - fn register_repo( - &mut self, - name: Arc, - git_ref: git::Ref, - num_tasks: usize, - ) -> ProgressAddr { - let id = self.next_id; - self.next_id = self.next_id.next_after(); - - self.state.repos.insert( - id, - display::RepoEntry { - frozen_elapsed: None, - git_ref, - msg: Arc::from(""), - name, - started_at: Instant::now(), - state: display::ItemState::New, - step: 0, - total: num_tasks, - }, - ); - - self.rows_dirty = true; - - ProgressAddr { - id, - tx: self.tx.clone(), - } + parent_ids.sort_unstable(); + parent_ids.dedup(); + for repo_id in parent_ids { + self.sync_parent_repo(repo_id); } - /// Run the actor event loop, dispatching to fancy or plain mode. - async fn run(mut self) { - if self.state.mode == display::Mode::Fancy { - self.run_fancy().await; - } else { - self.run_plain().await; + for (id, repo) in &mut self.state.repos { + if repo.state.is_live() { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Cancelled; + if repo.total > 0 { + repo.step = repo.total; } + repo.msg = Arc::from("cancelled"); + self.grid.mark_dirty(*id); + } } + } - // ── Fancy mode ──────────────────────────────────────────────────── - - /// Run the fancy (ratatui) render loop. - async fn run_fancy(&mut self) { - let viewport_height = crossterm::terminal::size().map_or(40, |(_, h)| h).min(40); - - let mut terminal = match ratatui::Terminal::with_options( - ratatui::backend::CrosstermBackend::new(io::stdout()), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Inline(viewport_height), - }, - ) { - Ok(terminal) => terminal, - Err(err) => { - eprintln!("tsdl: fancy display unavailable; falling back to plain progress: {err}"); - self.run_plain().await; - return; - } - }; - - // One-time bootstrap render so the user sees state immediately. - let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - if let Err(err) = draw_lines(&mut terminal, lines) { - ratatui::restore(); - println!(); - eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); - self.run_plain().await; - return; - } - - let mut tick_interval = time::interval(Duration::from_millis(100)); - - loop { - // ── wait for the next event ───────────────────────────── - let mut shutdown = None; - tokio::select! { - msg = self.rx.recv() => { - match msg { - Some(Message::Shutdown { interrupted, tx }) => { - shutdown = Some((interrupted, tx)); - } - Some(other) => self.handle_message(other), - None => break, - } - } - _ = tick_interval.tick() => { - // fall through to drain + render - } - } - - // ── drain any messages that queued up concurrently ───── - while let Ok(msg) = self.rx.try_recv() { - match msg { - Message::Shutdown { interrupted, tx } => { - shutdown = Some((interrupted, tx)); - break; - } - other => self.handle_message(other), - } - } - - // ── shutdown after draining ──────────────────────────── - if let Some((interrupted, tx)) = shutdown { - self.finish_fancy(&mut terminal, interrupted, tx); - return; - } - - // ── render once per event cycle ──────────────────────── - let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); - if let Err(err) = draw_lines(&mut terminal, lines) { - ratatui::restore(); - println!(); - eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); - self.run_plain().await; - return; - } - } - - // #[allow(unreachable_code)] - // ratatui::restore(); - // println!(); + /// Render the final fancy report and clean up the terminal. + fn finish_fancy>( + &mut self, + terminal: &mut ratatui::Terminal, + interrupted: bool, + tx: oneshot::Sender<()>, + ) { + if interrupted { + self.cancel_live_rows(); } - - // ── Plain mode ──────────────────────────────────────────────────── - - /// Run the plain-text progress loop. - async fn run_plain(&mut self) { - self.print_plain_metadata(); - - while let Some(msg) = self.rx.recv().await { - match msg { - Message::RegisterLanguage { - git_ref, - name, - num_tasks, - tx, - } => { - let addr = self.register_repo(name, git_ref, num_tasks); - let _ = tx.send(addr); - } - Message::RegisterGrammar { - git_ref, - language, - name, - num_tasks, - tx, - } => { - let plain_name = format!("{language}/{name}"); - self.update_plain_name_width(&plain_name); - let addr = self.register_grammar(language, name, git_ref, num_tasks); - let _ = tx.send(addr); - } - Message::RegisterReference { git_ref, name } => { - self.print_plain_ref(&name, git_ref.short()); - } - Message::Update { id, kind, msg } => { - self.apply_update(id, kind, msg); - if matches!( - kind, - UpdateKind::Msg - | UpdateKind::SetOutcomeCached - | UpdateKind::SetOutcomeBuilt - ) { - continue; - } - if let Some(line) = self.plain_progress_line(id, kind) { - self.print_plain_progress(&line); - } - } - Message::Shutdown { interrupted, tx } => { - if interrupted { - self.cancel_live_rows(); - } - self.print_plain_summary(); - let _ = tx.send(()); - break; - } - } + let term_width = terminal.size().map_or(80, |s| s.width); + let mut final_lines = self.materialize(term_width); + final_lines.push(Line::from("")); + + match render_final_report(terminal, final_lines) { + Ok(()) => { + ratatui::restore(); + if let Err(err) = clear_from_cursor_down() { + println!(); + eprintln!("tsdl: fancy display cleanup failed: {err}"); } + } + Err(err) => { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display final report failed: {err}"); + } } - /// Spawn the display actor in a background task and return an address handle. - #[must_use] - pub fn spawn(build_dir: PathBuf, mode: display::Mode, out_dir: PathBuf) -> DisplayAddr { - let (tx, rx) = mpsc::channel(256); - let actor = Self { - state: display::State::new(build_dir, mode, out_dir), - next_id: display::ItemId::new(NonZeroU64::MIN), - plain_name_width: 16, - plain_progress_started: false, - grid: display::GridCache::new(), - row_specs: Vec::new(), - rows_dirty: true, - last_term_width: None, - rx, - tx: tx.clone(), - }; - - tokio::spawn(async move { - actor.run().await; - }); - - DisplayAddr::new(mode, tx) - } - - /// Sync a parent repo's state based on its children's aggregated outcomes. - fn sync_parent_repo(&mut self, repo_id: display::ItemId) { - let has_any = self - .state - .grammars - .values() - .any(|g| g.repo_id == Some(repo_id)); - if !has_any { - return; - } - - let any_failed = self - .state - .grammars - .values() - .any(|g| g.repo_id == Some(repo_id) && g.state == display::ItemState::Failed); - let any_cancelled = self - .state - .grammars - .values() - .any(|g| g.repo_id == Some(repo_id) && g.state == display::ItemState::Cancelled); - let any_active = self - .state - .grammars - .values() - .any(|g| g.repo_id == Some(repo_id) && g.state.is_live()); - - let live_outcome = self.aggregate_child_live_outcome(repo_id); - let done_outcome = self.aggregate_child_done_outcome(repo_id); - - if let Some(repo) = self.state.repos.get_mut(&repo_id) { - if any_active { - repo.state = display::ItemState::InProgress(live_outcome); - repo.msg = Arc::from("building"); - repo.frozen_elapsed = None; - } else if any_failed { - repo.state = display::ItemState::Failed; - repo.msg = Arc::from("failed"); - repo.frozen_elapsed - .get_or_insert_with(|| repo.started_at.elapsed()); - } else if any_cancelled { - repo.state = display::ItemState::Cancelled; - repo.msg = Arc::from("cancelled"); - repo.frozen_elapsed - .get_or_insert_with(|| repo.started_at.elapsed()); - } else { - repo.state = display::ItemState::Done( - done_outcome.unwrap_or(display::SuccessOutcome::Built), - ); - repo.msg = Arc::from("done"); - repo.frozen_elapsed - .get_or_insert_with(|| repo.started_at.elapsed()); - } + let _ = tx.send(()); + } + + /// Process a single message from the channel. + fn handle_message(&mut self, msg: Message) { + match msg { + Message::RegisterLanguage { + git_ref, + name, + num_tasks, + tx, + } => { + let addr = self.register_repo(name, git_ref, num_tasks); + let _ = tx.send(addr); + } + Message::RegisterGrammar { + git_ref, + language, + name, + num_tasks, + tx, + } => { + let addr = self.register_grammar(language, name, git_ref, num_tasks); + let _ = tx.send(addr); + } + Message::RegisterReference { .. } => {} + Message::Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + } + Message::Shutdown { interrupted, tx } => { + if interrupted { + self.cancel_live_rows(); } - - self.grid.mark_dirty(repo_id); - } - - /// Update the max plain name width and return the current value. - fn update_plain_name_width(&mut self, name: &str) -> usize { - self.plain_name_width = self.plain_name_width.max(name.chars().count()); - self.plain_name_width - } -} - -impl DisplayAddr { - /// Register a repo-level progress row. - pub async fn add_language>>( - &self, - name: S, - git_ref: git::Ref, - num_tasks: usize, - ) -> ProgressAddr { - self.request(|tx| Message::RegisterLanguage { - git_ref, - name: name.into(), - num_tasks, - tx, - }) - .await - } - - /// Register a grammar-level progress row. - pub async fn add_grammar>>( - &self, - language: S, - name: S, - git_ref: git::Ref, - num_tasks: usize, - ) -> ProgressAddr { - self.request(|tx| Message::RegisterGrammar { - git_ref, - language: language.into(), - name: name.into(), - num_tasks, - tx, - }) - .await + let _ = tx.send(()); + } } + } - /// Create a new `DisplayAddr` wrapping a channel sender. - #[must_use] - pub fn new(mode: display::Mode, tx: mpsc::Sender) -> Self { - Self { mode, tx } - } + /// Assemble `Vec` from the grid cache. Rebuilds row order and layout + /// only when items have been added. Otherwise reuses the cached row order + /// and recomputes only stale cells (dirty items + running clocks). + fn materialize(&mut self, term_width: u16) -> Vec> { + let term_w = term_width as usize; - /// Send a reference line (plain mode; ignored in fancy mode). - pub async fn reference>>(&self, name: S, git_ref: git::Ref) { - self.fire(Message::RegisterReference { - git_ref, - name: name.into(), - }) - .await; + if self.last_term_width != Some(term_w) { + self.grid.invalidate_column(display::Column::Msg); + self.last_term_width = Some(term_w); } - /// Shut down the display actor, waiting for cleanup to complete. - pub async fn shutdown(&self, interrupted: bool) { - self.request(|tx| Message::Shutdown { interrupted, tx }) - .await; + // Rebuild row order if items were added + if self.rows_dirty { + self.row_specs = self.state.compute_row_order(); + let new_layout = self.state.compute_layout(&self.row_specs); + + // Invalidate columns whose width changed + if new_layout.ref_ != self.grid.layout.ref_ { + self.grid.invalidate_column(display::Column::Ref); + } + if new_layout.step != self.grid.layout.step { + self.grid.invalidate_column(display::Column::Step); + } + if new_layout.name != self.grid.layout.name { + self.grid.invalidate_column(display::Column::Name); + } + if new_layout.fixed_width != self.grid.layout.fixed_width { + self.grid.invalidate_column(display::Column::Msg); + } + self.grid.layout = new_layout; + self.rows_dirty = false; } -} -impl ProgressAddr { - /// Send a message update (fire-and-forget via `try_send`). - pub fn msg>>(&self, msg: S) { - let _ = self.tx.try_send(Message::Update { - id: self.id, - kind: UpdateKind::Msg, - msg: msg.into(), - }); + // Assemble rows from the grid cache + let layout = self.grid.layout.clone(); + let mut lines: Vec> = Vec::with_capacity(self.row_specs.len()); + + for spec in &self.row_specs { + let info = self.state.get_item_info(spec); + let item_id = spec.id; + let is_dirty = self.grid.dirty_items.contains(&item_id); + + // TIME: stale when dirty OR clock is still running + let time_stale = is_dirty || info.state().is_live(); + let time = self.grid.cell( + item_id, + display::Column::Time, + || display::compute_time_cell(&info, &layout), + time_stale, + ); + + let gref = self.grid.cell( + item_id, + display::Column::Ref, + || display::compute_ref_cell(&info, &layout), + is_dirty, + ); + + let step = self.grid.cell( + item_id, + display::Column::Step, + || display::compute_step_cell(&info, &layout), + is_dirty, + ); + + let icon = self.grid.cell( + item_id, + display::Column::Icon, + || display::compute_icon_cell(&info), + is_dirty, + ); + + let name = self.grid.cell( + item_id, + display::Column::Name, + || display::compute_name_cell(&spec.display_name, spec.indent, &info, &layout), + is_dirty, + ); + + let msg = self.grid.cell( + item_id, + display::Column::Msg, + || display::compute_msg_cell(&info, &layout, term_w), + is_dirty, + ); + + lines.push(Line::from(vec![ + time, + spacer(), + gref, + spacer(), + step, + spacer(), + icon, + spacer(), + name, + spacer(), + msg, + ])); } - /// Advance to the next step (fire-and-forget via `try_send`). - pub fn step>>(&self, msg: S) { - let _ = self.tx.try_send(Message::Update { - id: self.id, - kind: UpdateKind::Step, - msg: msg.into(), - }); + // Footer + if !lines.is_empty() { + lines.push(Line::from("")); } - - /// Send a state update message, awaiting capacity. - async fn send_state_update(&self, kind: UpdateKind, msg: Arc) { - let _ = self - .tx - .send(Message::Update { - id: self.id, - kind, - msg, - }) - .await; + lines.push(self.state.footer_build_line()); + lines.push(self.state.footer_out_line()); + lines.push(Line::from("")); + lines.push(self.state.format_footer_counts()); + + self.grid.clear_dirty(); + + lines + } + + /// Build a plain-text progress line for an item (repo or grammar). + fn plain_progress_line(&self, id: display::ItemId, kind: UpdateKind) -> Option { + if let Some(repo) = self.state.repos.get(&id) { + return Some(PlainLine { + name: repo.name.to_string(), + step: repo.step, + total: repo.total, + message: plain_repo_message(kind, repo.state, &repo.msg), + }); } - /// Mark the outcome as cached (async). - pub async fn set_outcome_cached(&self) { - self.send_state_update(UpdateKind::SetOutcomeCached, Arc::from("")) - .await; + self.state.grammars.get(&id).map(|grammar| PlainLine { + name: format!("{}/{}", grammar.repo, grammar.name), + step: grammar.step, + total: grammar.total, + message: plain_grammar_message(kind, grammar.state, &grammar.msg), + }) + } + + /// Print the build and out directory metadata lines (plain mode). + fn print_plain_metadata(&self) { + println!("build: {}", self.state.build_dir.display()); + println!("out: {}", self.state.out_dir.display()); + println!(); + } + + /// Print a plain progress line with name, step/total, and message. + fn print_plain_progress(&mut self, line: &PlainLine) { + if !self.plain_progress_started { + println!(); + self.plain_progress_started = true; } - /// Cancel the progress row. - pub async fn cancel(&self) { - self.send_state_update(UpdateKind::Cancel, Arc::from("cancelled")) - .await; - } - - /// Mark the outcome as built (async). - pub async fn set_outcome_built(&self) { - self.send_state_update(UpdateKind::SetOutcomeBuilt, Arc::from("")) - .await; - } - - /// Complete as cached (async). - pub async fn cached>>(&self, msg: S) { - self.send_state_update(UpdateKind::Cached, msg.into()).await; - } - - /// Complete as built (async). - pub async fn fin>>(&self, msg: S) { - self.send_state_update(UpdateKind::Fin, msg.into()).await; - } + let width = self.update_plain_name_width(&line.name); + println!( + "{name:, + name: Arc, + git_ref: git::Ref, + num_tasks: usize, + ) -> ProgressAddr { + let id = self.next_id; + self.next_id = self.next_id.next_after(); + + let repo_id = self + .state + .repos + .iter() + .find(|(_, r)| r.name == language) + .map(|(id, _)| *id); + + self.state.grammars.insert( + id, + display::GrammarEntry { + frozen_elapsed: None, + git_ref, + msg: Arc::from(""), + name, + repo: language, + repo_id, + started_at: Instant::now(), + state: display::ItemState::New, + step: 0, + total: num_tasks, + }, + ); - /// Complete as failed (async). - pub async fn err>>(&self, msg: S) { - self.send_state_update(UpdateKind::Err, msg.into()).await; + self.rows_dirty = true; + if let Some(repo_id) = repo_id { + self.sync_parent_repo(repo_id); } -} -#[cfg(test)] -mod tests { - use super::*; - - fn actor() -> DisplayActor { - let (tx, rx) = mpsc::channel(1); - DisplayActor { - state: display::State::new( - PathBuf::from("build"), - display::Mode::Fancy, - PathBuf::from("out"), - ), - next_id: display::ItemId::new(NonZeroU64::MIN), - plain_name_width: 16, - plain_progress_started: false, - grid: display::GridCache::new(), - row_specs: Vec::new(), - rows_dirty: true, - last_term_width: None, - rx, - tx, - } - } - - fn line_text(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() + ProgressAddr { + id, + tx: self.tx.clone(), } + } + + /// Register a new repo progress row and return its address. + fn register_repo(&mut self, name: Arc, git_ref: git::Ref, num_tasks: usize) -> ProgressAddr { + let id = self.next_id; + self.next_id = self.next_id.next_after(); + + self.state.repos.insert( + id, + display::RepoEntry { + frozen_elapsed: None, + git_ref, + msg: Arc::from(""), + name, + started_at: Instant::now(), + state: display::ItemState::New, + step: 0, + total: num_tasks, + }, + ); - #[test] - fn split_lines_for_viewport_keeps_the_visible_suffix() { - let lines = vec![ - Line::from("one"), - Line::from("two"), - Line::from("three"), - Line::from("four"), - ]; - - let (prefix, suffix) = split_lines_for_viewport(lines, 2); - - assert_eq!(line_text(&prefix[0]), "one"); - assert_eq!(line_text(&prefix[1]), "two"); - assert_eq!(line_text(&suffix[0]), "three"); - assert_eq!(line_text(&suffix[1]), "four"); - } + self.rows_dirty = true; - #[test] - fn render_final_report_inserts_prefix_and_replaces_viewport() { - use ratatui::backend::TestBackend; - use ratatui::style::{Color, Style}; - use ratatui::{Terminal, TerminalOptions, Viewport}; - - let backend = TestBackend::new(20, 5); - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(2), - }, - ) - .unwrap(); - - draw_lines( - &mut terminal, - vec![Line::from("old viewport 1"), Line::from("old viewport 2")], - ) - .unwrap(); - - render_final_report( - &mut terminal, - vec![ - Line::from("final 1"), - Line::from("final 2"), - Line::from("final 3"), - Line::from(Span::styled("final 4", Style::default().fg(Color::Blue))), - Line::from(""), - ], - ) - .unwrap(); - - terminal.backend().assert_buffer_lines(vec![ - Line::from("final 1 "), - Line::from("final 2 "), - Line::from("final 3 "), - Line::from(vec![ - Span::styled("final 4", Style::default().fg(Color::Blue)), - Span::raw(" "), - ]), - Line::from(" "), - ]); - terminal.backend().assert_scrollback_empty(); - terminal.backend_mut().assert_cursor_position((0, 4)); + ProgressAddr { + id, + tx: self.tx.clone(), } - - #[test] - fn summary_counts_include_repo_only_rows() { - let mut actor = actor(); - - let cached = actor.register_repo("tree-sitter-cli".into(), git::Ref::head(), 2); - actor.apply_update(cached.id, UpdateKind::SetOutcomeCached, Arc::from("")); - actor.apply_update(cached.id, UpdateKind::Fin, Arc::from("done")); - - let built = actor.register_repo("standalone".into(), git::Ref::head(), 1); - actor.apply_update(built.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); - actor.apply_update(built.id, UpdateKind::Fin, Arc::from("done")); - - let active = actor.register_repo("active".into(), git::Ref::head(), 1); - actor.apply_update(active.id, UpdateKind::Step, Arc::from("working")); - - let failed = actor.register_repo("failed".into(), git::Ref::head(), 1); - actor.apply_update(failed.id, UpdateKind::Err, Arc::from("failed")); - - assert_eq!( - actor.state.summary(), - display::BuildSummary { - building: 1, - built: 1, - cached: 1, - cancelled: 0, - failed: 1, - } - ); + } + + /// Run the actor event loop, dispatching to fancy or plain mode. + async fn run(mut self) { + if self.state.mode == display::Mode::Fancy { + self.run_fancy().await; + } else { + self.run_plain().await; } + } - #[test] - fn summary_counts_do_not_double_count_parent_repos_with_grammars() { - let mut actor = actor(); - - let repo = actor.register_repo("json".into(), git::Ref::head(), 2); - actor.apply_update(repo.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); - actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); - - let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); - actor.apply_update(grammar.id, UpdateKind::SetOutcomeCached, Arc::from("")); - actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); - - assert_eq!( - actor.state.summary(), - display::BuildSummary { - building: 0, - built: 0, - cached: 1, - cancelled: 0, - failed: 0, - } - ); - } + // ── Fancy mode ──────────────────────────────────────────────────── - #[test] - fn cancelled_grammar_is_terminal_and_updates_parent_repo() { - let mut actor = actor(); - - let repo = actor.register_repo("json".into(), git::Ref::head(), 2); - let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); - - actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); - actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); - - let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); - assert_eq!(grammar_entry.state, display::ItemState::Cancelled); - assert!(!grammar_entry.state.is_live()); - assert_eq!(grammar_entry.step, grammar_entry.total); - assert!(grammar_entry.frozen_elapsed.is_some()); - - let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, display::ItemState::Cancelled); - assert!(!repo_entry.state.is_live()); - assert_eq!(repo_entry.msg.as_ref(), "cancelled"); - assert!(repo_entry.frozen_elapsed.is_some()); - - assert_eq!( - actor.state.summary(), - display::BuildSummary { - building: 0, - built: 0, - cached: 0, - cancelled: 1, - failed: 0, - } - ); - } + /// Run the fancy (ratatui) render loop. + async fn run_fancy(&mut self) { + let viewport_height = crossterm::terminal::size().map_or(40, |(_, h)| h).min(40); - #[test] - fn cancelled_child_does_not_cancel_parent_while_sibling_is_active() { - let mut actor = actor(); - - let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); - let cancelled = actor.register_grammar( - "typescript".into(), - "typescript".into(), - git::Ref::head(), - 4, - ); - let active = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); - - actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); - actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); - actor.apply_update(active.id, UpdateKind::Step, Arc::from("building")); - - let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, display::ItemState::InProgress(None)); - assert_eq!(repo_entry.msg.as_ref(), "building"); - assert!(repo_entry.frozen_elapsed.is_none()); - - actor.apply_update(active.id, UpdateKind::Cancel, Arc::from("cancelled")); - - let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, display::ItemState::Cancelled); - assert_eq!(repo_entry.msg.as_ref(), "cancelled"); - assert!(repo_entry.frozen_elapsed.is_some()); + let mut terminal = match ratatui::Terminal::with_options( + ratatui::backend::CrosstermBackend::new(io::stdout()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Inline(viewport_height), + }, + ) { + Ok(terminal) => terminal, + Err(err) => { + eprintln!("tsdl: fancy display unavailable; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } + }; + + // One-time bootstrap render so the user sees state immediately. + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); + self.run_plain().await; + return; } - #[test] - fn failed_child_wins_over_cancelled_child_when_parent_terminal() { - let mut actor = actor(); - - let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); - let failing = actor.register_grammar( - "typescript".into(), - "typescript".into(), - git::Ref::head(), - 4, - ); - let cancelled = - actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); - - actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); - actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); - actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); - actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); - - let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, display::ItemState::Failed); - assert_eq!(repo_entry.msg.as_ref(), "failed"); - assert!(repo_entry.frozen_elapsed.is_some()); - - assert_eq!( - actor.state.summary(), - display::BuildSummary { - building: 0, - built: 0, - cached: 0, - cancelled: 1, - failed: 1, - } - ); + let mut tick_interval = time::interval(Duration::from_millis(100)); + + loop { + // ── wait for the next event ───────────────────────────── + let mut shutdown = None; + tokio::select! { + msg = self.rx.recv() => { + match msg { + Some(Message::Shutdown { interrupted, tx }) => { + shutdown = Some((interrupted, tx)); + } + Some(other) => self.handle_message(other), + None => break, + } + } + _ = tick_interval.tick() => { + // fall through to drain + render + } + } + + // ── drain any messages that queued up concurrently ───── + while let Ok(msg) = self.rx.try_recv() { + match msg { + Message::Shutdown { interrupted, tx } => { + shutdown = Some((interrupted, tx)); + break; + } + other => self.handle_message(other), + } + } + + // ── shutdown after draining ──────────────────────────── + if let Some((interrupted, tx)) = shutdown { + self.finish_fancy(&mut terminal, interrupted, tx); + return; + } + + // ── render once per event cycle ──────────────────────── + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } } - #[test] - #[should_panic(expected = "finish update received before cached/built path was set")] - fn finish_without_cached_or_built_path_panics() { - let mut actor = actor(); - let repo = actor.register_repo("json".into(), git::Ref::head(), 2); - - actor.apply_update(repo.id, UpdateKind::Step, Arc::from("scanning")); - actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); + // #[allow(unreachable_code)] + // ratatui::restore(); + // println!(); + } + + // ── Plain mode ──────────────────────────────────────────────────── + + /// Run the plain-text progress loop. + async fn run_plain(&mut self) { + self.print_plain_metadata(); + + while let Some(msg) = self.rx.recv().await { + match msg { + Message::RegisterLanguage { + git_ref, + name, + num_tasks, + tx, + } => { + let addr = self.register_repo(name, git_ref, num_tasks); + let _ = tx.send(addr); + } + Message::RegisterGrammar { + git_ref, + language, + name, + num_tasks, + tx, + } => { + let plain_name = format!("{language}/{name}"); + self.update_plain_name_width(&plain_name); + let addr = self.register_grammar(language, name, git_ref, num_tasks); + let _ = tx.send(addr); + } + Message::RegisterReference { git_ref, name } => { + self.print_plain_ref(&name, git_ref.short()); + } + Message::Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + if matches!( + kind, + UpdateKind::Msg | UpdateKind::SetOutcomeCached | UpdateKind::SetOutcomeBuilt + ) { + continue; + } + if let Some(line) = self.plain_progress_line(id, kind) { + self.print_plain_progress(&line); + } + } + Message::Shutdown { interrupted, tx } => { + if interrupted { + self.cancel_live_rows(); + } + self.print_plain_summary(); + let _ = tx.send(()); + break; + } + } } - - #[test] - fn interrupted_shutdown_cancels_live_rows() { - let mut actor = actor(); - - let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); - let grammar = - actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); - actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); - - actor.cancel_live_rows(); - - let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); - assert_eq!(grammar_entry.state, display::ItemState::Cancelled); - assert_eq!(grammar_entry.step, grammar_entry.total); - - let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, display::ItemState::Cancelled); - assert_eq!(repo_entry.msg.as_ref(), "cancelled"); - - assert_eq!( - actor.state.summary(), - display::BuildSummary { - building: 0, - built: 0, - cached: 0, - cancelled: 1, - failed: 0, - } - ); + } + + /// Spawn the display actor in a background task and return an address handle. + #[must_use] + pub fn spawn(build_dir: PathBuf, mode: display::Mode, out_dir: PathBuf) -> DisplayAddr { + let (tx, rx) = mpsc::channel(256); + let actor = Self { + state: display::State::new(build_dir, mode, out_dir), + next_id: display::ItemId::new(NonZeroU64::MIN), + plain_name_width: 16, + plain_progress_started: false, + grid: display::GridCache::new(), + row_specs: Vec::new(), + rows_dirty: true, + last_term_width: None, + rx, + tx: tx.clone(), + }; + + tokio::spawn(async move { + actor.run().await; + }); + + DisplayAddr::new(mode, tx) + } + + /// Sync a parent repo's state based on its children's aggregated outcomes. + fn sync_parent_repo(&mut self, repo_id: display::ItemId) { + let has_any = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id)); + if !has_any { + return; } - #[test] - fn terminal_direct_updates_are_absorbing() { - let mut actor = actor(); - - let repo = actor.register_repo("json".into(), git::Ref::head(), 4); - actor.apply_update(repo.id, UpdateKind::Step, Arc::from("building")); - actor.apply_update(repo.id, UpdateKind::Cancel, Arc::from("cancelled")); - actor.apply_update(repo.id, UpdateKind::Cached, Arc::from("done")); - actor.apply_update(repo.id, UpdateKind::Err, Arc::from("failed")); - - let entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(entry.state, display::ItemState::Cancelled); - assert_eq!(entry.msg.as_ref(), "cancelled"); - - let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); - actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); - actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); - actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); - actor.apply_update(grammar.id, UpdateKind::Err, Arc::from("failed")); - - let entry = actor.state.grammars.get(&grammar.id).unwrap(); - assert_eq!(entry.state, display::ItemState::Cancelled); - assert_eq!(entry.msg.as_ref(), "cancelled"); + let any_failed = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id) && g.state == display::ItemState::Failed); + let any_cancelled = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id) && g.state == display::ItemState::Cancelled); + let any_active = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id) && g.state.is_live()); + + let live_outcome = self.aggregate_child_live_outcome(repo_id); + let done_outcome = self.aggregate_child_done_outcome(repo_id); + + if let Some(repo) = self.state.repos.get_mut(&repo_id) { + if any_active { + repo.state = display::ItemState::InProgress(live_outcome); + repo.msg = Arc::from("building"); + repo.frozen_elapsed = None; + } else if any_failed { + repo.state = display::ItemState::Failed; + repo.msg = Arc::from("failed"); + repo + .frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); + } else if any_cancelled { + repo.state = display::ItemState::Cancelled; + repo.msg = Arc::from("cancelled"); + repo + .frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); + } else { + repo.state = + display::ItemState::Done(done_outcome.unwrap_or(display::SuccessOutcome::Built)); + repo.msg = Arc::from("done"); + repo + .frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); + } } - #[test] - fn parent_repo_stays_active_while_any_child_is_active() { - let mut actor = actor(); - - let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); - let failing = actor.register_grammar( - "typescript".into(), - "typescript".into(), - git::Ref::head(), - 4, - ); - let pending = - actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); - - actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); - actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); - - let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, display::ItemState::InProgress(None)); - assert_eq!(repo_entry.msg.as_ref(), "building"); - assert!(repo_entry.frozen_elapsed.is_none()); - - actor.apply_update(pending.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); - actor.apply_update(pending.id, UpdateKind::Fin, Arc::from("done")); - - let repo_entry = actor.state.repos.get(&repo.id).unwrap(); - assert_eq!(repo_entry.state, display::ItemState::Failed); - assert_eq!(repo_entry.msg.as_ref(), "failed"); - assert!(repo_entry.frozen_elapsed.is_some()); - } + self.grid.mark_dirty(repo_id); + } - #[test] - fn materialize_recomputes_message_cells_when_terminal_width_changes() { - let mut actor = actor(); - let progress = actor.register_repo("json".into(), git::Ref::head(), 1); - let message = "abcdefghijklmnop"; + /// Update the max plain name width and return the current value. + fn update_plain_name_width(&mut self, name: &str) -> usize { + self.plain_name_width = self.plain_name_width.max(name.chars().count()); + self.plain_name_width + } +} - actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); +impl DisplayAddr { + /// Register a repo-level progress row. + pub async fn add_language>>( + &self, + name: S, + git_ref: git::Ref, + num_tasks: usize, + ) -> ProgressAddr { + self + .request(|tx| Message::RegisterLanguage { + git_ref, + name: name.into(), + num_tasks, + tx, + }) + .await + } + + /// Register a grammar-level progress row. + pub async fn add_grammar>>( + &self, + language: S, + name: S, + git_ref: git::Ref, + num_tasks: usize, + ) -> ProgressAddr { + self + .request(|tx| Message::RegisterGrammar { + git_ref, + language: language.into(), + name: name.into(), + num_tasks, + tx, + }) + .await + } + + /// Create a new `DisplayAddr` wrapping a channel sender. + #[must_use] + pub fn new(mode: display::Mode, tx: mpsc::Sender) -> Self { + Self { mode, tx } + } + + /// Send a reference line (plain mode; ignored in fancy mode). + pub async fn reference>>(&self, name: S, git_ref: git::Ref) { + self + .fire(Message::RegisterReference { + git_ref, + name: name.into(), + }) + .await; + } + + /// Shut down the display actor, waiting for cleanup to complete. + pub async fn shutdown(&self, interrupted: bool) { + self + .request(|tx| Message::Shutdown { interrupted, tx }) + .await; + } +} - let narrow_lines = actor.materialize(30); - let narrow_row = line_text(&narrow_lines[0]); - assert!(narrow_row.contains("abcd…")); - assert!(!narrow_row.contains(message)); +impl ProgressAddr { + /// Send a message update (fire-and-forget via `try_send`). + pub fn msg>>(&self, msg: S) { + let _ = self.tx.try_send(Message::Update { + id: self.id, + kind: UpdateKind::Msg, + msg: msg.into(), + }); + } + + /// Advance to the next step (fire-and-forget via `try_send`). + pub fn step>>(&self, msg: S) { + let _ = self.tx.try_send(Message::Update { + id: self.id, + kind: UpdateKind::Step, + msg: msg.into(), + }); + } + + /// Send a state update message, awaiting capacity. + async fn send_state_update(&self, kind: UpdateKind, msg: Arc) { + let _ = self + .tx + .send(Message::Update { + id: self.id, + kind, + msg, + }) + .await; + } + + /// Mark the outcome as cached (async). + pub async fn set_outcome_cached(&self) { + self + .send_state_update(UpdateKind::SetOutcomeCached, Arc::from("")) + .await; + } + + /// Cancel the progress row. + pub async fn cancel(&self) { + self + .send_state_update(UpdateKind::Cancel, Arc::from("cancelled")) + .await; + } + + /// Mark the outcome as built (async). + pub async fn set_outcome_built(&self) { + self + .send_state_update(UpdateKind::SetOutcomeBuilt, Arc::from("")) + .await; + } + + /// Complete as cached (async). + pub async fn cached>>(&self, msg: S) { + self.send_state_update(UpdateKind::Cached, msg.into()).await; + } + + /// Complete as built (async). + pub async fn fin>>(&self, msg: S) { + self.send_state_update(UpdateKind::Fin, msg.into()).await; + } + + /// Complete as failed (async). + pub async fn err>>(&self, msg: S) { + self.send_state_update(UpdateKind::Err, msg.into()).await; + } +} - let wide_lines = actor.materialize(80); - let wide_row = line_text(&wide_lines[0]); - assert!(wide_row.contains(message)); +#[cfg(test)] +mod tests { + use super::*; + + fn actor() -> DisplayActor { + let (tx, rx) = mpsc::channel(1); + DisplayActor { + state: display::State::new( + PathBuf::from("build"), + display::Mode::Fancy, + PathBuf::from("out"), + ), + next_id: display::ItemId::new(NonZeroU64::MIN), + plain_name_width: 16, + plain_progress_started: false, + grid: display::GridCache::new(), + row_specs: Vec::new(), + rows_dirty: true, + last_term_width: None, + rx, + tx, } + } + + fn line_text(line: &Line<'_>) -> String { + line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn split_lines_for_viewport_keeps_the_visible_suffix() { + let lines = vec![ + Line::from("one"), + Line::from("two"), + Line::from("three"), + Line::from("four"), + ]; + + let (prefix, suffix) = split_lines_for_viewport(lines, 2); + + assert_eq!(line_text(&prefix[0]), "one"); + assert_eq!(line_text(&prefix[1]), "two"); + assert_eq!(line_text(&suffix[0]), "three"); + assert_eq!(line_text(&suffix[1]), "four"); + } + + #[test] + fn render_final_report_inserts_prefix_and_replaces_viewport() { + use ratatui::backend::TestBackend; + use ratatui::style::{Color, Style}; + use ratatui::{Terminal, TerminalOptions, Viewport}; + + let backend = TestBackend::new(20, 5); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(2), + }, + ) + .unwrap(); - #[test] - fn materialize_recomputes_message_cells_when_layout_width_changes() { - let mut actor = actor(); - let progress = actor.register_repo("json".into(), git::Ref::head(), 1); - let message = "abcdefghijklmnop"; - - actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); - - let initial_lines = actor.materialize(42); - let initial_row = line_text(&initial_lines[0]); - assert!(initial_row.contains(message)); - - actor.register_repo("zzzzzzzzzzzzzzzzzzzz".into(), git::Ref::head(), 1); - - let updated_lines = actor.materialize(42); - let updated_row = updated_lines - .iter() - .map(line_text) - .find(|line| line.contains("json")) - .expect("json row should still be rendered"); - assert!(updated_row.contains('…')); - assert!(!updated_row.contains(message)); - } + draw_lines( + &mut terminal, + vec![Line::from("old viewport 1"), Line::from("old viewport 2")], + ) + .unwrap(); + + render_final_report( + &mut terminal, + vec![ + Line::from("final 1"), + Line::from("final 2"), + Line::from("final 3"), + Line::from(Span::styled("final 4", Style::default().fg(Color::Blue))), + Line::from(""), + ], + ) + .unwrap(); + + terminal.backend().assert_buffer_lines(vec![ + Line::from("final 1 "), + Line::from("final 2 "), + Line::from("final 3 "), + Line::from(vec![ + Span::styled("final 4", Style::default().fg(Color::Blue)), + Span::raw(" "), + ]), + Line::from(" "), + ]); + terminal.backend().assert_scrollback_empty(); + terminal.backend_mut().assert_cursor_position((0, 4)); + } + + #[test] + fn summary_counts_include_repo_only_rows() { + let mut actor = actor(); + + let cached = actor.register_repo("tree-sitter-cli".into(), git::Ref::head(), 2); + actor.apply_update(cached.id, UpdateKind::SetOutcomeCached, Arc::from("")); + actor.apply_update(cached.id, UpdateKind::Fin, Arc::from("done")); + + let built = actor.register_repo("standalone".into(), git::Ref::head(), 1); + actor.apply_update(built.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); + actor.apply_update(built.id, UpdateKind::Fin, Arc::from("done")); + + let active = actor.register_repo("active".into(), git::Ref::head(), 1); + actor.apply_update(active.id, UpdateKind::Step, Arc::from("working")); + + let failed = actor.register_repo("failed".into(), git::Ref::head(), 1); + actor.apply_update(failed.id, UpdateKind::Err, Arc::from("failed")); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 1, + built: 1, + cached: 1, + cancelled: 0, + failed: 1, + } + ); + } + + #[test] + fn summary_counts_do_not_double_count_parent_repos_with_grammars() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); + actor.apply_update(repo.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); + actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); + + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); + actor.apply_update(grammar.id, UpdateKind::SetOutcomeCached, Arc::from("")); + actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 1, + cancelled: 0, + failed: 0, + } + ); + } + + #[test] + fn cancelled_grammar_is_terminal_and_updates_parent_repo() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); + + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(grammar_entry.state, display::ItemState::Cancelled); + assert!(!grammar_entry.state.is_live()); + assert_eq!(grammar_entry.step, grammar_entry.total); + assert!(grammar_entry.frozen_elapsed.is_some()); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); + assert!(!repo_entry.state.is_live()); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + assert!(repo_entry.frozen_elapsed.is_some()); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 0, + } + ); + } + + #[test] + fn cancelled_child_does_not_cancel_parent_while_sibling_is_active() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let cancelled = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); + let active = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); + + actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(active.id, UpdateKind::Step, Arc::from("building")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::InProgress(None)); + assert_eq!(repo_entry.msg.as_ref(), "building"); + assert!(repo_entry.frozen_elapsed.is_none()); + + actor.apply_update(active.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + assert!(repo_entry.frozen_elapsed.is_some()); + } + + #[test] + fn failed_child_wins_over_cancelled_child_when_parent_terminal() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let failing = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); + let cancelled = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); + + actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); + actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Failed); + assert_eq!(repo_entry.msg.as_ref(), "failed"); + assert!(repo_entry.frozen_elapsed.is_some()); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 1, + } + ); + } + + #[test] + #[should_panic(expected = "finish update received before cached/built path was set")] + fn finish_without_cached_or_built_path_panics() { + let mut actor = actor(); + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); + + actor.apply_update(repo.id, UpdateKind::Step, Arc::from("scanning")); + actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); + } + + #[test] + fn interrupted_shutdown_cancels_live_rows() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let grammar = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + + actor.cancel_live_rows(); + + let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(grammar_entry.state, display::ItemState::Cancelled); + assert_eq!(grammar_entry.step, grammar_entry.total); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 0, + } + ); + } + + #[test] + fn terminal_direct_updates_are_absorbing() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), git::Ref::head(), 4); + actor.apply_update(repo.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(repo.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(repo.id, UpdateKind::Cached, Arc::from("done")); + actor.apply_update(repo.id, UpdateKind::Err, Arc::from("failed")); + + let entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(entry.state, display::ItemState::Cancelled); + assert_eq!(entry.msg.as_ref(), "cancelled"); + + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); + actor.apply_update(grammar.id, UpdateKind::Err, Arc::from("failed")); + + let entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(entry.state, display::ItemState::Cancelled); + assert_eq!(entry.msg.as_ref(), "cancelled"); + } + + #[test] + fn parent_repo_stays_active_while_any_child_is_active() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let failing = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); + let pending = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); + + actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::InProgress(None)); + assert_eq!(repo_entry.msg.as_ref(), "building"); + assert!(repo_entry.frozen_elapsed.is_none()); + + actor.apply_update(pending.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); + actor.apply_update(pending.id, UpdateKind::Fin, Arc::from("done")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Failed); + assert_eq!(repo_entry.msg.as_ref(), "failed"); + assert!(repo_entry.frozen_elapsed.is_some()); + } + + #[test] + fn materialize_recomputes_message_cells_when_terminal_width_changes() { + let mut actor = actor(); + let progress = actor.register_repo("json".into(), git::Ref::head(), 1); + let message = "abcdefghijklmnop"; + + actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); + + let narrow_lines = actor.materialize(30); + let narrow_row = line_text(&narrow_lines[0]); + assert!(narrow_row.contains("abcd…")); + assert!(!narrow_row.contains(message)); + + let wide_lines = actor.materialize(80); + let wide_row = line_text(&wide_lines[0]); + assert!(wide_row.contains(message)); + } + + #[test] + fn materialize_recomputes_message_cells_when_layout_width_changes() { + let mut actor = actor(); + let progress = actor.register_repo("json".into(), git::Ref::head(), 1); + let message = "abcdefghijklmnop"; + + actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); + + let initial_lines = actor.materialize(42); + let initial_row = line_text(&initial_lines[0]); + assert!(initial_row.contains(message)); + + actor.register_repo("zzzzzzzzzzzzzzzzzzzz".into(), git::Ref::head(), 1); + + let updated_lines = actor.materialize(42); + let updated_row = updated_lines + .iter() + .map(line_text) + .find(|line| line.contains("json")) + .expect("json row should still be rendered"); + assert!(updated_row.contains('…')); + assert!(!updated_row.contains(message)); + } } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 8340106..e07c378 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -20,34 +20,36 @@ use crate::{Error, Result, args, parser, shutdown, tree_sitter}; // ============================================================ pub trait Addr { - type Message; - - fn name() -> &'static str; - fn sender(&self) -> &mpsc::Sender; - - #[allow(async_fn_in_trait)] - async fn fire(&self, msg: Self::Message) { - self.sender() - .send(msg) - .await - .unwrap_or_else(|_| panic!("{}: cannot send: channel closed", Self::name())); - } - - #[allow(async_fn_in_trait)] - async fn request(&self, msg: F) -> T - where - F: FnOnce(oneshot::Sender) -> Self::Message, - { - let (tx, rx) = oneshot::channel(); - - self.sender() - .send(msg(tx)) - .await - .unwrap_or_else(|_| panic!("{}: cannot send: channel closed", Self::name())); - - rx.await - .unwrap_or_else(|_| panic!("{}: cannot recv: channel closed", Self::name())) - } + type Message; + + fn name() -> &'static str; + fn sender(&self) -> &mpsc::Sender; + + #[allow(async_fn_in_trait)] + async fn fire(&self, msg: Self::Message) { + self + .sender() + .send(msg) + .await + .unwrap_or_else(|_| panic!("{}: cannot send: channel closed", Self::name())); + } + + #[allow(async_fn_in_trait)] + async fn request(&self, msg: F) -> T + where + F: FnOnce(oneshot::Sender) -> Self::Message, + { + let (tx, rx) = oneshot::channel(); + + self + .sender() + .send(msg(tx)) + .await + .unwrap_or_else(|_| panic!("{}: cannot send: channel closed", Self::name())); + + rx.await + .unwrap_or_else(|_| panic!("{}: cannot recv: channel closed", Self::name())) + } } // ============================================================ @@ -56,10 +58,10 @@ pub trait Addr { /// Wraps a [`oneshot::Sender`] with a debug-friendly kind label for logging. pub struct Response { - /// Debug label identifying which variant generated this response. - pub kind: K, - /// Channel to send the response value back. - pub tx: oneshot::Sender, + /// Debug label identifying which variant generated this response. + pub kind: K, + /// Channel to send the response value back. + pub tx: oneshot::Sender, } // ============================================================ @@ -69,246 +71,246 @@ pub struct Response { /// Clone a parser repo, scan it for grammar.js files, and produce a /// `GrammarBuild` for each one after resolving the tree-sitter CLI. async fn discover_grammars( - cache: CacheAddr, - display: DisplayAddr, - language: parser::LanguageBuild, - prepared_rx: &mut watch::Receiver>>, + cache: CacheAddr, + display: DisplayAddr, + language: parser::LanguageBuild, + prepared_rx: &mut watch::Receiver>>, ) -> Result> { + shutdown::test_delay().await; + shutdown::check()?; + debug!("[discover] lang={}", language.name); + + let progress = display + .add_language( + language.name.as_arc(), + language.spec.git_ref.requested().clone(), + 2, + ) + .await; + + // Phase 1: resolve the parser revision (cache check + possible clone). + // This runs concurrently with tree-sitter CLI preparation — it only + // touches the cache actor and git subprocesses, neither of which depend + // on the resolved tree-sitter version. + let revision = resolve_revision(&cache, &language, &progress).await?; + + // Phase 2: scan for grammar.js files (git ls-files + hashing). + // Also concurrent with CLI preparation. + progress.step("scanning"); + let grammars = match language.discover_grammars().await { + Ok(grammars) => grammars, + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("scan failed").await; + } + return Err(e); + } + }; + progress.fin("done").await; + + // Phase 3: wait for the tree-sitter CLI to be ready so we have the + // resolved tree-sitter version for cache comparisons and the binary path + // for computing artifact paths. + let prepared = wait_for_prepared(prepared_rx).await?; + let ts_cli = prepared.path; + let language = language.with_tree_sitter(prepared.tree_sitter); + + // Map the raw discovery data into Build structs, now with the resolved + // tree-sitter version available for accurate cache-key comparison. + let mut builds = Vec::new(); + for (name, hash, dir) in grammars { shutdown::test_delay().await; shutdown::check()?; - debug!("[discover] lang={}", language.name); - let progress = display - .add_language( - language.name.as_arc(), - language.spec.git_ref.requested().clone(), - 2, - ) - .await; - - // Phase 1: resolve the parser revision (cache check + possible clone). - // This runs concurrently with tree-sitter CLI preparation — it only - // touches the cache actor and git subprocesses, neither of which depend - // on the resolved tree-sitter version. - let revision = resolve_revision(&cache, &language, &progress).await?; - - // Phase 2: scan for grammar.js files (git ls-files + hashing). - // Also concurrent with CLI preparation. - progress.step("scanning"); - let grammars = match language.discover_grammars().await { - Ok(grammars) => grammars, - Err(e) => { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("scan failed").await; - } - return Err(e); - } + let key = crate::cache::Key::new(&language.name, &name); + let artifacts = parser::GrammarBuild::required_artifacts_for( + &name, + &language.output.build_dir, + &language.spec, + &ts_cli, + )?; + let cache_decision = cache + .needs_rebuild(key, hash.clone(), revision.clone(), language.spec.clone()) + .await; + + // Cache actor checks in-memory metadata only. When it reports a hit + // we still verify that the expected output files exist on disk. + let cache_decision = if cache_decision.is_hit() { + crate::cache::verify_artifacts(artifacts).await + } else { + cache_decision }; - progress.fin("done").await; - - // Phase 3: wait for the tree-sitter CLI to be ready so we have the - // resolved tree-sitter version for cache comparisons and the binary path - // for computing artifact paths. - let prepared = wait_for_prepared(prepared_rx).await?; - let ts_cli = prepared.path; - let language = language.with_tree_sitter(prepared.tree_sitter); - - // Map the raw discovery data into Build structs, now with the resolved - // tree-sitter version available for accurate cache-key comparison. - let mut builds = Vec::new(); - for (name, hash, dir) in grammars { - shutdown::test_delay().await; - shutdown::check()?; - - let key = crate::cache::Key::new(&language.name, &name); - let artifacts = parser::GrammarBuild::required_artifacts_for( - &name, - &language.output.build_dir, - &language.spec, - &ts_cli, - )?; - let cache_decision = cache - .needs_rebuild(key, hash.clone(), revision.clone(), language.spec.clone()) - .await; - - // Cache actor checks in-memory metadata only. When it reports a hit - // we still verify that the expected output files exist on disk. - let cache_decision = if cache_decision.is_hit() { - crate::cache::verify_artifacts(artifacts).await - } else { - cache_decision - }; - - let progress = display - .add_grammar( - language.name.as_arc(), - name.as_arc(), - language.spec.git_ref.requested().clone(), - 4, - ) - .await; - - builds.push(parser::GrammarBuild { - context: language.context.clone(), - cache_decision, - dir, - hash, - language: language.name.clone(), - name, - output: language.output.clone(), - progress, - revision: revision.clone(), - spec: language.spec.clone(), - ts_cli: ts_cli.clone(), - }); - } - Ok(builds) + let progress = display + .add_grammar( + language.name.as_arc(), + name.as_arc(), + language.spec.git_ref.requested().clone(), + 4, + ) + .await; + + builds.push(parser::GrammarBuild { + context: language.context.clone(), + cache_decision, + dir, + hash, + language: language.name.clone(), + name, + output: language.output.clone(), + progress, + revision: revision.clone(), + spec: language.spec.clone(), + ts_cli: ts_cli.clone(), + }); + } + + Ok(builds) } /// Determine the revision (stable or moving commit) for a parser and clone it if needed. async fn resolve_revision( - cache: &CacheAddr, - language: &parser::LanguageBuild, - progress: &ProgressAddr, + cache: &CacheAddr, + language: &parser::LanguageBuild, + progress: &ProgressAddr, ) -> Result { - if language.spec.git_ref.is_moving() { + if language.spec.git_ref.is_moving() { + info!( + "Resolving moving parser git ref for {}: {}", + language.name, + language.spec.git_ref.requested().as_str() + ); + progress.set_outcome_built().await; + progress.step("cloning"); + match language.checkout().await { + Ok(checkout) => { info!( - "Resolving moving parser git ref for {}: {}", - language.name, - language.spec.git_ref.requested().as_str() + "Resolved parser {} git ref {} to commit {}", + language.name, + language.spec.git_ref.requested().as_str(), + checkout.commit.as_str() ); - progress.set_outcome_built().await; - progress.step("cloning"); - match language.checkout().await { - Ok(checkout) => { - info!( - "Resolved parser {} git ref {} to commit {}", - language.name, - language.spec.git_ref.requested().as_str(), - checkout.commit.as_str() - ); - Ok(crate::cache::Revision::moving(checkout.commit)) - } - Err(e) => { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("clone failed").await; - } - Err(e) - } + Ok(crate::cache::Revision::moving(checkout.commit)) + } + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("clone failed").await; } - } else { - let has_compatible_entries = cache - .has_compatible_entries(language.name.clone(), language.spec.clone()) - .await; - let checkout_needed = !has_compatible_entries || !language.is_checkout_usable().await; - - if checkout_needed { - progress.set_outcome_built().await; - progress.step("cloning"); - if let Err(e) = language.checkout().await { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("clone failed").await; - } - return Err(e); - } + Err(e) + } + } + } else { + let has_compatible_entries = cache + .has_compatible_entries(language.name.clone(), language.spec.clone()) + .await; + let checkout_needed = !has_compatible_entries || !language.is_checkout_usable().await; + + if checkout_needed { + progress.set_outcome_built().await; + progress.step("cloning"); + if let Err(e) = language.checkout().await { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; } else { - progress.set_outcome_cached().await; + progress.err("clone failed").await; } - - Ok(crate::cache::Revision::stable()) + return Err(e); + } + } else { + progress.set_outcome_cached().await; } + + Ok(crate::cache::Revision::stable()) + } } /// The entire build pipeline. pub async fn run( - build_dir: &Path, - cache: CacheAddr, - display: DisplayAddr, - jobs: NonZeroUsize, - languages: Vec, - tree_sitter: &args::TreeSitter, + build_dir: &Path, + cache: CacheAddr, + display: DisplayAddr, + jobs: NonZeroUsize, + languages: Vec, + tree_sitter: &args::TreeSitter, ) -> Result<()> { - let tree_sitter_ref = match tree_sitter::display_tree_sitter_ref(&tree_sitter.version) { - Ok(git_ref) => git_ref, - Err(err) => { - display.shutdown(false).await; - return Err(Error::Context { - message: format!("Parsing tree-sitter git ref {:?}", tree_sitter.version), - source: err.into(), - }); - } - }; - - display.reference("tree-sitter-cli", tree_sitter_ref).await; - for language in &languages { - display - .reference( - language.name.as_arc(), - language.spec.git_ref.requested().clone(), - ) - .await; - } - - let result = run_inner( - build_dir, - cache, - display.clone(), - jobs, - languages, - tree_sitter, - ) - .await; - - let interrupted = shutdown::current().and_then(|s| s.reason()); - - // We need to shut down before returning the results to avoid display - // issues with the ratatui backend. - display.shutdown(interrupted.is_some()).await; - - // If shutdown was signalled, suppress build errors — they're expected - // cancellation artefacts, not real failures — but still return the - // interruption so the top-level process exits with the signal status. - if let Some(signal) = interrupted { - info!("pipeline shutdown signalled by {signal}, suppressing build errors"); - return Err(Error::Interrupted { signal }); + let tree_sitter_ref = match tree_sitter::display_tree_sitter_ref(&tree_sitter.version) { + Ok(git_ref) => git_ref, + Err(err) => { + display.shutdown(false).await; + return Err(Error::Context { + message: format!("Parsing tree-sitter git ref {:?}", tree_sitter.version), + source: err.into(), + }); } - - result + }; + + display.reference("tree-sitter-cli", tree_sitter_ref).await; + for language in &languages { + display + .reference( + language.name.as_arc(), + language.spec.git_ref.requested().clone(), + ) + .await; + } + + let result = run_inner( + build_dir, + cache, + display.clone(), + jobs, + languages, + tree_sitter, + ) + .await; + + let interrupted = shutdown::current().and_then(|s| s.reason()); + + // We need to shut down before returning the results to avoid display + // issues with the ratatui backend. + display.shutdown(interrupted.is_some()).await; + + // If shutdown was signalled, suppress build errors — they're expected + // cancellation artefacts, not real failures — but still return the + // interruption so the top-level process exits with the signal status. + if let Some(signal) = interrupted { + info!("pipeline shutdown signalled by {signal}, suppressing build errors"); + return Err(Error::Interrupted { signal }); + } + + result } /// Core pipeline: prepare tree-sitter CLI concurrently, discover grammars, build /// them, and accumulate results. async fn run_inner( - build_dir: &Path, - cache: CacheAddr, - display: DisplayAddr, - jobs: NonZeroUsize, - languages: Vec, - tree_sitter: &args::TreeSitter, + build_dir: &Path, + cache: CacheAddr, + display: DisplayAddr, + jobs: NonZeroUsize, + languages: Vec, + tree_sitter: &args::TreeSitter, ) -> Result<()> { - // Spawn tree-sitter CLI preparation concurrently so its network I/O - // (git ls-remote) overlaps with language discovery work. - let (prepared_tx, prepared_rx) = watch::channel(None::>); - let prepare_build_dir = build_dir.to_path_buf(); - let prepare_display = display.clone(); - let prepare_tree_sitter = tree_sitter.clone(); - tokio::spawn(async move { - let result = - tree_sitter::prepare(&prepare_build_dir, prepare_display, &prepare_tree_sitter).await; - let _ = prepared_tx.send(Some(result)); - }); - - let prepared_rx = prepared_rx; - let languages_empty = languages.is_empty(); - - let mut errors: Vec = + // Spawn tree-sitter CLI preparation concurrently so its network I/O + // (git ls-remote) overlaps with language discovery work. + let (prepared_tx, prepared_rx) = watch::channel(None::>); + let prepare_build_dir = build_dir.to_path_buf(); + let prepare_display = display.clone(); + let prepare_tree_sitter = tree_sitter.clone(); + tokio::spawn(async move { + let result = + tree_sitter::prepare(&prepare_build_dir, prepare_display, &prepare_tree_sitter).await; + let _ = prepared_tx.send(Some(result)); + }); + + let prepared_rx = prepared_rx; + let languages_empty = languages.is_empty(); + + let mut errors: Vec = // 1. Create a stream from the input list stream::iter(languages) // 2. Stage: Discovery @@ -363,51 +365,50 @@ async fn run_inner( }) .await; - // Drain the CLI preparation result so we never exit before the binary is - // downloaded. - let mut prepared_rx = prepared_rx; - if let Err(e) = wait_for_prepared(&mut prepared_rx).await - && languages_empty - { - errors.push(e); - // else: the pipeline already consumed `prepared_rx` via each - // `discover_grammars` call; a second read finding an error is a - // redundant delivery — suppress it to avoid double-counting. - } - - if let Err(e) = cache.save().await { - errors.push(e); - } - - if errors.is_empty() { - Ok(()) - } else { - Err(Error::Build { errors }) - } + // Drain the CLI preparation result so we never exit before the binary is + // downloaded. + let mut prepared_rx = prepared_rx; + if let Err(e) = wait_for_prepared(&mut prepared_rx).await + && languages_empty + { + errors.push(e); + // else: the pipeline already consumed `prepared_rx` via each + // `discover_grammars` call; a second read finding an error is a + // redundant delivery — suppress it to avoid double-counting. + } + + if let Err(e) = cache.save().await { + errors.push(e); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(Error::Build { errors }) + } } /// Wait for the tree-sitter CLI preparation task to finish, returning the /// prepared CLI or the error that preparation failed with. async fn wait_for_prepared( - rx: &mut watch::Receiver>>, + rx: &mut watch::Receiver>>, ) -> Result { - loop { - if let Some(result) = rx.borrow().as_ref() { - return match result { - Ok(prepared) => Ok(prepared.clone()), - Err(e) => Err(Error::Message { - message: format!("tree-sitter CLI preparation failed: {e}"), - }), - }; - } - if rx.changed().await.is_err() { - return Err(Error::Message { - message: - "tree-sitter CLI preparation failed unexpectedly (task panicked or was dropped)" - .into(), - }); - } + loop { + if let Some(result) = rx.borrow().as_ref() { + return match result { + Ok(prepared) => Ok(prepared.clone()), + Err(e) => Err(Error::Message { + message: format!("tree-sitter CLI preparation failed: {e}"), + }), + }; + } + if rx.changed().await.is_err() { + return Err(Error::Message { + message: "tree-sitter CLI preparation failed unexpectedly (task panicked or was dropped)" + .into(), + }); } + } } // ============================================================ @@ -415,13 +416,14 @@ async fn wait_for_prepared( // ============================================================ impl Response { - /// Send a value through the response channel. - /// # Panics - /// - /// Will panic channel is closed. - pub fn send(self, value: T) { - self.tx - .send(value) - .unwrap_or_else(|_| panic!("cannot send response: {:?}", self.kind)); - } + /// Send a value through the response channel. + /// # Panics + /// + /// Will panic channel is closed. + pub fn send(self, value: T) { + self + .tx + .send(value) + .unwrap_or_else(|_| panic!("cannot send response: {:?}", self.kind)); + } } diff --git a/src/app.rs b/src/app.rs index 11b9cfa..432e937 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,10 +14,10 @@ use crate::{Result, ResultExt, args, config, display, logging}; /// The selected command after resolving only the configuration it needs. pub enum ResolvedCommand { - Build(ResolvedBuild), - ConfigCurrent(ResolvedBuild), - ConfigDefault, - Selfupdate { force: bool, target: String }, + Build(ResolvedBuild), + ConfigCurrent(ResolvedBuild), + ConfigDefault, + Selfupdate { force: bool, target: String }, } // ============================================================ @@ -26,17 +26,17 @@ pub enum ResolvedCommand { /// Resolved application state, ready to run. pub struct App { - pub command: ResolvedCommand, - pub config_path: PathBuf, - pub logging: logging::Session, - pub progress_mode: display::Mode, - pub verbose: Verbosity, + pub command: ResolvedCommand, + pub config_path: PathBuf, + pub logging: logging::Session, + pub progress_mode: display::Mode, + pub verbose: Verbosity, } /// Resolved build state for commands that actually need build configuration. pub struct ResolvedBuild { - pub command: args::BuildCommand, - pub provenance: config::BuildProvenance, + pub command: args::BuildCommand, + pub provenance: config::BuildProvenance, } // ============================================================ @@ -46,115 +46,114 @@ pub struct ResolvedBuild { /// Resolve the build configuration for a given purpose (e.g. "build", "config /// current") and return both the resolved command and its provenance. fn resolve_build( - config_path: &Path, - matches: Option<&ArgMatches>, - purpose: &str, + config_path: &Path, + matches: Option<&ArgMatches>, + purpose: &str, ) -> Result { - let (command, provenance) = config::current_with_provenance(config_path, matches) - .with_context(|| format!("Resolving build configuration for {purpose}"))?; + let (command, provenance) = config::current_with_provenance(config_path, matches) + .with_context(|| format!("Resolving build configuration for {purpose}"))?; - Ok(ResolvedBuild { - command, - provenance, - }) + Ok(ResolvedBuild { + command, + provenance, + }) } /// map cli args + matches into a [`resolvedcommand`] (build, config, or self-update). fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { - match &args.command { - args::Command::Build => { - resolve_build(&args.config, args::build_matches(matches), "`build`") - .map(ResolvedCommand::Build) - } - - args::Command::Config { - command: args::ConfigCommand::Current, - } => resolve_build(&args.config, None, "`config current`") - .map(ResolvedCommand::ConfigCurrent), - - args::Command::Config { - command: args::ConfigCommand::Default, - } => Ok(ResolvedCommand::ConfigDefault), - - args::Command::Selfupdate { force, target } => Ok(ResolvedCommand::Selfupdate { force: *force, target: target.clone() }), - } + match &args.command { + args::Command::Build => resolve_build(&args.config, args::build_matches(matches), "`build`") + .map(ResolvedCommand::Build), + + args::Command::Config { + command: args::ConfigCommand::Current, + } => resolve_build(&args.config, None, "`config current`").map(ResolvedCommand::ConfigCurrent), + + args::Command::Config { + command: args::ConfigCommand::Default, + } => Ok(ResolvedCommand::ConfigDefault), + + args::Command::Selfupdate { force, target } => Ok(ResolvedCommand::Selfupdate { + force: *force, + target: target.clone(), + }), + } } /// Parse CLI args, resolve the command, and initialise logging. pub fn setup() -> Result { - let (args, matches) = config::parse_with_matches(); - let command = resolve_command(&args, &matches)?; - let logging = logging::init( - command.logging_policy(args.log.as_deref()), - args.log_color, - args.verbose, - )?; - - let progress_mode = display::mode_from_args(&args.progress, &args.verbose); - - Ok(App { - command, - config_path: args.config, - logging, - progress_mode, - verbose: args.verbose, - }) + let (args, matches) = config::parse_with_matches(); + let command = resolve_command(&args, &matches)?; + let logging = logging::init( + command.logging_policy(args.log.as_deref()), + args.log_color, + args.verbose, + )?; + + let progress_mode = display::mode_from_args(&args.progress, &args.verbose); + + Ok(App { + command, + config_path: args.config, + logging, + progress_mode, + verbose: args.verbose, + }) } // ============================================================ // Impls // ============================================================ - - impl ResolvedCommand { - /// Return a logging policy based on the command type. - fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { - let implicit = match self { - Self::Build(build) => logging::Implicit::BuildDir { - dir: &build.command.build_dir, - }, - _ => logging::Implicit::None - }; - - logging::Policy { explicit, implicit } - } - } +impl ResolvedCommand { + /// Return a logging policy based on the command type. + fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { + let implicit = match self { + Self::Build(build) => logging::Implicit::BuildDir { + dir: &build.command.build_dir, + }, + _ => logging::Implicit::None, + }; + + logging::Policy { explicit, implicit } + } +} #[cfg(test)] mod tests { - use super::*; - - fn parse_with_config(config: &Path, command: &[&str]) -> (args::Args, ArgMatches) { - let mut argv = vec![ - "tsdl".into(), - "--config".into(), - config.as_os_str().to_owned(), - ]; - argv.extend(command.iter().map(Into::into)); - config::try_parse_from_with_matches(argv).unwrap() - } - - #[test] - fn config_default_does_not_resolve_build_config() { - let temp = tempfile::tempdir().unwrap(); - let config_path = temp.path().join("parsers.toml"); - std::fs::write(&config_path, "not valid toml =").unwrap(); - - let (args, matches) = parse_with_config(&config_path, &["config", "default"]); - let command = resolve_command(&args, &matches).unwrap(); - - assert!(matches!(command, ResolvedCommand::ConfigDefault)); - } - - #[test] - fn selfupdate_does_not_resolve_build_config() { - let temp = tempfile::tempdir().unwrap(); - let config_path = temp.path().join("parsers.toml"); - std::fs::write(&config_path, "not valid toml =").unwrap(); - - let (args, matches) = parse_with_config(&config_path, &["selfupdate"]); - let command = resolve_command(&args, &matches).unwrap(); - - assert!(matches!(command, ResolvedCommand::Selfupdate{ .. })); - } + use super::*; + + fn parse_with_config(config: &Path, command: &[&str]) -> (args::Args, ArgMatches) { + let mut argv = vec![ + "tsdl".into(), + "--config".into(), + config.as_os_str().to_owned(), + ]; + argv.extend(command.iter().map(Into::into)); + config::try_parse_from_with_matches(argv).unwrap() + } + + #[test] + fn config_default_does_not_resolve_build_config() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("parsers.toml"); + std::fs::write(&config_path, "not valid toml =").unwrap(); + + let (args, matches) = parse_with_config(&config_path, &["config", "default"]); + let command = resolve_command(&args, &matches).unwrap(); + + assert!(matches!(command, ResolvedCommand::ConfigDefault)); + } + + #[test] + fn selfupdate_does_not_resolve_build_config() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("parsers.toml"); + std::fs::write(&config_path, "not valid toml =").unwrap(); + + let (args, matches) = parse_with_config(&config_path, &["selfupdate"]); + let command = resolve_command(&args, &matches).unwrap(); + + assert!(matches!(command, ResolvedCommand::Selfupdate { .. })); + } } diff --git a/src/args.rs b/src/args.rs index e0e9084..4e7d7db 100644 --- a/src/args.rs +++ b/src/args.rs @@ -3,16 +3,16 @@ use std::{collections::BTreeMap, fmt, num::NonZeroUsize, path::PathBuf}; use clap::{ - ArgMatches, - builder::styling::{AnsiColor, Color, Style}, - crate_authors, + ArgMatches, + builder::styling::{AnsiColor, Color, Style}, + crate_authors, }; use clap_verbosity_flag::{InfoLevel, Verbosity}; use serde::{Deserialize, Serialize}; use crate::consts::{ - BUILD_DIR, CONFIG_FILE, FORCE, FRESH, PARSER_OUT_DIR, PLATFORM, PREFIX, REPO, SHOW_CONFIG, - UNLOCK_TIMEOUT, VERSION, + BUILD_DIR, CONFIG_FILE, FORCE, FRESH, PARSER_OUT_DIR, PLATFORM, PREFIX, REPO, SHOW_CONFIG, + UNLOCK_TIMEOUT, VERSION, }; // ============================================================ @@ -28,47 +28,47 @@ const TSDL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/tsdl.version" /// CLI commands: build parsers, manage config, or self-update. #[derive(clap::Subcommand, Clone, Debug)] pub enum Command { - /// Build one or many parsers. - #[command(visible_alias = "b")] - Build, - - /// Configuration helpers. - #[command(visible_alias = "c")] - Config { - #[command(subcommand)] - command: ConfigCommand, - }, - - /// Update tsdl to its latest version. - #[command(visible_alias = "u")] - Selfupdate { - #[arg(long, short, default_value="false")] - force: bool, - - #[arg(long, short, default_value="minor")] - target: String - }, + /// Build one or many parsers. + #[command(visible_alias = "b")] + Build, + + /// Configuration helpers. + #[command(visible_alias = "c")] + Config { + #[command(subcommand)] + command: ConfigCommand, + }, + + /// Update tsdl to its latest version. + #[command(visible_alias = "u")] + Selfupdate { + #[arg(long, short, default_value = "false")] + force: bool, + + #[arg(long, short, default_value = "minor")] + target: String, + }, } /// Subcommands for the `config` command. #[derive(clap::Subcommand, Clone, Debug, Default)] pub enum ConfigCommand { - /// Show the currently active (merged) configuration. - #[default] - Current, - /// Show the built-in default configuration. - Default, + /// Show the currently active (merged) configuration. + #[default] + Current, + /// Show the built-in default configuration. + Default, } /// Whether to emit colored stderr logs (auto, on, off). #[derive(clap::ValueEnum, Clone, Copy, Debug, Deserialize, Serialize)] pub enum LogColor { - /// Respect terminal capabilities. - Auto, - /// Force plain text (no ANSI escapes). - No, - /// Force colored output. - Yes, + /// Respect terminal capabilities. + Auto, + /// Force plain text (no ANSI escapes). + No, + /// Force colored output. + Yes, } /// A single parser override, either a bare git ref string or a full block @@ -77,56 +77,56 @@ pub enum LogColor { #[serde(untagged)] #[serde(rename_all = "kebab-case")] pub enum ParserConfig { - /// Full parser configuration block. - Full { - /// Custom build script (e.g. "make" or a shell command). - #[serde(alias = "cmd", alias = "script")] - build_script: Option, - - /// Custom repository URL for this parser. - from: Option, - - /// Git ref to check out (tag, branch, or commit SHA). - #[serde(rename = "ref")] - git_ref: String, - }, - /// Short form: a bare git ref string. - Ref(String), + /// Full parser configuration block. + Full { + /// Custom build script (e.g. "make" or a shell command). + #[serde(alias = "cmd", alias = "script")] + build_script: Option, + + /// Custom repository URL for this parser. + from: Option, + + /// Git ref to check out (tag, branch, or commit SHA). + #[serde(rename = "ref")] + git_ref: String, + }, + /// Short form: a bare git ref string. + Ref(String), } /// Terminal progress display style: auto-detect, force fancy (ratatui), or force plain. #[derive(clap::ValueEnum, Clone, Debug, Deserialize, Serialize)] pub enum ProgressStyle { - /// Respect terminal TTY detection. - Auto, - /// Force ratatui inline rendering. - Fancy, - /// Force plain line-by-line output. - Plain, + /// Respect terminal TTY detection. + Auto, + /// Force ratatui inline rendering. + Fancy, + /// Force plain line-by-line output. + Plain, } /// Build target: native shared library, WebAssembly, or both. #[derive(clap::ValueEnum, Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum Target { - /// Build a native `.so`/`.dylib`. - #[default] - Native, - /// Build a `.wasm` binary. - Wasm, - /// Build both targets. - All, + /// Build a native `.so`/`.dylib`. + #[default] + Native, + /// Build a `.wasm` binary. + Wasm, + /// Build both targets. + All, } #[derive(Clone, Copy, Debug, Default)] pub enum VersionBump { - /// Update to the latest version, including breaking changes. - Major, - /// Update to the latest minor or patch version (same MAJOR). - #[default] - Minor, - /// Update to the latest patch version (same MAJOR.MINOR). - Patch, + /// Update to the latest version, including breaking changes. + Major, + /// Update to the latest minor or patch version (same MAJOR). + #[default] + Minor, + /// Update to the latest patch version (same MAJOR.MINOR). + Patch, } // ============================================================ @@ -137,67 +137,67 @@ pub enum VersionBump { #[derive(Clone, Debug, clap::Parser)] #[command(author = crate_authors!("\n"), version = TSDL_VERSION, about, styles=get_styles(), allow_external_subcommands = true)] #[command(help_template( - "{before-help}{name} {version} + "{before-help}{name} {version} {author-with-newline}{about-with-newline} {usage-heading} {usage} {all-args}{after-help}" ))] pub struct Args { - #[command(subcommand)] - pub command: Command, + #[command(subcommand)] + pub command: Command, - /// Path to the config file (TOML). - #[arg(short, long, env = "TSDL_CONFIG", default_value = CONFIG_FILE, global = true)] - pub config: PathBuf, + /// Path to the config file (TOML). + #[arg(short, long, env = "TSDL_CONFIG", default_value = CONFIG_FILE, global = true)] + pub config: PathBuf, - /// Path to the logging file. If unspecified, it will go to `build-dir/log`. - /// If the path is inside `build-dir`, it must be directly under that directory. - #[arg(short, long, env = "TSDL_LOG", global = true)] - pub log: Option, + /// Path to the logging file. If unspecified, it will go to `build-dir/log`. + /// If the path is inside `build-dir`, it must be directly under that directory. + #[arg(short, long, env = "TSDL_LOG", global = true)] + pub log: Option, - /// Whether to emit colored logs. - #[arg(long, value_enum, default_value_t = LogColor::Auto, global = true)] - pub log_color: LogColor, + /// Whether to emit colored logs. + #[arg(long, value_enum, default_value_t = LogColor::Auto, global = true)] + pub log_color: LogColor, - /// Progress style. - #[arg(long, value_enum, default_value_t = ProgressStyle::Auto, global = true)] - pub progress: ProgressStyle, + /// Progress style. + #[arg(long, value_enum, default_value_t = ProgressStyle::Auto, global = true)] + pub progress: ProgressStyle, - /// Verbosity level: -v, -vv, or -q, -qq. - #[command(flatten)] - pub verbose: Verbosity, + /// Verbosity level: -v, -vv, or -q, -qq. + #[command(flatten)] + pub verbose: Verbosity, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct BuildCommand { - /// Checkout and build directory root. - pub build_dir: PathBuf, - /// Ignore cache, force a full rebuild. - pub force: bool, - /// Clear build directory before starting. - pub fresh: bool, - /// Specific languages (positional CLI args); `None` means "all configured". - #[serde(skip_serializing)] - pub languages: Option>, - /// Max concurrent build jobs. - pub jobs: NonZeroUsize, - /// Directory for installed parser binaries. - #[serde(rename = "out-dir", alias = "out")] - pub out_dir: PathBuf, - /// Per-parser configuration overrides from config file. - pub parsers: Option>, - /// Output filename prefix for installed binaries. - pub prefix: String, - /// Print resolved config and exit. - pub show_config: bool, - /// Build output kind (native, wasm, or all). - pub target: Target, - /// Tree-sitter CLI version, platform, and repo. - pub tree_sitter: TreeSitter, - /// Seconds to wait for a stale lock to be released. - pub unlock_timeout: u64, + /// Checkout and build directory root. + pub build_dir: PathBuf, + /// Ignore cache, force a full rebuild. + pub force: bool, + /// Clear build directory before starting. + pub fresh: bool, + /// Specific languages (positional CLI args); `None` means "all configured". + #[serde(skip_serializing)] + pub languages: Option>, + /// Max concurrent build jobs. + pub jobs: NonZeroUsize, + /// Directory for installed parser binaries. + #[serde(rename = "out-dir", alias = "out")] + pub out_dir: PathBuf, + /// Per-parser configuration overrides from config file. + pub parsers: Option>, + /// Output filename prefix for installed binaries. + pub prefix: String, + /// Print resolved config and exit. + pub show_config: bool, + /// Build output kind (native, wasm, or all). + pub target: Target, + /// Tree-sitter CLI version, platform, and repo. + pub tree_sitter: TreeSitter, + /// Seconds to wait for a stale lock to be released. + pub unlock_timeout: u64, } /// Fully optional overrides for every build configuration field. @@ -208,63 +208,63 @@ pub struct BuildCommand { #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct OptionalBuildCommand { - #[serde(default)] - pub build_dir: Option, - #[serde(default)] - pub force: Option, - #[serde(default)] - pub fresh: Option, - #[serde(default, alias = "out", rename = "out-dir")] - pub out_dir: Option, - #[serde(default)] - pub jobs: Option, - #[serde(default)] - pub prefix: Option, - #[serde(default)] - pub show_config: Option, - #[serde(default)] - pub target: Option, - #[serde(default)] - pub tree_sitter: OptionalTreeSitter, - #[serde(default)] - pub unlock_timeout: Option, - #[serde(default)] - pub parsers: Option>, - #[serde(skip_deserializing)] - pub languages: Option>, + #[serde(default)] + pub build_dir: Option, + #[serde(default)] + pub force: Option, + #[serde(default)] + pub fresh: Option, + #[serde(default, alias = "out", rename = "out-dir")] + pub out_dir: Option, + #[serde(default)] + pub jobs: Option, + #[serde(default)] + pub prefix: Option, + #[serde(default)] + pub show_config: Option, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub tree_sitter: OptionalTreeSitter, + #[serde(default)] + pub unlock_timeout: Option, + #[serde(default)] + pub parsers: Option>, + #[serde(skip_deserializing)] + pub languages: Option>, } /// Fully optional overrides for [`TreeSitter`]. #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct OptionalTreeSitter { - #[serde(default, alias = "git-ref", alias = "ref")] - pub version: Option, - #[serde(default)] - pub platform: Option, - #[serde(default)] - pub repo: Option, + #[serde(default, alias = "git-ref", alias = "ref")] + pub version: Option, + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub repo: Option, } /// The tree-sitter CLI binary version, platform, and download repo. #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct TreeSitter { - /// Git tag (or ref) of the tree-sitter CLI release. - #[serde( - default = "default_tree_sitter_version", - alias = "git-ref", - alias = "ref" - )] - pub version: String, - - /// Target platform string used to select the correct release asset. - #[serde(default = "default_tree_sitter_platform")] - pub platform: String, - - /// GitHub repository to download the CLI from (owner/name). - #[serde(default = "default_tree_sitter_repo")] - pub repo: String, + /// Git tag (or ref) of the tree-sitter CLI release. + #[serde( + default = "default_tree_sitter_version", + alias = "git-ref", + alias = "ref" + )] + pub version: String, + + /// Target platform string used to select the correct release asset. + #[serde(default = "default_tree_sitter_platform")] + pub platform: String, + + /// GitHub repository to download the CLI from (owner/name). + #[serde(default = "default_tree_sitter_repo")] + pub repo: String, } // ============================================================ @@ -274,67 +274,67 @@ pub struct TreeSitter { /// Get the build subcommand matches, if any. #[must_use] pub fn build_matches(matches: &ArgMatches) -> Option<&ArgMatches> { - matches.subcommand().and_then(|(name, sub)| { - if matches!(name, "build" | "b") { - Some(sub) - } else { - None - } - }) + matches.subcommand().and_then(|(name, sub)| { + if matches!(name, "build" | "b") { + Some(sub) + } else { + None + } + }) } /// Return the number of available CPUs as the default job count. #[must_use] pub fn default_jobs() -> NonZeroUsize { - NonZeroUsize::new(num_cpus::get()).unwrap_or(NonZeroUsize::MIN) + NonZeroUsize::new(num_cpus::get()).unwrap_or(NonZeroUsize::MIN) } /// Get the default tree-sitter platform from compile-time constants. fn default_tree_sitter_platform() -> String { - PLATFORM.to_string() + PLATFORM.to_string() } /// Get the default tree-sitter download repo from compile-time constants. fn default_tree_sitter_repo() -> String { - REPO.to_string() + REPO.to_string() } /// Get the default tree-sitter version from compile-time constants. fn default_tree_sitter_version() -> String { - VERSION.to_string() + VERSION.to_string() } #[must_use] /// Define the CLI colour scheme. const fn get_styles() -> clap::builder::Styles { - clap::builder::Styles::styled() - .usage( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), - ) - .header( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), - ) - .literal(Style::new().fg_color(Some(Color::Ansi(AnsiColor::Blue)))) - .invalid( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Red))), - ) - .error( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Red))), - ) - .valid( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Blue))), - ) - .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::White)))) + clap::builder::Styles::styled() + .usage( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), + ) + .header( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), + ) + .literal(Style::new().fg_color(Some(Color::Ansi(AnsiColor::Blue)))) + .invalid( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Red))), + ) + .error( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Red))), + ) + .valid( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Blue))), + ) + .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::White)))) } // ============================================================ @@ -342,100 +342,99 @@ const fn get_styles() -> clap::builder::Styles { // ============================================================ impl Command { - /// Check whether this command is a build. - #[must_use] - pub const fn is_build(&self) -> bool { - matches!(self, Command::Build) - } + /// Check whether this command is a build. + #[must_use] + pub const fn is_build(&self) -> bool { + matches!(self, Command::Build) + } } impl Default for BuildCommand { - fn default() -> Self { - Self { - build_dir: PathBuf::from(BUILD_DIR), - force: FORCE, - fresh: FRESH, - languages: None, - jobs: default_jobs(), - out_dir: PathBuf::from(PARSER_OUT_DIR), - parsers: None, - prefix: String::from(PREFIX), - show_config: SHOW_CONFIG, - target: Target::default(), - tree_sitter: TreeSitter::default(), - unlock_timeout: UNLOCK_TIMEOUT, - } + fn default() -> Self { + Self { + build_dir: PathBuf::from(BUILD_DIR), + force: FORCE, + fresh: FRESH, + languages: None, + jobs: default_jobs(), + out_dir: PathBuf::from(PARSER_OUT_DIR), + parsers: None, + prefix: String::from(PREFIX), + show_config: SHOW_CONFIG, + target: Target::default(), + tree_sitter: TreeSitter::default(), + unlock_timeout: UNLOCK_TIMEOUT, } + } } impl Default for TreeSitter { - fn default() -> Self { - Self { - version: VERSION.to_string(), - platform: PLATFORM.to_string(), - repo: REPO.to_string(), - } + fn default() -> Self { + Self { + version: VERSION.to_string(), + platform: PLATFORM.to_string(), + repo: REPO.to_string(), } + } } impl fmt::Display for ConfigCommand { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{:?}", format!("{self:?}").to_lowercase()) - } + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", format!("{self:?}").to_lowercase()) + } } impl Target { - /// Check whether `self` covers the requested target (e.g. All covers everything). - #[must_use] - pub fn covers(&self, other: Target) -> bool { - matches!( - (self, other), - (Target::All, _) | (Target::Native, Target::Native) | (Target::Wasm, Target::Wasm) - ) + /// Check whether `self` covers the requested target (e.g. All covers everything). + #[must_use] + pub fn covers(&self, other: Target) -> bool { + matches!( + (self, other), + (Target::All, _) | (Target::Native, Target::Native) | (Target::Wasm, Target::Wasm) + ) + } + + /// Combine two targets into the broadest coverage. + #[must_use] + pub fn union(self, other: Self) -> Self { + match (self, other) { + (Self::Native, Self::Native) => Self::Native, + (Self::Wasm, Self::Wasm) => Self::Wasm, + (Self::All, _) | (_, Self::All) | (Self::Native, Self::Wasm) | (Self::Wasm, Self::Native) => { + Self::All + } } - - /// Combine two targets into the broadest coverage. - #[must_use] - pub fn union(self, other: Self) -> Self { - match (self, other) { - (Self::Native, Self::Native) => Self::Native, - (Self::Wasm, Self::Wasm) => Self::Wasm, - (Self::All, _) - | (_, Self::All) - | (Self::Native, Self::Wasm) - | (Self::Wasm, Self::Native) => Self::All, - } - } - - /// Check whether the target includes native builds. - #[must_use] - pub fn native(&self) -> bool { - matches!(self, Self::All | Self::Native) - } - - /// Check whether the target includes wasm builds. - #[must_use] - pub fn wasm(&self) -> bool { - matches!(self, Self::All | Self::Wasm) - } - - /// Return a lowercased string representation. - #[must_use] - pub fn to_lowercase(&self) -> &'static str { - match self { - Target::Native => "native", - Target::Wasm => "wasm", - Target::All => "all", - } + } + + /// Check whether the target includes native builds. + #[must_use] + pub fn native(&self) -> bool { + matches!(self, Self::All | Self::Native) + } + + /// Check whether the target includes wasm builds. + #[must_use] + pub fn wasm(&self) -> bool { + matches!(self, Self::All | Self::Wasm) + } + + /// Return a lowercased string representation. + #[must_use] + pub fn to_lowercase(&self) -> &'static str { + match self { + Target::Native => "native", + Target::Wasm => "wasm", + Target::All => "all", } + } } impl fmt::Display for VersionBump { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Major => write!(f, "major"), - Self::Minor => write!(f, "minor"), - Self::Patch => write!(f, "patch"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Major => write!(f, "major"), + Self::Minor => write!(f, "minor"), + Self::Patch => write!(f, "patch"), } + } } diff --git a/src/build.rs b/src/build.rs index da271f3..fc98d80 100644 --- a/src/build.rs +++ b/src/build.rs @@ -3,12 +3,12 @@ use std::fmt; use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::{Path, PathBuf}, - result::Result as StdResult, - sync::Arc, - time::Duration, + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + result::Result as StdResult, + sync::Arc, + time::Duration, }; use serde::{Deserialize, Serialize}; @@ -16,8 +16,8 @@ use tracing::info; use url::Url; use crate::{ - Error, Result, ResultExt, SafeCanonicalize, absolute_normalize, actors, app, args, cache, - consts, format_duration, lock, parser, prompt_user, shutdown, + Error, Result, ResultExt, SafeCanonicalize, absolute_normalize, actors, app, args, cache, consts, + format_duration, lock, parser, prompt_user, shutdown, }; // ============================================================ @@ -31,35 +31,35 @@ pub struct BuildDir(PathBuf); /// Per-language build context flags. #[derive(Debug, Clone, PartialEq)] pub struct Context { - /// Whether to overwrite existing output binaries. - pub overwrite_output: bool, + /// Whether to overwrite existing output binaries. + pub overwrite_output: bool, } /// Paths for the checkout and output directories of a parser build. #[derive(Debug, Clone)] pub struct OutputConfig { - /// Directory where the parser source is checked out. - pub build_dir: PathBuf, - /// Directory where built binaries are installed. - pub out_dir: PathBuf, + /// Directory where the parser source is checked out. + pub build_dir: PathBuf, + /// Directory where built binaries are installed. + pub out_dir: PathBuf, } /// Full build specification for a parser: source location, git ref, prefix, /// target, and tree-sitter CLI version. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Spec { - /// Optional custom build script (e.g. "make"). - pub build_script: Option, - /// Git ref to check out (tag, branch, or commit SHA). - pub git_ref: parser::Ref, - /// Filename prefix for installed binaries. - pub prefix: String, - /// Repository URL to clone. - pub repo: Url, - /// Build output kind. - pub target: args::Target, - /// Tree-sitter CLI configuration. - pub tree_sitter: args::TreeSitter, + /// Optional custom build script (e.g. "make"). + pub build_script: Option, + /// Git ref to check out (tag, branch, or commit SHA). + pub git_ref: parser::Ref, + /// Filename prefix for installed binaries. + pub prefix: String, + /// Repository URL to clone. + pub repo: Url, + /// Build output kind. + pub target: args::Target, + /// Tree-sitter CLI configuration. + pub tree_sitter: args::TreeSitter, } // ============================================================ @@ -68,275 +68,274 @@ pub struct Spec { /// Try to acquire the build lock, prompting the user if another process holds it. fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result { - // Loop because the lock owner may exit naturally between the prompt and - // SIGTERM, or another process may replace it. Each iteration re-checks - // the lock status and presents the current owner to the user. - loop { - match lock.try_acquire()? { - lock::Status::Acquired(guard) => return Ok(guard), - - lock::Status::Cyclic => { - info!("lock::Lock already held by this process (cyclic)."); - return Err(Error::Message { - message: "1+ lock acquisition".into(), - }); - } - - lock::Status::LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { - Ok(guard) => return Ok(guard), - Err(ref err) if err.is_retryable() => { - info!("{err}; re-checking lock status..."); - // continue to the next loop iteration - } - Err(err) => return Err(err.into()), - }, - - lock::Status::Unknown { pid, reason } => { - if let Some(pid) = pid { - info!( - "Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}" - ); - } else { - info!( - "Build directory is locked, but tsdl could not identify the owner: {reason}" - ); - } - return Err(Error::Message { - message: format!("Could not identify build lock owner: {reason}"), - }); - } + // Loop because the lock owner may exit naturally between the prompt and + // SIGTERM, or another process may replace it. Each iteration re-checks + // the lock status and presents the current owner to the user. + loop { + match lock.try_acquire()? { + lock::Status::Acquired(guard) => return Ok(guard), + + lock::Status::Cyclic => { + info!("lock::Lock already held by this process (cyclic)."); + return Err(Error::Message { + message: "1+ lock acquisition".into(), + }); + } + + lock::Status::LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { + Ok(guard) => return Ok(guard), + Err(ref err) if err.is_retryable() => { + info!("{err}; re-checking lock status..."); + // continue to the next loop iteration + } + Err(err) => return Err(err.into()), + }, + + lock::Status::Unknown { pid, reason } => { + if let Some(pid) = pid { + info!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"); + } else { + info!("Build directory is locked, but tsdl could not identify the owner: {reason}"); } + return Err(Error::Message { + message: format!("Could not identify build lock owner: {reason}"), + }); + } } + } } /// Optionally clear the build directory (--fresh) and ensure it exists. fn clear( - build_dir: &BuildDir, - guard: &lock::Guard, - fresh: bool, - log_path: Option<&Path>, + build_dir: &BuildDir, + guard: &lock::Guard, + fresh: bool, + log_path: Option<&Path>, ) -> Result<()> { - if fresh && build_dir.as_path().exists() { - let protected_files = log_path - .map(Path::to_path_buf) - .into_iter() - .collect::>(); - guard.clear_directory(&protected_files)?; - } + if fresh && build_dir.as_path().exists() { + let protected_files = log_path + .map(Path::to_path_buf) + .into_iter() + .collect::>(); + guard.clear_directory(&protected_files)?; + } - fs::create_dir_all(build_dir.as_path())?; + fs::create_dir_all(build_dir.as_path())?; - Ok(()) + Ok(()) } /// Collect language build definitions from the command arguments, partitioning /// results to return all errors if any language fails to resolve. fn collect_languages( - command: &args::BuildCommand, - build_dir: &BuildDir, + command: &args::BuildCommand, + build_dir: &BuildDir, ) -> Result> { - let results = unique_languages(command, build_dir); - let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); - - if err.is_empty() { - Ok(ok.into_iter().map(Result::unwrap).collect()) - } else { - Err(Error::LanguageCollection { - related: err.into_iter().map(Result::unwrap_err).collect(), - }) - } + let results = unique_languages(command, build_dir); + let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); + + if err.is_empty() { + Ok(ok.into_iter().map(Result::unwrap).collect()) + } else { + Err(Error::LanguageCollection { + related: err.into_iter().map(Result::unwrap_err).collect(), + }) + } } /// Build the default parser repo URL for a language. fn default_repo(language: &str) -> Result { - use consts::FROM; + use consts::FROM; - let url = format!("{FROM}{language}"); - Url::parse(&url).with_context(|| format!("Creating url {url} for {language}")) + let url = format!("{FROM}{language}"); + Url::parse(&url).with_context(|| format!("Creating url {url} for {language}")) } /// Look up the coordinates (repo URL, git ref, build script) for a language. fn get_language_coords( - language: &str, - defined_parsers: Option<&BTreeMap>, + language: &str, + defined_parsers: Option<&BTreeMap>, ) -> Result<(Url, parser::Ref, Option)> { - let config = defined_parsers.and_then(|parsers| parsers.get(language)); - - match config { - Some(args::ParserConfig::Ref(git_ref)) => Ok(( - default_repo(language)?, - parser::Ref::parse(git_ref) - .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, - None, - )), - - Some(args::ParserConfig::Full { - build_script, - git_ref, - from, - }) => { - let repo = match from { - Some(url_str) => Url::parse(url_str) - .with_context(|| format!("Parsing {url_str} for {language}"))?, - None => default_repo(language)?, - }; - - Ok(( - repo, - parser::Ref::parse(git_ref) - .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, - build_script.clone(), - )) + let config = defined_parsers.and_then(|parsers| parsers.get(language)); + + match config { + Some(args::ParserConfig::Ref(git_ref)) => Ok(( + default_repo(language)?, + parser::Ref::parse(git_ref) + .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, + None, + )), + + Some(args::ParserConfig::Full { + build_script, + git_ref, + from, + }) => { + let repo = match from { + Some(url_str) => { + Url::parse(url_str).with_context(|| format!("Parsing {url_str} for {language}"))? } - - None => Ok((default_repo(language)?, parser::Ref::head(), None)), + None => default_repo(language)?, + }; + + Ok(( + repo, + parser::Ref::parse(git_ref) + .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, + build_script.clone(), + )) } + + None => Ok((default_repo(language)?, parser::Ref::head(), None)), + } } /// Prompt the user to terminate the lock-holding process, then wait for release. fn handle_locked_by( - lock: &lock::Lock, - owner: &lock::Owner, - unlock_timeout: Duration, + lock: &lock::Lock, + owner: &lock::Owner, + unlock_timeout: Duration, ) -> StdResult { - info!("Build directory is locked by another process:"); - info!("{owner}"); - eprintln!( - "If you continue, tsdl will send SIGTERM to PID {} and wait up to {} \ + info!("Build directory is locked by another process:"); + info!("{owner}"); + eprintln!( + "If you continue, tsdl will send SIGTERM to PID {} and wait up to {} \ (--unlock-timeout {}) for the build lock to be released.", - owner.pid, - format_duration(unlock_timeout), - unlock_timeout.as_secs() + owner.pid, + format_duration(unlock_timeout), + unlock_timeout.as_secs() + ); + + if !prompt_user("Terminate this process and continue?", false)? { + return Err( + Error::Message { + message: "lock::Lock acquisition cancelled by user".into(), + } + .into(), ); - - if !prompt_user("Terminate this process and continue?", false)? { - return Err(Error::Message { - message: "lock::Lock acquisition cancelled by user".into(), - } - .into()); - } - - lock.terminate_owner(owner)?; - info!( - "Sent SIGTERM to PID {}. Waiting for the build lock to be released...", - owner.pid - ); - lock.wait_for_release(owner, unlock_timeout) + } + + lock.terminate_owner(owner)?; + info!( + "Sent SIGTERM to PID {}. Waiting for the build lock to be released...", + owner.pid + ); + lock.wait_for_release(owner, unlock_timeout) } /// Set up the runtime and spawn the build pipeline. fn ignite(command: &args::BuildCommand, app: &app::App, build_dir: &BuildDir) -> Result<()> { - fs::create_dir_all(&command.out_dir)?; - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - - let guard = rt.enter(); - - let cache_store = cache::Store::new(build_dir); - let db = cache_store.load()?; - let languages = collect_languages(command, build_dir)?; - - let result = rt.block_on(async move { - let shutdown = shutdown::Handle::new(); - let _signals = shutdown.spawn_signal_listener()?; - let cache = actors::CacheActor::spawn(db, command.force, cache_store); - let display_build_dir = build_dir.as_path().to_path_buf(); - let display_out_dir = command.out_dir.canon()?; - let display = - actors::DisplayActor::spawn(display_build_dir, app.progress_mode, display_out_dir); - - shutdown::scope(shutdown, async move { - actors::run( - build_dir.as_path(), - cache, - display, - command.jobs, - languages, - &command.tree_sitter, - ) - .await - }) - .await?; - - Ok(()) - }); - - drop(guard); - - result + fs::create_dir_all(&command.out_dir)?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + let guard = rt.enter(); + + let cache_store = cache::Store::new(build_dir); + let db = cache_store.load()?; + let languages = collect_languages(command, build_dir)?; + + let result = rt.block_on(async move { + let shutdown = shutdown::Handle::new(); + let _signals = shutdown.spawn_signal_listener()?; + let cache = actors::CacheActor::spawn(db, command.force, cache_store); + let display_build_dir = build_dir.as_path().to_path_buf(); + let display_out_dir = command.out_dir.canon()?; + let display = + actors::DisplayActor::spawn(display_build_dir, app.progress_mode, display_out_dir); + + shutdown::scope(shutdown, async move { + actors::run( + build_dir.as_path(), + cache, + display, + command.jobs, + languages, + &command.tree_sitter, + ) + .await + }) + .await?; + + Ok(()) + }); + + drop(guard); + + result } /// Acquire the build lock, clear if requested, and start the build. pub fn run(command: &args::BuildCommand, app: &app::App) -> Result<()> { - if command.show_config { - crate::config::show(command)?; - } + if command.show_config { + crate::config::show(command)?; + } - let build_dir = BuildDir::new(&command.build_dir)?; - let lock = lock::Lock::new(&build_dir); - let guard = acquire_lock(&lock, Duration::from_secs(command.unlock_timeout))?; + let build_dir = BuildDir::new(&command.build_dir)?; + let lock = lock::Lock::new(&build_dir); + let guard = acquire_lock(&lock, Duration::from_secs(command.unlock_timeout))?; - clear(&build_dir, &guard, command.fresh, app.logging.path())?; - ignite(command, app, &build_dir)?; - Ok(()) + clear(&build_dir, &guard, command.fresh, app.logging.path())?; + ignite(command, app, &build_dir)?; + Ok(()) } /// Produce a sorted, deduplicated list of language build definitions from the /// command's requested languages or defined parsers. fn unique_languages( - command: &args::BuildCommand, - build_dir: &BuildDir, + command: &args::BuildCommand, + build_dir: &BuildDir, ) -> Vec> { - let requested_languages = &command.languages; - let defined_parsers = command.parsers.as_ref(); - - let final_languages = match requested_languages { - Some(langs) if !langs.is_empty() => langs.clone(), - _ => defined_parsers - .map(|parsers| parsers.keys().cloned().collect()) - .unwrap_or_default(), + let requested_languages = &command.languages; + let defined_parsers = command.parsers.as_ref(); + + let final_languages = match requested_languages { + Some(langs) if !langs.is_empty() => langs.clone(), + _ => defined_parsers + .map(|parsers| parsers.keys().cloned().collect()) + .unwrap_or_default(), + }; + + let unique = final_languages.into_iter().collect::>(); + let mut results = Vec::with_capacity(unique.len()); + + for language in unique { + let result = match get_language_coords(&language, defined_parsers) { + Ok((repo, git_ref, build_script)) => Ok(parser::LanguageBuild::new( + Context { + overwrite_output: command.force, + }, + parser::LanguageName::from(language.clone()), + OutputConfig { + build_dir: build_dir + .checkout_dir(&language) + .canon() + .expect("Build dir canonicalization failed"), + out_dir: command + .out_dir + .canon() + .expect("Out dir canonicalization failed"), + }, + Arc::new(Spec { + build_script, + git_ref, + repo, + tree_sitter: command.tree_sitter.clone(), + prefix: command.prefix.clone(), + target: command.target, + }), + )), + Err(err) => Err(Error::Language { + name: language, + source: err.into(), + }), }; + results.push(result); + } - let unique = final_languages.into_iter().collect::>(); - let mut results = Vec::with_capacity(unique.len()); - - for language in unique { - let result = match get_language_coords(&language, defined_parsers) { - Ok((repo, git_ref, build_script)) => Ok(parser::LanguageBuild::new( - Context { - overwrite_output: command.force, - }, - parser::LanguageName::from(language.clone()), - OutputConfig { - build_dir: build_dir - .checkout_dir(&language) - .canon() - .expect("Build dir canonicalization failed"), - out_dir: command - .out_dir - .canon() - .expect("Out dir canonicalization failed"), - }, - Arc::new(Spec { - build_script, - git_ref, - repo, - tree_sitter: command.tree_sitter.clone(), - prefix: command.prefix.clone(), - target: command.target, - }), - )), - Err(err) => Err(Error::Language { - name: language, - source: err.into(), - }), - }; - results.push(result); - } - - results + results } // ============================================================ @@ -344,83 +343,83 @@ fn unique_languages( // ============================================================ impl AsRef for BuildDir { - fn as_ref(&self) -> &Path { - &self.0 - } + fn as_ref(&self) -> &Path { + &self.0 + } } impl BuildDir { - /// The absolute, normalised path as a `&Path`. - #[must_use] - pub fn as_path(&self) -> &Path { - &self.0 - } - - /// Path to the build cache TOML file inside this build directory. - #[must_use] - pub fn cache_file(&self) -> PathBuf { - self.0.join(consts::CACHE_FILE) - } - - /// Per-language checkout directory: `/tree-sitter-`. - #[must_use] - pub fn checkout_dir(&self, language: &str) -> PathBuf { - self.0.join(format!("tree-sitter-{language}")) - } - - /// Path to the PID lock file inside this build directory. - #[must_use] - pub fn lock_file(&self) -> PathBuf { - self.0.join(consts::LOCK_FILE) - } - - /// Path to the default log file inside this build directory. - #[must_use] - pub fn log_file(&self) -> PathBuf { - self.0.join(consts::LOG_FILE) - } - - /// Create a `BuildDir`, resolving relative paths against the current - /// working directory and normalising `.` / `..` components. - pub fn new(path: impl Into) -> Result { - let path = absolute_normalize(&path.into())?; - Ok(Self(path)) - } + /// The absolute, normalised path as a `&Path`. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } + + /// Path to the build cache TOML file inside this build directory. + #[must_use] + pub fn cache_file(&self) -> PathBuf { + self.0.join(consts::CACHE_FILE) + } + + /// Per-language checkout directory: `/tree-sitter-`. + #[must_use] + pub fn checkout_dir(&self, language: &str) -> PathBuf { + self.0.join(format!("tree-sitter-{language}")) + } + + /// Path to the PID lock file inside this build directory. + #[must_use] + pub fn lock_file(&self) -> PathBuf { + self.0.join(consts::LOCK_FILE) + } + + /// Path to the default log file inside this build directory. + #[must_use] + pub fn log_file(&self) -> PathBuf { + self.0.join(consts::LOG_FILE) + } + + /// Create a `BuildDir`, resolving relative paths against the current + /// working directory and normalising `.` / `..` components. + pub fn new(path: impl Into) -> Result { + let path = absolute_normalize(&path.into())?; + Ok(Self(path)) + } } impl fmt::Display for BuildDir { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0.display()) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.display()) + } } #[cfg(test)] mod tests { - use super::*; - use crate::args::BuildCommand; - - fn command_with_languages(languages: &[&str]) -> BuildCommand { - BuildCommand { - languages: Some( - languages - .iter() - .map(|language| (*language).to_string()) - .collect(), - ), - ..BuildCommand::default() - } + use super::*; + use crate::args::BuildCommand; + + fn command_with_languages(languages: &[&str]) -> BuildCommand { + BuildCommand { + languages: Some( + languages + .iter() + .map(|language| (*language).to_string()) + .collect(), + ), + ..BuildCommand::default() } + } - #[test] - fn unique_languages_sorts_and_deduplicates_requested_languages() { - let command = command_with_languages(&["rust", "json", "ruby", "json", "rust"]); - let build_dir = BuildDir::new("test-build").unwrap(); + #[test] + fn unique_languages_sorts_and_deduplicates_requested_languages() { + let command = command_with_languages(&["rust", "json", "ruby", "json", "rust"]); + let build_dir = BuildDir::new("test-build").unwrap(); - let languages = unique_languages(&command, &build_dir) - .into_iter() - .map(|language| language.unwrap().name.to_string()) - .collect::>(); + let languages = unique_languages(&command, &build_dir) + .into_iter() + .map(|language| language.unwrap().name.to_string()) + .collect::>(); - assert_eq!(languages, vec!["json", "ruby", "rust"]); - } + assert_eq!(languages, vec!["json", "ruby", "rust"]); + } } diff --git a/src/cache.rs b/src/cache.rs index 9ce65c9..196faac 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -3,13 +3,13 @@ //! needed. use std::{ - collections::BTreeMap, - fmt::{self, Write as _}, - io::{self, Write as _}, - path::{Path, PathBuf}, - pin::Pin, - sync::Arc, - task::{Context, Poll}, + collections::BTreeMap, + fmt::{self, Write as _}, + io::{self, Write as _}, + path::{Path, PathBuf}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, }; use serde::{Deserialize, Serialize}; @@ -26,53 +26,53 @@ use crate::{Error, Result, ResultExt, args, build, build::BuildDir, git, parser} /// A cache lookup result for a requested parser build. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Decision { - /// Entry exists and is fully valid. - Hit, - /// Entry is missing or something changed. - Miss(Miss), + /// Entry exists and is fully valid. + Hit, + /// Entry is missing or something changed. + Miss(Miss), } /// One reason a cached parser build cannot be reused. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MissReason { - /// No cache entry at all. - MissingEntry, - /// Cache was explicitly bypassed (--force). - CacheIgnored, - /// Hash of grammar.js changed. - HashChanged { - cached: GrammarHash, - current: GrammarHash, - }, - /// Parser repo URL changed. - RepoChanged { cached: String, current: String }, - /// Git ref changed (different tag or branch). - RefChanged { - cached: parser::Ref, - current: parser::Ref, - }, - /// Git ref resolved to a different commit. - RevisionChanged { cached: Revision, current: Revision }, - /// Tree-sitter CLI version changed. - TreeSitterChanged { - cached: args::TreeSitter, - current: args::TreeSitter, - }, - /// Build script changed. - BuildScriptChanged, - /// Output filename prefix changed. - PrefixChanged { cached: String, current: String }, - /// Requested output target not covered by cached entry. - OutputsMissing { - available: args::Target, - requested: args::Target, - }, - /// Expected output file is missing from disk. - ArtifactMissing { path: PathBuf }, - /// Expected output path exists but is not a regular file. - ArtifactNotFile { path: PathBuf }, - /// Expected output path exists but cannot be read. - ArtifactInaccessible { path: PathBuf, error: String }, + /// No cache entry at all. + MissingEntry, + /// Cache was explicitly bypassed (--force). + CacheIgnored, + /// Hash of grammar.js changed. + HashChanged { + cached: GrammarHash, + current: GrammarHash, + }, + /// Parser repo URL changed. + RepoChanged { cached: String, current: String }, + /// Git ref changed (different tag or branch). + RefChanged { + cached: parser::Ref, + current: parser::Ref, + }, + /// Git ref resolved to a different commit. + RevisionChanged { cached: Revision, current: Revision }, + /// Tree-sitter CLI version changed. + TreeSitterChanged { + cached: args::TreeSitter, + current: args::TreeSitter, + }, + /// Build script changed. + BuildScriptChanged, + /// Output filename prefix changed. + PrefixChanged { cached: String, current: String }, + /// Requested output target not covered by cached entry. + OutputsMissing { + available: args::Target, + requested: args::Target, + }, + /// Expected output file is missing from disk. + ArtifactMissing { path: PathBuf }, + /// Expected output path exists but is not a regular file. + ArtifactNotFile { path: PathBuf }, + /// Expected output path exists but cannot be read. + ArtifactInaccessible { path: PathBuf, error: String }, } /// Parser revision identity: stable (tag/commit) or moving (branch with @@ -80,10 +80,10 @@ pub enum MissReason { #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", tag = "kind")] pub enum Revision { - /// Stable ref — no per-build commit tracking needed. - Stable, - /// Moving ref — the specific commit SHA checked out this build. - Moving { commit: git::Sha }, + /// Stable ref — no per-build commit tracking needed. + Stable, + /// Moving ref — the specific commit SHA checked out this build. + Moving { commit: git::Sha }, } // ============================================================ @@ -93,23 +93,23 @@ pub enum Revision { /// The logical build cache contents. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { - /// Per-key cache entries (keyed by `"{language}/{grammar}"`). - #[serde(default)] - pub parsers: BTreeMap, + /// Per-key cache entries (keyed by `"{language}/{grammar}"`). + #[serde(default)] + pub parsers: BTreeMap, } /// Cache entry for a single parser. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { - /// Hash of the grammar.js file(s). - pub hash: GrammarHash, - /// Resolved parser revision used by the cache. Moving refs include the - /// checked-out commit. - pub revision: Revision, - /// Build specification used to produce this entry. - pub spec: Arc, - /// Parser outputs known to be available for this entry. - pub outputs: args::Target, + /// Hash of the grammar.js file(s). + pub hash: GrammarHash, + /// Resolved parser revision used by the cache. Moving refs include the + /// checked-out commit. + pub revision: Revision, + /// Build specification used to produce this entry. + pub spec: Arc, + /// Parser outputs known to be available for this entry. + pub outputs: args::Target, } /// SHA-1 hash of a grammar.js file. @@ -127,24 +127,24 @@ pub struct Key(Arc); /// Details explaining why a cache entry cannot satisfy a requested parser build. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Miss { - /// Individual reasons for the cache miss. - pub reasons: Vec, + /// Individual reasons for the cache miss. + pub reasons: Vec, } /// File-backed storage for a [`Db`]. #[derive(Debug, Clone)] pub struct Store { - /// Path to the cache file on disk. - file: PathBuf, + /// Path to the cache file on disk. + file: PathBuf, } /// Represents a "Delta" to be applied to the cache after a successful build #[derive(Debug, Clone)] pub struct Update { - /// The cache entry to store. - pub entry: Entry, - /// The key to store it under. - pub name: Key, + /// The cache entry to store. + pub entry: Entry, + /// The key to store it under. + pub name: Key, } // ============================================================ @@ -153,27 +153,27 @@ pub struct Update { /// Hash the contents of a file using SHA-1 and return the hex string. pub async fn hash_file(path: &Path) -> Result { - let mut file = tokio::fs::File::open(path) - .await - .with_context(|| format!("Opening file for hashing: {}", path.display()))?; - - let mut hasher = Sha1::new(); - - tokio::io::copy(&mut file, &mut HashWriter(&mut hasher)) - .await - .with_context(|| format!("Reading file for hashing: {}", path.display()))?; - - let result = hasher.finalize(); - let mut hex = String::with_capacity(result.len() * 2); - for byte in result { - write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail"); - } - Ok(GrammarHash::from(hex)) + let mut file = tokio::fs::File::open(path) + .await + .with_context(|| format!("Opening file for hashing: {}", path.display()))?; + + let mut hasher = Sha1::new(); + + tokio::io::copy(&mut file, &mut HashWriter(&mut hasher)) + .await + .with_context(|| format!("Reading file for hashing: {}", path.display()))?; + + let result = hasher.finalize(); + let mut hex = String::with_capacity(result.len() * 2); + for byte in result { + write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail"); + } + Ok(GrammarHash::from(hex)) } /// Sync the directory metadata to disk (best-effort for atomic write safety). fn sync_directory(path: &Path) -> std::io::Result<()> { - std::fs::File::open(path)?.sync_all() + std::fs::File::open(path)?.sync_all() } /// Verify that every path in `artifacts` exists as a regular file. @@ -181,62 +181,65 @@ fn sync_directory(path: &Path) -> std::io::Result<()> { /// Returns [`Decision::Hit`] when all artifacts are present, or a /// [`Decision::Miss`] listing every missing or non-file path. pub async fn verify_artifacts(artifacts: Vec) -> Decision { - let mut reasons = Vec::new(); - - for path in artifacts { - match tokio::fs::metadata(&path).await { - Ok(metadata) if metadata.is_file() => {} - Ok(_) => reasons.push(MissReason::ArtifactNotFile { path }), - Err(err) if err.kind() == io::ErrorKind::NotFound => { - reasons.push(MissReason::ArtifactMissing { path }); - } - Err(err) => reasons.push(MissReason::ArtifactInaccessible { - path, - error: err.to_string(), - }), - } - } - - Decision::from_reasons(reasons) + let mut reasons = Vec::new(); + + for path in artifacts { + match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() => {} + Ok(_) => reasons.push(MissReason::ArtifactNotFile { path }), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + reasons.push(MissReason::ArtifactMissing { path }); + } + Err(err) => reasons.push(MissReason::ArtifactInaccessible { + path, + error: err.to_string(), + }), + } + } + + Decision::from_reasons(reasons) } /// Atomically write cache contents to a file using a temporary file + rename. fn write_cache_file_atomically(file: &Path, contents: &str) -> Result<()> { - let parent = file.parent().ok_or_else(|| Error::Message { - message: format!( - "Cache file path has no parent directory: {}", - file.display() - ), - })?; - - std::fs::create_dir_all(parent) - .with_context(|| format!("Creating cache directory {}", parent.display()))?; - - let mut temp = tempfile::Builder::new() - .prefix(".cache.toml.") - .suffix(".tmp") - .tempfile_in(parent) - .with_context(|| format!("Creating temporary cache file in {}", parent.display()))?; - - temp.write_all(contents.as_bytes()) - .with_context(|| format!("Writing temporary cache file {}", temp.path().display()))?; - temp.as_file_mut() - .sync_all() - .with_context(|| format!("Syncing temporary cache file {}", temp.path().display()))?; - - temp.persist(file) - .map_err(|err| err.error) - .with_context(|| format!("Installing cache file to {}", file.display()))?; - - if let Err(err) = sync_directory(parent) { - debug!( - "Could not sync cache directory {} after saving {}: {err}", - parent.display(), - file.display() - ); - } - - Ok(()) + let parent = file.parent().ok_or_else(|| Error::Message { + message: format!( + "Cache file path has no parent directory: {}", + file.display() + ), + })?; + + std::fs::create_dir_all(parent) + .with_context(|| format!("Creating cache directory {}", parent.display()))?; + + let mut temp = tempfile::Builder::new() + .prefix(".cache.toml.") + .suffix(".tmp") + .tempfile_in(parent) + .with_context(|| format!("Creating temporary cache file in {}", parent.display()))?; + + temp + .write_all(contents.as_bytes()) + .with_context(|| format!("Writing temporary cache file {}", temp.path().display()))?; + temp + .as_file_mut() + .sync_all() + .with_context(|| format!("Syncing temporary cache file {}", temp.path().display()))?; + + temp + .persist(file) + .map_err(|err| err.error) + .with_context(|| format!("Installing cache file to {}", file.display()))?; + + if let Err(err) = sync_directory(parent) { + debug!( + "Could not sync cache directory {} after saving {}: {err}", + parent.display(), + file.display() + ); + } + + Ok(()) } // ============================================================ @@ -244,875 +247,873 @@ fn write_cache_file_atomically(file: &Path, contents: &str) -> Result<()> { // ============================================================ impl AsyncWrite for HashWriter<'_> { - /// Write bytes into the SHA-1 hasher. - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - self.0.update(buf); - Poll::Ready(Ok(buf.len())) - } - - /// Flush is a no-op for the hasher. - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - /// Shutdown is a no-op for the hasher. - fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } + /// Write bytes into the SHA-1 hasher. + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.0.update(buf); + Poll::Ready(Ok(buf.len())) + } + + /// Flush is a no-op for the hasher. + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + /// Shutdown is a no-op for the hasher. + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } } impl Db { - /// Clear all entries. - pub fn clear(&mut self) { - self.parsers.clear(); - } - - /// Get cache entry for a parser. - #[must_use] - pub fn get(&self, name: &Key) -> Option<&Entry> { - self.parsers.get(name) - } + /// Clear all entries. + pub fn clear(&mut self) { + self.parsers.clear(); + } + + /// Get cache entry for a parser. + #[must_use] + pub fn get(&self, name: &Key) -> Option<&Entry> { + self.parsers.get(name) + } + + /// True when the cache contains at least one entry for `language` + /// that was built from the same repository and git ref. + /// + /// The tree-sitter CLI version, build script, prefix, and target are + /// intentionally excluded — they affect artefact validity (checked by + /// `needs_rebuild`) but not whether a prior checkout of the parser + /// source is still usable. + #[must_use] + pub fn has_compatible_entry_for_language( + &self, + language: &parser::LanguageName, + spec: &build::Spec, + ) -> bool { + let prefix = Key::language_prefix(language); + self.parsers.iter().any(|(key, entry)| { + key.as_str().starts_with(&prefix) + && entry.spec.repo == spec.repo + && entry.spec.git_ref == spec.git_ref + }) + } + + /// Check if a parser needs rebuilding by comparing grammar hash and build definition. + #[must_use] + pub fn needs_rebuild( + &self, + name: &Key, + hash: &GrammarHash, + revision: &Revision, + spec: &build::Spec, + ) -> bool { + self + .rebuild_decision(name, hash, revision, spec) + .needs_rebuild() + } + + /// Explain whether a parser cache entry can satisfy the requested build. + pub fn rebuild_decision( + &self, + name: &Key, + hash: &GrammarHash, + revision: &Revision, + spec: &build::Spec, + ) -> Decision { + let decision = match self.get(name) { + None => Decision::miss(MissReason::MissingEntry), + Some(entry) => entry.rebuild_decision(hash, revision, spec), + }; + + debug!("Cache decision for {name}: {decision}"); + decision + } + + /// Insert or update a parser cache entry. + pub fn set(&mut self, name: Key, mut entry: Entry) { + if let Some(existing) = self.parsers.get(&name) + && existing.same_subject(&entry) + { + entry.outputs = existing.outputs.union(entry.outputs); + } + + self.parsers.insert(name, entry); + } +} - /// True when the cache contains at least one entry for `language` - /// that was built from the same repository and git ref. - /// - /// The tree-sitter CLI version, build script, prefix, and target are - /// intentionally excluded — they affect artefact validity (checked by - /// `needs_rebuild`) but not whether a prior checkout of the parser - /// source is still usable. - #[must_use] - pub fn has_compatible_entry_for_language( - &self, - language: &parser::LanguageName, - spec: &build::Spec, - ) -> bool { - let prefix = Key::language_prefix(language); - self.parsers.iter().any(|(key, entry)| { - key.as_str().starts_with(&prefix) - && entry.spec.repo == spec.repo - && entry.spec.git_ref == spec.git_ref - }) - } +impl Decision { + /// Create a Decision from a list of miss reasons (empty list → Hit). + #[must_use] + pub fn from_reasons(reasons: Vec) -> Self { + if reasons.is_empty() { + Self::Hit + } else { + Self::Miss(Miss { reasons }) + } + } + + /// Check whether this decision indicates a cache hit. + #[must_use] + pub fn is_hit(&self) -> bool { + matches!(self, Self::Hit) + } + + /// Create a Miss decision for a single reason. + #[must_use] + pub fn miss(reason: MissReason) -> Self { + Self::Miss(Miss { + reasons: vec![reason], + }) + } + + /// Check whether the decision requires a rebuild. + #[must_use] + pub fn needs_rebuild(&self) -> bool { + !self.is_hit() + } + + /// Return a short human-readable summary of the decision. + #[must_use] + pub fn short_message(&self) -> String { + match self { + Self::Hit => "cache hit".to_string(), + Self::Miss(miss) => miss.short_message(), + } + } +} - /// Check if a parser needs rebuilding by comparing grammar hash and build definition. - #[must_use] - pub fn needs_rebuild( - &self, - name: &Key, - hash: &GrammarHash, - revision: &Revision, - spec: &build::Spec, - ) -> bool { - self.rebuild_decision(name, hash, revision, spec) - .needs_rebuild() - } +impl Entry { + /// Compare the current build parameters against this entry and decide + /// whether the cached output is still valid. + #[must_use] + pub fn rebuild_decision( + &self, + hash: &GrammarHash, + revision: &Revision, + spec: &build::Spec, + ) -> Decision { + let mut reasons = Vec::new(); - /// Explain whether a parser cache entry can satisfy the requested build. - pub fn rebuild_decision( - &self, - name: &Key, - hash: &GrammarHash, - revision: &Revision, - spec: &build::Spec, - ) -> Decision { - let decision = match self.get(name) { - None => Decision::miss(MissReason::MissingEntry), - Some(entry) => entry.rebuild_decision(hash, revision, spec), - }; - - debug!("Cache decision for {name}: {decision}"); - decision + if &self.hash != hash { + reasons.push(MissReason::HashChanged { + cached: self.hash.clone(), + current: hash.clone(), + }); } - /// Insert or update a parser cache entry. - pub fn set(&mut self, name: Key, mut entry: Entry) { - if let Some(existing) = self.parsers.get(&name) - && existing.same_subject(&entry) - { - entry.outputs = existing.outputs.union(entry.outputs); - } - - self.parsers.insert(name, entry); + if self.spec.repo != spec.repo { + reasons.push(MissReason::RepoChanged { + cached: self.spec.repo.to_string(), + current: spec.repo.to_string(), + }); } -} -impl Decision { - /// Create a Decision from a list of miss reasons (empty list → Hit). - #[must_use] - pub fn from_reasons(reasons: Vec) -> Self { - if reasons.is_empty() { - Self::Hit - } else { - Self::Miss(Miss { reasons }) - } + let git_ref_changed = self.spec.git_ref != spec.git_ref; + if git_ref_changed { + reasons.push(MissReason::RefChanged { + cached: self.spec.git_ref.clone(), + current: spec.git_ref.clone(), + }); } - /// Check whether this decision indicates a cache hit. - #[must_use] - pub fn is_hit(&self) -> bool { - matches!(self, Self::Hit) + if !git_ref_changed && self.revision != *revision { + reasons.push(MissReason::RevisionChanged { + cached: self.revision.clone(), + current: revision.clone(), + }); } - /// Create a Miss decision for a single reason. - #[must_use] - pub fn miss(reason: MissReason) -> Self { - Self::Miss(Miss { - reasons: vec![reason], - }) + if self.spec.tree_sitter != spec.tree_sitter { + reasons.push(MissReason::TreeSitterChanged { + cached: self.spec.tree_sitter.clone(), + current: spec.tree_sitter.clone(), + }); } - /// Check whether the decision requires a rebuild. - #[must_use] - pub fn needs_rebuild(&self) -> bool { - !self.is_hit() + if self.spec.build_script != spec.build_script { + reasons.push(MissReason::BuildScriptChanged); } - /// Return a short human-readable summary of the decision. - #[must_use] - pub fn short_message(&self) -> String { - match self { - Self::Hit => "cache hit".to_string(), - Self::Miss(miss) => miss.short_message(), - } + if self.spec.prefix != spec.prefix { + reasons.push(MissReason::PrefixChanged { + cached: self.spec.prefix.clone(), + current: spec.prefix.clone(), + }); } -} -impl Entry { - /// Compare the current build parameters against this entry and decide - /// whether the cached output is still valid. - #[must_use] - pub fn rebuild_decision( - &self, - hash: &GrammarHash, - revision: &Revision, - spec: &build::Spec, - ) -> Decision { - let mut reasons = Vec::new(); - - if &self.hash != hash { - reasons.push(MissReason::HashChanged { - cached: self.hash.clone(), - current: hash.clone(), - }); - } - - if self.spec.repo != spec.repo { - reasons.push(MissReason::RepoChanged { - cached: self.spec.repo.to_string(), - current: spec.repo.to_string(), - }); - } - - let git_ref_changed = self.spec.git_ref != spec.git_ref; - if git_ref_changed { - reasons.push(MissReason::RefChanged { - cached: self.spec.git_ref.clone(), - current: spec.git_ref.clone(), - }); - } - - if !git_ref_changed && self.revision != *revision { - reasons.push(MissReason::RevisionChanged { - cached: self.revision.clone(), - current: revision.clone(), - }); - } - - if self.spec.tree_sitter != spec.tree_sitter { - reasons.push(MissReason::TreeSitterChanged { - cached: self.spec.tree_sitter.clone(), - current: spec.tree_sitter.clone(), - }); - } - - if self.spec.build_script != spec.build_script { - reasons.push(MissReason::BuildScriptChanged); - } - - if self.spec.prefix != spec.prefix { - reasons.push(MissReason::PrefixChanged { - cached: self.spec.prefix.clone(), - current: spec.prefix.clone(), - }); - } - - if !self.outputs.covers(spec.target) { - reasons.push(MissReason::OutputsMissing { - available: self.outputs, - requested: spec.target, - }); - } - - Decision::from_reasons(reasons) + if !self.outputs.covers(spec.target) { + reasons.push(MissReason::OutputsMissing { + available: self.outputs, + requested: spec.target, + }); } - /// Check whether two entries describe the same build subject (ignoring - /// output coverage). - #[must_use] - pub fn same_subject(&self, other: &Self) -> bool { - self.hash == other.hash - && self.revision == other.revision - && self.spec.build_script == other.spec.build_script - && self.spec.git_ref == other.spec.git_ref - && self.spec.prefix == other.spec.prefix - && self.spec.repo == other.spec.repo - && self.spec.tree_sitter == other.spec.tree_sitter - } + Decision::from_reasons(reasons) + } + + /// Check whether two entries describe the same build subject (ignoring + /// output coverage). + #[must_use] + pub fn same_subject(&self, other: &Self) -> bool { + self.hash == other.hash + && self.revision == other.revision + && self.spec.build_script == other.spec.build_script + && self.spec.git_ref == other.spec.git_ref + && self.spec.prefix == other.spec.prefix + && self.spec.repo == other.spec.repo + && self.spec.tree_sitter == other.spec.tree_sitter + } } impl fmt::Display for Decision { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Decision::Hit => write!(f, "cache hit"), - Decision::Miss(miss) => write!(f, "cache miss: {miss}"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Decision::Hit => write!(f, "cache hit"), + Decision::Miss(miss) => write!(f, "cache miss: {miss}"), } + } } impl fmt::Display for GrammarHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } impl fmt::Display for Key { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } impl fmt::Display for Miss { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for (index, reason) in self.reasons.iter().enumerate() { - if index > 0 { - write!(f, "; ")?; - } - write!(f, "{reason}")?; - } - Ok(()) + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, reason) in self.reasons.iter().enumerate() { + if index > 0 { + write!(f, "; ")?; + } + write!(f, "{reason}")?; } + Ok(()) + } } impl fmt::Display for MissReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MissingEntry => write!(f, "missing cache entry"), - Self::CacheIgnored => write!(f, "cache ignored"), - Self::HashChanged { cached, current } => { - write!(f, "grammar hash changed cached={cached} current={current}") - } - Self::RepoChanged { cached, current } => { - write!(f, "repo changed cached={cached} current={current}") - } - Self::RefChanged { cached, current } => write!( - f, - "git ref changed cached={} current={}", - cached.requested().as_str(), - current.requested().as_str() - ), - Self::RevisionChanged { cached, current } => { - write!(f, "revision changed cached={cached} current={current}") - } - Self::TreeSitterChanged { cached, current } => write!( - f, - "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", - cached.version, - cached.repo, - cached.platform, - current.version, - current.repo, - current.platform - ), - Self::BuildScriptChanged => write!(f, "build script changed"), - Self::PrefixChanged { cached, current } => { - write!(f, "prefix changed cached={cached:?} current={current:?}") - } - Self::OutputsMissing { - available, - requested, - } => { - write!( - f, - "requested output not cached available={available:?} requested={requested:?}" - ) - } - Self::ArtifactMissing { path } => { - write!(f, "artifact missing path={}", path.display()) - } - Self::ArtifactNotFile { path } => { - write!(f, "artifact is not a regular file path={}", path.display()) - } - Self::ArtifactInaccessible { path, error } => { - write!( - f, - "artifact inaccessible path={} error={error}", - path.display() - ) - } - } - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingEntry => write!(f, "missing cache entry"), + Self::CacheIgnored => write!(f, "cache ignored"), + Self::HashChanged { cached, current } => { + write!(f, "grammar hash changed cached={cached} current={current}") + } + Self::RepoChanged { cached, current } => { + write!(f, "repo changed cached={cached} current={current}") + } + Self::RefChanged { cached, current } => write!( + f, + "git ref changed cached={} current={}", + cached.requested().as_str(), + current.requested().as_str() + ), + Self::RevisionChanged { cached, current } => { + write!(f, "revision changed cached={cached} current={current}") + } + Self::TreeSitterChanged { cached, current } => write!( + f, + "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", + cached.version, + cached.repo, + cached.platform, + current.version, + current.repo, + current.platform + ), + Self::BuildScriptChanged => write!(f, "build script changed"), + Self::PrefixChanged { cached, current } => { + write!(f, "prefix changed cached={cached:?} current={current:?}") + } + Self::OutputsMissing { + available, + requested, + } => { + write!( + f, + "requested output not cached available={available:?} requested={requested:?}" + ) + } + Self::ArtifactMissing { path } => { + write!(f, "artifact missing path={}", path.display()) + } + Self::ArtifactNotFile { path } => { + write!(f, "artifact is not a regular file path={}", path.display()) + } + Self::ArtifactInaccessible { path, error } => { + write!( + f, + "artifact inaccessible path={} error={error}", + path.display() + ) + } + } + } } impl fmt::Display for Revision { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Stable => write!(f, "stable"), - Self::Moving { commit } => write!(f, "moving commit={}", commit.as_str()), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stable => write!(f, "stable"), + Self::Moving { commit } => write!(f, "moving commit={}", commit.as_str()), } + } } impl From<&str> for GrammarHash { - fn from(value: &str) -> Self { - Self(Arc::from(value)) - } + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } } impl From<&str> for Key { - fn from(value: &str) -> Self { - Self(Arc::from(value)) - } + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } } impl From for GrammarHash { - fn from(value: String) -> Self { - Self(value.into()) - } + fn from(value: String) -> Self { + Self(value.into()) + } } impl From for Key { - fn from(value: String) -> Self { - Self(value.into()) - } + fn from(value: String) -> Self { + Self(value.into()) + } } impl GrammarHash { - /// Return the hash as a string slice. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + /// Return the hash as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } } impl Key { - /// Return the key as a string slice. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Build the prefix for all keys belonging to a given language. - #[must_use] - pub fn language_prefix(language: &parser::LanguageName) -> String { - format!("{language}/") - } - - /// Create a cache key from a language name and grammar name. - #[must_use] - pub fn new(language: &parser::LanguageName, grammar: &parser::GrammarName) -> Self { - Self(Arc::from(format!("{language}/{grammar}"))) - } + /// Return the key as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Build the prefix for all keys belonging to a given language. + #[must_use] + pub fn language_prefix(language: &parser::LanguageName) -> String { + format!("{language}/") + } + + /// Create a cache key from a language name and grammar name. + #[must_use] + pub fn new(language: &parser::LanguageName, grammar: &parser::GrammarName) -> Self { + Self(Arc::from(format!("{language}/{grammar}"))) + } } impl Miss { - /// Return a short human-readable summary of the miss reasons. - #[must_use] - pub fn short_message(&self) -> String { - match self.reasons.as_slice() { - [] => "cache changed".to_string(), - [reason] => reason.short_message().to_string(), - reasons => { - let labels = reasons - .iter() - .map(MissReason::short_label) - .collect::>() - .join(", "); - format!("cache changed: {labels}") - } - } - } + /// Return a short human-readable summary of the miss reasons. + #[must_use] + pub fn short_message(&self) -> String { + match self.reasons.as_slice() { + [] => "cache changed".to_string(), + [reason] => reason.short_message().to_string(), + reasons => { + let labels = reasons + .iter() + .map(MissReason::short_label) + .collect::>() + .join(", "); + format!("cache changed: {labels}") + } + } + } } impl MissReason { - /// Return a compact label for this miss reason (suitable for lists). - #[must_use] - pub fn short_label(&self) -> &'static str { - match self { - Self::MissingEntry => "missing", - Self::CacheIgnored => "ignored", - Self::HashChanged { .. } => "hash", - Self::RepoChanged { .. } => "repo", - Self::RefChanged { .. } => "ref", - Self::RevisionChanged { .. } => "revision", - Self::TreeSitterChanged { .. } => "tree-sitter", - Self::BuildScriptChanged => "script", - Self::PrefixChanged { .. } => "prefix", - Self::OutputsMissing { .. } => "outputs", - Self::ArtifactMissing { .. } - | Self::ArtifactNotFile { .. } - | Self::ArtifactInaccessible { .. } => "artifact", - } - } - - /// Return a one-line message describing this miss reason. - #[must_use] - pub fn short_message(&self) -> &'static str { - match self { - Self::MissingEntry => "not cached", - Self::CacheIgnored => "cache ignored", - Self::HashChanged { .. } => "grammar changed", - Self::RepoChanged { .. } => "repo changed", - Self::RefChanged { .. } => "git ref changed", - Self::RevisionChanged { .. } => "git ref resolved commit changed", - Self::TreeSitterChanged { .. } => "tree-sitter changed", - Self::BuildScriptChanged => "build script changed", - Self::PrefixChanged { .. } => "prefix changed", - Self::OutputsMissing { .. } => "requested output not cached", - Self::ArtifactMissing { .. } => "artifact missing", - Self::ArtifactNotFile { .. } => "artifact invalid", - Self::ArtifactInaccessible { .. } => "artifact inaccessible", - } - } + /// Return a compact label for this miss reason (suitable for lists). + #[must_use] + pub fn short_label(&self) -> &'static str { + match self { + Self::MissingEntry => "missing", + Self::CacheIgnored => "ignored", + Self::HashChanged { .. } => "hash", + Self::RepoChanged { .. } => "repo", + Self::RefChanged { .. } => "ref", + Self::RevisionChanged { .. } => "revision", + Self::TreeSitterChanged { .. } => "tree-sitter", + Self::BuildScriptChanged => "script", + Self::PrefixChanged { .. } => "prefix", + Self::OutputsMissing { .. } => "outputs", + Self::ArtifactMissing { .. } + | Self::ArtifactNotFile { .. } + | Self::ArtifactInaccessible { .. } => "artifact", + } + } + + /// Return a one-line message describing this miss reason. + #[must_use] + pub fn short_message(&self) -> &'static str { + match self { + Self::MissingEntry => "not cached", + Self::CacheIgnored => "cache ignored", + Self::HashChanged { .. } => "grammar changed", + Self::RepoChanged { .. } => "repo changed", + Self::RefChanged { .. } => "git ref changed", + Self::RevisionChanged { .. } => "git ref resolved commit changed", + Self::TreeSitterChanged { .. } => "tree-sitter changed", + Self::BuildScriptChanged => "build script changed", + Self::PrefixChanged { .. } => "prefix changed", + Self::OutputsMissing { .. } => "requested output not cached", + Self::ArtifactMissing { .. } => "artifact missing", + Self::ArtifactNotFile { .. } => "artifact invalid", + Self::ArtifactInaccessible { .. } => "artifact inaccessible", + } + } } impl Revision { - /// Create a stable (non-moving) revision. - #[must_use] - pub const fn stable() -> Self { - Self::Stable - } - - /// Create a moving revision pinned to a specific commit. - #[must_use] - pub const fn moving(commit: git::Sha) -> Self { - Self::Moving { commit } - } + /// Create a stable (non-moving) revision. + #[must_use] + pub const fn stable() -> Self { + Self::Stable + } + + /// Create a moving revision pinned to a specific commit. + #[must_use] + pub const fn moving(commit: git::Sha) -> Self { + Self::Moving { commit } + } } impl Store { - /// Create a store backed by the build directory's cache file. - #[must_use] - pub fn new(build_dir: &BuildDir) -> Self { - Self { - file: build_dir.cache_file(), - } + /// Create a store backed by the build directory's cache file. + #[must_use] + pub fn new(build_dir: &BuildDir) -> Self { + Self { + file: build_dir.cache_file(), + } + } + + /// Return the path to the cache file on disk. + #[must_use] + pub fn path(&self) -> &Path { + &self.file + } + + /// Delete the cache file from disk. + pub async fn delete(&self) -> Result<()> { + match tokio::fs::metadata(&self.file).await { + Ok(_) => { + tokio::fs::remove_file(&self.file) + .await + .with_context(|| format!("Deleting cache file at {}", self.file.display()))?; + debug!("Cache file deleted"); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err) + .with_context(|| format!("Reading cache file metadata at {}", self.file.display())); + } } - /// Return the path to the cache file on disk. - #[must_use] - pub fn path(&self) -> &Path { - &self.file - } - - /// Delete the cache file from disk. - pub async fn delete(&self) -> Result<()> { - match tokio::fs::metadata(&self.file).await { - Ok(_) => { - tokio::fs::remove_file(&self.file) - .await - .with_context(|| format!("Deleting cache file at {}", self.file.display()))?; - debug!("Cache file deleted"); - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => { - return Err(err).with_context(|| { - format!("Reading cache file metadata at {}", self.file.display()) - }); - } - } - - Ok(()) - } - - /// Load the cache from disk, or return an empty cache. - pub fn load(&self) -> Result { - if !self.file.exists() { - debug!( - "Cache file not found at {}, returning empty cache", - self.file.display() - ); - return Ok(Db::default()); - } - - let contents = std::fs::read_to_string(&self.file) - .with_context(|| format!("Reading cache file at {}", self.file.display()))?; - - toml::from_str(&contents) - .with_context(|| format!("Parsing cache file at {}", self.file.display())) - } - - /// Atomically save the cache to disk. - pub async fn save(&self, db: &Db) -> Result<()> { - let contents = toml::to_string_pretty(db).context("Serializing cache to TOML")?; - let file = self.file.clone(); - let result = - tokio::task::spawn_blocking(move || write_cache_file_atomically(&file, &contents)) - .await - .context("Joining cache save task")?; - result?; - - debug!("Cache saved to {}", self.file.display()); - Ok(()) - } + Ok(()) + } + + /// Load the cache from disk, or return an empty cache. + pub fn load(&self) -> Result { + if !self.file.exists() { + debug!( + "Cache file not found at {}, returning empty cache", + self.file.display() + ); + return Ok(Db::default()); + } + + let contents = std::fs::read_to_string(&self.file) + .with_context(|| format!("Reading cache file at {}", self.file.display()))?; + + toml::from_str(&contents) + .with_context(|| format!("Parsing cache file at {}", self.file.display())) + } + + /// Atomically save the cache to disk. + pub async fn save(&self, db: &Db) -> Result<()> { + let contents = toml::to_string_pretty(db).context("Serializing cache to TOML")?; + let file = self.file.clone(); + let result = tokio::task::spawn_blocking(move || write_cache_file_atomically(&file, &contents)) + .await + .context("Joining cache save task")?; + result?; + + debug!("Cache saved to {}", self.file.display()); + Ok(()) + } } #[cfg(test)] mod tests { - use super::*; - use crate::{consts, git, parser}; - - const SHA1: &str = "636801770eea172d140e64b691815ff11f6b556f"; - const SHA2: &str = "736801770eea172d140e64b691815ff11f6b556f"; - - fn test_spec() -> build::Spec { - build::Spec { - build_script: None, - git_ref: parser::Ref::parse("v1.0.0").unwrap(), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: args::TreeSitter::default(), - prefix: String::new(), - target: args::Target::Native, - } - } - - fn moving_spec() -> build::Spec { - build::Spec { - git_ref: parser::Ref::parse("master").unwrap(), - ..test_spec() - } - } - - fn stable_revision() -> Revision { - Revision::stable() - } - - fn moving_revision(spec: &build::Spec, sha: &str) -> Revision { - assert!(spec.git_ref.is_moving()); - Revision::moving(git::Sha::new(sha).unwrap()) - } - - fn key() -> Key { - Key::from("test-parser") - } - - fn grammar_hash(value: &str) -> GrammarHash { - GrammarHash::from(value) - } - - fn entry(hash: &str, spec: &build::Spec, revision: Revision) -> Entry { - Entry { - hash: grammar_hash(hash), - revision, - spec: Arc::new(spec.clone()), - outputs: spec.target, - } - } - - fn cache_with_entry(hash: &str, spec: &build::Spec) -> Db { - cache_with_entry_and_revision(hash, spec, stable_revision()) - } - - fn cache_with_entry_and_revision(hash: &str, spec: &build::Spec, revision: Revision) -> Db { - let mut cache = Db::default(); - cache.set(key(), entry(hash, spec, revision)); - cache - } - - fn assert_miss(decision: Decision, expected: &[MissReason]) { - match decision { - Decision::Hit => panic!("expected cache miss"), - Decision::Miss(miss) => assert_eq!(miss.reasons, expected), - } - } - - #[test] - fn test_rebuild_decision_no_entry() { - let cache = Db::default(); - let spec = test_spec(); - - assert_miss( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &stable_revision(), &spec), - &[MissReason::MissingEntry], - ); - } - - #[test] - fn test_rebuild_decision_hash_mismatch() { - let spec = test_spec(); - let cache = cache_with_entry("abc123", &spec); - - assert_miss( - cache.rebuild_decision(&key(), &grammar_hash("def456"), &stable_revision(), &spec), - &[MissReason::HashChanged { - cached: grammar_hash("abc123"), - current: grammar_hash("def456"), - }], - ); - } - - #[test] - fn test_rebuild_decision_git_ref_mismatch() { - let cached = test_spec(); - let mut requested = cached.clone(); - requested.git_ref = parser::Ref::parse("v2.0.0").unwrap(); - let cache = cache_with_entry("abc123", &cached); - - assert_miss( - cache.rebuild_decision( - &key(), - &grammar_hash("abc123"), - &stable_revision(), - &requested, - ), - &[MissReason::RefChanged { - cached: parser::Ref::parse("v1.0.0").unwrap(), - current: parser::Ref::parse("v2.0.0").unwrap(), - }], - ); - } - - #[test] - fn test_rebuild_decision_outputs_must_cover_requested_target() { - let cached = test_spec(); - let mut requested = cached.clone(); - requested.target = args::Target::Wasm; - let cache = cache_with_entry("abc123", &cached); - - assert_miss( - cache.rebuild_decision( - &key(), - &grammar_hash("abc123"), - &stable_revision(), - &requested, - ), - &[MissReason::OutputsMissing { - available: args::Target::Native, - requested: args::Target::Wasm, - }], - ); - } - - #[test] - fn test_rebuild_decision_all_outputs_cover_narrower_targets() { - let mut cached = test_spec(); - cached.target = args::Target::All; - let cache = cache_with_entry("abc123", &cached); - - for target in [args::Target::Native, args::Target::Wasm] { - let mut requested = cached.clone(); - requested.target = target; - assert_eq!( - cache.rebuild_decision( - &key(), - &grammar_hash("abc123"), - &stable_revision(), - &requested, - ), - Decision::Hit - ); - } - } - - #[test] - fn test_rebuild_decision_cache_hit_exact() { - let spec = test_spec(); - let cache = cache_with_entry("abc123", &spec); - - assert_eq!( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &stable_revision(), &spec), - Decision::Hit - ); - assert!(!cache.needs_rebuild(&key(), &grammar_hash("abc123"), &stable_revision(), &spec)); - } - - #[test] - fn test_rebuild_decision_moving_revision_commit_changed() { - let spec = moving_spec(); - let cached_revision = moving_revision(&spec, SHA1); - let current_revision = moving_revision(&spec, SHA2); - let cache = cache_with_entry_and_revision("abc123", &spec, cached_revision.clone()); - - assert_miss( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), ¤t_revision, &spec), - &[MissReason::RevisionChanged { - cached: cached_revision, - current: current_revision, - }], - ); - } - - #[test] - fn test_rebuild_decision_moving_revision_commit_unchanged_hits() { - let spec = moving_spec(); - let revision = moving_revision(&spec, SHA1); - let cache = cache_with_entry_and_revision("abc123", &spec, revision.clone()); - - assert_eq!( - cache.rebuild_decision(&key(), &grammar_hash("abc123"), &revision, &spec), - Decision::Hit - ); - } - - #[test] - fn test_rebuild_decision_collects_multiple_reasons() { - let cached = test_spec(); - let mut requested = cached.clone(); - requested.build_script = Some("make".to_string()); - requested.prefix = "custom-".to_string(); - requested.target = args::Target::All; - let cache = cache_with_entry("abc123", &cached); - - assert_miss( - cache.rebuild_decision( - &key(), - &grammar_hash("def456"), - &stable_revision(), - &requested, - ), - &[ - MissReason::HashChanged { - cached: grammar_hash("abc123"), - current: grammar_hash("def456"), - }, - MissReason::BuildScriptChanged, - MissReason::PrefixChanged { - cached: String::new(), - current: "custom-".to_string(), - }, - MissReason::OutputsMissing { - available: args::Target::Native, - requested: args::Target::All, - }, - ], - ); - } - - #[test] - fn set_merges_outputs_for_same_entry_subject() { - let native = test_spec(); - let mut wasm = native.clone(); - wasm.target = args::Target::Wasm; - let mut cache = Db::default(); - - cache.set(key(), entry("abc123", &native, stable_revision())); - cache.set(key(), entry("abc123", &wasm, stable_revision())); - - assert_eq!(cache.get(&key()).unwrap().outputs, args::Target::All); - } - - #[test] - fn set_replaces_when_recipe_changes() { - let native = test_spec(); - let mut wasm = native.clone(); - wasm.target = args::Target::Wasm; - wasm.prefix = "other-".to_string(); - let mut cache = Db::default(); - - cache.set(key(), entry("abc123", &native, stable_revision())); - cache.set(key(), entry("abc123", &wasm, stable_revision())); - - let stored = cache.get(&key()).unwrap(); - assert_eq!(stored.outputs, args::Target::Wasm); - assert_eq!(stored.spec.prefix, "other-"); - } - - #[test] - fn compatible_entries_use_output_coverage() { - let mut cached = test_spec(); - cached.target = args::Target::All; - let mut requested = cached.clone(); - requested.target = args::Target::Native; - let mut cache = Db::default(); - - cache.set( - Key::from("rust/rust"), - entry("abc123", &cached, stable_revision()), - ); - - assert!( - cache - .has_compatible_entry_for_language(&parser::LanguageName::from("rust"), &requested) - ); - } - - #[test] - fn store_loads_cache_without_runtime_file_path() { - let temp = tempfile::TempDir::new().unwrap(); - let build_dir = BuildDir::new(temp.path()).unwrap(); - let store = Store::new(&build_dir); - let spec = test_spec(); - let cache = cache_with_entry("abc123", &spec); - let contents = toml::to_string_pretty(&cache).unwrap(); - - std::fs::write(store.path(), contents).unwrap(); - - let loaded = store.load().unwrap(); - assert!(loaded.get(&key()).is_some()); - } - - #[tokio::test] - async fn store_ignores_legacy_file_field_and_saves_to_current_path() { - let current = tempfile::TempDir::new().unwrap(); - let stale = tempfile::TempDir::new().unwrap(); - let build_dir = BuildDir::new(current.path()).unwrap(); - let store = Store::new(&build_dir); - let stale_file = stale.path().join(consts::CACHE_FILE); - let spec = test_spec(); - let cache = cache_with_entry("abc123", &spec); - let contents = toml::to_string_pretty(&cache).unwrap(); - let legacy_contents = format!( - "file = {:?}\n{contents}", - stale_file.to_string_lossy().as_ref() - ); - - std::fs::write(store.path(), legacy_contents).unwrap(); - - let mut loaded = store.load().unwrap(); - assert!(loaded.get(&key()).is_some()); - loaded.clear(); - - store.save(&loaded).await.unwrap(); - - assert!( - !stale_file.exists(), - "cache save should not use a legacy serialized file path" - ); - let saved = std::fs::read_to_string(store.path()).unwrap(); - assert!( - !saved.lines().any(|line| line.starts_with("file")), - "cache TOML should not serialize runtime storage path: {saved}" - ); - assert!(saved.contains("parsers")); - } - - #[tokio::test] - async fn verify_artifacts_hits_when_all_paths_are_files() { - let temp = tempfile::TempDir::new().unwrap(); - let artifact = temp.path().join("parser.so"); - tokio::fs::write(&artifact, b"parser").await.unwrap(); - - assert_eq!(verify_artifacts(vec![artifact]).await, Decision::Hit); - } - - #[tokio::test] - async fn verify_artifacts_reports_missing_and_non_file_paths() { - let temp = tempfile::TempDir::new().unwrap(); - let missing = temp.path().join("missing.so"); - let directory = temp.path().join("parser.so"); - tokio::fs::create_dir(&directory).await.unwrap(); - - assert_eq!( - verify_artifacts(vec![missing.clone(), directory.clone()]).await, - Decision::Miss(Miss { - reasons: vec![ - MissReason::ArtifactMissing { path: missing }, - MissReason::ArtifactNotFile { path: directory }, - ], - }) - ); - } + use super::*; + use crate::{consts, git, parser}; + + const SHA1: &str = "636801770eea172d140e64b691815ff11f6b556f"; + const SHA2: &str = "736801770eea172d140e64b691815ff11f6b556f"; + + fn test_spec() -> build::Spec { + build::Spec { + build_script: None, + git_ref: parser::Ref::parse("v1.0.0").unwrap(), + repo: "https://github.com/example/parser".parse().unwrap(), + tree_sitter: args::TreeSitter::default(), + prefix: String::new(), + target: args::Target::Native, + } + } + + fn moving_spec() -> build::Spec { + build::Spec { + git_ref: parser::Ref::parse("master").unwrap(), + ..test_spec() + } + } + + fn stable_revision() -> Revision { + Revision::stable() + } + + fn moving_revision(spec: &build::Spec, sha: &str) -> Revision { + assert!(spec.git_ref.is_moving()); + Revision::moving(git::Sha::new(sha).unwrap()) + } + + fn key() -> Key { + Key::from("test-parser") + } + + fn grammar_hash(value: &str) -> GrammarHash { + GrammarHash::from(value) + } + + fn entry(hash: &str, spec: &build::Spec, revision: Revision) -> Entry { + Entry { + hash: grammar_hash(hash), + revision, + spec: Arc::new(spec.clone()), + outputs: spec.target, + } + } + + fn cache_with_entry(hash: &str, spec: &build::Spec) -> Db { + cache_with_entry_and_revision(hash, spec, stable_revision()) + } + + fn cache_with_entry_and_revision(hash: &str, spec: &build::Spec, revision: Revision) -> Db { + let mut cache = Db::default(); + cache.set(key(), entry(hash, spec, revision)); + cache + } + + fn assert_miss(decision: Decision, expected: &[MissReason]) { + match decision { + Decision::Hit => panic!("expected cache miss"), + Decision::Miss(miss) => assert_eq!(miss.reasons, expected), + } + } + + #[test] + fn test_rebuild_decision_no_entry() { + let cache = Db::default(); + let spec = test_spec(); + + assert_miss( + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &stable_revision(), &spec), + &[MissReason::MissingEntry], + ); + } + + #[test] + fn test_rebuild_decision_hash_mismatch() { + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + + assert_miss( + cache.rebuild_decision(&key(), &grammar_hash("def456"), &stable_revision(), &spec), + &[MissReason::HashChanged { + cached: grammar_hash("abc123"), + current: grammar_hash("def456"), + }], + ); + } + + #[test] + fn test_rebuild_decision_git_ref_mismatch() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.git_ref = parser::Ref::parse("v2.0.0").unwrap(); + let cache = cache_with_entry("abc123", &cached); + + assert_miss( + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &stable_revision(), + &requested, + ), + &[MissReason::RefChanged { + cached: parser::Ref::parse("v1.0.0").unwrap(), + current: parser::Ref::parse("v2.0.0").unwrap(), + }], + ); + } + + #[test] + fn test_rebuild_decision_outputs_must_cover_requested_target() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.target = args::Target::Wasm; + let cache = cache_with_entry("abc123", &cached); + + assert_miss( + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &stable_revision(), + &requested, + ), + &[MissReason::OutputsMissing { + available: args::Target::Native, + requested: args::Target::Wasm, + }], + ); + } + + #[test] + fn test_rebuild_decision_all_outputs_cover_narrower_targets() { + let mut cached = test_spec(); + cached.target = args::Target::All; + let cache = cache_with_entry("abc123", &cached); + + for target in [args::Target::Native, args::Target::Wasm] { + let mut requested = cached.clone(); + requested.target = target; + assert_eq!( + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &stable_revision(), + &requested, + ), + Decision::Hit + ); + } + } + + #[test] + fn test_rebuild_decision_cache_hit_exact() { + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + + assert_eq!( + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &stable_revision(), &spec), + Decision::Hit + ); + assert!(!cache.needs_rebuild(&key(), &grammar_hash("abc123"), &stable_revision(), &spec)); + } + + #[test] + fn test_rebuild_decision_moving_revision_commit_changed() { + let spec = moving_spec(); + let cached_revision = moving_revision(&spec, SHA1); + let current_revision = moving_revision(&spec, SHA2); + let cache = cache_with_entry_and_revision("abc123", &spec, cached_revision.clone()); + + assert_miss( + cache.rebuild_decision(&key(), &grammar_hash("abc123"), ¤t_revision, &spec), + &[MissReason::RevisionChanged { + cached: cached_revision, + current: current_revision, + }], + ); + } + + #[test] + fn test_rebuild_decision_moving_revision_commit_unchanged_hits() { + let spec = moving_spec(); + let revision = moving_revision(&spec, SHA1); + let cache = cache_with_entry_and_revision("abc123", &spec, revision.clone()); + + assert_eq!( + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &revision, &spec), + Decision::Hit + ); + } + + #[test] + fn test_rebuild_decision_collects_multiple_reasons() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.build_script = Some("make".to_string()); + requested.prefix = "custom-".to_string(); + requested.target = args::Target::All; + let cache = cache_with_entry("abc123", &cached); + + assert_miss( + cache.rebuild_decision( + &key(), + &grammar_hash("def456"), + &stable_revision(), + &requested, + ), + &[ + MissReason::HashChanged { + cached: grammar_hash("abc123"), + current: grammar_hash("def456"), + }, + MissReason::BuildScriptChanged, + MissReason::PrefixChanged { + cached: String::new(), + current: "custom-".to_string(), + }, + MissReason::OutputsMissing { + available: args::Target::Native, + requested: args::Target::All, + }, + ], + ); + } + + #[test] + fn set_merges_outputs_for_same_entry_subject() { + let native = test_spec(); + let mut wasm = native.clone(); + wasm.target = args::Target::Wasm; + let mut cache = Db::default(); + + cache.set(key(), entry("abc123", &native, stable_revision())); + cache.set(key(), entry("abc123", &wasm, stable_revision())); + + assert_eq!(cache.get(&key()).unwrap().outputs, args::Target::All); + } + + #[test] + fn set_replaces_when_recipe_changes() { + let native = test_spec(); + let mut wasm = native.clone(); + wasm.target = args::Target::Wasm; + wasm.prefix = "other-".to_string(); + let mut cache = Db::default(); + + cache.set(key(), entry("abc123", &native, stable_revision())); + cache.set(key(), entry("abc123", &wasm, stable_revision())); + + let stored = cache.get(&key()).unwrap(); + assert_eq!(stored.outputs, args::Target::Wasm); + assert_eq!(stored.spec.prefix, "other-"); + } + + #[test] + fn compatible_entries_use_output_coverage() { + let mut cached = test_spec(); + cached.target = args::Target::All; + let mut requested = cached.clone(); + requested.target = args::Target::Native; + let mut cache = Db::default(); + + cache.set( + Key::from("rust/rust"), + entry("abc123", &cached, stable_revision()), + ); + + assert!( + cache.has_compatible_entry_for_language(&parser::LanguageName::from("rust"), &requested) + ); + } + + #[test] + fn store_loads_cache_without_runtime_file_path() { + let temp = tempfile::TempDir::new().unwrap(); + let build_dir = BuildDir::new(temp.path()).unwrap(); + let store = Store::new(&build_dir); + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + let contents = toml::to_string_pretty(&cache).unwrap(); + + std::fs::write(store.path(), contents).unwrap(); + + let loaded = store.load().unwrap(); + assert!(loaded.get(&key()).is_some()); + } + + #[tokio::test] + async fn store_ignores_legacy_file_field_and_saves_to_current_path() { + let current = tempfile::TempDir::new().unwrap(); + let stale = tempfile::TempDir::new().unwrap(); + let build_dir = BuildDir::new(current.path()).unwrap(); + let store = Store::new(&build_dir); + let stale_file = stale.path().join(consts::CACHE_FILE); + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + let contents = toml::to_string_pretty(&cache).unwrap(); + let legacy_contents = format!( + "file = {:?}\n{contents}", + stale_file.to_string_lossy().as_ref() + ); + + std::fs::write(store.path(), legacy_contents).unwrap(); + + let mut loaded = store.load().unwrap(); + assert!(loaded.get(&key()).is_some()); + loaded.clear(); + + store.save(&loaded).await.unwrap(); + + assert!( + !stale_file.exists(), + "cache save should not use a legacy serialized file path" + ); + let saved = std::fs::read_to_string(store.path()).unwrap(); + assert!( + !saved.lines().any(|line| line.starts_with("file")), + "cache TOML should not serialize runtime storage path: {saved}" + ); + assert!(saved.contains("parsers")); + } + + #[tokio::test] + async fn verify_artifacts_hits_when_all_paths_are_files() { + let temp = tempfile::TempDir::new().unwrap(); + let artifact = temp.path().join("parser.so"); + tokio::fs::write(&artifact, b"parser").await.unwrap(); + + assert_eq!(verify_artifacts(vec![artifact]).await, Decision::Hit); + } + + #[tokio::test] + async fn verify_artifacts_reports_missing_and_non_file_paths() { + let temp = tempfile::TempDir::new().unwrap(); + let missing = temp.path().join("missing.so"); + let directory = temp.path().join("parser.so"); + tokio::fs::create_dir(&directory).await.unwrap(); + + assert_eq!( + verify_artifacts(vec![missing.clone(), directory.clone()]).await, + Decision::Miss(Miss { + reasons: vec![ + MissReason::ArtifactMissing { path: missing }, + MissReason::ArtifactNotFile { path: directory }, + ], + }) + ); + } } diff --git a/src/columns.rs b/src/columns.rs index 0bfb7ea..63e8783 100644 --- a/src/columns.rs +++ b/src/columns.rs @@ -3,62 +3,62 @@ /// Layout direction for [`format()`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Layout { - /// Fill down each column before moving right. - Column, - /// Fill across each row before moving down. - Row, - /// Print one item per line. - Plain, + /// Fill down each column before moving right. + Column, + /// Fill across each row before moving down. + Row, + /// Print one item per line. + Plain, } #[derive(Debug, Clone, PartialEq, Eq)] struct DenseLayout { - rows: usize, - cols: usize, - widths: Vec, + rows: usize, + cols: usize, + widths: Vec, } /// Options controlling [`format()`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Options<'a> { - /// Layout direction. - pub layout: Layout, - /// Use per-column widths and try to reduce row count, like `git column --mode=...,dense`. - pub dense: bool, - /// Maximum output width, including indentation. - pub width: usize, - /// Minimum spaces between columns. - pub padding: usize, - /// Prefix printed before each output line. - pub indent: &'a str, - /// Suffix printed after each output line. - pub line_ending: &'a str, + /// Layout direction. + pub layout: Layout, + /// Use per-column widths and try to reduce row count, like `git column --mode=...,dense`. + pub dense: bool, + /// Maximum output width, including indentation. + pub width: usize, + /// Minimum spaces between columns. + pub padding: usize, + /// Prefix printed before each output line. + pub indent: &'a str, + /// Suffix printed after each output line. + pub line_ending: &'a str, } /// Compute the maximum width of each column from the item widths. fn compute_column_widths( - item_widths: &[usize], - cols: usize, - layout: Layout, - rows: usize, + item_widths: &[usize], + cols: usize, + layout: Layout, + rows: usize, ) -> Vec { - let mut widths = vec![0; cols]; - - for (x, column_width) in widths.iter_mut().enumerate() { - for y in 0..rows { - let item_index = linear_index(cols, layout, rows, x, y); - if let Some(width) = item_widths.get(item_index) { - *column_width = (*column_width).max(*width); - } - } + let mut widths = vec![0; cols]; + + for (x, column_width) in widths.iter_mut().enumerate() { + for y in 0..rows { + let item_index = linear_index(cols, layout, rows, x, y); + if let Some(width) = item_widths.get(item_index) { + *column_width = (*column_width).max(*width); + } } + } - widths + widths } /// Return the visible width of a string (accounting for CJK, emoji, etc.). fn display_width(value: &str) -> usize { - console::measure_text_width(value) + console::measure_text_width(value) } /// Format a list of cells using the same row/column rules as `git column`. @@ -68,344 +68,343 @@ fn display_width(value: &str) -> usize { /// dense mode shrinks the row count using per-column widths. #[must_use] pub fn format>(items: &[S], options: Options<'_>) -> String { - if items.is_empty() { - return String::new(); - } - - if options.layout == Layout::Plain { - return format_plain(items, options); - } - - let item_widths = items - .iter() - .map(|item| display_width(item.as_ref())) - .collect::>(); - let max_item_width = item_widths.iter().copied().max().unwrap_or(0); - let initial_width = max_item_width + options.padding; - let indent_width = display_width(options.indent); - let available_width = options.width.saturating_sub(indent_width); - - let mut cols = available_width - .checked_div(initial_width) - .unwrap_or(items.len()) - .max(1) - .min(items.len()); - let mut rows = items.len().div_ceil(cols); - let mut column_widths = None; - - if options.dense { - let dense = shrink_columns(&item_widths, options, rows, cols); - rows = dense.rows; - cols = dense.cols; - column_widths = Some(dense.widths); - } - - format_table( - items, - &item_widths, - options, - rows, - cols, - initial_width, - column_widths.as_deref(), - ) + if items.is_empty() { + return String::new(); + } + + if options.layout == Layout::Plain { + return format_plain(items, options); + } + + let item_widths = items + .iter() + .map(|item| display_width(item.as_ref())) + .collect::>(); + let max_item_width = item_widths.iter().copied().max().unwrap_or(0); + let initial_width = max_item_width + options.padding; + let indent_width = display_width(options.indent); + let available_width = options.width.saturating_sub(indent_width); + + let mut cols = available_width + .checked_div(initial_width) + .unwrap_or(items.len()) + .max(1) + .min(items.len()); + let mut rows = items.len().div_ceil(cols); + let mut column_widths = None; + + if options.dense { + let dense = shrink_columns(&item_widths, options, rows, cols); + rows = dense.rows; + cols = dense.cols; + column_widths = Some(dense.widths); + } + + format_table( + items, + &item_widths, + options, + rows, + cols, + initial_width, + column_widths.as_deref(), + ) } /// Format with options matching the current `git column` call site. #[must_use] pub fn format_git>(items: &[S], indent: &str, width: usize) -> String { - format(items, Options::git(indent, width)) + format(items, Options::git(indent, width)) } /// Format items one per line (indented). fn format_plain>(items: &[S], options: Options<'_>) -> String { - let mut output = String::new(); + let mut output = String::new(); - for item in items { - output.push_str(options.indent); - output.push_str(item.as_ref()); - output.push_str(options.line_ending); - } + for item in items { + output.push_str(options.indent); + output.push_str(item.as_ref()); + output.push_str(options.line_ending); + } - output + output } /// Render items into a row/column grid with optional per-column widths. fn format_table>( - items: &[S], - item_widths: &[usize], - options: Options<'_>, - rows: usize, - cols: usize, - initial_width: usize, - column_widths: Option<&[usize]>, + items: &[S], + item_widths: &[usize], + options: Options<'_>, + rows: usize, + cols: usize, + initial_width: usize, + column_widths: Option<&[usize]>, ) -> String { - let mut output = String::new(); + let mut output = String::new(); - for y in 0..rows { - for x in 0..cols { - let item_index = linear_index(cols, options.layout, rows, x, y); - if item_index >= items.len() { - break; - } - - if x == 0 { - output.push_str(options.indent); - } - - output.push_str(items[item_index].as_ref()); - - if is_last_cell_in_row(options.layout, item_index, items.len(), rows, cols, x) { - output.push_str(options.line_ending); - } else { - let target_width = column_widths.map_or(initial_width, |widths| { - widths.get(x).copied().unwrap_or(0) + options.padding - }); - push_spaces( - &mut output, - target_width.saturating_sub(item_widths[item_index]), - ); - } - } + for y in 0..rows { + for x in 0..cols { + let item_index = linear_index(cols, options.layout, rows, x, y); + if item_index >= items.len() { + break; + } + + if x == 0 { + output.push_str(options.indent); + } + + output.push_str(items[item_index].as_ref()); + + if is_last_cell_in_row(options.layout, item_index, items.len(), rows, cols, x) { + output.push_str(options.line_ending); + } else { + let target_width = column_widths.map_or(initial_width, |widths| { + widths.get(x).copied().unwrap_or(0) + options.padding + }); + push_spaces( + &mut output, + target_width.saturating_sub(item_widths[item_index]), + ); + } } + } - output + output } /// Check whether `item_index` is the last cell in its row. const fn is_last_cell_in_row( - layout: Layout, - item_index: usize, - item_count: usize, - rows: usize, - cols: usize, - x: usize, + layout: Layout, + item_index: usize, + item_count: usize, + rows: usize, + cols: usize, + x: usize, ) -> bool { - match layout { - Layout::Column => item_index + rows >= item_count, - Layout::Row => x == cols - 1 || item_index == item_count - 1, - Layout::Plain => unreachable!(), - } + match layout { + Layout::Column => item_index + rows >= item_count, + Layout::Row => x == cols - 1 || item_index == item_count - 1, + Layout::Plain => unreachable!(), + } } /// Convert grid coordinates to a linear index based on the layout direction. const fn linear_index(cols: usize, layout: Layout, rows: usize, x: usize, y: usize) -> usize { - match layout { - Layout::Column => x * rows + y, - Layout::Row => y * cols + x, - Layout::Plain => unreachable!(), - } + match layout { + Layout::Column => x * rows + y, + Layout::Row => y * cols + x, + Layout::Plain => unreachable!(), + } } /// Append `count` space characters to the output string. fn push_spaces(output: &mut String, count: usize) { - output.extend(std::iter::repeat_n(' ', count)); + output.extend(std::iter::repeat_n(' ', count)); } /// Try to reduce row count while keeping total width within the limit. fn shrink_columns( - item_widths: &[usize], - options: Options<'_>, - mut rows: usize, - mut cols: usize, + item_widths: &[usize], + options: Options<'_>, + mut rows: usize, + mut cols: usize, ) -> DenseLayout { - let indent_width = display_width(options.indent); - - while rows > 1 { - let previous_rows = rows; - let previous_cols = cols; - - rows -= 1; - cols = item_widths - .len() - .div_ceil(rows) - .max(1) - .min(item_widths.len()); - - let candidate_widths = compute_column_widths(item_widths, cols, options.layout, rows); - let total_width = indent_width - + candidate_widths.iter().sum::() - + options.padding.saturating_mul(cols); - - if total_width > options.width { - rows = previous_rows; - cols = previous_cols; - break; - } + let indent_width = display_width(options.indent); + + while rows > 1 { + let previous_rows = rows; + let previous_cols = cols; + + rows -= 1; + cols = item_widths + .len() + .div_ceil(rows) + .max(1) + .min(item_widths.len()); + + let candidate_widths = compute_column_widths(item_widths, cols, options.layout, rows); + let total_width = + indent_width + candidate_widths.iter().sum::() + options.padding.saturating_mul(cols); + + if total_width > options.width { + rows = previous_rows; + cols = previous_cols; + break; } + } - DenseLayout { - rows, - cols, - widths: compute_column_widths(item_widths, cols, options.layout, rows), - } + DenseLayout { + rows, + cols, + widths: compute_column_widths(item_widths, cols, options.layout, rows), + } } impl<'a> Options<'a> { - /// Options matching `git column --mode=always --indent= --width=`. - #[must_use] - pub const fn git(indent: &'a str, width: usize) -> Self { - Self { - dense: false, - indent, - layout: Layout::Column, - line_ending: "\n", - padding: 1, - width, - } + /// Options matching `git column --mode=always --indent= --width=`. + #[must_use] + pub const fn git(indent: &'a str, width: usize) -> Self { + Self { + dense: false, + indent, + layout: Layout::Column, + line_ending: "\n", + padding: 1, + width, } + } } #[cfg(test)] mod tests { - use super::*; - - const ITEMS: &[&str] = &[ - "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", - ]; - - const fn options(layout: Layout, width: usize) -> Options<'static> { - Options { - dense: false, - indent: "", - layout, - line_ending: "\n", - padding: 1, - width, - } - } - - #[test] - fn empty_list_is_empty() { - assert_eq!(format::<&str>(&[], Options::git(" ", 80)), ""); - } - - #[test] - fn plain_layout_prints_one_indented_item_per_line() { - let opts = Options { - dense: false, - indent: "Z", - layout: Layout::Plain, - line_ending: "\n", - padding: 1, - width: 80, - }; - - assert_eq!( - format(ITEMS, opts), - "Zone\nZtwo\nZthree\nZfour\nZfive\nZsix\nZseven\nZeight\nZnine\nZten\nZeleven\n" - ); - } - - #[test] - fn column_layout_fits_eighty_columns_like_git() { - assert_eq!( - format(ITEMS, options(Layout::Column, 80)), - "one two three four five six seven eight nine ten eleven\n" - ); - } - - #[test] - fn column_layout_width_one_falls_back_to_one_per_line() { - assert_eq!( - format(ITEMS, options(Layout::Column, 1)), - "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\n" - ); - } - - #[test] - fn column_layout_width_twenty_matches_git() { - assert_eq!( - format(ITEMS, options(Layout::Column, 20)), - "one seven\ntwo eight\nthree nine\nfour ten\nfive eleven\nsix\n" - ); - } - - #[test] - fn column_layout_width_twenty_padding_two_matches_git() { - let opts = Options { - padding: 2, - ..options(Layout::Column, 20) - }; - - assert_eq!( - format(ITEMS, opts), - "one seven\ntwo eight\nthree nine\nfour ten\nfive eleven\nsix\n" - ); - } - - #[test] - fn column_layout_width_twenty_indented_matches_git() { - let opts = Options { - indent: " ", - ..options(Layout::Column, 20) - }; - - assert_eq!( - format(ITEMS, opts), - " one seven\n two eight\n three nine\n four ten\n five eleven\n six\n" - ); - } - - #[test] - fn dense_column_layout_width_twenty_matches_git() { - let opts = Options { - dense: true, - ..options(Layout::Column, 20) - }; - - assert_eq!( - format(ITEMS, opts), - "one five nine\ntwo six ten\nthree seven eleven\nfour eight\n" - ); - } - - #[test] - fn row_layout_width_twenty_matches_git() { - assert_eq!( - format(ITEMS, options(Layout::Row, 20)), - "one two\nthree four\nfive six\nseven eight\nnine ten\neleven\n" - ); - } - - #[test] - fn dense_row_layout_width_twenty_matches_git() { - let opts = Options { - dense: true, - ..options(Layout::Row, 20) - }; - - assert_eq!( - format(ITEMS, opts), - "one two three\nfour five six\nseven eight nine\nten eleven\n" - ); - } - - #[test] - fn zero_padding_keeps_adjacent_columns_adjacent() { - let opts = Options { - padding: 0, - ..options(Layout::Column, 10) - }; - - assert_eq!(format(&["a", "b"], opts), "ab\n"); - } - - #[test] - fn long_items_are_not_truncated() { - assert_eq!( - format(&["abcdef", "g"], options(Layout::Column, 3)), - "abcdef\ng\n" - ); - } - - #[test] - fn format_git_matches_current_call_with_actual_cells() { - let languages = ["rust", "ruby", "json", "typescript", "python"]; - - assert_eq!( - format_git(&languages, " ", 80), - " rust ruby json typescript python\n" - ); + use super::*; + + const ITEMS: &[&str] = &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", + ]; + + const fn options(layout: Layout, width: usize) -> Options<'static> { + Options { + dense: false, + indent: "", + layout, + line_ending: "\n", + padding: 1, + width, } + } + + #[test] + fn empty_list_is_empty() { + assert_eq!(format::<&str>(&[], Options::git(" ", 80)), ""); + } + + #[test] + fn plain_layout_prints_one_indented_item_per_line() { + let opts = Options { + dense: false, + indent: "Z", + layout: Layout::Plain, + line_ending: "\n", + padding: 1, + width: 80, + }; + + assert_eq!( + format(ITEMS, opts), + "Zone\nZtwo\nZthree\nZfour\nZfive\nZsix\nZseven\nZeight\nZnine\nZten\nZeleven\n" + ); + } + + #[test] + fn column_layout_fits_eighty_columns_like_git() { + assert_eq!( + format(ITEMS, options(Layout::Column, 80)), + "one two three four five six seven eight nine ten eleven\n" + ); + } + + #[test] + fn column_layout_width_one_falls_back_to_one_per_line() { + assert_eq!( + format(ITEMS, options(Layout::Column, 1)), + "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\n" + ); + } + + #[test] + fn column_layout_width_twenty_matches_git() { + assert_eq!( + format(ITEMS, options(Layout::Column, 20)), + "one seven\ntwo eight\nthree nine\nfour ten\nfive eleven\nsix\n" + ); + } + + #[test] + fn column_layout_width_twenty_padding_two_matches_git() { + let opts = Options { + padding: 2, + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one seven\ntwo eight\nthree nine\nfour ten\nfive eleven\nsix\n" + ); + } + + #[test] + fn column_layout_width_twenty_indented_matches_git() { + let opts = Options { + indent: " ", + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + " one seven\n two eight\n three nine\n four ten\n five eleven\n six\n" + ); + } + + #[test] + fn dense_column_layout_width_twenty_matches_git() { + let opts = Options { + dense: true, + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one five nine\ntwo six ten\nthree seven eleven\nfour eight\n" + ); + } + + #[test] + fn row_layout_width_twenty_matches_git() { + assert_eq!( + format(ITEMS, options(Layout::Row, 20)), + "one two\nthree four\nfive six\nseven eight\nnine ten\neleven\n" + ); + } + + #[test] + fn dense_row_layout_width_twenty_matches_git() { + let opts = Options { + dense: true, + ..options(Layout::Row, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one two three\nfour five six\nseven eight nine\nten eleven\n" + ); + } + + #[test] + fn zero_padding_keeps_adjacent_columns_adjacent() { + let opts = Options { + padding: 0, + ..options(Layout::Column, 10) + }; + + assert_eq!(format(&["a", "b"], opts), "ab\n"); + } + + #[test] + fn long_items_are_not_truncated() { + assert_eq!( + format(&["abcdef", "g"], options(Layout::Column, 3)), + "abcdef\ng\n" + ); + } + + #[test] + fn format_git_matches_current_call_with_actual_cells() { + let languages = ["rust", "ruby", "json", "typescript", "python"]; + + assert_eq!( + format_git(&languages, " ", 80), + " rust ruby json typescript python\n" + ); + } } diff --git a/src/config.rs b/src/config.rs index a96f3c2..577b4ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,11 +2,11 @@ //! figment and diff-struct. use std::{ - ffi::OsString, - fs, - num::NonZeroUsize, - path::{Path, PathBuf}, - result::Result as StdResult, + ffi::OsString, + fs, + num::NonZeroUsize, + path::{Path, PathBuf}, + result::Result as StdResult, }; use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, parser::ValueSource}; @@ -48,15 +48,15 @@ const ARG_UNLOCK_TIMEOUT: &str = "unlock_timeout"; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum Source { - /// Compiled-in default. - #[default] - BuiltInDefault, - /// From the parser TOML config file. - ConfigFile, - /// From an environment variable. - Environment, - /// From a CLI flag. - CommandLine, + /// Compiled-in default. + #[default] + BuiltInDefault, + /// From the parser TOML config file. + ConfigFile, + /// From an environment variable. + Environment, + /// From a CLI flag. + CommandLine, } // ============================================================ @@ -67,25 +67,25 @@ pub enum Source { /// definitions and value extraction stay in sync. #[derive(clap::Args, Clone, Debug, Default)] pub struct BuildArgs { - /// Override build directory (`--build-dir`, `-b`). - #[arg(long = "build-dir", short = 'b', env = "BUILD_DIR")] - pub build_dir: Option, + /// Override build directory (`--build-dir`, `-b`). + #[arg(long = "build-dir", short = 'b', env = "BUILD_DIR")] + pub build_dir: Option, - /// Force rebuild (`--force`). - #[arg( + /// Force rebuild (`--force`). + #[arg( long = "force", env = "FORCE", num_args = 0..=1, require_equals = true, default_missing_value = "true" )] - pub force: Option, - /// Negate --force (`--no-force`). - #[arg(long = "no-force")] - pub no_force: bool, + pub force: Option, + /// Negate --force (`--no-force`). + #[arg(long = "no-force")] + pub no_force: bool, - /// Fresh build, clear build directory (`--fresh`, `-f`). - #[arg( + /// Fresh build, clear build directory (`--fresh`, `-f`). + #[arg( long = "fresh", short = 'f', env = "FRESH", @@ -93,54 +93,54 @@ pub struct BuildArgs { require_equals = true, default_missing_value = "true" )] - pub fresh: Option, - /// Negate --fresh (`--no-fresh`). - #[arg(long = "no-fresh")] - pub no_fresh: bool, + pub fresh: Option, + /// Negate --fresh (`--no-fresh`). + #[arg(long = "no-fresh")] + pub no_fresh: bool, - /// Languages to build (positional args, zero or more). - #[arg(num_args = 0..)] - pub languages: Vec, + /// Languages to build (positional args, zero or more). + #[arg(num_args = 0..)] + pub languages: Vec, - /// Max concurrent jobs (`--jobs`, `-j`). - #[arg(long = "jobs", short = 'j', env = "TSDL_NCPUS")] - pub jobs: Option, + /// Max concurrent jobs (`--jobs`, `-j`). + #[arg(long = "jobs", short = 'j', env = "TSDL_NCPUS")] + pub jobs: Option, - /// Output directory for installed binaries (`--out-dir`, `-o`). - #[arg(long = "out-dir", short = 'o', env = "PARSER_OUT_DIR")] - pub out_dir: Option, + /// Output directory for installed binaries (`--out-dir`, `-o`). + #[arg(long = "out-dir", short = 'o', env = "PARSER_OUT_DIR")] + pub out_dir: Option, - /// Output filename prefix (`--prefix`, `-p`). - #[arg(long = "prefix", short = 'p', env = "PREFIX")] - pub prefix: Option, + /// Output filename prefix (`--prefix`, `-p`). + #[arg(long = "prefix", short = 'p', env = "PREFIX")] + pub prefix: Option, - /// Print resolved config and exit (`--show-config`). - #[arg( + /// Print resolved config and exit (`--show-config`). + #[arg( long = "show-config", env = "SHOW_CONFIG", num_args = 0..=1, require_equals = true, default_missing_value = "true" )] - pub show_config: Option, - /// Negate --show-config (`--no-show-config`). - #[arg(long = "no-show-config")] - pub no_show_config: bool, + pub show_config: Option, + /// Negate --show-config (`--no-show-config`). + #[arg(long = "no-show-config")] + pub no_show_config: bool, - /// Build target (`--target`, `-t`). - #[arg(long = "target", short = 't', env = "TSDL_TARGET", value_enum)] - pub target: Option, + /// Build target (`--target`, `-t`). + #[arg(long = "target", short = 't', env = "TSDL_TARGET", value_enum)] + pub target: Option, - #[command(flatten)] - pub tree_sitter: TreeSitterArgs, + #[command(flatten)] + pub tree_sitter: TreeSitterArgs, - /// Lock wait timeout in seconds (`--unlock-timeout`). - #[arg( + /// Lock wait timeout in seconds (`--unlock-timeout`). + #[arg( long = "unlock-timeout", env = "UNLOCK_TIMEOUT", value_parser = clap::value_parser!(u64).range(1..) )] - pub unlock_timeout: Option, + pub unlock_timeout: Option, } /// Tracks the source provenance for every field in [`args::BuildCommand`]. @@ -151,32 +151,32 @@ pub struct BuildArgs { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct BuildProvenance { - pub build_dir: Source, - pub force: Source, - pub fresh: Source, - pub languages: Source, - pub jobs: Source, - pub out_dir: Source, - pub parsers: Source, - pub prefix: Source, - pub show_config: Source, - pub target: Source, - pub tree_sitter: TreeSitterProvenance, - pub unlock_timeout: Source, + pub build_dir: Source, + pub force: Source, + pub fresh: Source, + pub languages: Source, + pub jobs: Source, + pub out_dir: Source, + pub parsers: Source, + pub prefix: Source, + pub show_config: Source, + pub target: Source, + pub tree_sitter: TreeSitterProvenance, + pub unlock_timeout: Source, } /// Nested CLI arguments for tree-sitter configuration. #[derive(clap::Args, Clone, Debug, Default)] pub struct TreeSitterArgs { - /// CLI flag: `--tree-sitter-version` / `-V`. - #[arg(long = "tree-sitter-version", short = 'V', env = "TSDL_VERSION")] - pub version: Option, - /// CLI flag: `--tree-sitter-platform`. - #[arg(long = "tree-sitter-platform", env = "TSDL_PLATFORM")] - pub platform: Option, - /// CLI flag: `--tree-sitter-repo` / `-R`. - #[arg(long = "tree-sitter-repo", short = 'R', env = "TSDL_REPO")] - pub repo: Option, + /// CLI flag: `--tree-sitter-version` / `-V`. + #[arg(long = "tree-sitter-version", short = 'V', env = "TSDL_VERSION")] + pub version: Option, + /// CLI flag: `--tree-sitter-platform`. + #[arg(long = "tree-sitter-platform", env = "TSDL_PLATFORM")] + pub platform: Option, + /// CLI flag: `--tree-sitter-repo` / `-R`. + #[arg(long = "tree-sitter-repo", short = 'R', env = "TSDL_REPO")] + pub repo: Option, } /// Tracks the source provenance for every field in [`args::TreeSitter`]. @@ -187,9 +187,9 @@ pub struct TreeSitterArgs { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub struct TreeSitterProvenance { - pub version: Source, - pub platform: Source, - pub repo: Source, + pub version: Source, + pub platform: Source, + pub repo: Source, } // ============================================================ @@ -198,328 +198,327 @@ pub struct TreeSitterProvenance { /// Apply an Option value to a mutable field if present. fn apply_opt(field: &mut T, value: Option) { - if let Some(v) = value { - *field = v; - } + if let Some(v) = value { + *field = v; + } } /// Resolve the current merged configuration from defaults, config file, and CLI. pub fn current(config: &Path, matches: Option<&ArgMatches>) -> Result { - let (cmd, _provenance) = current_with_provenance(config, matches)?; - Ok(cmd) + let (cmd, _provenance) = current_with_provenance(config, matches)?; + Ok(cmd) } /// Resolve the current merged configuration with full provenance tracking. pub fn current_with_provenance( - config: &Path, - matches: Option<&ArgMatches>, + config: &Path, + matches: Option<&ArgMatches>, ) -> Result<(args::BuildCommand, BuildProvenance)> { - let defaults = args::BuildCommand::default(); - let file_overrides = read_file_overrides(config)?; - let file_provenance = file_provenance_from(&file_overrides); + let defaults = args::BuildCommand::default(); + let file_overrides = read_file_overrides(config)?; + let file_provenance = file_provenance_from(&file_overrides); - let (cli_overrides, cli_provenance) = if let Some(matches) = matches { - extract_overrides(matches) - } else { - ( - args::OptionalBuildCommand::default(), - BuildProvenance::default(), - ) - }; + let (cli_overrides, cli_provenance) = if let Some(matches) = matches { + extract_overrides(matches) + } else { + ( + args::OptionalBuildCommand::default(), + BuildProvenance::default(), + ) + }; - let command = merge(defaults, file_overrides, cli_overrides); - let provenance = merge_provenance(&file_provenance, &cli_provenance); + let command = merge(defaults, file_overrides, cli_overrides); + let provenance = merge_provenance(&file_provenance, &cli_provenance); - debug!(?provenance, ?command, "Resolved build configuration"); + debug!(?provenance, ?command, "Resolved build configuration"); - Ok((command, provenance)) + Ok((command, provenance)) } /// Extract CLI overrides from parsed `ArgMatches` into `OptionalBuildCommand` /// with provenance tracking. #[must_use] pub fn extract_overrides(matches: &ArgMatches) -> (args::OptionalBuildCommand, BuildProvenance) { - let cli = BuildArgs::from_arg_matches(matches).unwrap_or_else(|err| err.exit()); - overrides_from_build_args(cli, matches) + let cli = BuildArgs::from_arg_matches(matches).unwrap_or_else(|err| err.exit()); + overrides_from_build_args(cli, matches) } /// Build provenance for each config file override. fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenance { - let mut p = BuildProvenance::default(); - if overrides.build_dir.is_some() { - p.build_dir = Source::ConfigFile; - } - if overrides.force.is_some() { - p.force = Source::ConfigFile; - } - if overrides.fresh.is_some() { - p.fresh = Source::ConfigFile; - } - if overrides.jobs.is_some() { - p.jobs = Source::ConfigFile; - } - if overrides.out_dir.is_some() { - p.out_dir = Source::ConfigFile; - } - if overrides.parsers.is_some() { - p.parsers = Source::ConfigFile; - } - if overrides.prefix.is_some() { - p.prefix = Source::ConfigFile; - } - if overrides.show_config.is_some() { - p.show_config = Source::ConfigFile; - } - if overrides.target.is_some() { - p.target = Source::ConfigFile; - } - if overrides.unlock_timeout.is_some() { - p.unlock_timeout = Source::ConfigFile; - } - let ts = &overrides.tree_sitter; - if ts.version.is_some() { - p.tree_sitter.version = Source::ConfigFile; - } - if ts.platform.is_some() { - p.tree_sitter.platform = Source::ConfigFile; - } - if ts.repo.is_some() { - p.tree_sitter.repo = Source::ConfigFile; - } - p + let mut p = BuildProvenance::default(); + if overrides.build_dir.is_some() { + p.build_dir = Source::ConfigFile; + } + if overrides.force.is_some() { + p.force = Source::ConfigFile; + } + if overrides.fresh.is_some() { + p.fresh = Source::ConfigFile; + } + if overrides.jobs.is_some() { + p.jobs = Source::ConfigFile; + } + if overrides.out_dir.is_some() { + p.out_dir = Source::ConfigFile; + } + if overrides.parsers.is_some() { + p.parsers = Source::ConfigFile; + } + if overrides.prefix.is_some() { + p.prefix = Source::ConfigFile; + } + if overrides.show_config.is_some() { + p.show_config = Source::ConfigFile; + } + if overrides.target.is_some() { + p.target = Source::ConfigFile; + } + if overrides.unlock_timeout.is_some() { + p.unlock_timeout = Source::ConfigFile; + } + let ts = &overrides.tree_sitter; + if ts.version.is_some() { + p.tree_sitter.version = Source::ConfigFile; + } + if ts.platform.is_some() { + p.tree_sitter.platform = Source::ConfigFile; + } + if ts.repo.is_some() { + p.tree_sitter.repo = Source::ConfigFile; + } + p } /// Merge defaults + file overrides + CLI overrides into a final `BuildCommand`. fn merge( - defaults: args::BuildCommand, - file: args::OptionalBuildCommand, - cli: args::OptionalBuildCommand, + defaults: args::BuildCommand, + file: args::OptionalBuildCommand, + cli: args::OptionalBuildCommand, ) -> args::BuildCommand { - let mut cmd = defaults; - apply_opt(&mut cmd.build_dir, file.build_dir); - apply_opt(&mut cmd.build_dir, cli.build_dir); - apply_opt(&mut cmd.force, file.force); - apply_opt(&mut cmd.force, cli.force); - apply_opt(&mut cmd.fresh, file.fresh); - apply_opt(&mut cmd.fresh, cli.fresh); - apply_opt(&mut cmd.jobs, file.jobs); - apply_opt(&mut cmd.jobs, cli.jobs); - apply_opt(&mut cmd.out_dir, file.out_dir); - apply_opt(&mut cmd.out_dir, cli.out_dir); - if let Some(parsers) = file.parsers { - cmd.parsers = Some(parsers); - } - apply_opt(&mut cmd.prefix, file.prefix); - apply_opt(&mut cmd.prefix, cli.prefix); - apply_opt(&mut cmd.show_config, file.show_config); - apply_opt(&mut cmd.show_config, cli.show_config); - apply_opt(&mut cmd.target, file.target); - apply_opt(&mut cmd.target, cli.target); - apply_opt(&mut cmd.unlock_timeout, file.unlock_timeout); - apply_opt(&mut cmd.unlock_timeout, cli.unlock_timeout); - - apply_opt(&mut cmd.tree_sitter.version, file.tree_sitter.version); - apply_opt(&mut cmd.tree_sitter.version, cli.tree_sitter.version); - apply_opt(&mut cmd.tree_sitter.platform, file.tree_sitter.platform); - apply_opt(&mut cmd.tree_sitter.platform, cli.tree_sitter.platform); - apply_opt(&mut cmd.tree_sitter.repo, file.tree_sitter.repo); - apply_opt(&mut cmd.tree_sitter.repo, cli.tree_sitter.repo); - - if let Some(langs) = cli.languages { - cmd.languages = Some(langs); - } - - cmd + let mut cmd = defaults; + apply_opt(&mut cmd.build_dir, file.build_dir); + apply_opt(&mut cmd.build_dir, cli.build_dir); + apply_opt(&mut cmd.force, file.force); + apply_opt(&mut cmd.force, cli.force); + apply_opt(&mut cmd.fresh, file.fresh); + apply_opt(&mut cmd.fresh, cli.fresh); + apply_opt(&mut cmd.jobs, file.jobs); + apply_opt(&mut cmd.jobs, cli.jobs); + apply_opt(&mut cmd.out_dir, file.out_dir); + apply_opt(&mut cmd.out_dir, cli.out_dir); + if let Some(parsers) = file.parsers { + cmd.parsers = Some(parsers); + } + apply_opt(&mut cmd.prefix, file.prefix); + apply_opt(&mut cmd.prefix, cli.prefix); + apply_opt(&mut cmd.show_config, file.show_config); + apply_opt(&mut cmd.show_config, cli.show_config); + apply_opt(&mut cmd.target, file.target); + apply_opt(&mut cmd.target, cli.target); + apply_opt(&mut cmd.unlock_timeout, file.unlock_timeout); + apply_opt(&mut cmd.unlock_timeout, cli.unlock_timeout); + + apply_opt(&mut cmd.tree_sitter.version, file.tree_sitter.version); + apply_opt(&mut cmd.tree_sitter.version, cli.tree_sitter.version); + apply_opt(&mut cmd.tree_sitter.platform, file.tree_sitter.platform); + apply_opt(&mut cmd.tree_sitter.platform, cli.tree_sitter.platform); + apply_opt(&mut cmd.tree_sitter.repo, file.tree_sitter.repo); + apply_opt(&mut cmd.tree_sitter.repo, cli.tree_sitter.repo); + + if let Some(langs) = cli.languages { + cmd.languages = Some(langs); + } + + cmd } /// Merge file and CLI provenance into a single resolved provenance. fn merge_provenance(file: &BuildProvenance, cli: &BuildProvenance) -> BuildProvenance { - BuildProvenance { - build_dir: merge_source(file.build_dir, cli.build_dir), - force: merge_source(file.force, cli.force), - fresh: merge_source(file.fresh, cli.fresh), - languages: merge_source(file.languages, cli.languages), - jobs: merge_source(file.jobs, cli.jobs), - out_dir: merge_source(file.out_dir, cli.out_dir), - parsers: merge_source(file.parsers, cli.parsers), - prefix: merge_source(file.prefix, cli.prefix), - show_config: merge_source(file.show_config, cli.show_config), - target: merge_source(file.target, cli.target), - tree_sitter: TreeSitterProvenance { - platform: merge_source(file.tree_sitter.platform, cli.tree_sitter.platform), - repo: merge_source(file.tree_sitter.repo, cli.tree_sitter.repo), - version: merge_source(file.tree_sitter.version, cli.tree_sitter.version), - }, - unlock_timeout: merge_source(file.unlock_timeout, cli.unlock_timeout), - } + BuildProvenance { + build_dir: merge_source(file.build_dir, cli.build_dir), + force: merge_source(file.force, cli.force), + fresh: merge_source(file.fresh, cli.fresh), + languages: merge_source(file.languages, cli.languages), + jobs: merge_source(file.jobs, cli.jobs), + out_dir: merge_source(file.out_dir, cli.out_dir), + parsers: merge_source(file.parsers, cli.parsers), + prefix: merge_source(file.prefix, cli.prefix), + show_config: merge_source(file.show_config, cli.show_config), + target: merge_source(file.target, cli.target), + tree_sitter: TreeSitterProvenance { + platform: merge_source(file.tree_sitter.platform, cli.tree_sitter.platform), + repo: merge_source(file.tree_sitter.repo, cli.tree_sitter.repo), + version: merge_source(file.tree_sitter.version, cli.tree_sitter.version), + }, + unlock_timeout: merge_source(file.unlock_timeout, cli.unlock_timeout), + } } /// Pick the CLI source if set, otherwise fall back to file source. fn merge_source(file: Source, cli: Source) -> Source { - if cli == Source::default() { file } else { cli } + if cli == Source::default() { file } else { cli } } /// Convert CLI `BuildArgs` into `OptionalBuildCommand` + `BuildProvenance`. fn overrides_from_build_args( - cli: BuildArgs, - matches: &ArgMatches, + cli: BuildArgs, + matches: &ArgMatches, ) -> (args::OptionalBuildCommand, BuildProvenance) { - let mut o = args::OptionalBuildCommand::default(); - let mut p = BuildProvenance::default(); - - set_simple( - matches, - ARG_BUILD_DIR, - cli.build_dir, - &mut o.build_dir, - &mut p.build_dir, - ); - resolve_bool( - matches, - cli.force, - ARG_FORCE, - ARG_NO_FORCE, - &mut o.force, - &mut p.force, - ); - resolve_bool( - matches, - cli.fresh, - ARG_FRESH, - ARG_NO_FRESH, - &mut o.fresh, - &mut p.fresh, - ); - - if !cli.languages.is_empty() - && let Some(source) = source_for(matches, ARG_LANGUAGES) - { - o.languages = Some(cli.languages); - p.languages = source; - } - - if let Some(n) = cli.jobs.and_then(NonZeroUsize::new) - && let Some(source) = source_for(matches, ARG_JOBS) - { - o.jobs = Some(n); - p.jobs = source; - } - - set_simple( - matches, - ARG_OUT_DIR, - cli.out_dir, - &mut o.out_dir, - &mut p.out_dir, - ); - set_simple( - matches, - ARG_PREFIX, - cli.prefix, - &mut o.prefix, - &mut p.prefix, - ); - - resolve_bool( - matches, - cli.show_config, - ARG_SHOW_CONFIG, - ARG_NO_SHOW_CONFIG, - &mut o.show_config, - &mut p.show_config, - ); - - set_simple( - matches, - ARG_TARGET, - cli.target, - &mut o.target, - &mut p.target, - ); - - set_simple( - matches, - ARG_TS_VERSION, - cli.tree_sitter.version, - &mut o.tree_sitter.version, - &mut p.tree_sitter.version, - ); - set_simple( - matches, - ARG_TS_PLATFORM, - cli.tree_sitter.platform, - &mut o.tree_sitter.platform, - &mut p.tree_sitter.platform, - ); - set_simple( - matches, - ARG_TS_REPO, - cli.tree_sitter.repo, - &mut o.tree_sitter.repo, - &mut p.tree_sitter.repo, - ); - - set_simple( - matches, - ARG_UNLOCK_TIMEOUT, - cli.unlock_timeout, - &mut o.unlock_timeout, - &mut p.unlock_timeout, - ); - - (o, p) + let mut o = args::OptionalBuildCommand::default(); + let mut p = BuildProvenance::default(); + + set_simple( + matches, + ARG_BUILD_DIR, + cli.build_dir, + &mut o.build_dir, + &mut p.build_dir, + ); + resolve_bool( + matches, + cli.force, + ARG_FORCE, + ARG_NO_FORCE, + &mut o.force, + &mut p.force, + ); + resolve_bool( + matches, + cli.fresh, + ARG_FRESH, + ARG_NO_FRESH, + &mut o.fresh, + &mut p.fresh, + ); + + if !cli.languages.is_empty() + && let Some(source) = source_for(matches, ARG_LANGUAGES) + { + o.languages = Some(cli.languages); + p.languages = source; + } + + if let Some(n) = cli.jobs.and_then(NonZeroUsize::new) + && let Some(source) = source_for(matches, ARG_JOBS) + { + o.jobs = Some(n); + p.jobs = source; + } + + set_simple( + matches, + ARG_OUT_DIR, + cli.out_dir, + &mut o.out_dir, + &mut p.out_dir, + ); + set_simple( + matches, + ARG_PREFIX, + cli.prefix, + &mut o.prefix, + &mut p.prefix, + ); + + resolve_bool( + matches, + cli.show_config, + ARG_SHOW_CONFIG, + ARG_NO_SHOW_CONFIG, + &mut o.show_config, + &mut p.show_config, + ); + + set_simple( + matches, + ARG_TARGET, + cli.target, + &mut o.target, + &mut p.target, + ); + + set_simple( + matches, + ARG_TS_VERSION, + cli.tree_sitter.version, + &mut o.tree_sitter.version, + &mut p.tree_sitter.version, + ); + set_simple( + matches, + ARG_TS_PLATFORM, + cli.tree_sitter.platform, + &mut o.tree_sitter.platform, + &mut p.tree_sitter.platform, + ); + set_simple( + matches, + ARG_TS_REPO, + cli.tree_sitter.repo, + &mut o.tree_sitter.repo, + &mut p.tree_sitter.repo, + ); + + set_simple( + matches, + ARG_UNLOCK_TIMEOUT, + cli.unlock_timeout, + &mut o.unlock_timeout, + &mut p.unlock_timeout, + ); + + (o, p) } /// Parse CLI args using the augmented command definitions. #[must_use] pub fn parse_with_matches() -> (args::Args, ArgMatches) { - let mut cmd = args::Args::command(); - if let Some(build_sub) = cmd.find_subcommand_mut("build") { - *build_sub = BuildArgs::augment_args(build_sub.clone()); - } - let matches = cmd.get_matches(); - let args = args::Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); - (args, matches) + let mut cmd = args::Args::command(); + if let Some(build_sub) = cmd.find_subcommand_mut("build") { + *build_sub = BuildArgs::augment_args(build_sub.clone()); + } + let matches = cmd.get_matches(); + let args = args::Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); + (args, matches) } /// Print the current merged configuration as TOML. pub fn print_current(command: &args::BuildCommand) -> Result<()> { - println!( - "{}", - toml::to_string(command).context("Generating current TOML config")? - ); - Ok(()) + println!( + "{}", + toml::to_string(command).context("Generating current TOML config")? + ); + Ok(()) } /// Print the built-in default configuration as TOML. pub fn print_default() -> Result<()> { - println!( - "{}", - toml::to_string(&args::BuildCommand::default()) - .context("Generating default TOML config")? - ); - Ok(()) + println!( + "{}", + toml::to_string(&args::BuildCommand::default()).context("Generating default TOML config")? + ); + Ok(()) } /// Print a string with a per-line indent prefix. pub fn print_indent(s: &str, indent: &str) { - s.lines().for_each(|line| println!("{indent}{line}")); + s.lines().for_each(|line| println!("{indent}{line}")); } /// Read parser config file overrides from the TOML file at `config`. fn read_file_overrides(config: &Path) -> Result { - if !config.exists() { - return Ok(args::OptionalBuildCommand::default()); - } + if !config.exists() { + return Ok(args::OptionalBuildCommand::default()); + } - let contents = fs::read_to_string(config) - .with_context(|| format!("Reading config file {}", config.display()))?; + let contents = fs::read_to_string(config) + .with_context(|| format!("Reading config file {}", config.display()))?; - if contents.trim().is_empty() { - return Ok(args::OptionalBuildCommand::default()); - } + if contents.trim().is_empty() { + return Ok(args::OptionalBuildCommand::default()); + } - toml::from_str(&contents).with_context(|| format!("Parsing config file {}", config.display())) + toml::from_str(&contents).with_context(|| format!("Parsing config file {}", config.display())) } /// Resolve a boolean field that has both a positive (`--force`) and negative @@ -527,89 +526,89 @@ fn read_file_overrides(config: &Path) -> Result { /// explicitly on the command line; env-var / config values flow through the /// positive arg. fn resolve_bool( - matches: &ArgMatches, - positive: Option, - pos_id: &str, - neg_id: &str, - field: &mut Option, - provenance: &mut Source, + matches: &ArgMatches, + positive: Option, + pos_id: &str, + neg_id: &str, + field: &mut Option, + provenance: &mut Source, ) { - // --no-* on the command line always wins. - if matches!(source_for(matches, neg_id), Some(Source::CommandLine)) { - *field = Some(false); - *provenance = Source::CommandLine; - return; - } - - if let Some(source) = source_for(matches, pos_id) - && let Some(val) = positive - { - *field = Some(val); - *provenance = source; - } + // --no-* on the command line always wins. + if matches!(source_for(matches, neg_id), Some(Source::CommandLine)) { + *field = Some(false); + *provenance = Source::CommandLine; + return; + } + + if let Some(source) = source_for(matches, pos_id) + && let Some(val) = positive + { + *field = Some(val); + *provenance = source; + } } /// Run a config command (current or default). pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { - match command { - args::ConfigCommand::Current => print_current(¤t(config_path, None)?), - args::ConfigCommand::Default => print_default(), - } + match command { + args::ConfigCommand::Current => print_current(¤t(config_path, None)?), + args::ConfigCommand::Default => print_default(), + } } /// Set a simple `Option` override when the arg's `ValueSource` indicates /// the user supplied it (CLI or env). fn set_simple( - matches: &ArgMatches, - id: &str, - value: Option, - field: &mut Option, - provenance: &mut Source, + matches: &ArgMatches, + id: &str, + value: Option, + field: &mut Option, + provenance: &mut Source, ) { - if let Some(source) = source_for(matches, id) - && let Some(val) = value - { - *field = Some(val); - *provenance = source; - } + if let Some(source) = source_for(matches, id) + && let Some(val) = value + { + *field = Some(val); + *provenance = source; + } } /// Print a human-readable summary of the resolved configuration. pub fn show(command: &args::BuildCommand) -> Result<()> { - if let Some(langs) = &command.languages { - println!("Building the following languages:"); - println!(); - print!("{}", columns::format_git(langs, " ", 80)); - println!(); - } else { - println!("Building all languages."); - println!(); - } - println!("Running with the following configuration:"); + if let Some(langs) = &command.languages { + println!("Building the following languages:"); println!(); - print_indent(&toml::to_string(&command).context("Showing config")?, " "); + print!("{}", columns::format_git(langs, " ", 80)); println!(); - Ok(()) + } else { + println!("Building all languages."); + println!(); + } + println!("Running with the following configuration:"); + println!(); + print_indent(&toml::to_string(&command).context("Showing config")?, " "); + println!(); + Ok(()) } /// Look up the `ValueSource` for a given arg ID and convert it to a Source. fn source_for(matches: &ArgMatches, id: &str) -> Option { - matches.value_source(id).and_then(Source::from_value_source) + matches.value_source(id).and_then(Source::from_value_source) } /// Parse CLI args from an iterator (used in tests). pub fn try_parse_from_with_matches(itr: I) -> StdResult<(args::Args, ArgMatches), clap::Error> where - I: IntoIterator, - T: Into + Clone, + I: IntoIterator, + T: Into + Clone, { - let mut cmd = args::Args::command(); - if let Some(build_sub) = cmd.find_subcommand_mut("build") { - *build_sub = BuildArgs::augment_args(build_sub.clone()); - } - let matches = cmd.try_get_matches_from(itr)?; - let args = args::Args::from_arg_matches(&matches)?; - Ok((args, matches)) + let mut cmd = args::Args::command(); + if let Some(build_sub) = cmd.find_subcommand_mut("build") { + *build_sub = BuildArgs::augment_args(build_sub.clone()); + } + let matches = cmd.try_get_matches_from(itr)?; + let args = args::Args::from_arg_matches(&matches)?; + Ok((args, matches)) } // ============================================================ @@ -617,12 +616,12 @@ where // ============================================================ impl Source { - /// Convert a clap `ValueSource` to a config Source. - fn from_value_source(source: ValueSource) -> Option { - match source { - ValueSource::CommandLine => Some(Self::CommandLine), - ValueSource::EnvVariable => Some(Self::Environment), - _ => None, - } + /// Convert a clap `ValueSource` to a config Source. + fn from_value_source(source: ValueSource) -> Option { + match source { + ValueSource::CommandLine => Some(Self::CommandLine), + ValueSource::EnvVariable => Some(Self::Environment), + _ => None, } + } } diff --git a/src/display.rs b/src/display.rs index 04897e9..adb4576 100644 --- a/src/display.rs +++ b/src/display.rs @@ -43,25 +43,25 @@ const TIME_STYLE: Style = Style::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum Column { - /// Icon (status indicator). - Icon, - /// Message text. - Msg, - /// Parser/grammar name. - Name, - /// Git ref string. - Ref, - /// Step counter (e.g. "[1/4]"). - Step, - /// Elapsed time. - Time, + /// Icon (status indicator). + Icon, + /// Message text. + Msg, + /// Parser/grammar name. + Name, + /// Git ref string. + Ref, + /// Step counter (e.g. "[1/4]"). + Step, + /// Elapsed time. + Time, } pub(crate) enum ItemInfo<'a> { - /// References a grammar-level entry. - Grammar(&'a GrammarEntry), - /// References a repo-level entry. - Repo(&'a RepoEntry), + /// References a grammar-level entry. + Grammar(&'a GrammarEntry), + /// References a repo-level entry. + Repo(&'a RepoEntry), } /// Complete lifecycle state for a display row. @@ -70,27 +70,27 @@ pub(crate) enum ItemInfo<'a> { /// field, so a row cannot be both "cancelled" and "still building". #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ItemState { - /// Just registered, not yet started. - New, - /// In progress. The optional outcome is known after cache classification: - /// cached rows stay live while they are re-installed, and built rows stay - /// live while they are generated/built/installed. - InProgress(Option), - /// Successfully completed. - Done(SuccessOutcome), - /// Cancelled by shutdown. - Cancelled, - /// Failed. - Failed, + /// Just registered, not yet started. + New, + /// In progress. The optional outcome is known after cache classification: + /// cached rows stay live while they are re-installed, and built rows stay + /// live while they are generated/built/installed. + InProgress(Option), + /// Successfully completed. + Done(SuccessOutcome), + /// Cancelled by shutdown. + Cancelled, + /// Failed. + Failed, } /// The selected display backend. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Mode { - /// Ratatui inline terminal rendering. - Fancy, - /// Deterministic line-by-line output. - Plain, + /// Ratatui inline terminal rendering. + Fancy, + /// Deterministic line-by-line output. + Plain, } // ============================================================ @@ -100,19 +100,19 @@ pub enum Mode { /// The kind of rows: collapsed single-grammar repos and multi-grammar repos. #[derive(Debug, Clone)] pub(crate) enum RowKind { - /// A grammar-level progress row. - Grammar, - /// A repo-level progress row. - Repo, + /// A grammar-level progress row. + Grammar, + /// A repo-level progress row. + Repo, } /// Whether a completed build needed actual work or was served from cache. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SuccessOutcome { - /// Not cached; work was needed. - Built, - /// Cache hit. - Cached, + /// Not cached; work was needed. + Built, + /// Cache hit. + Cached, } // ============================================================ @@ -121,56 +121,56 @@ pub enum SuccessOutcome { #[derive(Debug, Clone)] pub(crate) struct CachedLayout { - /// Width of the time column. - pub time: usize, - /// Width of the git-ref column. - pub ref_: usize, - /// Width of the step column. - pub step: usize, - /// Width of the name column. - pub name: usize, - /// Total width consumed by non-message columns: time + sp + ref + sp + - /// step + sp + icon(1) + sp + name + sp. - pub fixed_width: usize, + /// Width of the time column. + pub time: usize, + /// Width of the git-ref column. + pub ref_: usize, + /// Width of the step column. + pub step: usize, + /// Width of the name column. + pub name: usize, + /// Total width consumed by non-message columns: time + sp + ref + sp + + /// step + sp + icon(1) + sp + name + sp. + pub fixed_width: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct CellKey { - item_id: ItemId, - column: Column, + item_id: ItemId, + column: Column, } #[derive(Debug, Clone)] pub(crate) struct GrammarEntry { - /// Parent repo name. - pub repo: Arc, - /// Parent repo's item ID (for batch updates). - pub repo_id: Option, - /// Grammar name within the repo. - pub name: Arc, - /// Git ref for display. - pub git_ref: git::Ref, - /// Current lifecycle state. - pub state: ItemState, - /// Current status message. - pub msg: Arc, - /// Current step index (0-based). - pub step: usize, - /// Total number of steps. - pub total: usize, - /// Creation time for the whole item. This is not reset between steps. - pub started_at: Instant, - /// Frozen whole-item elapsed time for terminal states. - pub frozen_elapsed: Option, + /// Parent repo name. + pub repo: Arc, + /// Parent repo's item ID (for batch updates). + pub repo_id: Option, + /// Grammar name within the repo. + pub name: Arc, + /// Git ref for display. + pub git_ref: git::Ref, + /// Current lifecycle state. + pub state: ItemState, + /// Current status message. + pub msg: Arc, + /// Current step index (0-based). + pub step: usize, + /// Total number of steps. + pub total: usize, + /// Creation time for the whole item. This is not reset between steps. + pub started_at: Instant, + /// Frozen whole-item elapsed time for terminal states. + pub frozen_elapsed: Option, } pub(crate) struct GridCache { - /// Cached rendered cells, keyed by (`item_id`, column). - cells: HashMap>, - /// Item IDs whose cells need recomputation. - pub dirty_items: HashSet, - /// Current column layout (widths). - pub layout: CachedLayout, + /// Cached rendered cells, keyed by (`item_id`, column). + cells: HashMap>, + /// Item IDs whose cells need recomputation. + pub dirty_items: HashSet, + /// Current column layout (widths). + pub layout: CachedLayout, } /// Unique identifier for a display row (repo or grammar). @@ -179,64 +179,64 @@ pub struct ItemId(NonZeroU64); #[derive(Debug, Clone)] pub(crate) struct RepoEntry { - /// Repo name. - pub name: Arc, - /// Git ref for display. - pub git_ref: git::Ref, - /// Current lifecycle state. - pub state: ItemState, - /// Current status message. - pub msg: Arc, - /// Current step index (0-based). - pub step: usize, - /// Total number of steps. - pub total: usize, - /// Creation time for the whole item. This is not reset between steps. - pub started_at: Instant, - /// Frozen whole-item elapsed time for terminal states. - pub frozen_elapsed: Option, + /// Repo name. + pub name: Arc, + /// Git ref for display. + pub git_ref: git::Ref, + /// Current lifecycle state. + pub state: ItemState, + /// Current status message. + pub msg: Arc, + /// Current step index (0-based). + pub step: usize, + /// Total number of steps. + pub total: usize, + /// Creation time for the whole item. This is not reset between steps. + pub started_at: Instant, + /// Frozen whole-item elapsed time for terminal states. + pub frozen_elapsed: Option, } #[derive(Debug, Clone)] pub(crate) struct RowSpec { - /// Unique row identifier. - pub id: ItemId, - /// Whether this row is a repo or a grammar. - pub kind: RowKind, - /// The text to display in the name column (already includes indent). - pub display_name: Arc, - /// Indentation prefix for the name column (applied before padding). - pub indent: &'static str, + /// Unique row identifier. + pub id: ItemId, + /// Whether this row is a repo or a grammar. + pub kind: RowKind, + /// The text to display in the name column (already includes indent). + pub display_name: Arc, + /// Indentation prefix for the name column (applied before padding). + pub indent: &'static str, } /// Tallies of terminal row states for the summary footer. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) struct BuildSummary { - /// Rows still building. - pub building: usize, - /// Rows that were built (not cached). - pub built: usize, - /// Rows served from cache. - pub cached: usize, - /// Rows cancelled by shutdown. - pub cancelled: usize, - /// Rows that failed. - pub failed: usize, + /// Rows still building. + pub building: usize, + /// Rows that were built (not cached). + pub built: usize, + /// Rows served from cache. + pub cached: usize, + /// Rows cancelled by shutdown. + pub cancelled: usize, + /// Rows that failed. + pub failed: usize, } pub(crate) struct State { - /// Build directory root path. - pub build_dir: PathBuf, - footer_build: Line<'static>, - footer_out: Line<'static>, - /// Grammar-level entries keyed by item ID. - pub grammars: HashMap, - /// Display mode. - pub mode: Mode, - /// Output directory path. - pub out_dir: PathBuf, - /// Repo-level entries keyed by item ID. - pub repos: HashMap, + /// Build directory root path. + pub build_dir: PathBuf, + footer_build: Line<'static>, + footer_out: Line<'static>, + /// Grammar-level entries keyed by item ID. + pub grammars: HashMap, + /// Display mode. + pub mode: Mode, + /// Output directory path. + pub out_dir: PathBuf, + /// Repo-level entries keyed by item ID. + pub repos: HashMap, } // ============================================================ @@ -245,99 +245,99 @@ pub(crate) struct State { /// Render the status icon cell (●, ✓, ✗) with indicator color. pub(crate) fn compute_icon_cell(info: &ItemInfo<'_>) -> Span<'static> { - let state = info.state(); - Span::styled( - state.icon(), - Style::default() - .fg(state.indicator_color()) - .add_modifier(Modifier::BOLD), - ) + let state = info.state(); + Span::styled( + state.icon(), + Style::default() + .fg(state.indicator_color()) + .add_modifier(Modifier::BOLD), + ) } /// Render the message cell, truncating to fit the available width. pub(crate) fn compute_msg_cell( - info: &ItemInfo<'_>, - layout: &CachedLayout, - term_width: usize, + info: &ItemInfo<'_>, + layout: &CachedLayout, + term_width: usize, ) -> Span<'static> { - let msg_width = term_width - .saturating_sub(layout.fixed_width) - .max(MIN_MSG_WIDTH); - Span::styled(truncate_str(info.msg(), msg_width), MSG_STYLE) + let msg_width = term_width + .saturating_sub(layout.fixed_width) + .max(MIN_MSG_WIDTH); + Span::styled(truncate_str(info.msg(), msg_width), MSG_STYLE) } /// Render the name cell with indentation and outcome-based colour. pub(crate) fn compute_name_cell( - display_name: &str, - indent: &str, - info: &ItemInfo<'_>, - layout: &CachedLayout, + display_name: &str, + indent: &str, + info: &ItemInfo<'_>, + layout: &CachedLayout, ) -> Span<'static> { - let full = format!("{indent}{display_name}"); - let padded = format!("{:, layout: &CachedLayout) -> Span<'static> { - let ref_str = info.git_ref().short(); - let padded = format!("{:, layout: &CachedLayout) -> Span<'static> { - let step_str = if info.total() > 0 { - format!("[{}/{}]", info.step().min(info.total()), info.total()) - } else { - String::new() - }; - // Right-aligned - let padded = format!("{:>width$}", step_str, width = layout.step); - Span::styled(padded, REF_STYLE) + let step_str = if info.total() > 0 { + format!("[{}/{}]", info.step().min(info.total()), info.total()) + } else { + String::new() + }; + // Right-aligned + let padded = format!("{:>width$}", step_str, width = layout.step); + Span::styled(padded, REF_STYLE) } /// Render the elapsed time cell, right-aligned. pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { - let time = format_elapsed_duration(info.elapsed()); - // Right-aligned - let padded = format!("{:>width$}", time, width = layout.time); - Span::styled(padded, TIME_STYLE) + let time = format_elapsed_duration(info.elapsed()); + // Right-aligned + let padded = format!("{:>width$}", time, width = layout.time); + Span::styled(padded, TIME_STYLE) } /// Accumulate a single item state into the summary counters. /// Accumulate a single item state into the summary counters. fn count_item_state( - state: ItemState, - cached: &mut usize, - built: &mut usize, - building: &mut usize, - failed: &mut usize, - cancelled: &mut usize, + state: ItemState, + cached: &mut usize, + built: &mut usize, + building: &mut usize, + failed: &mut usize, + cancelled: &mut usize, ) { - match state { - ItemState::New | ItemState::InProgress(_) => *building += 1, - ItemState::Done(SuccessOutcome::Cached) => *cached += 1, - ItemState::Done(SuccessOutcome::Built) => *built += 1, - ItemState::Cancelled => *cancelled += 1, - ItemState::Failed => *failed += 1, - } + match state { + ItemState::New | ItemState::InProgress(_) => *building += 1, + ItemState::Done(SuccessOutcome::Cached) => *cached += 1, + ItemState::Done(SuccessOutcome::Built) => *built += 1, + ItemState::Cancelled => *cancelled += 1, + ItemState::Failed => *failed += 1, + } } /// Pre-computed dimmed footer lines (static; never change). /// Build a dimmed footer line. fn dim_line(text: String) -> Line<'static> { - Line::from(Span::styled( - text, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::DIM), - )) + Line::from(Span::styled( + text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + )) } /// Pre-compute the fixed (non-message) portion of a row line. @@ -346,137 +346,138 @@ fn dim_line(text: String) -> Line<'static> { /// (5 spacers between 6 columns). /// Compute the total width consumed by all non-message columns. const fn fixed_width(time: usize, rf: usize, step: usize, name: usize) -> usize { - time + SPACER_WIDTH - + rf - + SPACER_WIDTH - + step - + SPACER_WIDTH - + ICON_COL_WIDTH - + SPACER_WIDTH - + name - + SPACER_WIDTH + time + + SPACER_WIDTH + + rf + + SPACER_WIDTH + + step + + SPACER_WIDTH + + ICON_COL_WIDTH + + SPACER_WIDTH + + name + + SPACER_WIDTH } /// Format a duration as " 0.00s" or " 3:45". /// Format a duration as " 0.00s" or " 3:45". fn format_elapsed_duration(dur: Duration) -> String { - let secs = dur.as_secs_f64(); - if secs < 60.0 { - format!("{secs:>5.2}s") - } else { - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let mins = secs as u64 / 60; - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let remaining = secs as u64 % 60; - format!("{mins:>3}:{remaining:02}") - } + let secs = dur.as_secs_f64(); + if secs < 60.0 { + format!("{secs:>5.2}s") + } else { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let mins = secs as u64 / 60; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let remaining = secs as u64 % 60; + format!("{mins:>3}:{remaining:02}") + } } /// Determine the display mode from CLI flags and environment. #[must_use] pub fn mode_from_args(progress: &ProgressStyle, verbose: &Verbosity) -> Mode { - let mut mode = match progress { - ProgressStyle::Auto => { - if atty::is(atty::Stream::Stdout) { - Mode::Fancy - } else { - Mode::Plain - } - } - ProgressStyle::Fancy => Mode::Fancy, - ProgressStyle::Plain => Mode::Plain, - }; - - if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { - mode = Mode::Plain; + let mut mode = match progress { + ProgressStyle::Auto => { + if atty::is(atty::Stream::Stdout) { + Mode::Fancy + } else { + Mode::Plain + } } + ProgressStyle::Fancy => Mode::Fancy, + ProgressStyle::Plain => Mode::Plain, + }; + + if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { + mode = Mode::Plain; + } - mode + mode } /// Append a "building" counter span to the summary line. fn push_summary_building(spans: &mut Vec>, count: usize) { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("{count} building"), - Style::default().fg(if count > 0 { - Color::White - } else { - Color::DarkGray - }), - )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{count} building"), + Style::default().fg(if count > 0 { + Color::White + } else { + Color::DarkGray + }), + )); } /// Append a "cancelled" counter span to the summary line. fn push_summary_cancelled(spans: &mut Vec>, count: usize, _leading: bool) { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("✗ {count} cancelled"), - Style::default().fg(if count > 0 { - Color::Yellow - } else { - Color::DarkGray - }), - )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗ {count} cancelled"), + Style::default().fg(if count > 0 { + Color::Yellow + } else { + Color::DarkGray + }), + )); } /// Append a "failed" counter span to the summary line. fn push_summary_failed(spans: &mut Vec>, count: usize) { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("✗ {count} failed"), - Style::default().fg(if count > 0 { - Color::Red - } else { - Color::DarkGray - }), - )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗ {count} failed"), + Style::default().fg(if count > 0 { + Color::Red + } else { + Color::DarkGray + }), + )); } /// Append a "cached"/"built" success counter span to the summary line. fn push_summary_success( - spans: &mut Vec>, - count: usize, - label: &'static str, - label_color: Color, - leading: bool, + spans: &mut Vec>, + count: usize, + label: &'static str, + label_color: Color, + leading: bool, ) { - if !leading { - spans.push(Span::raw(" ")); - } + if !leading { + spans.push(Span::raw(" ")); + } - if count > 0 { - spans.push(Span::styled( - format!("✓ {count} "), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - label, - Style::default() - .fg(label_color) - .add_modifier(Modifier::BOLD), - )); - } else { - spans.push(Span::styled( - format!("✓ {count} {label}"), - Style::default().fg(Color::DarkGray), - )); - } + if count > 0 { + spans.push(Span::styled( + format!("✓ {count} "), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + label, + Style::default() + .fg(label_color) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!("✓ {count} {label}"), + Style::default().fg(Color::DarkGray), + )); + } } /// Truncate a string with an ellipsis if it exceeds `max` characters. fn truncate_str(s: &str, max: usize) -> String { - if s.chars().count() <= max { - s.to_string() - } else if max <= 1 { - "…".to_string() - } else { - let mut truncated: String = s.chars().take(max - 1).collect(); - truncated.push('…'); - truncated - } + if s.chars().count() <= max { + s.to_string() + } else if max <= 1 { + "…".to_string() + } else { + let mut truncated: String = s.chars().take(max - 1).collect(); + truncated.push('…'); + truncated + } } // ============================================================ @@ -484,429 +485,433 @@ fn truncate_str(s: &str, max: usize) -> String { // ============================================================ impl Default for CachedLayout { - fn default() -> Self { - Self { - time: TIME_COL_WIDTH, - ref_: MIN_REF_WIDTH, - step: MIN_STEP_WIDTH, - name: MIN_NAME_WIDTH, - fixed_width: fixed_width( - TIME_COL_WIDTH, - MIN_REF_WIDTH, - MIN_STEP_WIDTH, - MIN_NAME_WIDTH, - ), - } - } + fn default() -> Self { + Self { + time: TIME_COL_WIDTH, + ref_: MIN_REF_WIDTH, + step: MIN_STEP_WIDTH, + name: MIN_NAME_WIDTH, + fixed_width: fixed_width( + TIME_COL_WIDTH, + MIN_REF_WIDTH, + MIN_STEP_WIDTH, + MIN_NAME_WIDTH, + ), + } + } } impl CachedLayout { - /// Apply minimum width constraints and recompute `fixed_width`. - fn finalize(&mut self) { - self.time = TIME_COL_WIDTH; - self.ref_ = self.ref_.max(MIN_REF_WIDTH); - self.step = self.step.max(MIN_STEP_WIDTH); - self.name = self.name.max(MIN_NAME_WIDTH); - self.fixed_width = fixed_width(self.time, self.ref_, self.step, self.name); - } + /// Apply minimum width constraints and recompute `fixed_width`. + fn finalize(&mut self) { + self.time = TIME_COL_WIDTH; + self.ref_ = self.ref_.max(MIN_REF_WIDTH); + self.step = self.step.max(MIN_STEP_WIDTH); + self.name = self.name.max(MIN_NAME_WIDTH); + self.fixed_width = fixed_width(self.time, self.ref_, self.step, self.name); + } } impl GrammarEntry { - /// Return the elapsed time (frozen or live). - pub(crate) fn elapsed(&self) -> Duration { - self.frozen_elapsed - .unwrap_or_else(|| self.started_at.elapsed()) - } + /// Return the elapsed time (frozen or live). + pub(crate) fn elapsed(&self) -> Duration { + self + .frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } } impl GridCache { - /// Create an empty grid cache. - pub fn new() -> Self { - Self { - cells: HashMap::new(), - dirty_items: HashSet::new(), - layout: CachedLayout::default(), - } - } - - /// Get a cell for the given item and column. If `stale` is true (or the - /// cell is not cached), `compute` is called, the result is cached, and - /// returned. Otherwise the cached cell is cloned. - pub fn cell( - &mut self, - item_id: ItemId, - column: Column, - compute: impl FnOnce() -> Span<'static>, - stale: bool, - ) -> Span<'static> { - let key = CellKey { item_id, column }; - if stale { - let span = compute(); - self.cells.insert(key, span.clone()); - span - } else if let Some(cached) = self.cells.get(&key) { - cached.clone() - } else { - let span = compute(); - self.cells.insert(key, span.clone()); - span - } + /// Create an empty grid cache. + pub fn new() -> Self { + Self { + cells: HashMap::new(), + dirty_items: HashSet::new(), + layout: CachedLayout::default(), + } + } + + /// Get a cell for the given item and column. If `stale` is true (or the + /// cell is not cached), `compute` is called, the result is cached, and + /// returned. Otherwise the cached cell is cloned. + pub fn cell( + &mut self, + item_id: ItemId, + column: Column, + compute: impl FnOnce() -> Span<'static>, + stale: bool, + ) -> Span<'static> { + let key = CellKey { item_id, column }; + if stale { + let span = compute(); + self.cells.insert(key, span.clone()); + span + } else if let Some(cached) = self.cells.get(&key) { + cached.clone() + } else { + let span = compute(); + self.cells.insert(key, span.clone()); + span } + } - /// Mark all cells for an item as needing recomputation. - pub fn mark_dirty(&mut self, item_id: ItemId) { - self.dirty_items.insert(item_id); - } + /// Mark all cells for an item as needing recomputation. + pub fn mark_dirty(&mut self, item_id: ItemId) { + self.dirty_items.insert(item_id); + } - /// Clear the dirty set after recomputation. - pub fn clear_dirty(&mut self) { - self.dirty_items.clear(); - } + /// Clear the dirty set after recomputation. + pub fn clear_dirty(&mut self) { + self.dirty_items.clear(); + } - /// Invalidate all cached cells for a given column (e.g. when layout width - /// for that column changes). - pub fn invalidate_column(&mut self, column: Column) { - self.cells.retain(|key, _| key.column != column); - } + /// Invalidate all cached cells for a given column (e.g. when layout width + /// for that column changes). + pub fn invalidate_column(&mut self, column: Column) { + self.cells.retain(|key, _| key.column != column); + } } impl ItemId { - /// Create an `ItemId` from a non-zero u64. - #[must_use] - pub(crate) fn new(value: NonZeroU64) -> Self { - Self(value) - } - - /// Return the next sequential `ItemId`. - #[must_use] - pub(crate) fn next_after(self) -> Self { - let next = self - .0 - .get() - .checked_add(1) - .and_then(NonZeroU64::new) - .expect("display item id exhausted"); - Self(next) - } + /// Create an `ItemId` from a non-zero u64. + #[must_use] + pub(crate) fn new(value: NonZeroU64) -> Self { + Self(value) + } + + /// Return the next sequential `ItemId`. + #[must_use] + pub(crate) fn next_after(self) -> Self { + let next = self + .0 + .get() + .checked_add(1) + .and_then(NonZeroU64::new) + .expect("display item id exhausted"); + Self(next) + } } impl ItemInfo<'_> { - /// Get the current item state. - pub(crate) fn state(&self) -> ItemState { - match self { - ItemInfo::Grammar(g) => g.state, - ItemInfo::Repo(r) => r.state, - } + /// Get the current item state. + pub(crate) fn state(&self) -> ItemState { + match self { + ItemInfo::Grammar(g) => g.state, + ItemInfo::Repo(r) => r.state, } + } - /// Get the current message string. - pub(crate) fn msg(&self) -> &str { - match self { - ItemInfo::Grammar(g) => &g.msg, - ItemInfo::Repo(r) => &r.msg, - } + /// Get the current message string. + pub(crate) fn msg(&self) -> &str { + match self { + ItemInfo::Grammar(g) => &g.msg, + ItemInfo::Repo(r) => &r.msg, } + } - /// Get the current step index (0-based). - pub(crate) fn step(&self) -> usize { - match self { - ItemInfo::Grammar(g) => g.step, - ItemInfo::Repo(r) => r.step, - } + /// Get the current step index (0-based). + pub(crate) fn step(&self) -> usize { + match self { + ItemInfo::Grammar(g) => g.step, + ItemInfo::Repo(r) => r.step, } + } - /// Get the total number of steps. - pub(crate) fn total(&self) -> usize { - match self { - ItemInfo::Grammar(g) => g.total, - ItemInfo::Repo(r) => r.total, - } + /// Get the total number of steps. + pub(crate) fn total(&self) -> usize { + match self { + ItemInfo::Grammar(g) => g.total, + ItemInfo::Repo(r) => r.total, } + } - /// Get the git ref for this item. - /// Get the git ref for this item. - pub(crate) fn git_ref(&self) -> &git::Ref { - match self { - ItemInfo::Grammar(g) => &g.git_ref, - ItemInfo::Repo(r) => &r.git_ref, - } + /// Get the git ref for this item. + /// Get the git ref for this item. + pub(crate) fn git_ref(&self) -> &git::Ref { + match self { + ItemInfo::Grammar(g) => &g.git_ref, + ItemInfo::Repo(r) => &r.git_ref, } + } - /// Get the elapsed time for this item. - pub(crate) fn elapsed(&self) -> Duration { - match self { - ItemInfo::Grammar(g) => g.elapsed(), - ItemInfo::Repo(r) => r.elapsed(), - } + /// Get the elapsed time for this item. + pub(crate) fn elapsed(&self) -> Duration { + match self { + ItemInfo::Grammar(g) => g.elapsed(), + ItemInfo::Repo(r) => r.elapsed(), } + } } impl ItemState { - /// Check whether the item is still live (new or in progress). - #[must_use] - pub fn is_live(self) -> bool { - matches!(self, Self::New | Self::InProgress(_)) - } - - /// Get the success outcome, if the item has reached a terminal outcome state. - #[must_use] - pub fn success_outcome(self) -> Option { - match self { - Self::InProgress(outcome) => outcome, - Self::Done(outcome) => Some(outcome), - Self::New | Self::Cancelled | Self::Failed => None, - } - } - - /// Return the status icon character. - fn icon(self) -> &'static str { - match self { - Self::New | Self::InProgress(_) => "●", - Self::Done(_) => "✓", - Self::Cancelled | Self::Failed => "✗", - } - } - - /// Return the colour for the name text. - fn name_color(self) -> Color { - match self { - Self::Failed => Color::Red, - Self::Cancelled => Color::Yellow, - Self::New | Self::InProgress(None) => Color::DarkGray, - Self::InProgress(Some(outcome)) | Self::Done(outcome) => outcome.color(), - } - } - - /// Return the colour for the status indicator. - fn indicator_color(self) -> Color { - match self { - Self::Done(_) => Color::Green, - Self::Cancelled => Color::Yellow, - Self::Failed => Color::Red, - Self::New | Self::InProgress(_) => Color::DarkGray, - } - } + /// Check whether the item is still live (new or in progress). + #[must_use] + pub fn is_live(self) -> bool { + matches!(self, Self::New | Self::InProgress(_)) + } + + /// Get the success outcome, if the item has reached a terminal outcome state. + #[must_use] + pub fn success_outcome(self) -> Option { + match self { + Self::InProgress(outcome) => outcome, + Self::Done(outcome) => Some(outcome), + Self::New | Self::Cancelled | Self::Failed => None, + } + } + + /// Return the status icon character. + fn icon(self) -> &'static str { + match self { + Self::New | Self::InProgress(_) => "●", + Self::Done(_) => "✓", + Self::Cancelled | Self::Failed => "✗", + } + } + + /// Return the colour for the name text. + fn name_color(self) -> Color { + match self { + Self::Failed => Color::Red, + Self::Cancelled => Color::Yellow, + Self::New | Self::InProgress(None) => Color::DarkGray, + Self::InProgress(Some(outcome)) | Self::Done(outcome) => outcome.color(), + } + } + + /// Return the colour for the status indicator. + fn indicator_color(self) -> Color { + match self { + Self::Done(_) => Color::Green, + Self::Cancelled => Color::Yellow, + Self::Failed => Color::Red, + Self::New | Self::InProgress(_) => Color::DarkGray, + } + } } impl RepoEntry { - /// Return the elapsed time (frozen or live). - pub(crate) fn elapsed(&self) -> Duration { - self.frozen_elapsed - .unwrap_or_else(|| self.started_at.elapsed()) - } + /// Return the elapsed time (frozen or live). + pub(crate) fn elapsed(&self) -> Duration { + self + .frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } } impl State { - /// Create a new display state with the given build dir, mode, and output dir. - pub fn new(build_dir: PathBuf, mode: Mode, out_dir: PathBuf) -> Self { - let footer_build = dim_line(format!("build: {}", build_dir.display())); - let footer_out = dim_line(format!("out: {}", out_dir.display())); - Self { - build_dir, - footer_build, - footer_out, - grammars: HashMap::new(), - mode, - out_dir, - repos: HashMap::new(), - } - } - - // ── Footer lines ────────────────────────────────────────────── - - /// Return the pre-computed "build:" footer line. - pub fn footer_build_line(&self) -> Line<'static> { - self.footer_build.clone() - } - - /// Return the pre-computed "out:" footer line. - pub fn footer_out_line(&self) -> Line<'static> { - self.footer_out.clone() - } - - /// Build the summary counts footer line. - pub fn format_footer_counts(&self) -> Line<'static> { - let s = self.summary(); - let mut spans: Vec> = Vec::new(); - - push_summary_success(&mut spans, s.cached, "cached", Color::Yellow, true); - push_summary_success(&mut spans, s.built, "built", Color::Blue, false); - if s.cancelled > 0 { - push_summary_cancelled(&mut spans, s.cancelled, false); - } - if s.building > 0 { - push_summary_building(&mut spans, s.building); - } - push_summary_failed(&mut spans, s.failed); - - Line::from(spans) - } - - // ── Row order ───────────────────────────────────────────────── - - /// Compute the sorted row order. Repos are sorted alphabetically by name; - /// grammars within each repo are sorted alphabetically by name. - /// Collapses single-grammar repos where the grammar name equals the repo - /// name into a single grammar row (no separate repo row). - pub fn compute_row_order(&self) -> Vec { - let mut rows = Vec::new(); - - // Sort repos alphabetically by name - let mut sorted_repos: Vec<(ItemId, &RepoEntry)> = - self.repos.iter().map(|(id, r)| (*id, r)).collect(); - sorted_repos.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); - - // Gather grammar (id, entry) pairs grouped by repo, sorted by name - let mut grammars_by_repo: HashMap> = HashMap::new(); - for (gid, g) in &self.grammars { - if let Some(repo_id) = g.repo_id { - grammars_by_repo.entry(repo_id).or_default().push((*gid, g)); - } - } - for list in grammars_by_repo.values_mut() { - list.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); - } - - for (repo_id, repo) in &sorted_repos { - let repo_grammars = grammars_by_repo.remove(repo_id).unwrap_or_default(); - - if repo_grammars.is_empty() { - rows.push(RowSpec { - id: *repo_id, - kind: RowKind::Repo, - display_name: repo.name.clone(), - indent: "", - }); - } else if repo_grammars.len() == 1 { - let (gid, grammar) = repo_grammars[0]; - let display_name: Arc = if grammar.name == repo.name { - repo.name.clone() - } else { - format!("{}/{}", repo.name, grammar.name).into() - }; - rows.push(RowSpec { - id: gid, - kind: RowKind::Grammar, - display_name, - indent: "", - }); - } else { - rows.push(RowSpec { - id: *repo_id, - kind: RowKind::Repo, - display_name: repo.name.clone(), - indent: "", - }); - for (gid, grammar) in repo_grammars { - rows.push(RowSpec { - id: gid, - kind: RowKind::Grammar, - display_name: grammar.name.clone(), - indent: " ", - }); - } - } - } - - rows - } - - // ── Item access ─────────────────────────────────────────────── - - /// Get the item info for a row spec. - pub fn get_item_info(&self, spec: &RowSpec) -> ItemInfo<'_> { - match spec.kind { - RowKind::Grammar => ItemInfo::Grammar( - self.grammars - .get(&spec.id) - .expect("grammar not found for row spec"), - ), - RowKind::Repo => ItemInfo::Repo( - self.repos - .get(&spec.id) - .expect("repo not found for row spec"), - ), - } - } - - // ── Layout ──────────────────────────────────────────────────── - - /// Compute column layout from the current row specs and state. - pub fn compute_layout(&self, row_specs: &[RowSpec]) -> CachedLayout { - let mut layout = CachedLayout::default(); - for spec in row_specs { - let info = self.get_item_info(spec); - let ref_str = info.git_ref().short(); - let step_str = if info.total() > 0 { - format!("[{}/{}]", info.step().min(info.total()), info.total()) - } else { - String::new() - }; - let name_full = format!("{}{}", spec.indent, spec.display_name); - layout.ref_ = layout.ref_.max(ref_str.chars().count()); - layout.step = layout.step.max(step_str.chars().count()); - layout.name = layout.name.max(name_full.chars().count()); - } - layout.finalize(); - layout - } - - // ── Summary counts ──────────────────────────────────────────── - - /// Compute build summary counts from all item states. - pub(crate) fn summary(&self) -> BuildSummary { - let mut cached = 0; - let mut built = 0; - let mut building = 0; - let mut failed = 0; - let mut cancelled = 0; - - let mut repos_with_grammars: HashSet = HashSet::new(); - for grammar in self.grammars.values() { - if let Some(repo_id) = grammar.repo_id { - repos_with_grammars.insert(repo_id); - } - count_item_state( - grammar.state, - &mut cached, - &mut built, - &mut building, - &mut failed, - &mut cancelled, - ); - } - - // Repos without grammars are visible terminal rows too (for example, - // tree-sitter-cli or a language that failed before grammar discovery). - for (repo_id, repo) in &self.repos { - if !repos_with_grammars.contains(repo_id) { - count_item_state( - repo.state, - &mut cached, - &mut built, - &mut building, - &mut failed, - &mut cancelled, - ); - } - } - - BuildSummary { - building, - built, - cached, - cancelled, - failed, + /// Create a new display state with the given build dir, mode, and output dir. + pub fn new(build_dir: PathBuf, mode: Mode, out_dir: PathBuf) -> Self { + let footer_build = dim_line(format!("build: {}", build_dir.display())); + let footer_out = dim_line(format!("out: {}", out_dir.display())); + Self { + build_dir, + footer_build, + footer_out, + grammars: HashMap::new(), + mode, + out_dir, + repos: HashMap::new(), + } + } + + // ── Footer lines ────────────────────────────────────────────── + + /// Return the pre-computed "build:" footer line. + pub fn footer_build_line(&self) -> Line<'static> { + self.footer_build.clone() + } + + /// Return the pre-computed "out:" footer line. + pub fn footer_out_line(&self) -> Line<'static> { + self.footer_out.clone() + } + + /// Build the summary counts footer line. + pub fn format_footer_counts(&self) -> Line<'static> { + let s = self.summary(); + let mut spans: Vec> = Vec::new(); + + push_summary_success(&mut spans, s.cached, "cached", Color::Yellow, true); + push_summary_success(&mut spans, s.built, "built", Color::Blue, false); + if s.cancelled > 0 { + push_summary_cancelled(&mut spans, s.cancelled, false); + } + if s.building > 0 { + push_summary_building(&mut spans, s.building); + } + push_summary_failed(&mut spans, s.failed); + + Line::from(spans) + } + + // ── Row order ───────────────────────────────────────────────── + + /// Compute the sorted row order. Repos are sorted alphabetically by name; + /// grammars within each repo are sorted alphabetically by name. + /// Collapses single-grammar repos where the grammar name equals the repo + /// name into a single grammar row (no separate repo row). + pub fn compute_row_order(&self) -> Vec { + let mut rows = Vec::new(); + + // Sort repos alphabetically by name + let mut sorted_repos: Vec<(ItemId, &RepoEntry)> = + self.repos.iter().map(|(id, r)| (*id, r)).collect(); + sorted_repos.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); + + // Gather grammar (id, entry) pairs grouped by repo, sorted by name + let mut grammars_by_repo: HashMap> = HashMap::new(); + for (gid, g) in &self.grammars { + if let Some(repo_id) = g.repo_id { + grammars_by_repo.entry(repo_id).or_default().push((*gid, g)); + } + } + for list in grammars_by_repo.values_mut() { + list.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); + } + + for (repo_id, repo) in &sorted_repos { + let repo_grammars = grammars_by_repo.remove(repo_id).unwrap_or_default(); + + if repo_grammars.is_empty() { + rows.push(RowSpec { + id: *repo_id, + kind: RowKind::Repo, + display_name: repo.name.clone(), + indent: "", + }); + } else if repo_grammars.len() == 1 { + let (gid, grammar) = repo_grammars[0]; + let display_name: Arc = if grammar.name == repo.name { + repo.name.clone() + } else { + format!("{}/{}", repo.name, grammar.name).into() + }; + rows.push(RowSpec { + id: gid, + kind: RowKind::Grammar, + display_name, + indent: "", + }); + } else { + rows.push(RowSpec { + id: *repo_id, + kind: RowKind::Repo, + display_name: repo.name.clone(), + indent: "", + }); + for (gid, grammar) in repo_grammars { + rows.push(RowSpec { + id: gid, + kind: RowKind::Grammar, + display_name: grammar.name.clone(), + indent: " ", + }); } - } + } + } + + rows + } + + // ── Item access ─────────────────────────────────────────────── + + /// Get the item info for a row spec. + pub fn get_item_info(&self, spec: &RowSpec) -> ItemInfo<'_> { + match spec.kind { + RowKind::Grammar => ItemInfo::Grammar( + self + .grammars + .get(&spec.id) + .expect("grammar not found for row spec"), + ), + RowKind::Repo => ItemInfo::Repo( + self + .repos + .get(&spec.id) + .expect("repo not found for row spec"), + ), + } + } + + // ── Layout ──────────────────────────────────────────────────── + + /// Compute column layout from the current row specs and state. + pub fn compute_layout(&self, row_specs: &[RowSpec]) -> CachedLayout { + let mut layout = CachedLayout::default(); + for spec in row_specs { + let info = self.get_item_info(spec); + let ref_str = info.git_ref().short(); + let step_str = if info.total() > 0 { + format!("[{}/{}]", info.step().min(info.total()), info.total()) + } else { + String::new() + }; + let name_full = format!("{}{}", spec.indent, spec.display_name); + layout.ref_ = layout.ref_.max(ref_str.chars().count()); + layout.step = layout.step.max(step_str.chars().count()); + layout.name = layout.name.max(name_full.chars().count()); + } + layout.finalize(); + layout + } + + // ── Summary counts ──────────────────────────────────────────── + + /// Compute build summary counts from all item states. + pub(crate) fn summary(&self) -> BuildSummary { + let mut cached = 0; + let mut built = 0; + let mut building = 0; + let mut failed = 0; + let mut cancelled = 0; + + let mut repos_with_grammars: HashSet = HashSet::new(); + for grammar in self.grammars.values() { + if let Some(repo_id) = grammar.repo_id { + repos_with_grammars.insert(repo_id); + } + count_item_state( + grammar.state, + &mut cached, + &mut built, + &mut building, + &mut failed, + &mut cancelled, + ); + } + + // Repos without grammars are visible terminal rows too (for example, + // tree-sitter-cli or a language that failed before grammar discovery). + for (repo_id, repo) in &self.repos { + if !repos_with_grammars.contains(repo_id) { + count_item_state( + repo.state, + &mut cached, + &mut built, + &mut building, + &mut failed, + &mut cancelled, + ); + } + } + + BuildSummary { + building, + built, + cached, + cancelled, + failed, + } + } } impl SuccessOutcome { - /// Return the display colour for entries with this outcome. - fn color(self) -> Color { - match self { - SuccessOutcome::Built => Color::Blue, - SuccessOutcome::Cached => Color::Yellow, - } + /// Return the display colour for entries with this outcome. + fn color(self) -> Color { + match self { + SuccessOutcome::Built => Color::Blue, + SuccessOutcome::Cached => Color::Yellow, } + } } diff --git a/src/error.rs b/src/error.rs index 051e587..d73d6b1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,11 +16,11 @@ pub type Result = std::result::Result; // ============================================================ pub trait ResultExt { - /// Wrap the error value with a context message. - fn context(self, message: impl Into) -> Result; + /// Wrap the error value with a context message. + fn context(self, message: impl Into) -> Result; - /// Wrap the error value with a lazily-evaluated context message. - fn with_context(self, message: impl FnOnce() -> String) -> Result; + /// Wrap the error value with a lazily-evaluated context message. + fn with_context(self, message: impl FnOnce() -> String) -> Result; } // ============================================================ @@ -30,63 +30,63 @@ pub trait ResultExt { /// Main error type for tsdl operations #[derive(Debug)] pub enum Error { - /// Build errors - Build { errors: Vec }, + /// Build errors + Build { errors: Vec }, - /// Command execution failed - Command { - msg: String, - stderr: String, - stdout: String, - }, + /// Command execution failed + Command { + msg: String, + stderr: String, + stdout: String, + }, - /// Configuration error - Config { message: String }, + /// Configuration error + Config { message: String }, - /// Context chain (linked list of context layers) - Context { message: String, source: Cause }, + /// Context chain (linked list of context layers) + Context { message: String, source: Cause }, - /// Generic IO error - Io { source: std::io::Error }, + /// Generic IO error + Io { source: std::io::Error }, - /// Build was interrupted by a Unix signal. - Interrupted { signal: Signal }, + /// Build was interrupted by a Unix signal. + Interrupted { signal: Signal }, - /// Simple error message - Message { message: String }, + /// Simple error message + Message { message: String }, - /// Language collection failed - LanguageCollection { related: Vec }, + /// Language collection failed + LanguageCollection { related: Vec }, - /// Individual language failed - Language { name: String, source: Cause }, + /// Individual language failed + Language { name: String, source: Cause }, - /// Specific step failed - Step { - name: Arc, - kind: ParserOp, - source: Cause, - }, + /// Specific step failed + Step { + name: Arc, + kind: ParserOp, + source: Cause, + }, } /// The specific parser operation that failed. #[derive(Debug, Display)] pub enum ParserOp { - /// Compilation step inside the checked-out grammar directory. - #[display("Could not build in {}", dir.display())] - Build { dir: PathBuf }, - /// Git clone into the checkout directory. - #[display("Could not clone to {}", dir.display())] - Clone { dir: PathBuf }, - /// Scanning the checkout for grammar.js files. - #[display("Could not discover grammars in {}", dir.display())] - Discover { dir: PathBuf }, - /// Copying or hard-linking the built artifact. - #[display("Could not copy {} to {}", src.display(), dst.display())] - Copy { src: PathBuf, dst: PathBuf }, - /// Running tree-sitter generate. - #[display("Could not generate in {}", dir.display())] - Generate { dir: PathBuf }, + /// Compilation step inside the checked-out grammar directory. + #[display("Could not build in {}", dir.display())] + Build { dir: PathBuf }, + /// Git clone into the checkout directory. + #[display("Could not clone to {}", dir.display())] + Clone { dir: PathBuf }, + /// Scanning the checkout for grammar.js files. + #[display("Could not discover grammars in {}", dir.display())] + Discover { dir: PathBuf }, + /// Copying or hard-linking the built artifact. + #[display("Could not copy {} to {}", src.display(), dst.display())] + Copy { src: PathBuf, dst: PathBuf }, + /// Running tree-sitter generate. + #[display("Could not generate in {}", dir.display())] + Generate { dir: PathBuf }, } // ============================================================ @@ -103,90 +103,90 @@ pub struct Cause(Box); /// Format a build error list with indentation. fn format_build_errors(w: &mut impl fmt::Write, errors: &[Error], indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!(w, "{prefix}Could not build all parsers.")?; + let prefix = " ".repeat(indent); + write!(w, "{prefix}Could not build all parsers.")?; - for error in errors { - write!(w, "\n\n")?; - error.format(w, indent + 2)?; - } + for error in errors { + write!(w, "\n\n")?; + error.format(w, indent + 2)?; + } - Ok(()) + Ok(()) } /// Format a command execution error with stdout/stderr and indentation. fn format_command( - w: &mut impl fmt::Write, - indent: usize, - msg: &str, - stdout: &str, - stderr: &str, + w: &mut impl fmt::Write, + indent: usize, + msg: &str, + stdout: &str, + stderr: &str, ) -> fmt::Result { - let prefix = " ".repeat(indent); - write!(w, "{prefix}$ {msg}")?; - - let has_stdout = !stdout.is_empty(); - let has_stderr = !stderr.is_empty(); - - if has_stdout && has_stderr { - let mut write_section = |header: &str, content: &str| -> fmt::Result { - writeln!(w, "\n{prefix} {header}:")?; - - let mut lines = content.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix} {first}")?; - for line in lines { - write!(w, "\n{prefix} {line}")?; - } - } - Ok(()) - }; - - write_section("stdout", stdout)?; - write_section("stderr", stderr)?; - } else if has_stderr { - writeln!(w)?; - let mut lines = stderr.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix}{first}")?; - for line in lines { - write!(w, "\n{prefix}{line}")?; - } - } - } else if has_stdout { - writeln!(w)?; - let mut lines = stdout.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix}{first}")?; - for line in lines { - write!(w, "\n{prefix}{line}")?; - } + let prefix = " ".repeat(indent); + write!(w, "{prefix}$ {msg}")?; + + let has_stdout = !stdout.is_empty(); + let has_stderr = !stderr.is_empty(); + + if has_stdout && has_stderr { + let mut write_section = |header: &str, content: &str| -> fmt::Result { + writeln!(w, "\n{prefix} {header}:")?; + + let mut lines = content.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix} {first}")?; + for line in lines { + write!(w, "\n{prefix} {line}")?; } + } + Ok(()) + }; + + write_section("stdout", stdout)?; + write_section("stderr", stderr)?; + } else if has_stderr { + writeln!(w)?; + let mut lines = stderr.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix}{first}")?; + for line in lines { + write!(w, "\n{prefix}{line}")?; + } + } + } else if has_stdout { + writeln!(w)?; + let mut lines = stdout.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix}{first}")?; + for line in lines { + write!(w, "\n{prefix}{line}")?; + } } + } - Ok(()) + Ok(()) } /// Format language collection errors as a comma-separated list. fn format_language_collection( - w: &mut impl fmt::Write, - related: &[Error], - indent: usize, + w: &mut impl fmt::Write, + related: &[Error], + indent: usize, ) -> fmt::Result { - let prefix = " ".repeat(indent); - writeln!(w, "{prefix}Could not figure out all languages:")?; + let prefix = " ".repeat(indent); + writeln!(w, "{prefix}Could not figure out all languages:")?; - for (i, error) in related.iter().enumerate() { - if i > 0 { - write!(w, ", ")?; - } - match error { - Error::Language { name, .. } => write!(w, "{name}")?, - _ => error.format(w, 0)?, - } + for (i, error) in related.iter().enumerate() { + if i > 0 { + write!(w, ", ")?; + } + match error { + Error::Language { name, .. } => write!(w, "{name}")?, + _ => error.format(w, 0)?, } + } - Ok(()) + Ok(()) } // ============================================================ @@ -194,247 +194,245 @@ fn format_language_collection( // ============================================================ impl Cause { - /// Wrap an error source into a boxed Cause. - #[must_use] - pub fn new(source: impl Into) -> Self { - Self(Box::new(source.into())) - } - - /// Borrow the inner error. - #[must_use] - pub fn as_error(&self) -> &Error { - &self.0 - } + /// Wrap an error source into a boxed Cause. + #[must_use] + pub fn new(source: impl Into) -> Self { + Self(Box::new(source.into())) + } + + /// Borrow the inner error. + #[must_use] + pub fn as_error(&self) -> &Error { + &self.0 + } } impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.format(f, 0) + } } impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::Build { .. } - | Error::Command { .. } - | Error::Config { .. } - | Error::Interrupted { .. } - | Error::LanguageCollection { .. } - | Error::Message { .. } => None, - Error::Context { source, .. } - | Error::Language { source, .. } - | Error::Step { source, .. } => Some(source.as_error()), - Error::Io { source } => Some(source), - } + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Build { .. } + | Error::Command { .. } + | Error::Config { .. } + | Error::Interrupted { .. } + | Error::LanguageCollection { .. } + | Error::Message { .. } => None, + Error::Context { source, .. } + | Error::Language { source, .. } + | Error::Step { source, .. } => Some(source.as_error()), + Error::Io { source } => Some(source), } + } } impl Error { - /// Format the error with a given indentation level, returning a String. - #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - let _ = self.format(&mut s, indent); - s - } - - /// Recursively format the error tree with per-level indentation. - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - match self { - Error::Build { errors } => format_build_errors(w, errors, indent), - Error::Command { - msg, - stderr, - stdout, - } => format_command(w, indent, msg, stdout, stderr), - Error::Config { message } => write!(w, "{prefix}Configuration error: {message}"), - Error::Context { message, source } => { - write!( - w, - "{}{}\n{}", - prefix, - message, - source.as_error().format_indent(indent + 2) - ) - } - Error::Io { source } => write!(w, "{prefix}IO error: {source}"), - Error::Interrupted { signal } => write!(w, "{prefix}Interrupted by {signal}"), - Error::Language { name, source } => { - write!( - w, - "{}{}\n{}", - prefix, - name, - source.as_error().format_indent(indent + 2) - ) - } - Error::LanguageCollection { related } => format_language_collection(w, related, indent), - Error::Message { message } => write!(w, "{prefix}{message}"), - Error::Step { name, kind, source } => { - write!( - w, - "{}{}: {}.\n{}", - prefix, - name, - kind, - source.as_error().format_indent(indent + 4) - ) - } - } + /// Format the error with a given indentation level, returning a String. + #[must_use] + pub fn format_indent(&self, indent: usize) -> String { + let mut s = String::new(); + let _ = self.format(&mut s, indent); + s + } + + /// Recursively format the error tree with per-level indentation. + fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { + let prefix = " ".repeat(indent); + match self { + Error::Build { errors } => format_build_errors(w, errors, indent), + Error::Command { + msg, + stderr, + stdout, + } => format_command(w, indent, msg, stdout, stderr), + Error::Config { message } => write!(w, "{prefix}Configuration error: {message}"), + Error::Context { message, source } => { + write!( + w, + "{}{}\n{}", + prefix, + message, + source.as_error().format_indent(indent + 2) + ) + } + Error::Io { source } => write!(w, "{prefix}IO error: {source}"), + Error::Interrupted { signal } => write!(w, "{prefix}Interrupted by {signal}"), + Error::Language { name, source } => { + write!( + w, + "{}{}\n{}", + prefix, + name, + source.as_error().format_indent(indent + 2) + ) + } + Error::LanguageCollection { related } => format_language_collection(w, related, indent), + Error::Message { message } => write!(w, "{prefix}{message}"), + Error::Step { name, kind, source } => { + write!( + w, + "{}{}: {}.\n{}", + prefix, + name, + kind, + source.as_error().format_indent(indent + 4) + ) + } } + } } impl From for Cause where - E: Into, + E: Into, { - fn from(source: E) -> Self { - Self::new(source) - } + fn from(source: E) -> Self { + Self::new(source) + } } impl From for Error { - fn from(error: reqwest::Error) -> Self { - Error::Message { - message: format!("HTTP request error: {error}"), - } + fn from(error: reqwest::Error) -> Self { + Error::Message { + message: format!("HTTP request error: {error}"), } + } } impl From for Error { - fn from(error: reqwest::header::InvalidHeaderValue) -> Self { - Error::Message { - message: format!("Invalid header value: {error}"), - } + fn from(error: reqwest::header::InvalidHeaderValue) -> Self { + Error::Message { + message: format!("Invalid header value: {error}"), } + } } impl From for Error { - fn from(error: self_update::errors::Error) -> Self { - Error::Message { - message: format!("Self-update error: {error}"), - } + fn from(error: self_update::errors::Error) -> Self { + Error::Message { + message: format!("Self-update error: {error}"), } + } } impl From for Error { - fn from(error: semver::Error) -> Self { - Error::Message { - message: format!("Semver error: {error}"), - } + fn from(error: semver::Error) -> Self { + Error::Message { + message: format!("Semver error: {error}"), } + } } impl From for Error { - fn from(error: std::fmt::Error) -> Self { - Error::Message { - message: format!("formatting error: {error}"), - } + fn from(error: std::fmt::Error) -> Self { + Error::Message { + message: format!("formatting error: {error}"), } + } } impl From for Error { - fn from(source: std::io::Error) -> Self { - Error::Io { source } - } + fn from(source: std::io::Error) -> Self { + Error::Io { source } + } } impl From for Error { - fn from(error: std::string::FromUtf8Error) -> Self { - Error::Message { - message: format!("UTF-8 conversion error: {error}"), - } + fn from(error: std::string::FromUtf8Error) -> Self { + Error::Message { + message: format!("UTF-8 conversion error: {error}"), } + } } impl From for Error { - fn from(error: tokio::task::JoinError) -> Self { - Error::Message { - message: format!("Task join error: {error}"), - } + fn from(error: tokio::task::JoinError) -> Self { + Error::Message { + message: format!("Task join error: {error}"), } + } } impl From for Error { - fn from(error: toml::de::Error) -> Self { - Error::Message { - message: format!("TOML deserialization error: {error}"), - } + fn from(error: toml::de::Error) -> Self { + Error::Message { + message: format!("TOML deserialization error: {error}"), } + } } impl From for Error { - fn from(error: toml::ser::Error) -> Self { - Error::Message { - message: format!("TOML serialization error: {error}"), - } + fn from(error: toml::ser::Error) -> Self { + Error::Message { + message: format!("TOML serialization error: {error}"), } + } } impl From for Error { - fn from(error: url::ParseError) -> Self { - Error::Message { - message: format!("URL parse error: {error}"), - } + fn from(error: url::ParseError) -> Self { + Error::Message { + message: format!("URL parse error: {error}"), } + } } impl ResultExt for std::result::Result where - E: Into, + E: Into, { - fn context(self, message: impl Into) -> Result { - self.map_err(|source| Error::Context { - message: message.into(), - source: Cause::new(source), - }) - } - - fn with_context(self, message: impl FnOnce() -> String) -> Result { - self.map_err(|source| Error::Context { - message: message(), - source: Cause::new(source), - }) - } + fn context(self, message: impl Into) -> Result { + self.map_err(|source| Error::Context { + message: message.into(), + source: Cause::new(source), + }) + } + + fn with_context(self, message: impl FnOnce() -> String) -> Result { + self.map_err(|source| Error::Context { + message: message(), + source: Cause::new(source), + }) + } } #[cfg(test)] mod tests { - use super::*; - - #[test] - fn test_error_formatting_with_indentation() { - let stderr = "remote: Repository not found.\nfatal: repository 'https://github.com/tree-sitter/tree-sitter-jsonxxx/' not found"; - let command_error = Error::Command { - msg: "git fetch origin --depth 1 HEAD failed with exit status 128.".to_string(), - stderr: stderr.to_string(), - stdout: String::new(), - }; - - let step_error = Error::Step { - name: "jsonxxx".into(), - kind: ParserOp::Clone { - dir: PathBuf::from( - "/home/firas/src/github.com/stackmystack/tsdl/tmp/tree-sitter-jsonxxx", - ), - }, - source: command_error.into(), - }; - - let err = Error::Build { - errors: vec![step_error], - }; - let formatted = err.format_indent(0); - - let expected = r"Could not build all parsers. + use super::*; + + #[test] + fn test_error_formatting_with_indentation() { + let stderr = "remote: Repository not found.\nfatal: repository 'https://github.com/tree-sitter/tree-sitter-jsonxxx/' not found"; + let command_error = Error::Command { + msg: "git fetch origin --depth 1 HEAD failed with exit status 128.".to_string(), + stderr: stderr.to_string(), + stdout: String::new(), + }; + + let step_error = Error::Step { + name: "jsonxxx".into(), + kind: ParserOp::Clone { + dir: PathBuf::from("/home/firas/src/github.com/stackmystack/tsdl/tmp/tree-sitter-jsonxxx"), + }, + source: command_error.into(), + }; + + let err = Error::Build { + errors: vec![step_error], + }; + let formatted = err.format_indent(0); + + let expected = r"Could not build all parsers. jsonxxx: Could not clone to /home/firas/src/github.com/stackmystack/tsdl/tmp/tree-sitter-jsonxxx. $ git fetch origin --depth 1 HEAD failed with exit status 128. remote: Repository not found. fatal: repository 'https://github.com/tree-sitter/tree-sitter-jsonxxx/' not found"; - assert_eq!(formatted, expected); - } + assert_eq!(formatted, expected); + } } diff --git a/src/git.rs b/src/git.rs index 2b1d191..49381da 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,11 +1,11 @@ //! Git operations: clone, fetch, checkout, ls-files, tag resolution. use std::{ - ffi::OsStr, - fmt, - path::{Component, Path, PathBuf}, - result::Result as StdResult, - sync::Arc, + ffi::OsStr, + fmt, + path::{Component, Path, PathBuf}, + result::Result as StdResult, + sync::Arc, }; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; @@ -22,25 +22,25 @@ type RefResult = StdResult; /// Error type for git ref and SHA validation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RefError { - /// Ref string is empty. - EmptyRef, - /// Ref contains an invalid character. - InvalidRefCharacter { index: usize, character: char }, - /// Ref has invalid syntax. - InvalidRefSyntax { reason: &'static str }, - /// SHA string is not 40 characters long. - InvalidShaLength { actual: usize }, - /// SHA string contains non-hex characters. - InvalidShaHex { index: usize, character: char }, + /// Ref string is empty. + EmptyRef, + /// Ref contains an invalid character. + InvalidRefCharacter { index: usize, character: char }, + /// Ref has invalid syntax. + InvalidRefSyntax { reason: &'static str }, + /// SHA string is not 40 characters long. + InvalidShaLength { actual: usize }, + /// SHA string contains non-hex characters. + InvalidShaHex { index: usize, character: char }, } /// A resolved git ref after tag lookup or branch checkout. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum ResolvedRef { - /// A tag with its resolved commit SHA. - Tag { label: String, sha: Sha }, - /// A bare git ref (no tag resolution). - Ref(Ref), + /// A tag with its resolved commit SHA. + Tag { label: String, sha: Sha }, + /// A bare git ref (no tag resolution). + Ref(Ref), } // ============================================================ @@ -50,8 +50,8 @@ pub enum ResolvedRef { /// The commit SHA checked out for a given git ref. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Checkout { - /// The checked-out commit hash. - pub commit: Sha, + /// The checked-out commit hash. + pub commit: Sha, } /// A validated git ref string (branch name, tag, or commit SHA). @@ -69,331 +69,331 @@ pub struct Sha(Arc); /// Check out a git ref in the given directory (with force flag to handle /// remote URL changes). pub async fn checkout(repo: &str, cwd: &Path, git_ref: &Ref) -> Result { - checkout_with_force(repo, cwd, git_ref, false).await + checkout_with_force(repo, cwd, git_ref, false).await } /// Like [`checkout`], but optionally force-clean the working directory even /// when the remote URL matches. pub async fn checkout_with_force( - repo: &str, - cwd: &Path, - git_ref: &Ref, - force: bool, + repo: &str, + cwd: &Path, + git_ref: &Ref, + force: bool, ) -> Result { - if force || !is_same_remote(cwd, repo).await { - clean_anyway(cwd).await?; - } - if is_valid_git_dir(cwd).await { - reset_head_hard(cwd, git_ref).await?; - } else { - init_fetch_and_checkout(cwd, repo, git_ref).await?; - } - let commit = get_head_sha(cwd) - .await - .with_context(|| format!("Resolving checked out commit for {}", cwd.display()))?; - Ok(Checkout { commit }) + if force || !is_same_remote(cwd, repo).await { + clean_anyway(cwd).await?; + } + if is_valid_git_dir(cwd).await { + reset_head_hard(cwd, git_ref).await?; + } else { + init_fetch_and_checkout(cwd, repo, git_ref).await?; + } + let commit = get_head_sha(cwd) + .await + .with_context(|| format!("Resolving checked out commit for {}", cwd.display()))?; + Ok(Checkout { commit }) } // TODO: get rid of async fs completely. /// Remove a directory or file at `cwd` if it exists. async fn clean_anyway(cwd: &Path) -> Result<()> { - if cwd.exists() { - if cwd.is_dir() { - fs::remove_dir_all(cwd).await - } else { - fs::remove_file(cwd).await - }?; - } - Ok(()) + if cwd.exists() { + if cwd.is_dir() { + fs::remove_dir_all(cwd).await + } else { + fs::remove_file(cwd).await + }?; + } + Ok(()) } /// Clone a repository and pull if it already exists. pub async fn clone(repo: &str, cwd: &Path) -> Result<()> { - if cwd.exists() { - Command::new("git") - .current_dir(cwd) - .args(["pull"]) - .exec() - .await?; - } else { - Command::new("git") - .args(["clone", repo, &format!("{}", cwd.display())]) - .exec() - .await?; - } - Ok(()) + if cwd.exists() { + Command::new("git") + .current_dir(cwd) + .args(["pull"]) + .exec() + .await?; + } else { + Command::new("git") + .args(["clone", repo, &format!("{}", cwd.display())]) + .exec() + .await?; + } + Ok(()) } /// Fetch from origin and reset to `FETCH_HEAD`. async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> Result<()> { - Command::new("git") - .env("GIT_TERMINAL_PROMPT", "0") - .current_dir(cwd) - .args(["fetch", "origin", "--depth", "1", git_ref.as_str()]) - .exec() - .await?; - Command::new("git") - .current_dir(cwd) - .args(["reset", "--hard", "FETCH_HEAD"]) - .exec() - .await?; - Ok(()) + Command::new("git") + .env("GIT_TERMINAL_PROMPT", "0") + .current_dir(cwd) + .args(["fetch", "origin", "--depth", "1", git_ref.as_str()]) + .exec() + .await?; + Command::new("git") + .current_dir(cwd) + .args(["reset", "--hard", "FETCH_HEAD"]) + .exec() + .await?; + Ok(()) } /// Get the full SHA of HEAD in the given repo. async fn get_head_sha(cwd: &Path) -> Result { - let value = get_head_sha1(cwd).await?; - Sha::new(value.trim()).context("Parsing HEAD commit") + let value = get_head_sha1(cwd).await?; + Sha::new(value.trim()).context("Parsing HEAD commit") } /// Get the raw SHA-1 string of HEAD. async fn get_head_sha1(cwd: &Path) -> Result { - String::from_utf8( - Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "HEAD"]) - .exec() - .await? - .stdout, - ) - .context("rev-parse HEAD is not a valid utf-8") + String::from_utf8( + Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "HEAD"]) + .exec() + .await? + .stdout, + ) + .context("rev-parse HEAD is not a valid utf-8") } /// Get the remote URL for origin. async fn get_remote_url(cwd: &Path) -> Result { - String::from_utf8( - Command::new("git") - .current_dir(cwd) - .args(["remote", "get-url", "origin"]) - .exec() - .await? - .stdout, - ) - .context("remote get-url origin did not return a valid utf-8") + String::from_utf8( + Command::new("git") + .current_dir(cwd) + .args(["remote", "get-url", "origin"]) + .exec() + .await? + .stdout, + ) + .context("remote get-url origin did not return a valid utf-8") } /// Initialise a new repo, add a remote, and fetch+checkout. async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> Result<()> { - clean_anyway(cwd).await?; - fs::create_dir_all(cwd).await?; + clean_anyway(cwd).await?; + fs::create_dir_all(cwd).await?; - Command::new("git") - .current_dir(cwd) - .arg("init") - .exec() - .await?; + Command::new("git") + .current_dir(cwd) + .arg("init") + .exec() + .await?; - Command::new("git") - .current_dir(cwd) - .args(["remote", "add", "origin", repo]) - .exec() - .await?; + Command::new("git") + .current_dir(cwd) + .args(["remote", "add", "origin", repo]) + .exec() + .await?; - fetch_and_checkout(cwd, git_ref).await?; + fetch_and_checkout(cwd, git_ref).await?; - Ok(()) + Ok(()) } /// Check whether the checkout directory contains a usable git repo with the /// expected remote URL. pub async fn is_checkout_usable(repo: &str, cwd: &Path) -> bool { - is_valid_git_dir(cwd).await && is_same_remote(cwd, repo).await + is_valid_git_dir(cwd).await && is_same_remote(cwd, repo).await } /// Check whether a directory has the same origin remote URL as `remote`. async fn is_same_remote(cwd: &Path, remote: &str) -> bool { - remote == get_remote_url(cwd).await.unwrap_or_default().trim() + remote == get_remote_url(cwd).await.unwrap_or_default().trim() } /// Check whether `cwd` is inside a valid git work tree with a parseable HEAD. async fn is_valid_git_dir(cwd: &Path) -> bool { - let is_inside_work_tree = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "--is-inside-work-tree"]) - .exec() - .await - .is_ok(); - let can_parse_head = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "HEAD"]) - .exec() - .await - .is_ok(); - - is_inside_work_tree && can_parse_head + let is_inside_work_tree = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "--is-inside-work-tree"]) + .exec() + .await + .is_ok(); + let can_parse_head = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "HEAD"]) + .exec() + .await + .is_ok(); + + is_inside_work_tree && can_parse_head } /// List all grammar.js files tracked by git (excluding common non-source /// directories). pub async fn list_grammar_files(cwd: &Path) -> Result> { - let output = Command::new("git") - .current_dir(cwd) - .args(["ls-files", "--cached", "--others", "--exclude-standard"]) - .exec() - .await?; - - let stdout = - String::from_utf8(output.stdout).context("git ls-files output is not valid utf-8")?; - - let exclude = [ - ".github", "bindings", "doc", "docs", "examples", "queries", "script", "scripts", "test", - "tests", - ]; - - let result: Vec = stdout - .lines() - .filter_map(|line| { - if line.is_empty() { - return None; - } - - let path = Path::new(line); - - // Check if filename is exactly "grammar.js" - if path.file_name() != Some(OsStr::new("grammar.js")) { - return None; - } - - // Check if any path component is in excluded dirs - let has_excluded = path.components().any(|comp| { - if let Component::Normal(name) = comp { - exclude.contains(&name.to_string_lossy().as_ref()) - } else { - false - } - }); - - if has_excluded { - return None; - } - - Some(PathBuf::from(line)) - }) - .collect(); - - Ok(result) + let output = Command::new("git") + .current_dir(cwd) + .args(["ls-files", "--cached", "--others", "--exclude-standard"]) + .exec() + .await?; + + let stdout = + String::from_utf8(output.stdout).context("git ls-files output is not valid utf-8")?; + + let exclude = [ + ".github", "bindings", "doc", "docs", "examples", "queries", "script", "scripts", "test", + "tests", + ]; + + let result: Vec = stdout + .lines() + .filter_map(|line| { + if line.is_empty() { + return None; + } + + let path = Path::new(line); + + // Check if filename is exactly "grammar.js" + if path.file_name() != Some(OsStr::new("grammar.js")) { + return None; + } + + // Check if any path component is in excluded dirs + let has_excluded = path.components().any(|comp| { + if let Component::Normal(name) = comp { + exclude.contains(&name.to_string_lossy().as_ref()) + } else { + false + } + }); + + if has_excluded { + return None; + } + + Some(PathBuf::from(line)) + }) + .collect(); + + Ok(result) } /// Reset HEAD hard to the given ref, fetching first if needed. async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> Result<()> { - if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { - Command::new("git") - .current_dir(cwd) - .args(["reset", "--hard", "HEAD"]) - .exec() - .await?; - fetch_and_checkout(cwd, git_ref).await?; - } - Ok(()) + if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { + Command::new("git") + .current_dir(cwd) + .args(["reset", "--hard", "HEAD"]) + .exec() + .await?; + fetch_and_checkout(cwd, git_ref).await?; + } + Ok(()) } /// Resolve a git ref to a tag name or fall back to commit SHA. pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> Result { - // Try to find a tag for this ref - let tag = Command::new("git") - .current_dir(cwd) - .args(["describe", "--abbrev=0", "--tags", git_ref.as_str()]) - .exec() - .await; - - if let Ok(output) = tag { - // Found a tag, use it - String::from_utf8(output.stdout) - .context("Failed to parse git tag output as UTF-8") - .map(|s| s.trim().to_string()) - } else { - // No tag found (e.g., ref is a branch), fall back to commit SHA1 - let sha1 = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", git_ref.as_str()]) - .exec() - .await?; - String::from_utf8(sha1.stdout) - .context("Failed to parse git rev-parse output as UTF-8") - .map(|s| s.trim().to_string()) - } + // Try to find a tag for this ref + let tag = Command::new("git") + .current_dir(cwd) + .args(["describe", "--abbrev=0", "--tags", git_ref.as_str()]) + .exec() + .await; + + if let Ok(output) = tag { + // Found a tag, use it + String::from_utf8(output.stdout) + .context("Failed to parse git tag output as UTF-8") + .map(|s| s.trim().to_string()) + } else { + // No tag found (e.g., ref is a branch), fall back to commit SHA1 + let sha1 = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", git_ref.as_str()]) + .exec() + .await?; + String::from_utf8(sha1.stdout) + .context("Failed to parse git rev-parse output as UTF-8") + .map(|s| s.trim().to_string()) + } } /// Validate a git ref string against Git's refname rules. fn validate_git_ref(value: &str) -> RefResult<()> { - if value.is_empty() { - return Err(RefError::EmptyRef); - } - - if value == "@" { - return Err(RefError::InvalidRefSyntax { - reason: "single @ is not a ref", - }); - } - - if value.starts_with('/') || value.ends_with('/') { - return Err(RefError::InvalidRefSyntax { - reason: "refs cannot start or end with /", - }); - } - - if value.ends_with('.') { - return Err(RefError::InvalidRefSyntax { - reason: "refs cannot end with .", - }); - } - - if value.contains("..") { - return Err(RefError::InvalidRefSyntax { - reason: "refs cannot contain ..", - }); - } - - if value.contains("@{") { - return Err(RefError::InvalidRefSyntax { - reason: "refs cannot contain @{", - }); - } - - if let Some((index, character)) = value.char_indices().find(|(_, c)| { - c.is_ascii_control() - || c.is_ascii_whitespace() - || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\') - }) { - return Err(RefError::InvalidRefCharacter { index, character }); - } - - for component in value.split('/') { - if component.is_empty() { - return Err(RefError::InvalidRefSyntax { - reason: "refs cannot contain empty path components", - }); - } - - if component.starts_with('.') { - return Err(RefError::InvalidRefSyntax { - reason: "ref path components cannot start with .", - }); - } - - if component.strip_suffix(".lock").is_some() { - return Err(RefError::InvalidRefSyntax { - reason: "ref path components cannot end with .lock", - }); - } - } - - Ok(()) + if value.is_empty() { + return Err(RefError::EmptyRef); + } + + if value == "@" { + return Err(RefError::InvalidRefSyntax { + reason: "single @ is not a ref", + }); + } + + if value.starts_with('/') || value.ends_with('/') { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot start or end with /", + }); + } + + if value.ends_with('.') { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot end with .", + }); + } + + if value.contains("..") { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot contain ..", + }); + } + + if value.contains("@{") { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot contain @{", + }); + } + + if let Some((index, character)) = value.char_indices().find(|(_, c)| { + c.is_ascii_control() + || c.is_ascii_whitespace() + || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\') + }) { + return Err(RefError::InvalidRefCharacter { index, character }); + } + + for component in value.split('/') { + if component.is_empty() { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot contain empty path components", + }); + } + + if component.starts_with('.') { + return Err(RefError::InvalidRefSyntax { + reason: "ref path components cannot start with .", + }); + } + + if component.strip_suffix(".lock").is_some() { + return Err(RefError::InvalidRefSyntax { + reason: "ref path components cannot end with .lock", + }); + } + } + + Ok(()) } /// Validate a 40-character hex SHA-1 string. fn validate_git_sha(value: &str) -> RefResult<()> { - if value.len() != 40 { - return Err(RefError::InvalidShaLength { - actual: value.len(), - }); - } + if value.len() != 40 { + return Err(RefError::InvalidShaLength { + actual: value.len(), + }); + } - if let Some((index, character)) = value.char_indices().find(|(_, c)| !c.is_ascii_hexdigit()) { - return Err(RefError::InvalidShaHex { index, character }); - } + if let Some((index, character)) = value.char_indices().find(|(_, c)| !c.is_ascii_hexdigit()) { + return Err(RefError::InvalidShaHex { index, character }); + } - Ok(()) + Ok(()) } // ============================================================ @@ -401,307 +401,307 @@ fn validate_git_sha(value: &str) -> RefResult<()> { // ============================================================ impl AsRef for Ref { - fn as_ref(&self) -> &str { - self.as_str() - } + fn as_ref(&self) -> &str { + self.as_str() + } } impl AsRef for Sha { - fn as_ref(&self) -> &str { - self.as_str() - } + fn as_ref(&self) -> &str { + self.as_str() + } } impl<'de> Deserialize<'de> for Ref { - fn deserialize(deserializer: D) -> StdResult - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(de::Error::custom) - } + fn deserialize(deserializer: D) -> StdResult + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } } impl<'de> Deserialize<'de> for Sha { - fn deserialize(deserializer: D) -> StdResult - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(de::Error::custom) - } + fn deserialize(deserializer: D) -> StdResult + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } } impl fmt::Display for Ref { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.short()) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) + } } impl fmt::Display for RefError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyRef => write!(f, "git ref cannot be empty"), - Self::InvalidRefCharacter { index, character } => { - write!( - f, - "git ref contains invalid character at byte {index}: {character:?}" - ) - } - Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), - Self::InvalidShaLength { actual } => { - write!(f, "git SHA must be exactly 40 hex characters, got {actual}") - } - Self::InvalidShaHex { index, character } => { - write!( - f, - "git SHA contains non-hex character at byte {index}: {character:?}" - ) - } - } - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyRef => write!(f, "git ref cannot be empty"), + Self::InvalidRefCharacter { index, character } => { + write!( + f, + "git ref contains invalid character at byte {index}: {character:?}" + ) + } + Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), + Self::InvalidShaLength { actual } => { + write!(f, "git SHA must be exactly 40 hex characters, got {actual}") + } + Self::InvalidShaHex { index, character } => { + write!( + f, + "git SHA contains non-hex character at byte {index}: {character:?}" + ) + } + } + } } impl fmt::Display for ResolvedRef { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Tag { label, .. } => write!(f, "{label}"), - Self::Ref(git_ref) => write!(f, "{git_ref}"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Tag { label, .. } => write!(f, "{label}"), + Self::Ref(git_ref) => write!(f, "{git_ref}"), } + } } impl fmt::Display for Sha { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.short()) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) + } } impl From for Error { - fn from(error: RefError) -> Self { - Error::Message { - message: error.to_string(), - } + fn from(error: RefError) -> Self { + Error::Message { + message: error.to_string(), } + } } impl From for Ref { - fn from(sha: Sha) -> Self { - Self(sha.0) - } + fn from(sha: Sha) -> Self { + Self(sha.0) + } } impl std::str::FromStr for Ref { - type Err = RefError; + type Err = RefError; - fn from_str(value: &str) -> StdResult { - Self::new(value) - } + fn from_str(value: &str) -> StdResult { + Self::new(value) + } } impl std::str::FromStr for Sha { - type Err = RefError; + type Err = RefError; - fn from_str(value: &str) -> StdResult { - Self::new(value) - } + fn from_str(value: &str) -> StdResult { + Self::new(value) + } } impl Ref { - /// Create a validated git ref. - pub fn new(value: impl Into>) -> RefResult { - let value = value.into(); - validate_git_ref(&value)?; - Ok(Self(value)) - } - - /// The default ref used for unpinned parser builds. - #[must_use] - pub fn head() -> Self { - Self(Arc::from("HEAD")) - } - - /// Get the exact git ref string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Get a human-oriented representation, shortening full commit SHAs. - #[must_use] - pub fn short(&self) -> &str { - if Sha::is_full_sha(self.as_str()) { - &self.0[..7] - } else { - &self.0 - } + /// Create a validated git ref. + pub fn new(value: impl Into>) -> RefResult { + let value = value.into(); + validate_git_ref(&value)?; + Ok(Self(value)) + } + + /// The default ref used for unpinned parser builds. + #[must_use] + pub fn head() -> Self { + Self(Arc::from("HEAD")) + } + + /// Get the exact git ref string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get a human-oriented representation, shortening full commit SHAs. + #[must_use] + pub fn short(&self) -> &str { + if Sha::is_full_sha(self.as_str()) { + &self.0[..7] + } else { + &self.0 } + } - /// Check whether the ref is an exact 40-character SHA. - #[must_use] - pub fn is_exact_sha(&self) -> bool { - Sha::is_full_sha(self.as_str()) - } + /// Check whether the ref is an exact 40-character SHA. + #[must_use] + pub fn is_exact_sha(&self) -> bool { + Sha::is_full_sha(self.as_str()) + } } impl Serialize for Ref { - fn serialize(&self, serializer: S) -> StdResult - where - S: Serializer, - { - serializer.serialize_str(self.as_str()) - } + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } } impl Serialize for Sha { - fn serialize(&self, serializer: S) -> StdResult - where - S: Serializer, - { - serializer.serialize_str(self.as_str()) - } + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } } impl Sha { - /// Create a validated full 40-character git SHA-1. - pub fn new(value: impl Into>) -> RefResult { - let value = value.into(); - validate_git_sha(&value)?; - Ok(Sha(value)) - } - - /// Return the SHA as a string slice. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Return the first 7 hex characters of the SHA. - #[must_use] - pub fn short(&self) -> &str { - &self.0[..7] - } - - /// Check whether a string looks like a full 40-character hex SHA. - #[must_use] - pub fn is_full_sha(value: &str) -> bool { - value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) - } + /// Create a validated full 40-character git SHA-1. + pub fn new(value: impl Into>) -> RefResult { + let value = value.into(); + validate_git_sha(&value)?; + Ok(Sha(value)) + } + + /// Return the SHA as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Return the first 7 hex characters of the SHA. + #[must_use] + pub fn short(&self) -> &str { + &self.0[..7] + } + + /// Check whether a string looks like a full 40-character hex SHA. + #[must_use] + pub fn is_full_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) + } } impl std::error::Error for RefError {} impl TryFrom<&str> for Ref { - type Error = RefError; + type Error = RefError; - fn try_from(value: &str) -> StdResult { - Self::new(value) - } + fn try_from(value: &str) -> StdResult { + Self::new(value) + } } impl TryFrom<&str> for Sha { - type Error = RefError; + type Error = RefError; - fn try_from(value: &str) -> StdResult { - Self::new(value) - } + fn try_from(value: &str) -> StdResult { + Self::new(value) + } } impl TryFrom for Ref { - type Error = RefError; + type Error = RefError; - fn try_from(value: String) -> StdResult { - Self::new(value) - } + fn try_from(value: String) -> StdResult { + Self::new(value) + } } impl TryFrom for Sha { - type Error = RefError; + type Error = RefError; - fn try_from(value: String) -> StdResult { - Self::new(value) - } + fn try_from(value: String) -> StdResult { + Self::new(value) + } } #[cfg(test)] mod tests { - use super::*; - - const FULL_SHA: &str = "636801770eea172d140e64b691815ff11f6b556f"; - - #[test] - fn git_ref_rejects_empty_refs() { - assert_eq!(Ref::new(""), Err(RefError::EmptyRef)); - } - - #[test] - fn git_ref_rejects_whitespace() { - assert_eq!( - Ref::new("feature branch"), - Err(RefError::InvalidRefCharacter { - index: 7, - character: ' ' - }) - ); - } - - #[test] - fn git_ref_rejects_invalid_git_ref_syntax() { - assert_eq!( - Ref::new("feature..branch"), - Err(RefError::InvalidRefSyntax { - reason: "refs cannot contain .." - }) - ); - assert_eq!( - Ref::new("feature.lock"), - Err(RefError::InvalidRefSyntax { - reason: "ref path components cannot end with .lock", - }) - ); - assert_eq!( - Ref::new("refs/heads/main"), - Ok(Ref::new("refs/heads/main").unwrap()) - ); - } - - #[test] - fn git_ref_preserves_branches_and_full_shas() { - assert_eq!(Ref::new("master").unwrap().as_str(), "master"); - assert_eq!(Ref::new(FULL_SHA).unwrap().as_str(), FULL_SHA); - } - - #[test] - fn git_ref_display_is_short_but_as_str_is_exact() { - let git_ref = Ref::new(FULL_SHA).unwrap(); - - assert_eq!(git_ref.as_str(), FULL_SHA); - assert_eq!(git_ref.short(), "6368017"); - assert_eq!(git_ref.to_string(), "6368017"); - } - - #[test] - fn git_sha_requires_full_hex_sha() { - assert_eq!(Sha::new(FULL_SHA).unwrap().as_str(), FULL_SHA); - assert_eq!(Sha::new(FULL_SHA).unwrap().short(), "6368017"); - assert_eq!( - Sha::new("6368017"), - Err(RefError::InvalidShaLength { actual: 7 }) - ); - assert_eq!( - Sha::new("636801770eea172d140e64b691815ff11f6b556x"), - Err(RefError::InvalidShaHex { - index: 39, - character: 'x' - }) - ); - } - - #[test] - fn serde_rejects_invalid_git_refs() { - let err = toml::from_str::>(r#"git_ref = """#) - .unwrap_err(); - - assert!(err.to_string().contains("git ref cannot be empty")); - } + use super::*; + + const FULL_SHA: &str = "636801770eea172d140e64b691815ff11f6b556f"; + + #[test] + fn git_ref_rejects_empty_refs() { + assert_eq!(Ref::new(""), Err(RefError::EmptyRef)); + } + + #[test] + fn git_ref_rejects_whitespace() { + assert_eq!( + Ref::new("feature branch"), + Err(RefError::InvalidRefCharacter { + index: 7, + character: ' ' + }) + ); + } + + #[test] + fn git_ref_rejects_invalid_git_ref_syntax() { + assert_eq!( + Ref::new("feature..branch"), + Err(RefError::InvalidRefSyntax { + reason: "refs cannot contain .." + }) + ); + assert_eq!( + Ref::new("feature.lock"), + Err(RefError::InvalidRefSyntax { + reason: "ref path components cannot end with .lock", + }) + ); + assert_eq!( + Ref::new("refs/heads/main"), + Ok(Ref::new("refs/heads/main").unwrap()) + ); + } + + #[test] + fn git_ref_preserves_branches_and_full_shas() { + assert_eq!(Ref::new("master").unwrap().as_str(), "master"); + assert_eq!(Ref::new(FULL_SHA).unwrap().as_str(), FULL_SHA); + } + + #[test] + fn git_ref_display_is_short_but_as_str_is_exact() { + let git_ref = Ref::new(FULL_SHA).unwrap(); + + assert_eq!(git_ref.as_str(), FULL_SHA); + assert_eq!(git_ref.short(), "6368017"); + assert_eq!(git_ref.to_string(), "6368017"); + } + + #[test] + fn git_sha_requires_full_hex_sha() { + assert_eq!(Sha::new(FULL_SHA).unwrap().as_str(), FULL_SHA); + assert_eq!(Sha::new(FULL_SHA).unwrap().short(), "6368017"); + assert_eq!( + Sha::new("6368017"), + Err(RefError::InvalidShaLength { actual: 7 }) + ); + assert_eq!( + Sha::new("636801770eea172d140e64b691815ff11f6b556x"), + Err(RefError::InvalidShaHex { + index: 39, + character: 'x' + }) + ); + } + + #[test] + fn serde_rejects_invalid_git_refs() { + let err = + toml::from_str::>(r#"git_ref = """#).unwrap_err(); + + assert!(err.to_string().contains("git ref cannot be empty")); + } } diff --git a/src/lib.rs b/src/lib.rs index 0318271..14ef914 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,10 +50,10 @@ //! example configuration. use std::{ - env, - io::{self, Write}, - path::{Component, Path, PathBuf}, - time::Duration, + env, + io::{self, Write}, + path::{Component, Path, PathBuf}, + time::Duration, }; extern crate log; @@ -85,7 +85,7 @@ pub mod walk; // ============================================================ pub trait SafeCanonicalize { - fn canon(&self) -> Result; + fn canon(&self) -> Result; } // ============================================================ @@ -95,104 +95,104 @@ pub trait SafeCanonicalize { /// Convert a path to an absolute, lexically-normalized path without requiring /// the final path to exist. pub fn absolute_normalize(path: &Path) -> Result { - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - env::current_dir() - .context("Failed to get current directory")? - .join(path) - }; - - Ok(normalize_components(&absolute)) + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .context("Failed to get current directory")? + .join(path) + }; + + Ok(normalize_components(&absolute)) } /// Convert a duration to a human-readable string (e.g. "0.50s", "1mn 30s"). #[must_use] pub fn format_duration(duration: Duration) -> String { - let total_seconds = duration.as_secs(); - let millis = duration.subsec_millis(); + let total_seconds = duration.as_secs(); + let millis = duration.subsec_millis(); - // Base case: sub-minute gets full precision - if total_seconds < 60 { - return format!("{total_seconds}.{millis:02}s"); - } + // Base case: sub-minute gets full precision + if total_seconds < 60 { + return format!("{total_seconds}.{millis:02}s"); + } - let seconds = total_seconds % 60; - let minutes = (total_seconds / 60) % 60; - let hours = total_seconds / 3600; + let seconds = total_seconds % 60; + let minutes = (total_seconds / 60) % 60; + let hours = total_seconds / 3600; - let mut parts = Vec::new(); + let mut parts = Vec::new(); - if hours > 0 { - parts.push(format!("{hours}h")); - } + if hours > 0 { + parts.push(format!("{hours}h")); + } - if minutes > 0 { - parts.push(format!("{minutes}mn")); - } + if minutes > 0 { + parts.push(format!("{minutes}mn")); + } - if seconds > 0 || millis > 0 { - if millis > 0 { - parts.push(format!("{seconds}.{millis:03}s")); - } else { - parts.push(format!("{seconds}s")); - } + if seconds > 0 || millis > 0 { + if millis > 0 { + parts.push(format!("{seconds}.{millis:03}s")); + } else { + parts.push(format!("{seconds}s")); } + } - parts.join(" ") + parts.join(" ") } /// Resolve `.` and `..` components without requiring the path to exist. fn normalize_components(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - - for component in path.components() { - match component { - Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), - Component::RootDir => normalized.push(component.as_os_str()), - Component::CurDir => {} - Component::ParentDir => { - normalized.pop(); - } - Component::Normal(part) => normalized.push(part), - } + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), } + } - normalized + normalized } /// Prompt user for confirmation with default behavior pub fn prompt_user(question: &str, default_yes: bool) -> Result { - let options = if default_yes { "[Y/n]" } else { "[y/N]" }; + let options = if default_yes { "[Y/n]" } else { "[y/N]" }; - eprint!("{question} {options}: "); + eprint!("{question} {options}: "); - let _ = io::stderr().flush(); - let mut input = String::new(); + let _ = io::stderr().flush(); + let mut input = String::new(); - io::stdin() - .read_line(&mut input) - .context("Reading user input")?; + io::stdin() + .read_line(&mut input) + .context("Reading user input")?; - let input = input.trim().to_lowercase(); + let input = input.trim().to_lowercase(); - if input.is_empty() { - return Ok(default_yes); - } + if input.is_empty() { + return Ok(default_yes); + } - Ok(input == "y") + Ok(input == "y") } /// Strip the current working directory prefix from a path when possible. pub fn relative_to_cwd(dir: &Path) -> PathBuf { - let canon = dir.canon().unwrap_or_else(|_| dir.to_path_buf()); - let cwd = env::current_dir().unwrap_or_else(|_| dir.to_path_buf()); - - if canon != cwd && canon.starts_with(&cwd) { - dir.strip_prefix(cwd).map_or(canon, Path::to_path_buf) - } else { - canon - } + let canon = dir.canon().unwrap_or_else(|_| dir.to_path_buf()); + let cwd = env::current_dir().unwrap_or_else(|_| dir.to_path_buf()); + + if canon != cwd && canon.starts_with(&cwd) { + dir.strip_prefix(cwd).map_or(canon, Path::to_path_buf) + } else { + canon + } } // ============================================================ @@ -200,18 +200,18 @@ pub fn relative_to_cwd(dir: &Path) -> PathBuf { // ============================================================ impl SafeCanonicalize for Path { - fn canon(&self) -> Result { - if self.is_absolute() { - Ok(self.to_path_buf()) - } else { - let current_dir = env::current_dir().context("Failed to get current directory")?; - Ok(current_dir.join(self)) - } + fn canon(&self) -> Result { + if self.is_absolute() { + Ok(self.to_path_buf()) + } else { + let current_dir = env::current_dir().context("Failed to get current directory")?; + Ok(current_dir.join(self)) } + } } impl SafeCanonicalize for PathBuf { - fn canon(&self) -> Result { - self.as_path().canon() - } + fn canon(&self) -> Result { + self.as_path().canon() + } } diff --git a/src/lock.rs b/src/lock.rs index 9a0b917..5331b3b 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -1,16 +1,16 @@ //! PID-based filesystem lock to prevent concurrent builds. use std::{ - collections::HashSet, - ffi::OsString, - fmt, - fs::{self, File, OpenOptions}, - io::{self, Seek, SeekFrom, Write}, - path::{Path, PathBuf}, - process, - result::Result as StdResult, - thread, - time::{Duration, Instant}, + collections::HashSet, + ffi::OsString, + fmt, + fs::{self, File, OpenOptions}, + io::{self, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + process, + result::Result as StdResult, + thread, + time::{Duration, Instant}, }; use fs2::FileExt; @@ -18,7 +18,7 @@ use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, Update use tracing::info; use crate::{ - Error, Result, ResultExt, absolute_normalize, build::BuildDir, consts, format_duration, + Error, Result, ResultExt, absolute_normalize, build::BuildDir, consts, format_duration, }; // ============================================================ @@ -28,51 +28,51 @@ use crate::{ /// Last observed state while waiting for a lock takeover to complete. #[derive(Debug, Clone)] pub enum Observation { - /// The lock is still held by an identifiable owner. - LockedBy(Box), - /// The lock is still held, but owner metadata could not be resolved. - Unknown { pid: Option, reason: String }, + /// The lock is still held by an identifiable owner. + LockedBy(Box), + /// The lock is still held, but owner metadata could not be resolved. + Unknown { pid: Option, reason: String }, } /// Result of checking lock status. #[derive(Debug)] pub enum Status { - /// Lock acquired successfully. - Acquired(Guard), - /// Acquired lock is cyclic (same process). - Cyclic, - /// Lock is held by a different process. - LockedBy(Owner), - /// Lock is held, but the owner could not be identified. - Unknown { pid: Option, reason: String }, + /// Lock acquired successfully. + Acquired(Guard), + /// Acquired lock is cyclic (same process). + Cyclic, + /// Lock is held by a different process. + LockedBy(Owner), + /// Lock is held, but the owner could not be identified. + Unknown { pid: Option, reason: String }, } /// Error returned while terminating a lock owner or waiting for its lock to release. #[derive(Debug)] pub enum TakeoverError { - /// The previously observed owner exited before it could be signalled. - OwnerDisappeared { previous: Box }, - /// The lock owner changed while takeover was in progress. - OwnerChanged { - previous: Box, - current: Box, - }, - /// Lock status became cyclic while waiting. - Cyclic, - /// The lock remained held by an unidentifiable owner. - Unknown { pid: Option, reason: String }, - /// Sending SIGTERM to the owner failed. - SignalFailed { owner: Box }, - /// SIGTERM is unavailable on this platform. - SignalUnsupported { owner: Box }, - /// The timeout elapsed before the lock could be acquired. - Timeout { - previous: Box, - timeout: Duration, - last_observation: Box, - }, - /// An underlying tsdl error occurred while taking over the lock. - Source(Error), + /// The previously observed owner exited before it could be signalled. + OwnerDisappeared { previous: Box }, + /// The lock owner changed while takeover was in progress. + OwnerChanged { + previous: Box, + current: Box, + }, + /// Lock status became cyclic while waiting. + Cyclic, + /// The lock remained held by an unidentifiable owner. + Unknown { pid: Option, reason: String }, + /// Sending SIGTERM to the owner failed. + SignalFailed { owner: Box }, + /// SIGTERM is unavailable on this platform. + SignalUnsupported { owner: Box }, + /// The timeout elapsed before the lock could be acquired. + Timeout { + previous: Box, + timeout: Duration, + last_observation: Box, + }, + /// An underlying tsdl error occurred while taking over the lock. + Source(Error), } // ============================================================ @@ -86,27 +86,27 @@ pub enum TakeoverError { /// not to path existence. #[derive(Debug)] pub struct Guard { - file: File, - lock_path: PathBuf, + file: File, + lock_path: PathBuf, } /// Manages lock configuration and acquisition. pub struct Lock { - current_pid: Pid, - lock_path: PathBuf, + current_pid: Pid, + lock_path: PathBuf, } /// Information about the process currently holding the build lock. #[derive(Debug, Clone)] pub struct Owner { - pub pid: Pid, - pub name: String, - pub command: Option, - pub exe: Option, - pub cwd: Option, - pub status: String, - pub run_time: u64, - pub start_time: u64, + pub pid: Pid, + pub name: String, + pub command: Option, + pub exe: Option, + pub cwd: Option, + pub status: String, + pub run_time: u64, + pub start_time: u64, } // ============================================================ @@ -115,26 +115,27 @@ pub struct Owner { /// Join OS command arguments into a single display string. fn command_line(cmd: &[std::ffi::OsString]) -> Option { - if cmd.is_empty() { - return None; - } - - Some( - cmd.iter() - .map(|arg| arg.to_string_lossy()) - .collect::>() - .join(" "), - ) + if cmd.is_empty() { + return None; + } + + Some( + cmd + .iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "), + ) } /// Check if an IO error is caused by lock contention (`WouldBlock`). fn is_lock_contention(err: &io::Error) -> bool { - err.kind() == io::ErrorKind::WouldBlock + err.kind() == io::ErrorKind::WouldBlock } /// Check if two owner records refer to the same process instance (same PID and start time). fn same_owner(current: &Owner, previous: &Owner) -> bool { - current.pid == previous.pid && current.start_time == previous.start_time + current.pid == previous.pid && current.start_time == previous.start_time } // ============================================================ @@ -142,472 +143,476 @@ fn same_owner(current: &Owner, previous: &Owner) -> bool { // ============================================================ impl fmt::Display for Observation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::LockedBy(owner) => { - write!(f, "lock is held by PID {} ({})", owner.pid, owner.name) - } - Self::Unknown { pid, reason } => { - if let Some(pid) = pid { - write!( - f, - "lock is held by PID {pid}, but owner is unknown: {reason}" - ) - } else { - write!(f, "lock is held by an unknown owner: {reason}") - } - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LockedBy(owner) => { + write!(f, "lock is held by PID {} ({})", owner.pid, owner.name) + } + Self::Unknown { pid, reason } => { + if let Some(pid) = pid { + write!( + f, + "lock is held by PID {pid}, but owner is unknown: {reason}" + ) + } else { + write!(f, "lock is held by an unknown owner: {reason}") } + } } + } } impl fmt::Display for Owner { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, " pid: {}", self.pid)?; - writeln!(f, " process: {}", self.name)?; - writeln!( - f, - " command: {}", - self.command.as_deref().unwrap_or("") - )?; - writeln!( - f, - " exe: {}", - self.exe - .as_ref() - .map_or_else(|| "".to_string(), |p| p.display().to_string()) - )?; - writeln!( - f, - " cwd: {}", - self.cwd - .as_ref() - .map_or_else(|| "".to_string(), |p| p.display().to_string()) - )?; - writeln!( - f, - " runtime: {}", - format_duration(Duration::from_secs(self.run_time)) - )?; - write!(f, " status: {}", self.status) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, " pid: {}", self.pid)?; + writeln!(f, " process: {}", self.name)?; + writeln!( + f, + " command: {}", + self.command.as_deref().unwrap_or("") + )?; + writeln!( + f, + " exe: {}", + self + .exe + .as_ref() + .map_or_else(|| "".to_string(), |p| p.display().to_string()) + )?; + writeln!( + f, + " cwd: {}", + self + .cwd + .as_ref() + .map_or_else(|| "".to_string(), |p| p.display().to_string()) + )?; + writeln!( + f, + " runtime: {}", + format_duration(Duration::from_secs(self.run_time)) + )?; + write!(f, " status: {}", self.status) + } } impl fmt::Display for TakeoverError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::OwnerDisappeared { previous } => write!( - f, - "Lock owner PID {} ({}) is no longer running; retry lock acquisition", - previous.pid, previous.name - ), - Self::OwnerChanged { previous, current } => write!( - f, - "Lock owner changed while taking over: previous PID {} ({}), current PID {} ({})", - previous.pid, previous.name, current.pid, current.name - ), - Self::Cyclic => write!(f, "Lock became cyclic while waiting for release"), - Self::Unknown { pid, reason } => { - if let Some(pid) = pid { - write!(f, "Could not identify build lock owner PID {pid}: {reason}") - } else { - write!(f, "Could not identify build lock owner: {reason}") - } - } - Self::SignalFailed { owner } => { - write!( - f, - "Failed to send SIGTERM to lock owner PID {} ({})", - owner.pid, owner.name - ) - } - Self::SignalUnsupported { .. } => { - write!( - f, - "SIGTERM is not supported on this platform; cannot terminate lock owner" - ) - } - Self::Timeout { - previous, - timeout, - last_observation, - } => write!( - f, - "Timed out after {} waiting for PID {} ({}) to release the build lock; last observation: {last_observation}", - format_duration(*timeout), - previous.pid, - previous.name - ), - Self::Source(err) => write!(f, "{err}"), + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OwnerDisappeared { previous } => write!( + f, + "Lock owner PID {} ({}) is no longer running; retry lock acquisition", + previous.pid, previous.name + ), + Self::OwnerChanged { previous, current } => write!( + f, + "Lock owner changed while taking over: previous PID {} ({}), current PID {} ({})", + previous.pid, previous.name, current.pid, current.name + ), + Self::Cyclic => write!(f, "Lock became cyclic while waiting for release"), + Self::Unknown { pid, reason } => { + if let Some(pid) = pid { + write!(f, "Could not identify build lock owner PID {pid}: {reason}") + } else { + write!(f, "Could not identify build lock owner: {reason}") } + } + Self::SignalFailed { owner } => { + write!( + f, + "Failed to send SIGTERM to lock owner PID {} ({})", + owner.pid, owner.name + ) + } + Self::SignalUnsupported { .. } => { + write!( + f, + "SIGTERM is not supported on this platform; cannot terminate lock owner" + ) + } + Self::Timeout { + previous, + timeout, + last_observation, + } => write!( + f, + "Timed out after {} waiting for PID {} ({}) to release the build lock; last observation: {last_observation}", + format_duration(*timeout), + previous.pid, + previous.name + ), + Self::Source(err) => write!(f, "{err}"), } + } } impl Drop for Guard { - fn drop(&mut self) { - let _ = self.file.unlock(); - } + fn drop(&mut self) { + let _ = self.file.unlock(); + } } impl From for TakeoverError { - fn from(err: Error) -> Self { - Self::Source(err) - } + fn from(err: Error) -> Self { + Self::Source(err) + } } impl From for Error { - fn from(err: TakeoverError) -> Self { - Error::Message { - message: err.to_string(), - } + fn from(err: TakeoverError) -> Self { + Error::Message { + message: err.to_string(), } + } } impl Guard { - /// Delete every entry in the build directory except the lock file itself - /// and explicitly protected root-level files, such as the active log file. - /// - /// The build directory itself is preserved; only its children are removed. - /// This is used by `--fresh` to clean the build directory without - /// invalidating OS locks or unlinking files held open by the current process. - pub fn clear_directory(&self, protected_files: &[PathBuf]) -> Result<()> { - let build_dir = self.lock_path.parent().ok_or_else(|| Error::Message { - message: format!( - "Lock path has no parent directory: {}", - self.lock_path.display() - ), - })?; - let build_dir_abs = absolute_normalize(build_dir)?; - let mut protected_names = HashSet::::new(); - - let lock_name = self - .lock_path - .file_name() - .map_or_else(|| OsString::from(consts::LOCK_FILE), OsString::from); - protected_names.insert(lock_name); - - for protected in protected_files { - let protected_abs = absolute_normalize(protected)?; - if protected_abs.parent() == Some(build_dir_abs.as_path()) - && let Some(name) = protected_abs.file_name() - { - protected_names.insert(name.to_os_string()); - } - } - - for entry in fs::read_dir(build_dir) - .with_context(|| format!("Reading build directory {}", build_dir.display()))? - { - let entry = entry - .with_context(|| format!("Reading directory entry in {}", build_dir.display()))?; - - let name = entry.file_name(); - - if protected_names.contains(&name) { - continue; - } - - let path = entry.path(); - let file_type = entry - .file_type() - .with_context(|| format!("Statting {}", path.display()))?; - - if file_type.is_dir() { - fs::remove_dir_all(&path) - .with_context(|| format!("Removing directory {}", path.display()))?; - } else { - fs::remove_file(&path) - .with_context(|| format!("Removing file {}", path.display()))?; - } - } + /// Delete every entry in the build directory except the lock file itself + /// and explicitly protected root-level files, such as the active log file. + /// + /// The build directory itself is preserved; only its children are removed. + /// This is used by `--fresh` to clean the build directory without + /// invalidating OS locks or unlinking files held open by the current process. + pub fn clear_directory(&self, protected_files: &[PathBuf]) -> Result<()> { + let build_dir = self.lock_path.parent().ok_or_else(|| Error::Message { + message: format!( + "Lock path has no parent directory: {}", + self.lock_path.display() + ), + })?; + let build_dir_abs = absolute_normalize(build_dir)?; + let mut protected_names = HashSet::::new(); + + let lock_name = self + .lock_path + .file_name() + .map_or_else(|| OsString::from(consts::LOCK_FILE), OsString::from); + protected_names.insert(lock_name); + + for protected in protected_files { + let protected_abs = absolute_normalize(protected)?; + if protected_abs.parent() == Some(build_dir_abs.as_path()) + && let Some(name) = protected_abs.file_name() + { + protected_names.insert(name.to_os_string()); + } + } - info!("Cleaned {}", build_dir.display()); - Ok(()) + for entry in fs::read_dir(build_dir) + .with_context(|| format!("Reading build directory {}", build_dir.display()))? + { + let entry = + entry.with_context(|| format!("Reading directory entry in {}", build_dir.display()))?; + + let name = entry.file_name(); + + if protected_names.contains(&name) { + continue; + } + + let path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("Statting {}", path.display()))?; + + if file_type.is_dir() { + fs::remove_dir_all(&path) + .with_context(|| format!("Removing directory {}", path.display()))?; + } else { + fs::remove_file(&path).with_context(|| format!("Removing file {}", path.display()))?; + } } + + info!("Cleaned {}", build_dir.display()); + Ok(()) + } } impl Lock { - /// Create a lock manager for the given build directory. - #[must_use] - pub fn new(build_dir: &BuildDir) -> Self { - Self { - lock_path: build_dir.lock_file(), - current_pid: Pid::from(process::id() as usize), - } + /// Create a lock manager for the given build directory. + #[must_use] + pub fn new(build_dir: &BuildDir) -> Self { + Self { + lock_path: build_dir.lock_file(), + current_pid: Pid::from(process::id() as usize), } - - /// Check lock status and acquire the OS lock if available. - pub fn try_acquire(&self) -> Result { - let file = self.open_lock_file()?; - - match file.try_lock_exclusive() { - Ok(()) => self.activate(file).map(Status::Acquired), - Err(err) if is_lock_contention(&err) => Ok(self.lock_status()), - Err(err) => Err(Error::Context { - message: format!("Acquiring build lock {}", self.lock_path.display()), - source: err.into(), - }), - } + } + + /// Check lock status and acquire the OS lock if available. + pub fn try_acquire(&self) -> Result { + let file = self.open_lock_file()?; + + match file.try_lock_exclusive() { + Ok(()) => self.activate(file).map(Status::Acquired), + Err(err) if is_lock_contention(&err) => Ok(self.lock_status()), + Err(err) => Err(Error::Context { + message: format!("Acquiring build lock {}", self.lock_path.display()), + source: err.into(), + }), + } + } + + /// Send SIGTERM to the process that held the lock when `owner` was captured. + pub fn terminate_owner(&self, owner: &Owner) -> StdResult<(), TakeoverError> { + info!( + "Sending SIGTERM to lock owner PID {} ({})", + owner.pid, owner.name + ); + let system = Self::system_for_pid(owner.pid); + let process = system + .process(owner.pid) + .ok_or_else(|| TakeoverError::OwnerDisappeared { + previous: Box::new(owner.clone()), + })?; + + if process.start_time() != owner.start_time { + let current = + Self::owner_for_pid(owner.pid).ok_or_else(|| TakeoverError::OwnerDisappeared { + previous: Box::new(owner.clone()), + })?; + return Err(TakeoverError::OwnerChanged { + previous: Box::new(owner.clone()), + current: Box::new(current), + }); } - /// Send SIGTERM to the process that held the lock when `owner` was captured. - pub fn terminate_owner(&self, owner: &Owner) -> StdResult<(), TakeoverError> { - info!( - "Sending SIGTERM to lock owner PID {} ({})", - owner.pid, owner.name - ); - let system = Self::system_for_pid(owner.pid); - let process = system - .process(owner.pid) - .ok_or_else(|| TakeoverError::OwnerDisappeared { - previous: Box::new(owner.clone()), - })?; - - if process.start_time() != owner.start_time { - let current = - Self::owner_for_pid(owner.pid).ok_or_else(|| TakeoverError::OwnerDisappeared { - previous: Box::new(owner.clone()), - })?; + match process.kill_with(Signal::Term) { + Some(true) => { + info!("Sent SIGTERM to lock owner PID {}", owner.pid); + Ok(()) + } + Some(false) => Err(TakeoverError::SignalFailed { + owner: Box::new(owner.clone()), + }), + None => Err(TakeoverError::SignalUnsupported { + owner: Box::new(owner.clone()), + }), + } + } + + /// Wait for the build lock to become available after the lock owner was terminated. + /// + /// The OS lock remains the source of truth. Each poll tries to acquire it first; + /// if it is still held, owner metadata is re-read so races with a new lock owner + /// are reported explicitly instead of waiting on stale information. + pub fn wait_for_release( + &self, + owner: &Owner, + timeout: Duration, + ) -> StdResult { + info!( + "Waiting up to {} for lock release from PID {}", + crate::format_duration(timeout), + owner.pid + ); + + let deadline = Instant::now() + timeout; + let mut delay = Duration::from_millis(50); + let mut last_observation = Observation::LockedBy(Box::new(owner.clone())); + + loop { + let now = Instant::now(); + if now >= deadline { + return Err(TakeoverError::Timeout { + previous: Box::new(owner.clone()), + timeout, + last_observation: Box::new(last_observation), + }); + } + + match self.try_acquire()? { + Status::Acquired(guard) => return Ok(guard), + + Status::Cyclic => return Err(TakeoverError::Cyclic), + + Status::LockedBy(current) => { + if same_owner(¤t, owner) { + last_observation = Observation::LockedBy(Box::new(current)); + } else { return Err(TakeoverError::OwnerChanged { - previous: Box::new(owner.clone()), - current: Box::new(current), + previous: Box::new(owner.clone()), + current: Box::new(current), }); + } } - match process.kill_with(Signal::Term) { - Some(true) => { - info!("Sent SIGTERM to lock owner PID {}", owner.pid); - Ok(()) - } - Some(false) => Err(TakeoverError::SignalFailed { - owner: Box::new(owner.clone()), - }), - None => Err(TakeoverError::SignalUnsupported { - owner: Box::new(owner.clone()), - }), + Status::Unknown { pid, reason } => { + // If metadata now points at a different PID that we cannot inspect, + // stop waiting on the previous owner. Otherwise keep polling until + // timeout so transient metadata reads or inherited lock handles have + // time to settle. + if pid.is_some_and(|pid| pid != owner.pid) { + return Err(TakeoverError::Unknown { pid, reason }); + } + last_observation = Observation::Unknown { pid, reason }; } - } + } - /// Wait for the build lock to become available after the lock owner was terminated. - /// - /// The OS lock remains the source of truth. Each poll tries to acquire it first; - /// if it is still held, owner metadata is re-read so races with a new lock owner - /// are reported explicitly instead of waiting on stale information. - pub fn wait_for_release( - &self, - owner: &Owner, - timeout: Duration, - ) -> StdResult { - info!( - "Waiting up to {} for lock release from PID {}", - crate::format_duration(timeout), - owner.pid - ); - - let deadline = Instant::now() + timeout; - let mut delay = Duration::from_millis(50); - let mut last_observation = Observation::LockedBy(Box::new(owner.clone())); - - loop { - let now = Instant::now(); - if now >= deadline { - return Err(TakeoverError::Timeout { - previous: Box::new(owner.clone()), - timeout, - last_observation: Box::new(last_observation), - }); - } - - match self.try_acquire()? { - Status::Acquired(guard) => return Ok(guard), - - Status::Cyclic => return Err(TakeoverError::Cyclic), - - Status::LockedBy(current) => { - if same_owner(¤t, owner) { - last_observation = Observation::LockedBy(Box::new(current)); - } else { - return Err(TakeoverError::OwnerChanged { - previous: Box::new(owner.clone()), - current: Box::new(current), - }); - } - } - - Status::Unknown { pid, reason } => { - // If metadata now points at a different PID that we cannot inspect, - // stop waiting on the previous owner. Otherwise keep polling until - // timeout so transient metadata reads or inherited lock handles have - // time to settle. - if pid.is_some_and(|pid| pid != owner.pid) { - return Err(TakeoverError::Unknown { pid, reason }); - } - last_observation = Observation::Unknown { pid, reason }; - } - } - - let remaining = deadline.saturating_duration_since(Instant::now()); - thread::sleep(delay.min(remaining)); - delay = delay.saturating_mul(2).min(Duration::from_millis(500)); - } + let remaining = deadline.saturating_duration_since(Instant::now()); + thread::sleep(delay.min(remaining)); + delay = delay.saturating_mul(2).min(Duration::from_millis(500)); } - /// Write PID metadata and construct a Guard after acquiring the OS lock. - fn activate(&self, mut file: File) -> Result { - self.write_metadata(&mut file)?; - info!("Acquired lock on build directory"); - Ok(Guard { - file, - lock_path: self.lock_path.clone(), - }) + } + /// Write PID metadata and construct a Guard after acquiring the OS lock. + fn activate(&self, mut file: File) -> Result { + self.write_metadata(&mut file)?; + info!("Acquired lock on build directory"); + Ok(Guard { + file, + lock_path: self.lock_path.clone(), + }) + } + + /// Open (or create) the lock file for exclusive access. + fn open_lock_file(&self) -> Result { + if let Some(parent) = self.lock_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Creating build directory {}", parent.display()))?; } - /// Open (or create) the lock file for exclusive access. - fn open_lock_file(&self) -> Result { - if let Some(parent) = self.lock_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Creating build directory {}", parent.display()))?; - } - - OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&self.lock_path) - .with_context(|| format!("Opening lock file {}", self.lock_path.display())) - } - - /// Helper for checking process status and determining lock conflicts. - fn lock_status(&self) -> Status { - let lock_pid = match self.read_pid() { - Ok(pid) => pid, - Err(err) => { - return Status::Unknown { - pid: None, - reason: format!("lock is held, but owner metadata could not be read: {err}"), - }; - } + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&self.lock_path) + .with_context(|| format!("Opening lock file {}", self.lock_path.display())) + } + + /// Helper for checking process status and determining lock conflicts. + fn lock_status(&self) -> Status { + let lock_pid = match self.read_pid() { + Ok(pid) => pid, + Err(err) => { + return Status::Unknown { + pid: None, + reason: format!("lock is held, but owner metadata could not be read: {err}"), }; + } + }; - if lock_pid == self.current_pid { - return Status::Cyclic; - } - - match Self::owner_for_pid(lock_pid) { - Some(owner) => Status::LockedBy(owner), - None => Status::Unknown { - pid: Some(lock_pid), - reason: "lock is held, but the metadata PID is not running or cannot be inspected" - .to_string(), - }, - } + if lock_pid == self.current_pid { + return Status::Cyclic; } - /// Read process metadata for a given PID (name, command, cwd, etc.). - fn owner_for_pid(pid: Pid) -> Option { - let system = Self::system_for_pid(pid); - let process = system.process(pid)?; - - Some(Owner { - pid: process.pid(), - name: process.name().to_string_lossy().to_string(), - command: command_line(process.cmd()), - exe: process.exe().map(Path::to_path_buf), - cwd: process.cwd().map(Path::to_path_buf), - status: format!("{:?}", process.status()), - run_time: process.run_time(), - start_time: process.start_time(), - }) - } - - /// Read the PID value stored in the lock file. - fn read_pid(&self) -> Result { - let content = fs::read_to_string(&self.lock_path) - .with_context(|| format!("Reading lock file {}", self.lock_path.display()))?; - - let pid: usize = content.trim().parse().map_err(|_| Error::Message { - message: format!( - "Invalid PID '{}' in lock file {}", - content.trim(), - self.lock_path.display() - ), - })?; - - Ok(Pid::from(pid)) - } - - /// Create a System snapshot with process info for a single PID. - fn system_for_pid(pid: Pid) -> System { - let mut system = System::new(); - system.refresh_processes_specifics( - ProcessesToUpdate::Some(&[pid]), - true, - ProcessRefreshKind::nothing() - .with_cmd(UpdateKind::Always) - .with_cwd(UpdateKind::Always) - .with_exe(UpdateKind::Always) - .with_user(UpdateKind::Always), - ); - system - } - - /// Write the current PID into the lock file. - fn write_metadata(&self, file: &mut File) -> Result<()> { - file.set_len(0) - .with_context(|| format!("Truncating lock file {}", self.lock_path.display()))?; - file.seek(SeekFrom::Start(0)) - .with_context(|| format!("Seeking lock file {}", self.lock_path.display()))?; - write!(file, "{}", self.current_pid.as_u32()).with_context(|| { - format!( - "Writing lock file {} with PID {}", - self.lock_path.display(), - self.current_pid - ) - })?; - file.sync_all() - .with_context(|| format!("Syncing lock file {}", self.lock_path.display())) + match Self::owner_for_pid(lock_pid) { + Some(owner) => Status::LockedBy(owner), + None => Status::Unknown { + pid: Some(lock_pid), + reason: "lock is held, but the metadata PID is not running or cannot be inspected" + .to_string(), + }, } + } + + /// Read process metadata for a given PID (name, command, cwd, etc.). + fn owner_for_pid(pid: Pid) -> Option { + let system = Self::system_for_pid(pid); + let process = system.process(pid)?; + + Some(Owner { + pid: process.pid(), + name: process.name().to_string_lossy().to_string(), + command: command_line(process.cmd()), + exe: process.exe().map(Path::to_path_buf), + cwd: process.cwd().map(Path::to_path_buf), + status: format!("{:?}", process.status()), + run_time: process.run_time(), + start_time: process.start_time(), + }) + } + + /// Read the PID value stored in the lock file. + fn read_pid(&self) -> Result { + let content = fs::read_to_string(&self.lock_path) + .with_context(|| format!("Reading lock file {}", self.lock_path.display()))?; + + let pid: usize = content.trim().parse().map_err(|_| Error::Message { + message: format!( + "Invalid PID '{}' in lock file {}", + content.trim(), + self.lock_path.display() + ), + })?; + + Ok(Pid::from(pid)) + } + + /// Create a System snapshot with process info for a single PID. + fn system_for_pid(pid: Pid) -> System { + let mut system = System::new(); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + true, + ProcessRefreshKind::nothing() + .with_cmd(UpdateKind::Always) + .with_cwd(UpdateKind::Always) + .with_exe(UpdateKind::Always) + .with_user(UpdateKind::Always), + ); + system + } + + /// Write the current PID into the lock file. + fn write_metadata(&self, file: &mut File) -> Result<()> { + file + .set_len(0) + .with_context(|| format!("Truncating lock file {}", self.lock_path.display()))?; + file + .seek(SeekFrom::Start(0)) + .with_context(|| format!("Seeking lock file {}", self.lock_path.display()))?; + write!(file, "{}", self.current_pid.as_u32()).with_context(|| { + format!( + "Writing lock file {} with PID {}", + self.lock_path.display(), + self.current_pid + ) + })?; + file + .sync_all() + .with_context(|| format!("Syncing lock file {}", self.lock_path.display())) + } } impl std::error::Error for TakeoverError {} impl TakeoverError { - /// Whether the caller should re-check lock status and retry the outer takeover loop. - #[must_use] - pub fn is_retryable(&self) -> bool { - matches!( - self, - Self::OwnerDisappeared { .. } | Self::OwnerChanged { .. } - ) - } + /// Whether the caller should re-check lock status and retry the outer takeover loop. + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::OwnerDisappeared { .. } | Self::OwnerChanged { .. } + ) + } } // ── Tests ─────────────────────────────────────────────────────── #[cfg(test)] mod tests { - use super::*; - - fn unused_pid() -> usize { - let system = System::new_all(); - (1_000_000..10_000_000) - .find(|pid| system.process(Pid::from(*pid)).is_none()) - .expect("could not find an unused PID for lock test") - } - - #[test] - fn stale_lock_metadata_does_not_prevent_acquiring_free_os_lock() { - let temp = tempfile::tempdir().unwrap(); - let lock_file = temp.path().join(consts::LOCK_FILE); - fs::write(&lock_file, unused_pid().to_string()).unwrap(); - - let build_dir = BuildDir::new(temp.path()).unwrap(); - let lock = Lock::new(&build_dir); - let status = lock.try_acquire().unwrap(); - - assert!( - matches!(status, Status::Acquired(_)), - "stale lock metadata should not block acquiring a free OS lock" - ); - } + use super::*; + + fn unused_pid() -> usize { + let system = System::new_all(); + (1_000_000..10_000_000) + .find(|pid| system.process(Pid::from(*pid)).is_none()) + .expect("could not find an unused PID for lock test") + } + + #[test] + fn stale_lock_metadata_does_not_prevent_acquiring_free_os_lock() { + let temp = tempfile::tempdir().unwrap(); + let lock_file = temp.path().join(consts::LOCK_FILE); + fs::write(&lock_file, unused_pid().to_string()).unwrap(); + + let build_dir = BuildDir::new(temp.path()).unwrap(); + let lock = Lock::new(&build_dir); + let status = lock.try_acquire().unwrap(); + + assert!( + matches!(status, Status::Acquired(_)), + "stale lock metadata should not block acquiring a free OS lock" + ); + } } diff --git a/src/logging.rs b/src/logging.rs index afebc6a..e6d374f 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,9 +1,9 @@ //! Logging/tracing setup: dual stderr+file output, configurable levels. use std::{ - ffi::OsStr, - fs::{self, File}, - path::{Path, PathBuf}, + ffi::OsStr, + fs::{self, File}, + path::{Path, PathBuf}, }; use tracing::level_filters::LevelFilter; @@ -20,10 +20,10 @@ use crate::{Error, Result, ResultExt, absolute_normalize, args, consts}; /// Describes how the log path is determined: explicitly via `--log` or /// implicitly derived from the build directory. pub enum Implicit<'a> { - /// Derive from build directory. - BuildDir { dir: &'a Path }, - /// No implicit path — log is disabled unless `--log` is given. - None, + /// Derive from build directory. + BuildDir { dir: &'a Path }, + /// No implicit path — log is disabled unless `--log` is given. + None, } // ============================================================ @@ -37,18 +37,18 @@ pub struct Guard(WorkerGuard); /// Log path policy: an explicitly provided path or a fallback to the build /// directory. pub struct Policy<'a> { - /// Path provided via `--log` flag. - pub explicit: Option<&'a Path>, - /// Fallback policy when no explicit path is given. - pub implicit: Implicit<'a>, + /// Path provided via `--log` flag. + pub explicit: Option<&'a Path>, + /// Fallback policy when no explicit path is given. + pub implicit: Implicit<'a>, } /// Active logging session: owns the resolved log path and the writer guard. pub struct Session { - /// Resolved log file path on disk. - path: Option, - /// Non-blocking writer guard (keeps writer alive). - _guard: Option, + /// Resolved log file path on disk. + path: Option, + /// Non-blocking writer guard (keeps writer alive). + _guard: Option, } // ============================================================ @@ -57,190 +57,190 @@ pub struct Session { /// Build a tracing layer that writes formatted log output to a writer. fn file_layer( - writer: tracing_appender::non_blocking::NonBlocking, - color: bool, - filter: LevelFilter, + writer: tracing_appender::non_blocking::NonBlocking, + color: bool, + filter: LevelFilter, ) -> Box + Send + Sync> { - tracing_subscriber::fmt::layer() - .compact() - .with_ansi(color) - .with_file(true) - .with_level(true) - .with_line_number(true) - .with_target(true) - .with_thread_ids(true) - .with_writer(writer) - .with_filter(filter) - .boxed() + tracing_subscriber::fmt::layer() + .compact() + .with_ansi(color) + .with_file(true) + .with_level(true) + .with_line_number(true) + .with_target(true) + .with_thread_ids(true) + .with_writer(writer) + .with_filter(filter) + .boxed() } /// Initialize the logging system from a policy, color setting, and verbosity. pub fn init( - policy: Policy<'_>, - log_color: args::LogColor, - verbose: clap_verbosity_flag::Verbosity, + policy: Policy<'_>, + log_color: args::LogColor, + verbose: clap_verbosity_flag::Verbosity, ) -> Result { - let color = match log_color { - args::LogColor::Auto => atty::is(atty::Stream::Stdout), - args::LogColor::No => false, - args::LogColor::Yes => true, - }; - console::set_colors_enabled(color); - - let filter = verbose.log_level_filter().as_trace(); - let path = resolve_log_path(policy)?; - let (writer, guard) = match path.as_ref() { - Some(path) => { - let file = open_log_file(path)?; - let (writer, guard) = tracing_appender::non_blocking(file); - (Some(writer), Some(Guard(guard))) - } - None => (None, None), - }; - - init_tracing(writer, color, filter); - Ok(Session { - path, - _guard: guard, - }) + let color = match log_color { + args::LogColor::Auto => atty::is(atty::Stream::Stdout), + args::LogColor::No => false, + args::LogColor::Yes => true, + }; + console::set_colors_enabled(color); + + let filter = verbose.log_level_filter().as_trace(); + let path = resolve_log_path(policy)?; + let (writer, guard) = match path.as_ref() { + Some(path) => { + let file = open_log_file(path)?; + let (writer, guard) = tracing_appender::non_blocking(file); + (Some(writer), Some(Guard(guard))) + } + None => (None, None), + }; + + init_tracing(writer, color, filter); + Ok(Session { + path, + _guard: guard, + }) } /// Register the global tracing subscriber with optional file and stderr layers. fn init_tracing( - writer: Option, - color: bool, - filter: LevelFilter, + writer: Option, + color: bool, + filter: LevelFilter, ) { - let mut layers: Vec + Send + Sync>> = Vec::new(); + let mut layers: Vec + Send + Sync>> = Vec::new(); - if let Some(writer) = writer { - layers.push(file_layer(writer, color, filter)); - } + if let Some(writer) = writer { + layers.push(file_layer(writer, color, filter)); + } - if filter == LevelFilter::DEBUG || filter == LevelFilter::TRACE { - layers.push(stderr_layer(color, filter)); - } + if filter == LevelFilter::DEBUG || filter == LevelFilter::TRACE { + layers.push(stderr_layer(color, filter)); + } - let subscriber = tracing_subscriber::registry().with(layers); - tracing::subscriber::set_global_default(subscriber).unwrap(); + let subscriber = tracing_subscriber::registry().with(layers); + tracing::subscriber::set_global_default(subscriber).unwrap(); } /// Open or create the log file. fn open_log_file(log: &Path) -> Result { - let parent = log.parent().unwrap_or(Path::new(".")); - if !parent.exists() { - fs::create_dir_all(parent).context("Preparing log directory")?; - } - File::create(log).context("Creating log file") + let parent = log.parent().unwrap_or(Path::new(".")); + if !parent.exists() { + fs::create_dir_all(parent).context("Preparing log directory")?; + } + File::create(log).context("Creating log file") } /// Resolve the log path from a policy (explicit + implicit fallback). fn resolve_log_path(policy: Policy<'_>) -> Result> { - match (policy.explicit, policy.implicit) { - (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), - (Some(log), Implicit::None) => validate_standalone_log_path(log).map(Some), - (None, Implicit::BuildDir { dir }) => { - validate_log_path(dir, &dir.join(consts::LOG_FILE)).map(Some) - } - (None, Implicit::None) => Ok(None), + match (policy.explicit, policy.implicit) { + (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), + (Some(log), Implicit::None) => validate_standalone_log_path(log).map(Some), + (None, Implicit::BuildDir { dir }) => { + validate_log_path(dir, &dir.join(consts::LOG_FILE)).map(Some) } + (None, Implicit::None) => Ok(None), + } } /// Build a tracing layer for stderr output (used at DEBUG/TRACE levels). fn stderr_layer( - color: bool, - filter: LevelFilter, + color: bool, + filter: LevelFilter, ) -> Box + Send + Sync> { - tracing_subscriber::fmt::layer() - .compact() - .with_ansi(color) - .with_file(true) - .with_level(true) - .with_line_number(true) - .with_target(true) - .with_thread_ids(false) - .with_writer(std::io::stderr) - .without_time() - .with_filter(filter) - .boxed() + tracing_subscriber::fmt::layer() + .compact() + .with_ansi(color) + .with_file(true) + .with_level(true) + .with_line_number(true) + .with_target(true) + .with_thread_ids(false) + .with_writer(std::io::stderr) + .without_time() + .with_filter(filter) + .boxed() } /// Validate that a log path relative to the build directory is acceptable. fn validate_log_path(build_dir: &Path, log: &Path) -> Result { - let build_dir = absolute_normalize(build_dir)?; - let log = absolute_normalize(log)?; - - if log == build_dir { - return Err(Error::Message { - message: format!( - "--log must be a file path, not the build directory {}", - build_dir.display() - ), - }); + let build_dir = absolute_normalize(build_dir)?; + let log = absolute_normalize(log)?; + + if log == build_dir { + return Err(Error::Message { + message: format!( + "--log must be a file path, not the build directory {}", + build_dir.display() + ), + }); + } + + if log.is_dir() { + return Err(Error::Message { + message: format!( + "--log must be a file path, not a directory: {}", + log.display() + ), + }); + } + + if log.starts_with(&build_dir) { + let relative = log.strip_prefix(&build_dir).map_err(|e| Error::Message { + message: format!( + "Could not compare log path {} with build directory {}: {e}", + log.display(), + build_dir.display() + ), + })?; + let component_count = relative.components().count(); + + if component_count != 1 { + return Err(Error::Message { + message: format!( + "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", + log.display(), + build_dir.display() + ), + }); } - if log.is_dir() { - return Err(Error::Message { - message: format!( - "--log must be a file path, not a directory: {}", - log.display() - ), - }); - } + let Some(name) = relative.file_name() else { + return Err(Error::Message { + message: format!("--log must be a file path: {}", log.display()), + }); + }; - if log.starts_with(&build_dir) { - let relative = log.strip_prefix(&build_dir).map_err(|e| Error::Message { - message: format!( - "Could not compare log path {} with build directory {}: {e}", - log.display(), - build_dir.display() - ), - })?; - let component_count = relative.components().count(); - - if component_count != 1 { - return Err(Error::Message { - message: format!( - "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", - log.display(), - build_dir.display() - ), - }); - } - - let Some(name) = relative.file_name() else { - return Err(Error::Message { - message: format!("--log must be a file path: {}", log.display()), - }); - }; - - if name == OsStr::new(consts::LOCK_FILE) || name == OsStr::new(consts::CACHE_FILE) { - return Err(Error::Message { - message: format!( - "--log path {} conflicts with a tsdl runtime/build file", - log.display() - ), - }); - } + if name == OsStr::new(consts::LOCK_FILE) || name == OsStr::new(consts::CACHE_FILE) { + return Err(Error::Message { + message: format!( + "--log path {} conflicts with a tsdl runtime/build file", + log.display() + ), + }); } + } - Ok(log) + Ok(log) } /// Validate a standalone log path (no build directory context). fn validate_standalone_log_path(log: &Path) -> Result { - let log = absolute_normalize(log)?; - - if log.is_dir() { - return Err(Error::Message { - message: format!( - "--log must be a file path, not a directory: {}", - log.display() - ), - }); - } - - Ok(log) + let log = absolute_normalize(log)?; + + if log.is_dir() { + return Err(Error::Message { + message: format!( + "--log must be a file path, not a directory: {}", + log.display() + ), + }); + } + + Ok(log) } // ============================================================ @@ -248,9 +248,9 @@ fn validate_standalone_log_path(log: &Path) -> Result { // ============================================================ impl Session { - /// Return the resolved log file path, if any. - #[must_use] - pub fn path(&self) -> Option<&Path> { - self.path.as_deref() - } + /// Return the resolved log file path, if any. + #[must_use] + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } } diff --git a/src/main.rs b/src/main.rs index 2a83bb3..9dfbf0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,76 +5,77 @@ use tracing::{error, info}; use tsdl::{Error, Result, app}; fn main() -> ExitCode { - set_panic_hook(); - let app = match app::setup() { - Ok(app) => app, - Err(e) => { - eprintln!("{e}"); - return ExitCode::FAILURE; - } - }; + set_panic_hook(); + let app = match app::setup() { + Ok(app) => app, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; - info!("Starting"); - match run(&app) { - Err(Error::Interrupted { signal }) => ExitCode::from(signal.shell_exit_code()), - Err(e) => { - eprintln!("{e}"); - ExitCode::FAILURE - } - Ok(()) => ExitCode::SUCCESS, + info!("Starting"); + match run(&app) { + Err(Error::Interrupted { signal }) => ExitCode::from(signal.shell_exit_code()), + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE } + Ok(()) => ExitCode::SUCCESS, + } } fn run(app: &app::App) -> Result<()> { - match &app.command { - app::ResolvedCommand::Build(build) => { - let (result, duration) = time(|| tsdl::build::run(&build.command, app)); - match &result { - Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), - Err(Error::Interrupted { signal }) => println!( - "{}", - style(format!("Interrupted by {signal} after {duration}")).yellow() - ), - Err(_) => println!("{}", style(format!("Done in {duration}")).red()), - } - result - } - app::ResolvedCommand::ConfigCurrent(build) => tsdl::config::print_current(&build.command), - app::ResolvedCommand::ConfigDefault => tsdl::config::print_default(), - app::ResolvedCommand::Selfupdate{ force, target} => tsdl::selfupdate::run(*force, target.as_str()), + match &app.command { + app::ResolvedCommand::Build(build) => { + let (result, duration) = time(|| tsdl::build::run(&build.command, app)); + match &result { + Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), + Err(Error::Interrupted { signal }) => println!( + "{}", + style(format!("Interrupted by {signal} after {duration}")).yellow() + ), + Err(_) => println!("{}", style(format!("Done in {duration}")).red()), + } + result + } + app::ResolvedCommand::ConfigCurrent(build) => tsdl::config::print_current(&build.command), + app::ResolvedCommand::ConfigDefault => tsdl::config::print_default(), + app::ResolvedCommand::Selfupdate { force, target } => { + tsdl::selfupdate::run(*force, target.as_str()) } + } } pub fn set_panic_hook() { - std::panic::set_hook(Box::new(move |info| { - #[cfg(not(debug_assertions))] - { - use human_panic::{Metadata, handle_dump, print_msg}; - let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) - .authors(env!("CARGO_PKG_AUTHORS").replace(':', ", ")) - .homepage(env!("CARGO_PKG_HOMEPAGE")); - let file_path = handle_dump(&meta, info); - print_msg(file_path, &meta) - .expect("human-panic: printing error message to console failed"); - } - #[cfg(debug_assertions)] - { - better_panic::Settings::auto() - .most_recent_first(false) - .lineno_suffix(true) - .verbosity(better_panic::Verbosity::Full) - .create_panic_handler()(info); - } - error!("{}", info); - std::process::exit(1); - })); + std::panic::set_hook(Box::new(move |info| { + #[cfg(not(debug_assertions))] + { + use human_panic::{Metadata, handle_dump, print_msg}; + let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) + .authors(env!("CARGO_PKG_AUTHORS").replace(':', ", ")) + .homepage(env!("CARGO_PKG_HOMEPAGE")); + let file_path = handle_dump(&meta, info); + print_msg(file_path, &meta).expect("human-panic: printing error message to console failed"); + } + #[cfg(debug_assertions)] + { + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(info); + } + error!("{}", info); + std::process::exit(1); + })); } fn time(f: F) -> (T, String) where - F: FnOnce() -> T, + F: FnOnce() -> T, { - let start = Instant::now(); - let result = f(); - (result, tsdl::format_duration(start.elapsed())) + let start = Instant::now(); + let result = f(); + (result, tsdl::format_duration(start.elapsed())) } diff --git a/src/parser.rs b/src/parser.rs index 48ca6c2..0a10c13 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -2,16 +2,16 @@ //! build, install. use std::{ - env::consts::DLL_EXTENSION, - fmt, - fs::Metadata, - io, - os::unix::fs::MetadataExt, - path::{Path, PathBuf}, - process, - result::Result as StdResult, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, + env::consts::DLL_EXTENSION, + fmt, + fs::Metadata, + io, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, + process, + result::Result as StdResult, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, }; use tokio::{fs, process::Command}; @@ -21,10 +21,10 @@ use crate::args::TreeSitter; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use crate::{ - Error, Result, ResultExt, actors, build, cache, error, git, - sh::{Exec, Script}, - shutdown, - walk::collect_grammar_paths, + Error, Result, ResultExt, actors, build, cache, error, git, + sh::{Exec, Script}, + shutdown, + walk::collect_grammar_paths, }; // ============================================================ @@ -39,18 +39,18 @@ pub const WASM_EXTENSION: &str = "wasm"; #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] enum ArtifactKind { - Native, - Wasm, + Native, + Wasm, } /// A parser git ref: either a stable tag/commit or a moving branch whose /// checked-out commit gets resolved and pinned at clone time. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum Ref { - /// Stable ref (tag or commit SHA): revision is always `Stable`. - Stable(git::Ref), - /// Moving ref (branch): revision changes on each build. - Moving(git::Ref), + /// Stable ref (tag or commit SHA): revision is always `Stable`. + Stable(git::Ref), + /// Moving ref (branch): revision changes on each build. + Moving(git::Ref), } // ============================================================ @@ -60,28 +60,28 @@ pub enum Ref { /// A grammar ready to be built, combining definition and cache state #[derive(Clone, Debug)] pub struct GrammarBuild { - /// Overwrite and other per-build flags. - pub context: build::Context, - /// Cache lookup result for this grammar. - pub cache_decision: cache::Decision, - /// Directory containing the checked-out grammar source. - pub dir: PathBuf, - /// SHA-1 hash of the grammar.js source. - pub hash: cache::GrammarHash, - /// Parent language name (for cache keys and error reporting). - pub language: LanguageName, - /// Grammar name within the parser repo. - pub name: GrammarName, - /// Build and output directory paths. - pub output: build::OutputConfig, - /// Handle for updating the progress display. - pub progress: actors::ProgressAddr, - /// Resolved parser revision (commit SHA for moving refs). - pub revision: cache::Revision, - /// Full build spec (source, ref, tree-sitter version, etc.). - pub spec: Arc, - /// Path to the tree-sitter CLI binary. - pub ts_cli: PathBuf, + /// Overwrite and other per-build flags. + pub context: build::Context, + /// Cache lookup result for this grammar. + pub cache_decision: cache::Decision, + /// Directory containing the checked-out grammar source. + pub dir: PathBuf, + /// SHA-1 hash of the grammar.js source. + pub hash: cache::GrammarHash, + /// Parent language name (for cache keys and error reporting). + pub language: LanguageName, + /// Grammar name within the parser repo. + pub name: GrammarName, + /// Build and output directory paths. + pub output: build::OutputConfig, + /// Handle for updating the progress display. + pub progress: actors::ProgressAddr, + /// Resolved parser revision (commit SHA for moving refs). + pub revision: cache::Revision, + /// Full build spec (source, ref, tree-sitter version, etc.). + pub spec: Arc, + /// Path to the tree-sitter CLI binary. + pub ts_cli: PathBuf, } /// A validated grammar name string, used for cache keys and display. @@ -93,14 +93,14 @@ pub struct GrammarName(Arc); /// paths, and a resolved build spec. #[derive(Clone, Debug)] pub struct LanguageBuild { - /// Overwrite and other per-build flags. - pub context: build::Context, - /// Language name. - pub name: LanguageName, - /// Build and output directory paths. - pub output: build::OutputConfig, - /// Full build spec (source, ref, tree-sitter version, etc.). - pub spec: Arc, + /// Overwrite and other per-build flags. + pub context: build::Context, + /// Language name. + pub name: LanguageName, + /// Build and output directory paths. + pub output: build::OutputConfig, + /// Full build spec (source, ref, tree-sitter version, etc.). + pub spec: Arc, } /// A validated language name string, used for cache keys, display, and error @@ -115,177 +115,180 @@ pub struct LanguageName(Arc); /// Derive an artifact subdirectory name from the tree-sitter CLI filename. fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> Result { - let file_name = ts_cli - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| Error::Message { - message: format!( - "Could not derive artifact id from tree-sitter CLI path {}", - ts_cli.display() - ), - })?; - let id = file_name.strip_prefix("tree-sitter-").unwrap_or(file_name); - Ok(format!("tsdl-{}", sanitize_path_component(id))) + let file_name = ts_cli + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Message { + message: format!( + "Could not derive artifact id from tree-sitter CLI path {}", + ts_cli.display() + ), + })?; + let id = file_name.strip_prefix("tree-sitter-").unwrap_or(file_name); + Ok(format!("tsdl-{}", sanitize_path_component(id))) } /// Compute the path to a built artifact for a grammar. fn artifact_path_for( - grammar_name: &GrammarName, - build_dir: &Path, - kind: ArtifactKind, - spec: &build::Spec, - ts_cli: &Path, + grammar_name: &GrammarName, + build_dir: &Path, + kind: ArtifactKind, + spec: &build::Spec, + ts_cli: &Path, ) -> Result { - Ok(build_dir - .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) - .join(parser_name_and_ext(grammar_name, kind, &spec.prefix))) + Ok( + build_dir + .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) + .join(parser_name_and_ext(grammar_name, kind, &spec.prefix)), + ) } /// Create the parent directory for a path if it doesn't exist. async fn ensure_parent_dir(path: &Path) -> Result<()> { - let parent = path.parent().ok_or_else(|| Error::Message { - message: format!( - "Could not determine parent directory for {}", - path.display() - ), - })?; - - fs::create_dir_all(parent) - .await - .with_context(|| format!("Creating {}", parent.display())) + let parent = path.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not determine parent directory for {}", + path.display() + ), + })?; + + fs::create_dir_all(parent) + .await + .with_context(|| format!("Creating {}", parent.display())) } /// Get the last path component as a string. fn extract_dir_name(dir: &Path) -> Result { - dir.file_name() - .map(|n| n.to_string_lossy().to_string()) - .ok_or_else(|| Error::Message { - message: format!("Could not get dir name for {}", dir.display()), - }) + dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .ok_or_else(|| Error::Message { + message: format!("Could not get dir name for {}", dir.display()), + }) } /// Extract grammar name from directory (strips "tree-sitter-" prefix if present) fn extract_grammar_name(dir: &Path) -> Result { - let dir_name = extract_dir_name(dir)?; - let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); - Ok(GrammarName::from(name)) + let dir_name = extract_dir_name(dir)?; + let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); + Ok(GrammarName::from(name)) } /// Check whether a string looks like a dotted-numeric version (e.g. "0.21.0"). fn is_dotted_numeric_version(value: &str) -> bool { - !value.is_empty() - && value - .split('.') - .all(|part| !part.is_empty() && part.parse::().is_ok()) + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) } /// Determine whether a source ref is "stable" (tag, SHA, version number). fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { - git_ref.is_exact_sha() - || is_dotted_numeric_version(input) - || is_v_dotted_numeric_version(input) - || git_ref.as_str().starts_with("refs/tags/") + git_ref.is_exact_sha() + || is_dotted_numeric_version(input) + || is_v_dotted_numeric_version(input) + || git_ref.as_str().starts_with("refs/tags/") } /// Check if a string matches "v" + dotted numeric version. fn is_v_dotted_numeric_version(value: &str) -> bool { - value - .strip_prefix('v') - .is_some_and(is_dotted_numeric_version) + value + .strip_prefix('v') + .is_some_and(is_dotted_numeric_version) } /// Normalize a user-provided source ref: adds "v" prefix to bare version numbers. fn normalize_source_ref(value: &str) -> String { - if git::Sha::is_full_sha(value) || value.starts_with('v') { - value.to_string() - } else if is_dotted_numeric_version(value) { - format!("v{value}") - } else { - value.to_string() - } + if git::Sha::is_full_sha(value) || value.starts_with('v') { + value.to_string() + } else if is_dotted_numeric_version(value) { + format!("v{value}") + } else { + value.to_string() + } } /// Build the output filename for a grammar artifact. fn parser_name_and_ext(grammar_name: &GrammarName, kind: ArtifactKind, prefix: &str) -> String { - format!("{prefix}{grammar_name}.{}", kind.extension()) + format!("{prefix}{grammar_name}.{}", kind.extension()) } /// Check whether two metadata structs refer to the same file (same device + inode). fn same_file_identity(a: &Metadata, b: &Metadata) -> bool { - a.dev() == b.dev() && a.ino() == b.ino() + a.dev() == b.dev() && a.ino() == b.ino() } /// Check if two files have identical contents (via SHA-1 hash). async fn same_regular_file_contents( - dst: &Path, - dst_metadata: &Metadata, - src: &Path, - src_metadata: &Metadata, + dst: &Path, + dst_metadata: &Metadata, + src: &Path, + src_metadata: &Metadata, ) -> Result { - if src_metadata.len() != dst_metadata.len() { - return Ok(false); - } + if src_metadata.len() != dst_metadata.len() { + return Ok(false); + } - let src_hash = cache::hash_file(src).await?; - let dst_hash = cache::hash_file(dst).await?; - Ok(src_hash == dst_hash) + let src_hash = cache::hash_file(src).await?; + let dst_hash = cache::hash_file(dst).await?; + Ok(src_hash == dst_hash) } /// Sanitise a string for use as a filesystem path component. fn sanitize_path_component(value: &str) -> String { - let sanitized = value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { - ch - } else { - '-' - } - }) - .collect::(); - - if sanitized.is_empty() { - "unknown".to_string() - } else { - sanitized - } + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '-' + } + }) + .collect::(); + + if sanitized.is_empty() { + "unknown".to_string() + } else { + sanitized + } } /// Create a temporary path next to the destination for atomic install. fn temp_install_path(dst: &Path) -> Result { - let file_name = dst.file_name().ok_or_else(|| Error::Message { - message: format!( - "Could not create temporary install path for {}", - dst.display() - ), - })?; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| Error::Message { - message: format!( - "System clock is before UNIX epoch while creating temporary install path: {e}" - ), - })? - .as_nanos(); - let mut tmp_name = file_name.to_os_string(); - tmp_name.push(format!(".tsdl-{}-{nanos}", process::id())); - - Ok(dst.with_file_name(tmp_name)) + let file_name = dst.file_name().ok_or_else(|| Error::Message { + message: format!( + "Could not create temporary install path for {}", + dst.display() + ), + })?; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| Error::Message { + message: format!( + "System clock is before UNIX epoch while creating temporary install path: {e}" + ), + })? + .as_nanos(); + let mut tmp_name = file_name.to_os_string(); + tmp_name.push(format!(".tsdl-{}-{nanos}", process::id())); + + Ok(dst.with_file_name(tmp_name)) } /// Check that a path exists and is a regular file. async fn verify_artifact(path: &Path) -> Result<()> { - let metadata = fs::metadata(path) - .await - .with_context(|| format!("Reading built artifact {}", path.display()))?; - - if metadata.is_file() { - Ok(()) - } else { - Err(Error::Message { - message: format!("Built artifact is not a regular file: {}", path.display()), - }) - } + let metadata = fs::metadata(path) + .await + .with_context(|| format!("Reading built artifact {}", path.display()))?; + + if metadata.is_file() { + Ok(()) + } else { + Err(Error::Message { + message: format!("Built artifact is not a regular file: {}", path.display()), + }) + } } // ============================================================ @@ -293,1090 +296,1091 @@ async fn verify_artifact(path: &Path) -> Result<()> { // ============================================================ impl ArtifactKind { - /// Get the file extension for this artifact kind (e.g. "so", "dylib", "wasm"). - #[must_use] - fn extension(self) -> &'static str { - match self { - Self::Native => DLL_EXTENSION, - Self::Wasm => WASM_EXTENSION, - } - } - - /// Check whether this is a wasm target. - #[must_use] - const fn is_wasm(self) -> bool { - matches!(self, Self::Wasm) - } + /// Get the file extension for this artifact kind (e.g. "so", "dylib", "wasm"). + #[must_use] + fn extension(self) -> &'static str { + match self { + Self::Native => DLL_EXTENSION, + Self::Wasm => WASM_EXTENSION, + } + } + + /// Check whether this is a wasm target. + #[must_use] + const fn is_wasm(self) -> bool { + matches!(self, Self::Wasm) + } } impl<'de> Deserialize<'de> for Ref { - fn deserialize(deserializer: D) -> StdResult - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::parse(&value).map_err(de::Error::custom) - } + fn deserialize(deserializer: D) -> StdResult + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } } impl fmt::Display for GrammarName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } impl fmt::Display for LanguageName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } impl fmt::Display for Ref { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.requested()) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.requested()) + } } impl From<&str> for GrammarName { - fn from(value: &str) -> Self { - Self(Arc::from(value)) - } + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } } impl From<&str> for LanguageName { - fn from(value: &str) -> Self { - Self(Arc::from(value)) - } + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } } impl From> for GrammarName { - fn from(value: Arc) -> Self { - Self(value) - } + fn from(value: Arc) -> Self { + Self(value) + } } impl From> for LanguageName { - fn from(value: Arc) -> Self { - Self(value) - } + fn from(value: Arc) -> Self { + Self(value) + } } impl From for GrammarName { - fn from(value: String) -> Self { - Self(value.into()) - } + fn from(value: String) -> Self { + Self(value.into()) + } } impl From for LanguageName { - fn from(value: String) -> Self { - Self(value.into()) - } + fn from(value: String) -> Self { + Self(value.into()) + } } impl GrammarBuild { - /// Build this grammar, returning a cache update if it was built. - /// Uses the language's progress handle for progress reporting. - pub async fn build(&self) -> Result> { - shutdown::test_delay().await; - shutdown::check()?; - debug!( - "[grammar:build] start: lang={} grammar={}", - self.language, self.name - ); - - self.progress.step("checking cache"); - let key = cache::Key::new(&self.language, &self.name); - - // Cache decisions are computed centrally by the cache actor, including - // verification that all required managed artifacts exist. - debug!("[grammar:cache] {key}: {}", self.cache_decision); - - if self.cache_decision.is_hit() { - self.progress.set_outcome_cached().await; - // Install the binary from the build directory - if let Err(e) = self.install().await { - self.progress.err("install failed").await; - return Err(e); - } - - self.progress.cached("done").await; - return Ok(None); - } - - self.progress.set_outcome_built().await; - self.progress.msg(self.cache_decision.short_message()); - - // Use the grammar directory path provided - if !self.dir.exists() { - let err = Error::Message { - message: format!("Grammar directory not found: {}", self.dir.display()), - }; - self.progress.err("missing grammar directory").await; - return Err(err); - } - - // Build the grammar - if let Err(e) = self.build_grammar().await { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - self.progress.cancel().await; - } else { - self.progress.err("build failed").await; - } - return Err(e); - } - - // Return cache update for this grammar - let update = cache::Update { - name: key, - entry: cache::Entry { - hash: self.hash.clone(), - revision: self.revision.clone(), - spec: self.spec.clone(), - outputs: self.spec.target, - }, - }; - - self.progress.fin("done").await; - - Ok(Some(update)) - } - - /// Build the tree-sitter CLI command line for a given target. - fn builtin_build_command(&self, kind: ArtifactKind, output_path: &Path) -> Command { - let mut cmd = Command::new(self.ts_cli.as_os_str()); - cmd.arg("build"); - - if kind.is_wasm() { - cmd.arg("--wasm"); - } - - cmd.arg("--output").arg(output_path); - cmd - } - - /// Run the full build pipeline for this grammar (generate, build, install). - async fn build_grammar(&self) -> Result<()> { - shutdown::test_delay().await; - shutdown::check()?; - - // Generate parser if no custom build script - self.progress.step("generating"); - if self.spec.build_script.is_none() { - self.generate().await?; - } else { - warn!("Custom build scripts not supported for generate step (TypeScript limitation)"); - } - - // Build native and/or wasm targets - self.progress.step("building"); - self.build_targets().await?; - - // Install built parsers - self.progress.step("installing"); - self.install().await?; - - Ok(()) - } - - /// Build a single target (native or wasm) and return the artifact path. - async fn build_target(&self, kind: ArtifactKind) -> Result { - shutdown::test_delay().await; - shutdown::check()?; - let ext = kind.extension(); - debug!( - "[grammar:build_target] lang={} grammar={} ext={ext}", - self.language, self.name - ); - - if let Some(script) = &self.spec.build_script { - return self.build_custom_target(kind, script).await; - } + /// Build this grammar, returning a cache update if it was built. + /// Uses the language's progress handle for progress reporting. + pub async fn build(&self) -> Result> { + shutdown::test_delay().await; + shutdown::check()?; + debug!( + "[grammar:build] start: lang={} grammar={}", + self.language, self.name + ); + + self.progress.step("checking cache"); + let key = cache::Key::new(&self.language, &self.name); + + // Cache decisions are computed centrally by the cache actor, including + // verification that all required managed artifacts exist. + debug!("[grammar:cache] {key}: {}", self.cache_decision); + + if self.cache_decision.is_hit() { + self.progress.set_outcome_cached().await; + // Install the binary from the build directory + if let Err(e) = self.install().await { + self.progress.err("install failed").await; + return Err(e); + } + + self.progress.cached("done").await; + return Ok(None); + } + + self.progress.set_outcome_built().await; + self.progress.msg(self.cache_decision.short_message()); + + // Use the grammar directory path provided + if !self.dir.exists() { + let err = Error::Message { + message: format!("Grammar directory not found: {}", self.dir.display()), + }; + self.progress.err("missing grammar directory").await; + return Err(err); + } + + // Build the grammar + if let Err(e) = self.build_grammar().await { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + self.progress.cancel().await; + } else { + self.progress.err("build failed").await; + } + return Err(e); + } + + // Return cache update for this grammar + let update = cache::Update { + name: key, + entry: cache::Entry { + hash: self.hash.clone(), + revision: self.revision.clone(), + spec: self.spec.clone(), + outputs: self.spec.target, + }, + }; - self.build_builtin_target(kind).await - } + self.progress.fin("done").await; - /// Build a target using the built-in tree-sitter CLI. - async fn build_builtin_target(&self, kind: ArtifactKind) -> Result { - let artifact = self.artifact_path(kind)?; - ensure_parent_dir(&artifact).await?; + Ok(Some(update)) + } - let mut cmd = self.builtin_build_command(kind, &artifact); - cmd.current_dir(self.dir.as_path()) - .exec() - .await - .map_err(|err| self.build_step_error(err))?; + /// Build the tree-sitter CLI command line for a given target. + fn builtin_build_command(&self, kind: ArtifactKind, output_path: &Path) -> Command { + let mut cmd = Command::new(self.ts_cli.as_os_str()); + cmd.arg("build"); - verify_artifact(&artifact).await?; - Ok(artifact) + if kind.is_wasm() { + cmd.arg("--wasm"); } - /// Build a target using a custom build script. - async fn build_custom_target(&self, kind: ArtifactKind, script: &str) -> Result { - let mut cmd = Command::from_str(script); - cmd.current_dir(self.dir.as_path()) - .exec() - .await - .map_err(|err| self.build_step_error(err))?; - - let discovered = self.brute_force_discover(kind).await?; - let artifact = self.artifact_path(kind)?; - self.stage_artifact(&discovered, &artifact).await?; - verify_artifact(&artifact).await?; - Ok(artifact) - } + cmd.arg("--output").arg(output_path); + cmd + } - /// Create a Step error indicating a build failure. - fn build_step_error(&self, err: Error) -> Error { - Error::Step { - name: self.language.as_arc(), - kind: error::ParserOp::Build { - dir: self.dir.clone(), - }, - source: err.into(), - } - } + /// Run the full build pipeline for this grammar (generate, build, install). + async fn build_grammar(&self) -> Result<()> { + shutdown::test_delay().await; + shutdown::check()?; - /// Build every target (native and/or wasm) requested by the spec. - async fn build_targets(&self) -> Result<()> { - if self.spec.target.native() { - self.build_target(ArtifactKind::Native).await?; - } - - if self.spec.target.wasm() { - self.build_target(ArtifactKind::Wasm).await?; - } - - Ok(()) - } - - /// Create a hardlink from src to dst. - async fn create_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { - fs::hard_link(src, dst).await.with_context(|| { - format!( - "Could not hardlink {} to {}. build-dir and out-dir must be on the same filesystem", - src.display(), - dst.display() - ) - }) - } + // Generate parser if no custom build script + self.progress.step("generating"); + if self.spec.build_script.is_none() { + self.generate().await?; + } else { + warn!("Custom build scripts not supported for generate step (TypeScript limitation)"); + } + + // Build native and/or wasm targets + self.progress.step("building"); + self.build_targets().await?; + + // Install built parsers + self.progress.step("installing"); + self.install().await?; + + Ok(()) + } + + /// Build a single target (native or wasm) and return the artifact path. + async fn build_target(&self, kind: ArtifactKind) -> Result { + shutdown::test_delay().await; + shutdown::check()?; + let ext = kind.extension(); + debug!( + "[grammar:build_target] lang={} grammar={} ext={ext}", + self.language, self.name + ); + + if let Some(script) = &self.spec.build_script { + return self.build_custom_target(kind, script).await; + } + + self.build_builtin_target(kind).await + } + + /// Build a target using the built-in tree-sitter CLI. + async fn build_builtin_target(&self, kind: ArtifactKind) -> Result { + let artifact = self.artifact_path(kind)?; + ensure_parent_dir(&artifact).await?; + + let mut cmd = self.builtin_build_command(kind, &artifact); + cmd + .current_dir(self.dir.as_path()) + .exec() + .await + .map_err(|err| self.build_step_error(err))?; + + verify_artifact(&artifact).await?; + Ok(artifact) + } + + /// Build a target using a custom build script. + async fn build_custom_target(&self, kind: ArtifactKind, script: &str) -> Result { + let mut cmd = Command::from_str(script); + cmd + .current_dir(self.dir.as_path()) + .exec() + .await + .map_err(|err| self.build_step_error(err))?; + + let discovered = self.brute_force_discover(kind).await?; + let artifact = self.artifact_path(kind)?; + self.stage_artifact(&discovered, &artifact).await?; + verify_artifact(&artifact).await?; + Ok(artifact) + } + + /// Create a Step error indicating a build failure. + fn build_step_error(&self, err: Error) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Build { + dir: self.dir.clone(), + }, + source: err.into(), + } + } + + /// Build every target (native and/or wasm) requested by the spec. + async fn build_targets(&self) -> Result<()> { + if self.spec.target.native() { + self.build_target(ArtifactKind::Native).await?; + } + + if self.spec.target.wasm() { + self.build_target(ArtifactKind::Wasm).await?; + } + + Ok(()) + } + + /// Create a hardlink from src to dst. + async fn create_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { + fs::hard_link(src, dst).await.with_context(|| { + format!( + "Could not hardlink {} to {}. build-dir and out-dir must be on the same filesystem", + src.display(), + dst.display() + ) + }) + } + + /// Brute-force discover a built artifact by searching the grammar directory. + async fn brute_force_discover(&self, kind: ArtifactKind) -> Result { + let ext = kind.extension(); + let expected_name = self.parser_name_and_ext(kind); + let mut files = fs::read_dir(self.dir.as_path()) + .await + .with_context(|| format!("Failed to read directory {}", self.dir.display()))?; + + let mut exact_match = None; + let mut candidates = Vec::new(); + + loop { + let Some(entry) = files + .next_entry() + .await + .with_context(|| format!("Failed to read directory entry in {}", self.dir.display()))? + else { + break; + }; + + let path = entry.path(); + let file_type = entry + .file_type() + .await + .with_context(|| format!("Failed to read file type for {}", path.display()))?; + + if !file_type.is_file() { + continue; + } + + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + + if name == expected_name { + exact_match = Some(path); + break; + } + + if Path::new(&file_name).extension().and_then(|e| e.to_str()) == Some(ext) { + candidates.push(path); + } + } + + match (exact_match, candidates.len()) { + (Some(path), _) => Ok(path), + (None, 0) => Err(self.missing_parser_error(ext)), + (None, 1) => Ok(candidates.into_iter().next().unwrap()), + (None, _) => Err(self.multiple_parsers_error(ext, &candidates)), + } + } + + /// Run tree-sitter generate in the grammar directory. + async fn generate(&self) -> Result<()> { + Command::new(self.ts_cli.as_os_str()) + .current_dir(self.dir.as_path()) + .arg("generate") + .exec() + .await + .map(|_| ()) + .map_err(|err| Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Generate { + dir: self.dir.clone(), + }, + source: err.into(), + }) + } + + /// Install built binary artifacts to the output directory. + async fn install(&self) -> Result<()> { + // Find and install parser binary for each extension + if self.spec.target.native() { + self.install_binary(ArtifactKind::Native).await?; + } + + if self.spec.target.wasm() { + self.install_binary(ArtifactKind::Wasm).await?; + } + + Ok(()) + } + + /// Install a single binary artifact to the output directory. + async fn install_binary(&self, kind: ArtifactKind) -> Result<()> { + let src = self.artifact_path(kind)?; + let dst = self.output.out_dir.join(self.parser_name_and_ext(kind)); + let src_metadata = fs::metadata(&src) + .await + .with_context(|| format!("Reading {}", src.display()))?; + + let dst_link_metadata = match fs::symlink_metadata(&dst).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + self.create_hardlink(&src, &dst).await?; + return Ok(()); + } + Err(err) => { + return Err(Error::Context { + message: format!("Reading {}", dst.display()), + source: err.into(), + }); + } + }; - /// Brute-force discover a built artifact by searching the grammar directory. - async fn brute_force_discover(&self, kind: ArtifactKind) -> Result { - let ext = kind.extension(); - let expected_name = self.parser_name_and_ext(kind); - let mut files = fs::read_dir(self.dir.as_path()) - .await - .with_context(|| format!("Failed to read directory {}", self.dir.display()))?; - - let mut exact_match = None; - let mut candidates = Vec::new(); - - loop { - let Some(entry) = files.next_entry().await.with_context(|| { - format!("Failed to read directory entry in {}", self.dir.display()) - })? - else { - break; - }; - - let path = entry.path(); - let file_type = entry - .file_type() - .await - .with_context(|| format!("Failed to read file type for {}", path.display()))?; - - if !file_type.is_file() { - continue; - } - - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); - - if name == expected_name { - exact_match = Some(path); - break; - } - - if Path::new(&file_name).extension().and_then(|e| e.to_str()) == Some(ext) { - candidates.push(path); - } - } + self + .install_over_existing(&src, &dst, &dst_link_metadata, &src_metadata) + .await + } - match (exact_match, candidates.len()) { - (Some(path), _) => Ok(path), - (None, 0) => Err(self.missing_parser_error(ext)), - (None, 1) => Ok(candidates.into_iter().next().unwrap()), - (None, _) => Err(self.multiple_parsers_error(ext, &candidates)), - } - } + /// Handle the case where a destination output file already exists. + async fn install_over_existing( + &self, + src: &Path, + dst: &Path, + dst_link_metadata: &Metadata, + src_metadata: &Metadata, + ) -> Result<()> { + let dst_file_type = dst_link_metadata.file_type(); - /// Run tree-sitter generate in the grammar directory. - async fn generate(&self) -> Result<()> { - Command::new(self.ts_cli.as_os_str()) - .current_dir(self.dir.as_path()) - .arg("generate") - .exec() - .await - .map(|_| ()) - .map_err(|err| Error::Step { - name: self.language.as_arc(), - kind: error::ParserOp::Generate { - dir: self.dir.clone(), - }, - source: err.into(), - }) + if dst_file_type.is_dir() { + return Err(Error::Message { + message: format!( + "Output path is a directory and cannot be replaced: {}", + dst.display() + ), + }); } - /// Install built binary artifacts to the output directory. - async fn install(&self) -> Result<()> { - // Find and install parser binary for each extension - if self.spec.target.native() { - self.install_binary(ArtifactKind::Native).await?; - } - - if self.spec.target.wasm() { - self.install_binary(ArtifactKind::Wasm).await?; - } + if dst_file_type.is_symlink() { + if !self.context.overwrite_output { + return Err(Error::Message { + message: format!( + "Output path is a symlink and will not be replaced without --force: {}", + dst.display() + ), + }); + } - Ok(()) + self.replace_with_hardlink(src, dst).await?; + self.progress.msg("Reinstalled"); + return Ok(()); } - /// Install a single binary artifact to the output directory. - async fn install_binary(&self, kind: ArtifactKind) -> Result<()> { - let src = self.artifact_path(kind)?; - let dst = self.output.out_dir.join(self.parser_name_and_ext(kind)); - let src_metadata = fs::metadata(&src) - .await - .with_context(|| format!("Reading {}", src.display()))?; - - let dst_link_metadata = match fs::symlink_metadata(&dst).await { - Ok(metadata) => metadata, - Err(err) if err.kind() == io::ErrorKind::NotFound => { - self.create_hardlink(&src, &dst).await?; - return Ok(()); - } - Err(err) => { - return Err(Error::Context { - message: format!("Reading {}", dst.display()), - source: err.into(), - }); - } - }; - - self.install_over_existing(&src, &dst, &dst_link_metadata, &src_metadata) - .await + if !dst_file_type.is_file() { + return Err(Error::Message { + message: format!( + "Output path is not a regular file and cannot be replaced: {}", + dst.display() + ), + }); } - /// Handle the case where a destination output file already exists. - async fn install_over_existing( - &self, - src: &Path, - dst: &Path, - dst_link_metadata: &Metadata, - src_metadata: &Metadata, - ) -> Result<()> { - let dst_file_type = dst_link_metadata.file_type(); - - if dst_file_type.is_dir() { - return Err(Error::Message { - message: format!( - "Output path is a directory and cannot be replaced: {}", - dst.display() - ), - }); - } + let dst_metadata = fs::metadata(dst) + .await + .with_context(|| format!("Reading {}", dst.display()))?; - if dst_file_type.is_symlink() { - if !self.context.overwrite_output { - return Err(Error::Message { - message: format!( - "Output path is a symlink and will not be replaced without --force: {}", - dst.display() - ), - }); - } - - self.replace_with_hardlink(src, dst).await?; - self.progress.msg("Reinstalled"); - return Ok(()); - } - - if !dst_file_type.is_file() { - return Err(Error::Message { - message: format!( - "Output path is not a regular file and cannot be replaced: {}", - dst.display() - ), - }); - } - - let dst_metadata = fs::metadata(dst) - .await - .with_context(|| format!("Reading {}", dst.display()))?; - - if same_file_identity(src_metadata, &dst_metadata) { - return Ok(()); - } - - let same_contents = - same_regular_file_contents(dst, &dst_metadata, src, src_metadata).await?; - - if !same_contents && !self.context.overwrite_output { - return Err(Error::Message { - message: format!( - "Output already exists and differs from the built parser: {}. Use --force to replace it.", - dst.display() - ), - }); - } - - self.replace_with_hardlink(src, dst).await?; - self.progress.msg("reinstalled"); - Ok(()) + if same_file_identity(src_metadata, &dst_metadata) { + return Ok(()); } - /// Copy a custom-built artifact to its canonical build-dir location. - async fn stage_artifact(&self, src: &Path, dst: &Path) -> Result<()> { - ensure_parent_dir(dst).await?; - let src_metadata = fs::metadata(src) - .await - .with_context(|| format!("Reading {}", src.display()))?; - - if !src_metadata.is_file() { - return Err(Error::Message { - message: format!( - "Discovered parser artifact is not a regular file: {}", - src.display() - ), - }); - } - - match fs::metadata(dst).await { - Ok(dst_metadata) if same_file_identity(&src_metadata, &dst_metadata) => return Ok(()), - Ok(_) => {} - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - Err(err) => { - return Err(Error::Context { - message: format!("Reading {}", dst.display()), - source: err.into(), - }); - } - } + let same_contents = same_regular_file_contents(dst, &dst_metadata, src, src_metadata).await?; - self.replace_with_hardlink(src, dst).await - } - - /// Return the list of artifact paths that must exist on disk for a cache hit. - pub fn required_artifacts_for( - grammar_name: &GrammarName, - build_dir: &Path, - spec: &build::Spec, - ts_cli: &Path, - ) -> Result> { - let mut artifacts = Vec::new(); - - if spec.target.native() { - artifacts.push(artifact_path_for( - grammar_name, - build_dir, - ArtifactKind::Native, - spec, - ts_cli, - )?); - } - - if spec.target.wasm() { - artifacts.push(artifact_path_for( - grammar_name, - build_dir, - ArtifactKind::Wasm, - spec, - ts_cli, - )?); - } - - Ok(artifacts) + if !same_contents && !self.context.overwrite_output { + return Err(Error::Message { + message: format!( + "Output already exists and differs from the built parser: {}. Use --force to replace it.", + dst.display() + ), + }); } - /// Compute the artifact path for a given target kind. - fn artifact_path(&self, kind: ArtifactKind) -> Result { - artifact_path_for( - &self.name, - &self.output.build_dir, - kind, - &self.spec, - &self.ts_cli, - ) - } + self.replace_with_hardlink(src, dst).await?; + self.progress.msg("reinstalled"); + Ok(()) + } - /// Atomically replace dst with a hardlink to src (via a temp file + rename). - async fn replace_with_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { - let tmp = temp_install_path(dst)?; - self.create_hardlink(src, &tmp).await?; - - if let Err(err) = fs::rename(&tmp, dst).await { - let _ = fs::remove_file(&tmp).await; - return Err(Error::Context { - message: format!("Installing {} to {}", src.display(), dst.display()), - source: err.into(), - }); - } + /// Copy a custom-built artifact to its canonical build-dir location. + async fn stage_artifact(&self, src: &Path, dst: &Path) -> Result<()> { + ensure_parent_dir(dst).await?; + let src_metadata = fs::metadata(src) + .await + .with_context(|| format!("Reading {}", src.display()))?; - Ok(()) + if !src_metadata.is_file() { + return Err(Error::Message { + message: format!( + "Discovered parser artifact is not a regular file: {}", + src.display() + ), + }); } - /// Create an error reporting a missing parser binary. - fn missing_parser_error(&self, ext: &str) -> Error { - Error::Step { - name: self.language.as_arc(), - kind: error::ParserOp::Copy { - src: self.output.out_dir.clone(), - dst: self.output.build_dir.clone(), - }, - source: Error::Message { - message: format!("Couldn't find any {ext} file"), - } - .into(), - } + match fs::metadata(dst).await { + Ok(dst_metadata) if same_file_identity(&src_metadata, &dst_metadata) => return Ok(()), + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(Error::Context { + message: format!("Reading {}", dst.display()), + source: err.into(), + }); + } } - /// Create an error reporting multiple candidate parser binaries. - fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> Error { - Error::Step { - name: self.language.as_arc(), - kind: error::ParserOp::Copy { - src: self.output.out_dir.clone(), - dst: self.output.build_dir.clone(), - }, - source: Error::Message { - message: format!("Found multiple {ext} files: {candidates:?}"), - } - .into(), - } - } + self.replace_with_hardlink(src, dst).await + } - /// Build the output filename for a grammar artifact (with prefix). - fn parser_name_and_ext(&self, kind: ArtifactKind) -> String { - parser_name_and_ext(&self.name, kind, &self.spec.prefix) - } + /// Return the list of artifact paths that must exist on disk for a cache hit. + pub fn required_artifacts_for( + grammar_name: &GrammarName, + build_dir: &Path, + spec: &build::Spec, + ts_cli: &Path, + ) -> Result> { + let mut artifacts = Vec::new(); + + if spec.target.native() { + artifacts.push(artifact_path_for( + grammar_name, + build_dir, + ArtifactKind::Native, + spec, + ts_cli, + )?); + } + + if spec.target.wasm() { + artifacts.push(artifact_path_for( + grammar_name, + build_dir, + ArtifactKind::Wasm, + spec, + ts_cli, + )?); + } + + Ok(artifacts) + } + + /// Compute the artifact path for a given target kind. + fn artifact_path(&self, kind: ArtifactKind) -> Result { + artifact_path_for( + &self.name, + &self.output.build_dir, + kind, + &self.spec, + &self.ts_cli, + ) + } + + /// Atomically replace dst with a hardlink to src (via a temp file + rename). + async fn replace_with_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { + let tmp = temp_install_path(dst)?; + self.create_hardlink(src, &tmp).await?; + + if let Err(err) = fs::rename(&tmp, dst).await { + let _ = fs::remove_file(&tmp).await; + return Err(Error::Context { + message: format!("Installing {} to {}", src.display(), dst.display()), + source: err.into(), + }); + } + + Ok(()) + } + + /// Create an error reporting a missing parser binary. + fn missing_parser_error(&self, ext: &str) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Copy { + src: self.output.out_dir.clone(), + dst: self.output.build_dir.clone(), + }, + source: Error::Message { + message: format!("Couldn't find any {ext} file"), + } + .into(), + } + } + + /// Create an error reporting multiple candidate parser binaries. + fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Copy { + src: self.output.out_dir.clone(), + dst: self.output.build_dir.clone(), + }, + source: Error::Message { + message: format!("Found multiple {ext} files: {candidates:?}"), + } + .into(), + } + } + + /// Build the output filename for a grammar artifact (with prefix). + fn parser_name_and_ext(&self, kind: ArtifactKind) -> String { + parser_name_and_ext(&self.name, kind, &self.spec.prefix) + } } impl GrammarName { - /// Return the grammar name as a string slice. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Return the grammar name as a cloned `Arc`. - #[must_use] - pub fn as_arc(&self) -> Arc { - self.0.clone() - } + /// Return the grammar name as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Return the grammar name as a cloned `Arc`. + #[must_use] + pub fn as_arc(&self) -> Arc { + self.0.clone() + } } impl LanguageBuild { - /// Create a new `LanguageBuild`. - #[must_use] - pub fn new( - context: build::Context, - name: LanguageName, - output: build::OutputConfig, - spec: Arc, - ) -> Self { - Self { - context, - name, - output, - spec, - } - } - - /// Scan the checkout directory for grammar.js files and return their - /// names, hashes, and parent directories. - pub async fn discover_grammars( - &self, - ) -> Result> { - let file_results = collect_grammar_paths(&self.output.build_dir).await?; - let mut grammars = Vec::new(); - - for (grammar_path, hash) in file_results { - let grammar_dir = grammar_path.parent().ok_or_else(|| Error::Message { - message: format!( - "Could not get parent directory for {}", - grammar_path.display() - ), - })?; - let grammar_name = extract_grammar_name(grammar_dir)?; - grammars.push((grammar_name, hash, grammar_dir.to_path_buf())); - } - - if grammars.is_empty() { - return Err(Error::Step { - name: self.name.as_arc(), - kind: error::ParserOp::Discover { - dir: self.output.build_dir.clone(), - }, - source: Error::Message { - message: format!( - "No grammar.js files found for parser {} (repo: {}, ref: {})", - self.name, - self.spec.repo, - self.spec.git_ref.requested().as_str() - ), - } - .into(), - }); + /// Create a new `LanguageBuild`. + #[must_use] + pub fn new( + context: build::Context, + name: LanguageName, + output: build::OutputConfig, + spec: Arc, + ) -> Self { + Self { + context, + name, + output, + spec, + } + } + + /// Scan the checkout directory for grammar.js files and return their + /// names, hashes, and parent directories. + pub async fn discover_grammars(&self) -> Result> { + let file_results = collect_grammar_paths(&self.output.build_dir).await?; + let mut grammars = Vec::new(); + + for (grammar_path, hash) in file_results { + let grammar_dir = grammar_path.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not get parent directory for {}", + grammar_path.display() + ), + })?; + let grammar_name = extract_grammar_name(grammar_dir)?; + grammars.push((grammar_name, hash, grammar_dir.to_path_buf())); + } + + if grammars.is_empty() { + return Err(Error::Step { + name: self.name.as_arc(), + kind: error::ParserOp::Discover { + dir: self.output.build_dir.clone(), + }, + source: Error::Message { + message: format!( + "No grammar.js files found for parser {} (repo: {}, ref: {})", + self.name, + self.spec.repo, + self.spec.git_ref.requested().as_str() + ), } - - Ok(grammars) - } - - /// Return a new `LanguageBuild` with the tree-sitter version replaced - /// (once the CLI has been resolved). - #[must_use] - pub fn with_tree_sitter(mut self, tree_sitter: TreeSitter) -> Self { - let mut spec = self.spec.as_ref().clone(); - spec.tree_sitter = tree_sitter; - self.spec = Arc::new(spec); - self - } - - /// Clone/checkout the parser repository at the configured ref. - pub async fn checkout(&self) -> Result { - git::checkout( - self.spec.repo.as_str(), - &self.output.build_dir, - self.spec.git_ref.requested(), - ) - .await - .map_err(|err| Error::Step { - name: self.name.as_arc(), - kind: error::ParserOp::Clone { - dir: self.output.build_dir.clone(), - }, - source: err.into(), - }) - } - - /// Check whether the existing checkout directory is usable for this language. - pub async fn is_checkout_usable(&self) -> bool { - git::is_checkout_usable(self.spec.repo.as_str(), &self.output.build_dir).await - } + .into(), + }); + } + + Ok(grammars) + } + + /// Return a new `LanguageBuild` with the tree-sitter version replaced + /// (once the CLI has been resolved). + #[must_use] + pub fn with_tree_sitter(mut self, tree_sitter: TreeSitter) -> Self { + let mut spec = self.spec.as_ref().clone(); + spec.tree_sitter = tree_sitter; + self.spec = Arc::new(spec); + self + } + + /// Clone/checkout the parser repository at the configured ref. + pub async fn checkout(&self) -> Result { + git::checkout( + self.spec.repo.as_str(), + &self.output.build_dir, + self.spec.git_ref.requested(), + ) + .await + .map_err(|err| Error::Step { + name: self.name.as_arc(), + kind: error::ParserOp::Clone { + dir: self.output.build_dir.clone(), + }, + source: err.into(), + }) + } + + /// Check whether the existing checkout directory is usable for this language. + pub async fn is_checkout_usable(&self) -> bool { + git::is_checkout_usable(self.spec.repo.as_str(), &self.output.build_dir).await + } } impl LanguageName { - /// Return the language name as a string slice. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Return the language name as a cloned `Arc`. - #[must_use] - pub fn as_arc(&self) -> Arc { - self.0.clone() - } + /// Return the language name as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Return the language name as a cloned `Arc`. + #[must_use] + pub fn as_arc(&self) -> Arc { + self.0.clone() + } } impl Ref { - /// The default source ref used for unpinned parser builds. - #[must_use] - pub fn head() -> Self { - Self::Moving(git::Ref::head()) + /// The default source ref used for unpinned parser builds. + #[must_use] + pub fn head() -> Self { + Self::Moving(git::Ref::head()) + } + + /// Create a parser source ref from user input, preserving parser-version + /// normalization while classifying refs for cache semantics. + pub fn parse(value: &str) -> StdResult { + let normalized = normalize_source_ref(value); + let git_ref = git::Ref::new(normalized)?; + if is_stable_source_ref(value, &git_ref) { + Ok(Self::Stable(git_ref)) + } else { + Ok(Self::Moving(git_ref)) } + } - /// Create a parser source ref from user input, preserving parser-version - /// normalization while classifying refs for cache semantics. - pub fn parse(value: &str) -> StdResult { - let normalized = normalize_source_ref(value); - let git_ref = git::Ref::new(normalized)?; - if is_stable_source_ref(value, &git_ref) { - Ok(Self::Stable(git_ref)) - } else { - Ok(Self::Moving(git_ref)) - } - } + /// Check whether this is a moving ref (branch). + #[must_use] + pub const fn is_moving(&self) -> bool { + matches!(self, Self::Moving(_)) + } - /// Check whether this is a moving ref (branch). - #[must_use] - pub const fn is_moving(&self) -> bool { - matches!(self, Self::Moving(_)) - } + /// Check whether this is a stable ref (tag or SHA). + #[must_use] + pub const fn is_stable(&self) -> bool { + matches!(self, Self::Stable(_)) + } - /// Check whether this is a stable ref (tag or SHA). - #[must_use] - pub const fn is_stable(&self) -> bool { - matches!(self, Self::Stable(_)) - } - - /// Return the underlying git ref, regardless of moving/stable classification. - #[must_use] - pub fn requested(&self) -> &git::Ref { - match self { - Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, - } + /// Return the underlying git ref, regardless of moving/stable classification. + #[must_use] + pub fn requested(&self) -> &git::Ref { + match self { + Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, } + } } impl Serialize for Ref { - fn serialize(&self, serializer: S) -> StdResult - where - S: Serializer, - { - serializer.serialize_str(self.requested().as_str()) - } + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.requested().as_str()) + } } #[cfg(test)] mod tests { - use super::*; - use std::{os::unix::fs::symlink, process::Command as StdCommand}; - - use crate::{ - actors::{DisplayActor, DisplayAddr}, - args::{Target, TreeSitter}, - display::Mode, - git, + use super::*; + use std::{os::unix::fs::symlink, process::Command as StdCommand}; + + use crate::{ + actors::{DisplayActor, DisplayAddr}, + args::{Target, TreeSitter}, + display::Mode, + git, + }; + use tempfile::TempDir; + + const FULL_SHA: &str = "636801770eea172d140e64b691815ff11f6b556f"; + + #[test] + fn ref_classifies_stable_refs() { + assert!(Ref::parse("0.21.0").unwrap().is_stable()); + assert!(Ref::parse("v0.21.0").unwrap().is_stable()); + assert!(Ref::parse("refs/tags/release").unwrap().is_stable()); + assert!(Ref::parse(FULL_SHA).unwrap().is_stable()); + } + + #[test] + fn ref_classifies_moving_refs() { + assert!(Ref::head().is_moving()); + assert!(Ref::parse("master").unwrap().is_moving()); + assert!(Ref::parse("refs/heads/main").unwrap().is_moving()); + assert!(Ref::parse("vnext").unwrap().is_moving()); + } + + #[test] + fn ref_serializes_as_requested_ref() { + #[derive(serde::Serialize)] + struct Wrapper { + git_ref: Ref, + } + + let source = Ref::parse("0.21.0").unwrap(); + + assert_eq!( + toml::to_string(&Wrapper { git_ref: source }).unwrap(), + "git_ref = \"v0.21.0\"\n" + ); + } + + fn test_language_build(build_dir: PathBuf, out_dir: PathBuf) -> LanguageBuild { + LanguageBuild::new( + build::Context { + overwrite_output: false, + }, + LanguageName::from("empty"), + build::OutputConfig { build_dir, out_dir }, + Arc::new(build::Spec { + build_script: None, + git_ref: Ref::parse("v1.0.0").unwrap(), + prefix: String::new(), + repo: "https://example.com/tree-sitter-empty".parse().unwrap(), + target: Target::Native, + tree_sitter: TreeSitter::default(), + }), + ) + } + + fn init_git_repo(path: &Path) { + std::fs::create_dir_all(path).unwrap(); + let output = StdCommand::new("git") + .arg("init") + .arg("--quiet") + .current_dir(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + async fn test_grammar_build( + grammar_dir: PathBuf, + out_dir: PathBuf, + overwrite_output: bool, + ) -> (GrammarBuild, DisplayAddr) { + let display = DisplayActor::spawn(grammar_dir.clone(), Mode::Plain, out_dir.clone()); + let progress = display + .add_grammar("rust", "rust", git::Ref::head(), 1) + .await; + let source_ref = Ref::parse("v1.0.0").unwrap(); + let build = GrammarBuild { + context: build::Context { overwrite_output }, + cache_decision: cache::Decision::miss(cache::MissReason::MissingEntry), + dir: grammar_dir.clone(), + hash: cache::GrammarHash::from("test"), + language: "rust".into(), + name: "rust".into(), + output: build::OutputConfig { + build_dir: grammar_dir, + out_dir, + }, + progress, + revision: cache::Revision::stable(), + spec: Arc::new(build::Spec { + build_script: None, + git_ref: source_ref, + prefix: String::new(), + repo: "https://example.com/tree-sitter-rust".parse().unwrap(), + target: Target::Native, + tree_sitter: TreeSitter::default(), + }), + ts_cli: PathBuf::from("tree-sitter-macos-arm64-v0.26.5"), }; - use tempfile::TempDir; - - const FULL_SHA: &str = "636801770eea172d140e64b691815ff11f6b556f"; - - #[test] - fn ref_classifies_stable_refs() { - assert!(Ref::parse("0.21.0").unwrap().is_stable()); - assert!(Ref::parse("v0.21.0").unwrap().is_stable()); - assert!(Ref::parse("refs/tags/release").unwrap().is_stable()); - assert!(Ref::parse(FULL_SHA).unwrap().is_stable()); - } - - #[test] - fn ref_classifies_moving_refs() { - assert!(Ref::head().is_moving()); - assert!(Ref::parse("master").unwrap().is_moving()); - assert!(Ref::parse("refs/heads/main").unwrap().is_moving()); - assert!(Ref::parse("vnext").unwrap().is_moving()); - } - #[test] - fn ref_serializes_as_requested_ref() { - #[derive(serde::Serialize)] - struct Wrapper { - git_ref: Ref, - } - - let source = Ref::parse("0.21.0").unwrap(); - - assert_eq!( - toml::to_string(&Wrapper { git_ref: source }).unwrap(), - "git_ref = \"v0.21.0\"\n" - ); - } - - fn test_language_build(build_dir: PathBuf, out_dir: PathBuf) -> LanguageBuild { - LanguageBuild::new( - build::Context { - overwrite_output: false, - }, - LanguageName::from("empty"), - build::OutputConfig { build_dir, out_dir }, - Arc::new(build::Spec { - build_script: None, - git_ref: Ref::parse("v1.0.0").unwrap(), - prefix: String::new(), - repo: "https://example.com/tree-sitter-empty".parse().unwrap(), - target: Target::Native, - tree_sitter: TreeSitter::default(), - }), - ) - } - - fn init_git_repo(path: &Path) { - std::fs::create_dir_all(path).unwrap(); - let output = StdCommand::new("git") - .arg("init") - .arg("--quiet") - .current_dir(path) - .output() - .unwrap(); - assert!( - output.status.success(), - "git init failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - async fn test_grammar_build( - grammar_dir: PathBuf, - out_dir: PathBuf, - overwrite_output: bool, - ) -> (GrammarBuild, DisplayAddr) { - let display = DisplayActor::spawn(grammar_dir.clone(), Mode::Plain, out_dir.clone()); - let progress = display - .add_grammar("rust", "rust", git::Ref::head(), 1) - .await; - let source_ref = Ref::parse("v1.0.0").unwrap(); - let build = GrammarBuild { - context: build::Context { overwrite_output }, - cache_decision: cache::Decision::miss(cache::MissReason::MissingEntry), - dir: grammar_dir.clone(), - hash: cache::GrammarHash::from("test"), - language: "rust".into(), - name: "rust".into(), - output: build::OutputConfig { - build_dir: grammar_dir, - out_dir, - }, - progress, - revision: cache::Revision::stable(), - spec: Arc::new(build::Spec { - build_script: None, - git_ref: source_ref, - prefix: String::new(), - repo: "https://example.com/tree-sitter-rust".parse().unwrap(), - target: Target::Native, - tree_sitter: TreeSitter::default(), - }), - ts_cli: PathBuf::from("tree-sitter-macos-arm64-v0.26.5"), - }; - - (build, display) - } - - async fn write_artifact(build: &GrammarBuild, kind: ArtifactKind, contents: &[u8]) -> PathBuf { - let path = build.artifact_path(kind).unwrap(); - tokio::fs::create_dir_all(path.parent().unwrap()) - .await - .unwrap(); - tokio::fs::write(&path, contents).await.unwrap(); - path - } - - fn same_identity(a: &Path, b: &Path) -> bool { - let a = std::fs::metadata(a).unwrap(); - let b = std::fs::metadata(b).unwrap(); - same_file_identity(&a, &b) - } - - #[tokio::test] - async fn discover_grammars_errors_when_no_grammar_js_files_are_found() { - let temp_dir = TempDir::new().unwrap(); - let build_dir = temp_dir.path().join("tree-sitter-empty"); - let out_dir = temp_dir.path().join("out"); - init_git_repo(&build_dir); - tokio::fs::create_dir_all(&out_dir).await.unwrap(); - - let language = test_language_build(build_dir.clone(), out_dir); - let err = language.discover_grammars().await.unwrap_err(); - - match err { - Error::Step { name, kind, source } => { - assert_eq!(name.as_ref(), "empty"); - assert!(matches!(kind, error::ParserOp::Discover { dir } if dir == build_dir)); - let message = source.as_error().to_string(); - assert!(message.contains("No grammar.js files found")); - assert!(message.contains("https://example.com/tree-sitter-empty")); - assert!(message.contains("v1.0.0")); - } - other => panic!("expected discovery step error, got {other:?}"), - } - } - - #[test] - fn test_cache_key_format() { - let key = cache::Key::new( - &LanguageName::from("typescript"), - &GrammarName::from("typescript"), - ); - assert_eq!(key.as_str(), "typescript/typescript"); - - let key = cache::Key::new(&LanguageName::from("typescript"), &GrammarName::from("tsx")); - assert_eq!(key.as_str(), "typescript/tsx"); - } - - #[test] - fn test_extract_grammar_name() { - let dir = Path::new("/tmp/build/tree-sitter-typescript"); - let name = extract_grammar_name(dir).unwrap(); - assert_eq!(name.as_str(), "typescript"); - - let dir = Path::new("/tmp/build/custom-parser"); - let name = extract_grammar_name(dir).unwrap(); - assert_eq!(name.as_str(), "custom-parser"); - } - - #[test] - fn test_extract_grammar_name_strips_prefix() { - let name_with_prefix = "tree-sitter-typescript"; - let stripped = name_with_prefix - .strip_prefix("tree-sitter-") - .unwrap_or(name_with_prefix); - assert_eq!(stripped, "typescript"); - - let name_without_prefix = "custom-parser"; - let not_stripped = name_without_prefix - .strip_prefix("tree-sitter-") - .unwrap_or(name_without_prefix); - assert_eq!(not_stripped, "custom-parser"); - } - - #[test] - fn test_artifact_dir_name_from_tree_sitter_cli() { - assert_eq!( - artifact_dir_name_from_tree_sitter_cli(Path::new( - "/tmp/tsdl/tree-sitter-macos-arm64-v0.26.5" - )) - .unwrap(), - "tsdl-macos-arm64-v0.26.5" - ); - } - - #[test] - fn test_parser_name_and_ext() { - let name = parser_name_and_ext(&GrammarName::from("typescript"), ArtifactKind::Native, ""); - assert_eq!(name, format!("typescript.{DLL_EXTENSION}")); - } - - #[test] - fn test_parser_name_with_prefix() { - let name = parser_name_and_ext( - &GrammarName::from("typescript"), - ArtifactKind::Native, - "lib", - ); - assert_eq!(name, format!("libtypescript.{DLL_EXTENSION}")); - } - - #[tokio::test] - async fn install_missing_destination_creates_hardlink() { - let temp_dir = TempDir::new().unwrap(); - let grammar_dir = temp_dir.path().join("grammar"); - let out_dir = temp_dir.path().join("out"); - tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); - tokio::fs::create_dir_all(&out_dir).await.unwrap(); - - let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); - - let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; - let src = write_artifact(&build, ArtifactKind::Native, b"parser").await; - build.install_binary(ArtifactKind::Native).await.unwrap(); - display.shutdown(false).await; - - assert!(same_identity(&src, &dst)); - } - - #[tokio::test] - async fn install_same_content_relinks_without_force() { - let temp_dir = TempDir::new().unwrap(); - let grammar_dir = temp_dir.path().join("grammar"); - let out_dir = temp_dir.path().join("out"); - tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); - tokio::fs::create_dir_all(&out_dir).await.unwrap(); - - let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); - - let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; - let src = write_artifact(&build, ArtifactKind::Native, b"parser").await; - tokio::fs::write(&dst, b"parser").await.unwrap(); - assert!(!same_identity(&src, &dst)); - - build.install_binary(ArtifactKind::Native).await.unwrap(); - display.shutdown(false).await; - - assert!(same_identity(&src, &dst)); - } - - #[tokio::test] - async fn install_same_size_different_content_requires_force() { - let temp_dir = TempDir::new().unwrap(); - let grammar_dir = temp_dir.path().join("grammar"); - let out_dir = temp_dir.path().join("out"); - tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); - tokio::fs::create_dir_all(&out_dir).await.unwrap(); - - let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); - - let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; - let src = write_artifact(&build, ArtifactKind::Native, b"new").await; - tokio::fs::write(&dst, b"old").await.unwrap(); - let err = build - .install_binary(ArtifactKind::Native) - .await - .unwrap_err(); - display.shutdown(false).await; - - assert!(err.to_string().contains("differs from the built parser")); - assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"old"); - assert!(!same_identity(&src, &dst)); - } - - #[tokio::test] - async fn install_same_size_different_content_with_force_replaces() { - let temp_dir = TempDir::new().unwrap(); - let grammar_dir = temp_dir.path().join("grammar"); - let out_dir = temp_dir.path().join("out"); - tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); - tokio::fs::create_dir_all(&out_dir).await.unwrap(); - - let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); - - let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; - let src = write_artifact(&build, ArtifactKind::Native, b"new").await; - tokio::fs::write(&dst, b"old").await.unwrap(); - build.install_binary(ArtifactKind::Native).await.unwrap(); - display.shutdown(false).await; - - assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"new"); - assert!(same_identity(&src, &dst)); - } - - #[tokio::test] - async fn install_symlink_requires_force() { - let temp_dir = TempDir::new().unwrap(); - let grammar_dir = temp_dir.path().join("grammar"); - let out_dir = temp_dir.path().join("out"); - tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); - tokio::fs::create_dir_all(&out_dir).await.unwrap(); - - let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); - let target = out_dir.join(format!("target.{DLL_EXTENSION}")); - - let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; - let _src = write_artifact(&build, ArtifactKind::Native, b"new").await; - tokio::fs::write(&target, b"old").await.unwrap(); - symlink(&target, &dst).unwrap(); - let err = build - .install_binary(ArtifactKind::Native) - .await - .unwrap_err(); - display.shutdown(false).await; - - assert!(err.to_string().contains("symlink")); - assert_eq!(std::fs::read_link(&dst).unwrap(), target); - assert_eq!(tokio::fs::read(&target).await.unwrap(), b"old"); - } - - #[tokio::test] - async fn install_directory_destination_errors_even_with_force() { - let temp_dir = TempDir::new().unwrap(); - let grammar_dir = temp_dir.path().join("grammar"); - let out_dir = temp_dir.path().join("out"); - tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); - tokio::fs::create_dir_all(&out_dir).await.unwrap(); - - let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); - - let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; - let _src = write_artifact(&build, ArtifactKind::Native, b"new").await; - tokio::fs::create_dir(&dst).await.unwrap(); - let err = build - .install_binary(ArtifactKind::Native) - .await - .unwrap_err(); - display.shutdown(false).await; - - assert!(err.to_string().contains("directory")); - assert!(dst.is_dir()); - } + (build, display) + } + + async fn write_artifact(build: &GrammarBuild, kind: ArtifactKind, contents: &[u8]) -> PathBuf { + let path = build.artifact_path(kind).unwrap(); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, contents).await.unwrap(); + path + } + + fn same_identity(a: &Path, b: &Path) -> bool { + let a = std::fs::metadata(a).unwrap(); + let b = std::fs::metadata(b).unwrap(); + same_file_identity(&a, &b) + } + + #[tokio::test] + async fn discover_grammars_errors_when_no_grammar_js_files_are_found() { + let temp_dir = TempDir::new().unwrap(); + let build_dir = temp_dir.path().join("tree-sitter-empty"); + let out_dir = temp_dir.path().join("out"); + init_git_repo(&build_dir); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let language = test_language_build(build_dir.clone(), out_dir); + let err = language.discover_grammars().await.unwrap_err(); + + match err { + Error::Step { name, kind, source } => { + assert_eq!(name.as_ref(), "empty"); + assert!(matches!(kind, error::ParserOp::Discover { dir } if dir == build_dir)); + let message = source.as_error().to_string(); + assert!(message.contains("No grammar.js files found")); + assert!(message.contains("https://example.com/tree-sitter-empty")); + assert!(message.contains("v1.0.0")); + } + other => panic!("expected discovery step error, got {other:?}"), + } + } + + #[test] + fn test_cache_key_format() { + let key = cache::Key::new( + &LanguageName::from("typescript"), + &GrammarName::from("typescript"), + ); + assert_eq!(key.as_str(), "typescript/typescript"); + + let key = cache::Key::new(&LanguageName::from("typescript"), &GrammarName::from("tsx")); + assert_eq!(key.as_str(), "typescript/tsx"); + } + + #[test] + fn test_extract_grammar_name() { + let dir = Path::new("/tmp/build/tree-sitter-typescript"); + let name = extract_grammar_name(dir).unwrap(); + assert_eq!(name.as_str(), "typescript"); + + let dir = Path::new("/tmp/build/custom-parser"); + let name = extract_grammar_name(dir).unwrap(); + assert_eq!(name.as_str(), "custom-parser"); + } + + #[test] + fn test_extract_grammar_name_strips_prefix() { + let name_with_prefix = "tree-sitter-typescript"; + let stripped = name_with_prefix + .strip_prefix("tree-sitter-") + .unwrap_or(name_with_prefix); + assert_eq!(stripped, "typescript"); + + let name_without_prefix = "custom-parser"; + let not_stripped = name_without_prefix + .strip_prefix("tree-sitter-") + .unwrap_or(name_without_prefix); + assert_eq!(not_stripped, "custom-parser"); + } + + #[test] + fn test_artifact_dir_name_from_tree_sitter_cli() { + assert_eq!( + artifact_dir_name_from_tree_sitter_cli(Path::new( + "/tmp/tsdl/tree-sitter-macos-arm64-v0.26.5" + )) + .unwrap(), + "tsdl-macos-arm64-v0.26.5" + ); + } + + #[test] + fn test_parser_name_and_ext() { + let name = parser_name_and_ext(&GrammarName::from("typescript"), ArtifactKind::Native, ""); + assert_eq!(name, format!("typescript.{DLL_EXTENSION}")); + } + + #[test] + fn test_parser_name_with_prefix() { + let name = parser_name_and_ext( + &GrammarName::from("typescript"), + ArtifactKind::Native, + "lib", + ); + assert_eq!(name, format!("libtypescript.{DLL_EXTENSION}")); + } + + #[tokio::test] + async fn install_missing_destination_creates_hardlink() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, ArtifactKind::Native, b"parser").await; + build.install_binary(ArtifactKind::Native).await.unwrap(); + display.shutdown(false).await; + + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_content_relinks_without_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, ArtifactKind::Native, b"parser").await; + tokio::fs::write(&dst, b"parser").await.unwrap(); + assert!(!same_identity(&src, &dst)); + + build.install_binary(ArtifactKind::Native).await.unwrap(); + display.shutdown(false).await; + + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_size_different_content_requires_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, ArtifactKind::Native, b"new").await; + tokio::fs::write(&dst, b"old").await.unwrap(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("differs from the built parser")); + assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"old"); + assert!(!same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_size_different_content_with_force_replaces() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; + let src = write_artifact(&build, ArtifactKind::Native, b"new").await; + tokio::fs::write(&dst, b"old").await.unwrap(); + build.install_binary(ArtifactKind::Native).await.unwrap(); + display.shutdown(false).await; + + assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"new"); + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_symlink_requires_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + let target = out_dir.join(format!("target.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let _src = write_artifact(&build, ArtifactKind::Native, b"new").await; + tokio::fs::write(&target, b"old").await.unwrap(); + symlink(&target, &dst).unwrap(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("symlink")); + assert_eq!(std::fs::read_link(&dst).unwrap(), target); + assert_eq!(tokio::fs::read(&target).await.unwrap(), b"old"); + } + + #[tokio::test] + async fn install_directory_destination_errors_even_with_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; + let _src = write_artifact(&build, ArtifactKind::Native, b"new").await; + tokio::fs::create_dir(&dst).await.unwrap(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("directory")); + assert!(dst.is_dir()); + } } diff --git a/src/selfupdate.rs b/src/selfupdate.rs index ea9e3a3..6f4301a 100644 --- a/src/selfupdate.rs +++ b/src/selfupdate.rs @@ -3,165 +3,160 @@ use std::{fs, path::PathBuf}; use self_update::self_replace; use semver::Version; -use crate::{args::VersionBump, consts::PLATFORM, Error, prompt_user, Result, ResultExt }; +use crate::{Error, Result, ResultExt, args::VersionBump, consts::PLATFORM, prompt_user}; enum UpdateTarget { - Exact(Version), - Relative(VersionBump), + Exact(Version), + Relative(VersionBump), } -fn download_and_replace( - asset_name: &str, - download_url: &str, - version: &Version, -) -> Result<()> { - let tsdl = env!("CARGO_PKG_NAME"); - let tmp_dir = tempfile::tempdir() - .context("Failed to create temporary directory")?; - let tmp_gz_path = tmp_dir.path().join(asset_name); - let tmp_gz = fs::File::create_new(&tmp_gz_path) - .context("Failed to create temporary file")?; - - eprintln!("downloading {version}"); - self_update::Download::from_url(download_url) - .set_header( - reqwest::header::ACCEPT, - "application/octet-stream" - .parse() - .context("Failed to parse accept header")?, - ) - .download_to(&tmp_gz) - .context("Failed to download release asset")?; - - eprintln!("extracting {version}"); - let tsdl_bin = PathBuf::from(tsdl); - self_update::Extract::from_source(&tmp_gz_path) - .archive(self_update::ArchiveKind::Plain(Some( - self_update::Compression::Gz, - ))) - .extract_file(tmp_dir.path(), &tsdl_bin) - .with_context(|| "Failed to extract release asset".to_string())?; - - let new_exe = tmp_dir.path().join(tsdl_bin); - self_replace::self_replace(new_exe) - .with_context(|| "Failed to replace current executable".to_string())?; - - eprintln!("{version}"); - Ok(()) +fn download_and_replace(asset_name: &str, download_url: &str, version: &Version) -> Result<()> { + let tsdl = env!("CARGO_PKG_NAME"); + let tmp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; + let tmp_gz_path = tmp_dir.path().join(asset_name); + let tmp_gz = fs::File::create_new(&tmp_gz_path).context("Failed to create temporary file")?; + + eprintln!("downloading {version}"); + self_update::Download::from_url(download_url) + .set_header( + reqwest::header::ACCEPT, + "application/octet-stream" + .parse() + .context("Failed to parse accept header")?, + ) + .download_to(&tmp_gz) + .context("Failed to download release asset")?; + + eprintln!("extracting {version}"); + let tsdl_bin = PathBuf::from(tsdl); + self_update::Extract::from_source(&tmp_gz_path) + .archive(self_update::ArchiveKind::Plain(Some( + self_update::Compression::Gz, + ))) + .extract_file(tmp_dir.path(), &tsdl_bin) + .with_context(|| "Failed to extract release asset".to_string())?; + + let new_exe = tmp_dir.path().join(tsdl_bin); + self_replace::self_replace(new_exe) + .with_context(|| "Failed to replace current executable".to_string())?; + + eprintln!("{version}"); + Ok(()) } fn parse_target(raw: &str) -> Result { - match raw { - "major" => Ok(UpdateTarget::Relative(VersionBump::Major)), - "minor" => Ok(UpdateTarget::Relative(VersionBump::Minor)), - "patch" => Ok(UpdateTarget::Relative(VersionBump::Patch)), - other => Version::parse(other).map(UpdateTarget::Exact).context( - "expected 'patch', 'minor', 'major', or a semver like '2.5.0'" - ), - } + match raw { + "major" => Ok(UpdateTarget::Relative(VersionBump::Major)), + "minor" => Ok(UpdateTarget::Relative(VersionBump::Minor)), + "patch" => Ok(UpdateTarget::Relative(VersionBump::Patch)), + other => Version::parse(other) + .map(UpdateTarget::Exact) + .context("expected 'patch', 'minor', 'major', or a semver like '2.5.0'"), + } } pub fn run(force: bool, target: &str) -> Result<()> { - let update_target = parse_target(target)?; - - let tsdl = env!("CARGO_PKG_NAME"); - let current_version = Version::parse(env!("CARGO_PKG_VERSION")) - .with_context(|| "Failed to parse current version".to_string())?; - - eprintln!("fetching releases"); - let releases = self_update::backends::github::ReleaseList::configure() - .repo_owner("stackmystack") - .repo_name(tsdl) - .build() - .with_context(|| "Failed to build release list configuration".to_string())? - .fetch() - .with_context(|| "Failed to fetch releases".to_string())?; - - if releases.is_empty() { - return Err(Error::Message{ message: "No releases found".to_string() }); + let update_target = parse_target(target)?; + + let tsdl = env!("CARGO_PKG_NAME"); + let current_version = Version::parse(env!("CARGO_PKG_VERSION")) + .with_context(|| "Failed to parse current version".to_string())?; + + eprintln!("fetching releases"); + let releases = self_update::backends::github::ReleaseList::configure() + .repo_owner("stackmystack") + .repo_name(tsdl) + .build() + .with_context(|| "Failed to build release list configuration".to_string())? + .fetch() + .with_context(|| "Failed to fetch releases".to_string())?; + + if releases.is_empty() { + return Err(Error::Message { + message: "No releases found".to_string(), + }); + } + + let (release, version) = match update_target { + UpdateTarget::Exact(target_version) => { + let Some(found_release) = releases + .iter() + .find(|r| Version::parse(&r.version).is_ok_and(|v| v == target_version)) + else { + return Err(Error::Message { + message: format!("version {target_version} not found in releases"), + }); + }; + + if target_version == current_version { + eprintln!("already at {target_version}"); + return Ok(()); + } + + let is_downgrade = target_version < current_version; + if is_downgrade + && !force + && !prompt_user( + &format!("Downgrade from {current_version} to {target_version}?"), + false, + )? + { + eprintln!("downgrade cancelled"); + return Ok(()); + } + + (found_release.clone(), target_version) } - let (release, version) = match update_target { - UpdateTarget::Exact(target_version) => { - let Some(found_release) = releases - .iter() - .find(|r| Version::parse(&r.version).is_ok_and(|v| v == target_version)) - else { - return Err(Error::Message { - message: format!("version {target_version} not found in releases") - }); - }; - - if target_version == current_version { - eprintln!("already at {target_version}"); - return Ok(()); + UpdateTarget::Relative(bump) => { + let compatible: Vec<_> = releases + .iter() + .filter(|r| { + let Ok(rel_ver) = Version::parse(&r.version) else { + return false; + }; + if rel_ver <= current_version { + return false; + } + match bump { + VersionBump::Major => true, + VersionBump::Minor => rel_ver.major == current_version.major, + VersionBump::Patch => { + rel_ver.major == current_version.major && rel_ver.minor == current_version.minor } - - let is_downgrade = target_version < current_version; - if is_downgrade - && !force - && !prompt_user( - &format!("Downgrade from {current_version} to {target_version}?"), - false, - )? - { - eprintln!("downgrade cancelled"); - return Ok(()); - } - - (found_release.clone(), target_version) + } + }) + .collect(); + + if compatible.is_empty() { + let overall_latest = Version::parse(&releases[0].version).ok(); + if overall_latest + .as_ref() + .is_some_and(|v| v > ¤t_version) + { + eprintln!( + "no compatible {bump} update (latest is {}; use `tsdl selfupdate major` to install it)", + releases[0].version, + ); + } else { + eprintln!("already at the latest version"); } + return Ok(()); + } - UpdateTarget::Relative(bump) => { - let compatible: Vec<_> = releases - .iter() - .filter(|r| { - let Ok(rel_ver) = Version::parse(&r.version) else { - return false; - }; - if rel_ver <= current_version { - return false; - } - match bump { - VersionBump::Major => true, - VersionBump::Minor => rel_ver.major == current_version.major, - VersionBump::Patch => { - rel_ver.major == current_version.major - && rel_ver.minor == current_version.minor - } - } - }) - .collect(); - - if compatible.is_empty() { - let overall_latest = Version::parse(&releases[0].version).ok(); - if overall_latest - .as_ref() - .is_some_and(|v| v > ¤t_version) - { - eprintln!( - "no compatible {bump} update (latest is {}; use `tsdl selfupdate major` to install it)", - releases[0].version, - ); - } else { - eprintln!("already at the latest version"); - } - return Ok(()); - } - - let latest_release = compatible[0]; - let latest_version = Version::parse(&latest_release.version) - .context("Failed to parse latest version")?; - (latest_release.clone(), latest_version) - } - }; + let latest_release = compatible[0]; + let latest_version = + Version::parse(&latest_release.version).context("Failed to parse latest version")?; + (latest_release.clone(), latest_version) + } + }; - let asset_name = format!("{tsdl}-{PLATFORM}.gz"); - let Some(asset) = release.assets.iter().find(|a| a.name == asset_name) else { - return Err(Error::Message { - message: "Could not find a suitable release for your platform".to_string(), - }); - }; + let asset_name = format!("{tsdl}-{PLATFORM}.gz"); + let Some(asset) = release.assets.iter().find(|a| a.name == asset_name) else { + return Err(Error::Message { + message: "Could not find a suitable release for your platform".to_string(), + }); + }; - download_and_replace(&asset.name, &asset.download_url, &version) + download_and_replace(&asset.name, &asset.download_url, &version) } diff --git a/src/sh.rs b/src/sh.rs index 8142924..c6454ba 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -7,8 +7,8 @@ use tokio::{process::Command, time}; use tracing::{debug, error, info, trace, warn}; use crate::{ - Error, Result, ResultExt, - shutdown::{self, PgId}, + Error, Result, ResultExt, + shutdown::{self, PgId}, }; // ============================================================ @@ -16,13 +16,13 @@ use crate::{ // ============================================================ pub trait Exec { - fn display(&self) -> Result; - fn display_full(&self) -> Result; - fn exec(&mut self) -> impl std::future::Future>; + fn display(&self) -> Result; + fn display_full(&self) -> Result; + fn exec(&mut self) -> impl std::future::Future>; } pub trait Script { - fn from_str(script: &str) -> Command; + fn from_str(script: &str) -> Command; } // ============================================================ @@ -31,16 +31,16 @@ pub trait Script { /// Look up the human-readable name of a Unix signal number. fn signal_display(number: i32) -> Option { - let ptr = unsafe { libc::strsignal(number) }; - if ptr.is_null() { - None - } else { - Some( - unsafe { std::ffi::CStr::from_ptr(ptr) } - .to_string_lossy() - .into_owned(), - ) - } + let ptr = unsafe { libc::strsignal(number) }; + if ptr.is_null() { + None + } else { + Some( + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) + } } // ============================================================ @@ -48,165 +48,166 @@ fn signal_display(number: i32) -> Option { // ============================================================ impl Exec for Command { - /// Format the command as a one-line string (program + args). - fn display(&self) -> Result { - let program = self.as_std().get_program().to_string_lossy(); - let args = self.as_std().get_args(); - let mut res = String::new(); - - write!(res, "{program} ").context("Failed to write program to display string")?; + /// Format the command as a one-line string (program + args). + fn display(&self) -> Result { + let program = self.as_std().get_program().to_string_lossy(); + let args = self.as_std().get_args(); + let mut res = String::new(); - for arg in args { - write!(res, "{} ", arg.to_string_lossy()) - .context("Failed to write argument to display string")?; - } + write!(res, "{program} ").context("Failed to write program to display string")?; - Ok(res.trim_end().to_string()) + for arg in args { + write!(res, "{} ", arg.to_string_lossy()) + .context("Failed to write argument to display string")?; } - /// Format the command including the working directory prefix. - fn display_full(&self) -> Result { - let cwd = self.as_std().get_current_dir(); - let base = self.display()?; + Ok(res.trim_end().to_string()) + } - match cwd { - Some(path) => Ok(format!("[{}] {}", path.display(), base)), - None => Ok(base), - } - } + /// Format the command including the working directory prefix. + fn display_full(&self) -> Result { + let cwd = self.as_std().get_current_dir(); + let base = self.display()?; - /// Execute the command, handle shutdown signals, and return the output. - #[tracing::instrument(skip(self))] - async fn exec(&mut self) -> Result { - let cmd_full = self.display_full()?; - let cmd_short = self.display()?; - trace!("{cmd_full}"); - - // Check for shutdown before spawning - shutdown::check()?; - - // Capture stdout/stderr (mimics `self.output()` which we can't use - // because we need to spawn+race rather than spawn+wait). - self.stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - // Put child in its own process group so signals can target the whole - // sub-tree (e.g. `tree-sitter build` → `node` → `cc`). - self.as_std_mut().process_group(0); - - let child = self.spawn().context("Failed to execute command")?; - - let child_pid = child.id(); - let pgid = child_pid - .map(PgId::try_from) - .transpose() - .map_err(|e| Error::Message { - message: format!("Invalid child process group id: {e}"), - })?; - debug!("spawned pid={child_pid:?} cmd={cmd_short}"); - - let pgid_guard = match (pgid, shutdown::current()) { - (Some(pgid), Some(shutdown)) => { - debug!("registered pgid={pgid}"); - Some(shutdown.register_pgid(pgid)) - } - _ => None, - }; - - // Test hook: pause while the child is running so an external - // shutdown signal has a guaranteed window to arrive mid-execution. - shutdown::test_delay().await; - - // Race: child completion vs. shutdown signal. The wait future is kept - // alive after cancellation so the child can be reaped before returning. - let wait = child.wait_with_output(); - tokio::pin!(wait); - - let output = tokio::select! { - output = &mut wait => { - debug!("child exited pid={child_pid:?}"); - output - } - signal = shutdown::cancelled() => { - let grace = Duration::from_secs(5); - info!("Command interrupted by {signal}; waiting up to {grace:?} for pid={child_pid:?}: {cmd_short}"); - match time::timeout(grace, &mut wait).await { - Ok(Ok(_)) => { - debug!("child exited after {signal} pid={child_pid:?}"); - } - Ok(Err(err)) => { - debug!("child wait after {signal} failed pid={child_pid:?}: {err}"); - } - Err(_) => { - if let (Some(pgid), Some(shutdown)) = (pgid, shutdown::current()) { - shutdown.kill_pgid(pgid); - - let kill_grace = Duration::from_secs(2); - match time::timeout(kill_grace, &mut wait).await { - Ok(Ok(_)) => { - debug!("child reaped after SIGKILL pid={child_pid:?}"); - } - Ok(Err(err)) => { - debug!("child wait after SIGKILL failed pid={child_pid:?}: {err}"); - } - Err(_) => { - warn!( - "Timed out waiting {kill_grace:?} to reap pid={child_pid:?} after SIGKILL; returning interruption" - ); - } + match cwd { + Some(path) => Ok(format!("[{}] {}", path.display(), base)), + None => Ok(base), + } + } + + /// Execute the command, handle shutdown signals, and return the output. + #[tracing::instrument(skip(self))] + async fn exec(&mut self) -> Result { + let cmd_full = self.display_full()?; + let cmd_short = self.display()?; + trace!("{cmd_full}"); + + // Check for shutdown before spawning + shutdown::check()?; + + // Capture stdout/stderr (mimics `self.output()` which we can't use + // because we need to spawn+race rather than spawn+wait). + self + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + // Put child in its own process group so signals can target the whole + // sub-tree (e.g. `tree-sitter build` → `node` → `cc`). + self.as_std_mut().process_group(0); + + let child = self.spawn().context("Failed to execute command")?; + + let child_pid = child.id(); + let pgid = child_pid + .map(PgId::try_from) + .transpose() + .map_err(|e| Error::Message { + message: format!("Invalid child process group id: {e}"), + })?; + debug!("spawned pid={child_pid:?} cmd={cmd_short}"); + + let pgid_guard = match (pgid, shutdown::current()) { + (Some(pgid), Some(shutdown)) => { + debug!("registered pgid={pgid}"); + Some(shutdown.register_pgid(pgid)) + } + _ => None, + }; + + // Test hook: pause while the child is running so an external + // shutdown signal has a guaranteed window to arrive mid-execution. + shutdown::test_delay().await; + + // Race: child completion vs. shutdown signal. The wait future is kept + // alive after cancellation so the child can be reaped before returning. + let wait = child.wait_with_output(); + tokio::pin!(wait); + + let output = tokio::select! { + output = &mut wait => { + debug!("child exited pid={child_pid:?}"); + output + } + signal = shutdown::cancelled() => { + let grace = Duration::from_secs(5); + info!("Command interrupted by {signal}; waiting up to {grace:?} for pid={child_pid:?}: {cmd_short}"); + match time::timeout(grace, &mut wait).await { + Ok(Ok(_)) => { + debug!("child exited after {signal} pid={child_pid:?}"); + } + Ok(Err(err)) => { + debug!("child wait after {signal} failed pid={child_pid:?}: {err}"); + } + Err(_) => { + if let (Some(pgid), Some(shutdown)) = (pgid, shutdown::current()) { + shutdown.kill_pgid(pgid); + + let kill_grace = Duration::from_secs(2); + match time::timeout(kill_grace, &mut wait).await { + Ok(Ok(_)) => { + debug!("child reaped after SIGKILL pid={child_pid:?}"); + } + Ok(Err(err)) => { + debug!("child wait after SIGKILL failed pid={child_pid:?}: {err}"); + } + Err(_) => { + warn!( + "Timed out waiting {kill_grace:?} to reap pid={child_pid:?} after SIGKILL; returning interruption" + ); } - } else { - warn!("Timed out waiting for pid={child_pid:?}, but no process group was registered; returning interruption"); } + } else { + warn!("Timed out waiting for pid={child_pid:?}, but no process group was registered; returning interruption"); } } - - // If a second signal arrives before we drop the pgid_guard, - // `kill_children()` could target a stale PGID. PGID reuse in such a - // tiny window is rare, but this is exactly the kind of edge case - // process-group code tries to avoid. - drop(pgid_guard); - return Err(Error::Interrupted { signal }); } - }; - drop(pgid_guard); + // If a second signal arrives before we drop the pgid_guard, + // `kill_children()` could target a stale PGID. PGID reuse in such a + // tiny window is rare, but this is exactly the kind of edge case + // process-group code tries to avoid. + drop(pgid_guard); + return Err(Error::Interrupted { signal }); + } + }; - let output = output.context("Failed to execute command")?; + drop(pgid_guard); - if output.status.success() { - return Ok(output); - } + let output = output.context("Failed to execute command")?; - let program = self.as_std().get_program().to_string_lossy(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - - let msg = if let Some(code) = output.status.code() { - format!("{cmd_short} failed with exit status {code}.") - } else { - let sig = signal_display(output.status.signal().expect("a proper signal code")) - .unwrap_or_else(|| "UNKNOWN".to_string()); - format!("{program} interrupted by signal {sig}.") - }; - - error!("{msg}\nStdOut:\n{stdout}\nStdErr\n{stderr}"); - - Err(Error::Command { - msg, - stderr, - stdout, - }) + if output.status.success() { + return Ok(output); } + + let program = self.as_std().get_program().to_string_lossy(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + let msg = if let Some(code) = output.status.code() { + format!("{cmd_short} failed with exit status {code}.") + } else { + let sig = signal_display(output.status.signal().expect("a proper signal code")) + .unwrap_or_else(|| "UNKNOWN".to_string()); + format!("{program} interrupted by signal {sig}.") + }; + + error!("{msg}\nStdOut:\n{stdout}\nStdErr\n{stderr}"); + + Err(Error::Command { + msg, + stderr, + stdout, + }) + } } impl Script for Command { - /// Create a shell command to run a script string. - fn from_str(script: &str) -> Command { - let shell = env::var("SHELL").unwrap_or_else(|_| String::from("sh")); - let mut cmd = Command::new(shell); - cmd.args(["-c", script]); - cmd - } + /// Create a shell command to run a script string. + fn from_str(script: &str) -> Command { + let shell = env::var("SHELL").unwrap_or_else(|_| String::from("sh")); + let mut cmd = Command::new(shell); + cmd.args(["-c", script]); + cmd + } } diff --git a/src/shutdown.rs b/src/shutdown.rs index 6a41c4a..ceb884c 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -2,12 +2,12 @@ //! delay hooks. use std::{ - collections::HashSet, - fmt, - future::Future, - num::NonZeroU32, - result::Result as StdResult, - sync::{Arc, Mutex}, + collections::HashSet, + fmt, + future::Future, + num::NonZeroU32, + result::Result as StdResult, + sync::{Arc, Mutex}, }; use tokio::sync::watch; @@ -26,10 +26,10 @@ tokio::task_local! { /// Error returned when constructing a [`PgId`] from a raw value. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PgIdError { - /// Process group ID zero would target the current process group. - Zero, - /// The value cannot be represented by the platform's `pid_t` type. - OutOfRange(u32), + /// Process group ID zero would target the current process group. + Zero, + /// The value cannot be represented by the platform's `pid_t` type. + OutOfRange(u32), } // ============================================================ @@ -39,11 +39,11 @@ pub enum PgIdError { /// Cooperative shutdown signal shared across build tasks. #[derive(Clone, Debug)] pub struct Handle { - tx: watch::Sender>, - rx: watch::Receiver>, - /// Active child process group IDs. Build commands are put in their own - /// process groups so a signal can target each command's whole subprocess tree. - active_pgids: Arc>>, + tx: watch::Sender>, + rx: watch::Receiver>, + /// Active child process group IDs. Build commands are put in their own + /// process groups so a signal can target each command's whole subprocess tree. + active_pgids: Arc>>, } /// A positive Unix process group ID. @@ -64,15 +64,15 @@ pub struct PgId(NonZeroU32); /// later shutdown escalation. #[derive(Debug)] pub struct PgIdGuard { - shutdown: Handle, - pgid: PgId, + shutdown: Handle, + pgid: PgId, } /// A Unix signal that requested shutdown. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Signal { - pub number: i32, - pub name: &'static str, + pub number: i32, + pub name: &'static str, } // ============================================================ @@ -81,47 +81,47 @@ pub struct Signal { /// Wait for the shutdown signal from the current task-local handle. pub async fn cancelled() -> Signal { - match current() { - Some(shutdown) => shutdown.cancelled().await, - None => std::future::pending::().await, - } + match current() { + Some(shutdown) => shutdown.cancelled().await, + None => std::future::pending::().await, + } } /// Check if shutdown was requested by the current task-local handle. pub fn check() -> Result<()> { - current().map_or(Ok(()), |shutdown| shutdown.check()) + current().map_or(Ok(()), |shutdown| shutdown.check()) } /// Get the shutdown handle scoped to the current task, if any. #[must_use] pub fn current() -> Option { - CURRENT_SHUTDOWN.try_with(Clone::clone).ok() + CURRENT_SHUTDOWN.try_with(Clone::clone).ok() } /// Get the current task-local handle or a no-op default. #[must_use] pub fn current_or_default() -> Handle { - current().unwrap_or_default() + current().unwrap_or_default() } /// Install a shutdown handle for the duration of the given future. pub async fn scope(shutdown: Handle, future: F) -> F::Output where - F: Future, + F: Future, { - CURRENT_SHUTDOWN.scope(shutdown, future).await + CURRENT_SHUTDOWN.scope(shutdown, future).await } /// Send a Unix signal to an entire process group via `killpg`. fn signal_process_group(pgid: PgId, signal: Signal) { - let rc = unsafe { libc::killpg(pgid.as_pid_t(), signal.number) }; - if rc != 0 { - debug!( - "failed to send {} to pgid={pgid}: {}", - signal.name, - std::io::Error::last_os_error() - ); - } + let rc = unsafe { libc::killpg(pgid.as_pid_t(), signal.number) }; + if rc != 0 { + debug!( + "failed to send {} to pgid={pgid}: {}", + signal.name, + std::io::Error::last_os_error() + ); + } } /// Test helper: when `TSDL_TEST_DELAY_MS` is set, inserts a sleep at every step @@ -130,20 +130,20 @@ fn signal_process_group(pgid: PgId, signal: Signal) { /// Only active in debug/test builds; a noop in release. #[cfg(debug_assertions)] pub async fn test_delay() { - if let Ok(ms) = std::env::var("TSDL_TEST_DELAY_MS") - && let Ok(ms) = ms.parse::() - { - let d = std::time::Duration::from_millis(ms); - debug!("[tsdl:test_delay] sleeping {ms}ms"); - tokio::time::sleep(d).await; - } + if let Ok(ms) = std::env::var("TSDL_TEST_DELAY_MS") + && let Ok(ms) = ms.parse::() + { + let d = std::time::Duration::from_millis(ms); + debug!("[tsdl:test_delay] sleeping {ms}ms"); + tokio::time::sleep(d).await; + } } /// Test helper that inserts a sleep at every step boundary when /// `TSDL_TEST_DELAY_MS` is set. This is a noop in release builds. #[cfg(not(debug_assertions))] pub async fn test_delay() { - // noop in release + // noop in release } // ============================================================ @@ -151,295 +151,296 @@ pub async fn test_delay() { // ============================================================ impl Default for Handle { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } impl Drop for PgIdGuard { - fn drop(&mut self) { - self.shutdown.unregister_pgid(self.pgid); - } + fn drop(&mut self) { + self.shutdown.unregister_pgid(self.pgid); + } } impl fmt::Display for PgId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.get()) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.get()) + } } impl fmt::Display for PgIdError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Zero => write!(f, "process group id cannot be zero"), - Self::OutOfRange(value) => { - write!(f, "process group id {value} does not fit in libc::pid_t") - } - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Zero => write!(f, "process group id cannot be zero"), + Self::OutOfRange(value) => { + write!(f, "process group id {value} does not fit in libc::pid_t") + } } + } } impl fmt::Display for Signal { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.name) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } } impl Handle { - /// Create a new shutdown handle with no signal set. - #[must_use] - pub fn new() -> Self { - let (tx, rx) = watch::channel(None); - Self { - tx, - rx, - active_pgids: Arc::new(Mutex::new(HashSet::new())), - } + /// Create a new shutdown handle with no signal set. + #[must_use] + pub fn new() -> Self { + let (tx, rx) = watch::channel(None); + Self { + tx, + rx, + active_pgids: Arc::new(Mutex::new(HashSet::new())), } + } - /// Record shutdown and forward the received signal to all active children. - pub fn cancel_with_signal(&self, signal: Signal) { - if self.reason().is_none() { - let _ = self.tx.send(Some(signal)); - } - self.signal_children(signal); + /// Record shutdown and forward the received signal to all active children. + pub fn cancel_with_signal(&self, signal: Signal) { + if self.reason().is_none() { + let _ = self.tx.send(Some(signal)); } - - /// Register an active child process group. - /// - /// If shutdown was already requested, the process group is immediately sent - /// the recorded shutdown signal so late-spawned children cannot escape it. - #[must_use] - pub fn register_pgid(&self, pgid: PgId) -> PgIdGuard { - { - let mut active = self - .active_pgids - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - active.insert(pgid); - } - - if let Some(signal) = self.reason() { - self.signal_pgid(pgid, signal); - } - - PgIdGuard { - shutdown: self.clone(), - pgid, - } + self.signal_children(signal); + } + + /// Register an active child process group. + /// + /// If shutdown was already requested, the process group is immediately sent + /// the recorded shutdown signal so late-spawned children cannot escape it. + #[must_use] + pub fn register_pgid(&self, pgid: PgId) -> PgIdGuard { + { + let mut active = self + .active_pgids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + active.insert(pgid); } - fn unregister_pgid(&self, pgid: PgId) { - let mut active = self - .active_pgids - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - active.remove(&pgid); + if let Some(signal) = self.reason() { + self.signal_pgid(pgid, signal); } - /// Check whether a shutdown signal has been recorded. - #[must_use] - pub fn is_cancelled(&self) -> bool { - self.reason().is_some() + PgIdGuard { + shutdown: self.clone(), + pgid, } - - /// Return the signal that triggered shutdown, if any. - #[must_use] - pub fn reason(&self) -> Option { - *self.rx.borrow() + } + + fn unregister_pgid(&self, pgid: PgId) { + let mut active = self + .active_pgids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + active.remove(&pgid); + } + + /// Check whether a shutdown signal has been recorded. + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.reason().is_some() + } + + /// Return the signal that triggered shutdown, if any. + #[must_use] + pub fn reason(&self) -> Option { + *self.rx.borrow() + } + + /// Wait until a shutdown signal is set, then return it. + pub async fn cancelled(&self) -> Signal { + if let Some(signal) = self.reason() { + return signal; } - /// Wait until a shutdown signal is set, then return it. - pub async fn cancelled(&self) -> Signal { - if let Some(signal) = self.reason() { - return signal; - } - - let mut rx = self.rx.clone(); - loop { - if rx.changed().await.is_err() { - return std::future::pending::().await; - } - if let Some(signal) = *rx.borrow() { - return signal; - } - } + let mut rx = self.rx.clone(); + loop { + if rx.changed().await.is_err() { + return std::future::pending::().await; + } + if let Some(signal) = *rx.borrow() { + return signal; + } } - - /// Return `Err(Interrupted)` if shutdown was signalled, or `Ok(())` otherwise. - pub fn check(&self) -> Result<()> { - if let Some(signal) = self.reason() { - Err(Error::Interrupted { signal }) - } else { - Ok(()) - } + } + + /// Return `Err(Interrupted)` if shutdown was signalled, or `Ok(())` otherwise. + pub fn check(&self) -> Result<()> { + if let Some(signal) = self.reason() { + Err(Error::Interrupted { signal }) + } else { + Ok(()) } + } - /// Send a signal to every active child process group. - pub fn signal_children(&self, signal: Signal) { - for pgid in self.active_pgids() { - self.signal_pgid(pgid, signal); - } + /// Send a signal to every active child process group. + pub fn signal_children(&self, signal: Signal) { + for pgid in self.active_pgids() { + self.signal_pgid(pgid, signal); } - - /// Send a signal to one child process group. - pub fn signal_pgid(&self, pgid: PgId, signal: Signal) { - info!("SHUTDOWN sending {} to pgid={pgid}", signal.name); - signal_process_group(pgid, signal); + } + + /// Send a signal to one child process group. + pub fn signal_pgid(&self, pgid: PgId, signal: Signal) { + info!("SHUTDOWN sending {} to pgid={pgid}", signal.name); + signal_process_group(pgid, signal); + } + + /// Send SIGKILL to every active child process group. + pub fn kill_children(&self) { + for pgid in self.active_pgids() { + self.kill_pgid(pgid); } - - /// Send SIGKILL to every active child process group. - pub fn kill_children(&self) { - for pgid in self.active_pgids() { - self.kill_pgid(pgid); + } + + /// Send SIGKILL to one child process group. + pub fn kill_pgid(&self, pgid: PgId) { + info!("SHUTDOWN killing pgid={pgid}"); + signal_process_group(pgid, Signal::KILL); + } + + /// Collect all currently registered child process group IDs. + fn active_pgids(&self) -> Vec { + self + .active_pgids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .copied() + .collect() + } + + /// Spawn a background task that listens for OS signals and triggers graceful then forceful shutdown. + #[cfg(unix)] + pub fn spawn_signal_listener(&self) -> Result> { + use std::process; + + use tokio::signal::unix::{SignalKind, signal}; + + let mut sighup = + signal(SignalKind::from_raw(Signal::HUP.number)).context("Installing SIGHUP handler")?; + let mut sigint = + signal(SignalKind::from_raw(Signal::INT.number)).context("Installing SIGINT handler")?; + let mut sigquit = + signal(SignalKind::from_raw(Signal::QUIT.number)).context("Installing SIGQUIT handler")?; + let mut sigterm = + signal(SignalKind::from_raw(Signal::TERM.number)).context("Installing SIGTERM handler")?; + let shutdown = self.clone(); + + Ok(tokio::spawn(async move { + let mut graceful = true; + + loop { + let signal = tokio::select! { + s = sighup.recv() => s.map(|()| Signal::HUP), + s = sigint.recv() => s.map(|()| Signal::INT), + s = sigquit.recv() => s.map(|()| Signal::QUIT), + s = sigterm.recv() => s.map(|()| Signal::TERM), + }; + + let Some(signal) = signal else { + // Signal stream ended unexpectedly; stop listening. + return; + }; + + if graceful { + graceful = false; + info!("Received {signal}; stopping running build commands..."); + shutdown.cancel_with_signal(signal); + } else { + // Second signal: escalate. Kill every known child process group, + // then force-exit with the conventional signal status. + info!("Received second {signal}; forcing exit."); + shutdown.kill_children(); + process::exit(signal.shell_exit_code().into()); } - } - - /// Send SIGKILL to one child process group. - pub fn kill_pgid(&self, pgid: PgId) { - info!("SHUTDOWN killing pgid={pgid}"); - signal_process_group(pgid, Signal::KILL); - } - - /// Collect all currently registered child process group IDs. - fn active_pgids(&self) -> Vec { - self.active_pgids - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .iter() - .copied() - .collect() - } - - /// Spawn a background task that listens for OS signals and triggers graceful then forceful shutdown. - #[cfg(unix)] - pub fn spawn_signal_listener(&self) -> Result> { - use std::process; - - use tokio::signal::unix::{SignalKind, signal}; - - let mut sighup = signal(SignalKind::from_raw(Signal::HUP.number)) - .context("Installing SIGHUP handler")?; - let mut sigint = signal(SignalKind::from_raw(Signal::INT.number)) - .context("Installing SIGINT handler")?; - let mut sigquit = signal(SignalKind::from_raw(Signal::QUIT.number)) - .context("Installing SIGQUIT handler")?; - let mut sigterm = signal(SignalKind::from_raw(Signal::TERM.number)) - .context("Installing SIGTERM handler")?; - let shutdown = self.clone(); - - Ok(tokio::spawn(async move { - let mut graceful = true; - - loop { - let signal = tokio::select! { - s = sighup.recv() => s.map(|()| Signal::HUP), - s = sigint.recv() => s.map(|()| Signal::INT), - s = sigquit.recv() => s.map(|()| Signal::QUIT), - s = sigterm.recv() => s.map(|()| Signal::TERM), - }; - - let Some(signal) = signal else { - // Signal stream ended unexpectedly; stop listening. - return; - }; - - if graceful { - graceful = false; - info!("Received {signal}; stopping running build commands..."); - shutdown.cancel_with_signal(signal); - } else { - // Second signal: escalate. Kill every known child process group, - // then force-exit with the conventional signal status. - info!("Received second {signal}; forcing exit."); - shutdown.kill_children(); - process::exit(signal.shell_exit_code().into()); - } - } - })) - } + } + })) + } } impl PgId { - /// Return the raw positive process group ID value. - #[must_use] - pub fn get(self) -> u32 { - self.0.get() - } - - /// Convert to the platform `pid_t` type (panics if the invariant is violated). - fn as_pid_t(self) -> libc::pid_t { - ::try_from(self.0.get()).expect("PgId invariant: value fits in libc::pid_t") - } + /// Return the raw positive process group ID value. + #[must_use] + pub fn get(self) -> u32 { + self.0.get() + } + + /// Convert to the platform `pid_t` type (panics if the invariant is violated). + fn as_pid_t(self) -> libc::pid_t { + ::try_from(self.0.get()).expect("PgId invariant: value fits in libc::pid_t") + } } impl Signal { - pub const HUP: Self = Self { - number: libc::SIGHUP, - name: "SIGHUP", - }; - pub const INT: Self = Self { - number: libc::SIGINT, - name: "SIGINT", - }; - pub const QUIT: Self = Self { - number: libc::SIGQUIT, - name: "SIGQUIT", - }; - pub const TERM: Self = Self { - number: libc::SIGTERM, - name: "SIGTERM", - }; - pub const KILL: Self = Self { - number: libc::SIGKILL, - name: "SIGKILL", - }; - - /// Conventional shell exit status for commands terminated by signal N. - #[must_use] - pub fn shell_exit_code(self) -> u8 { - u8::try_from(128 + self.number).unwrap_or(255) - } + pub const HUP: Self = Self { + number: libc::SIGHUP, + name: "SIGHUP", + }; + pub const INT: Self = Self { + number: libc::SIGINT, + name: "SIGINT", + }; + pub const QUIT: Self = Self { + number: libc::SIGQUIT, + name: "SIGQUIT", + }; + pub const TERM: Self = Self { + number: libc::SIGTERM, + name: "SIGTERM", + }; + pub const KILL: Self = Self { + number: libc::SIGKILL, + name: "SIGKILL", + }; + + /// Conventional shell exit status for commands terminated by signal N. + #[must_use] + pub fn shell_exit_code(self) -> u8 { + u8::try_from(128 + self.number).unwrap_or(255) + } } impl std::error::Error for PgIdError {} impl TryFrom for PgId { - type Error = PgIdError; + type Error = PgIdError; - fn try_from(value: u32) -> StdResult { - let value = NonZeroU32::new(value).ok_or(PgIdError::Zero)?; - ::try_from(value.get()).map_err(|_| PgIdError::OutOfRange(value.get()))?; - Ok(Self(value)) - } + fn try_from(value: u32) -> StdResult { + let value = NonZeroU32::new(value).ok_or(PgIdError::Zero)?; + ::try_from(value.get()).map_err(|_| PgIdError::OutOfRange(value.get()))?; + Ok(Self(value)) + } } #[cfg(test)] mod tests { - use super::*; - - #[test] - fn pgid_rejects_zero() { - assert_eq!(PgId::try_from(0), Err(PgIdError::Zero)); - } - - #[test] - fn pgid_accepts_positive_value() { - let pgid = PgId::try_from(1).unwrap(); - assert_eq!(pgid.get(), 1); - } - - #[test] - fn pgid_rejects_values_that_do_not_fit_pid_t() { - let too_large = u32::try_from(::MAX) - .unwrap_or(u32::MAX) - .saturating_add(1); - - if ::try_from(too_large).is_err() { - assert_eq!( - PgId::try_from(too_large), - Err(PgIdError::OutOfRange(too_large)) - ); - } + use super::*; + + #[test] + fn pgid_rejects_zero() { + assert_eq!(PgId::try_from(0), Err(PgIdError::Zero)); + } + + #[test] + fn pgid_accepts_positive_value() { + let pgid = PgId::try_from(1).unwrap(); + assert_eq!(pgid.get(), 1); + } + + #[test] + fn pgid_rejects_values_that_do_not_fit_pid_t() { + let too_large = u32::try_from(::MAX) + .unwrap_or(u32::MAX) + .saturating_add(1); + + if ::try_from(too_large).is_err() { + assert_eq!( + PgId::try_from(too_large), + Err(PgIdError::OutOfRange(too_large)) + ); } + } } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index aab9cb9..c4acad6 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -25,9 +25,9 @@ use crate::{Error, Result, ResultExt, SafeCanonicalize}; #[derive(Debug, PartialEq, Eq)] enum CliCacheStatus { - Hit, - Missing, - Invalid(String), + Hit, + Missing, + Invalid(String), } // ============================================================ @@ -37,10 +37,10 @@ enum CliCacheStatus { /// A downloaded and cached tree-sitter CLI binary, ready to use. #[derive(Debug, Clone)] pub struct PreparedCli { - /// Path to the CLI binary on disk. - pub path: PathBuf, - /// The tree-sitter version, platform, and repo used to fetch it. - pub tree_sitter: args::TreeSitter, + /// Path to the CLI binary on disk. + pub path: PathBuf, + /// The tree-sitter version, platform, and repo used to fetch it. + pub tree_sitter: args::TreeSitter, } // ============================================================ @@ -49,557 +49,558 @@ pub struct PreparedCli { /// Check whether a cached tree-sitter CLI binary is still valid. async fn check_cached_cli(path: &Path, tag: &str) -> Result { - let metadata = match fs::symlink_metadata(path).await { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(CliCacheStatus::Missing); - } - Err(err) => { - return Err(err) - .with_context(|| format!("Inspecting cached tree-sitter CLI {}", path.display())); - } - }; - - let file_type = metadata.file_type(); - if file_type.is_dir() { - return Err(Error::Message { - message: format!( - "Cached tree-sitter CLI path is a directory and cannot be replaced: {}", - path.display() - ), - }); - } - - if file_type.is_symlink() { - return Ok(CliCacheStatus::Invalid( - "cached path is a symbolic link".to_string(), - )); + let metadata = match fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(CliCacheStatus::Missing); } - - if !file_type.is_file() { - return Ok(CliCacheStatus::Invalid( - "cached path is not a regular file".to_string(), - )); - } - - if metadata.permissions().mode() & 0o111 == 0 { - return Ok(CliCacheStatus::Invalid( - "cached file is not executable".to_string(), - )); - } - - match verify_cli(path, tag).await { - Ok(()) => Ok(CliCacheStatus::Hit), - Err(Error::Interrupted { signal }) => Err(Error::Interrupted { signal }), - Err(err) => Ok(CliCacheStatus::Invalid(first_line(&err.to_string()))), + Err(err) => { + return Err(err) + .with_context(|| format!("Inspecting cached tree-sitter CLI {}", path.display())); } + }; + + let file_type = metadata.file_type(); + if file_type.is_dir() { + return Err(Error::Message { + message: format!( + "Cached tree-sitter CLI path is a directory and cannot be replaced: {}", + path.display() + ), + }); + } + + if file_type.is_symlink() { + return Ok(CliCacheStatus::Invalid( + "cached path is a symbolic link".to_string(), + )); + } + + if !file_type.is_file() { + return Ok(CliCacheStatus::Invalid( + "cached path is not a regular file".to_string(), + )); + } + + if metadata.permissions().mode() & 0o111 == 0 { + return Ok(CliCacheStatus::Invalid( + "cached file is not executable".to_string(), + )); + } + + match verify_cli(path, tag).await { + Ok(()) => Ok(CliCacheStatus::Hit), + Err(Error::Interrupted { signal }) => Err(Error::Interrupted { signal }), + Err(err) => Ok(CliCacheStatus::Invalid(first_line(&err.to_string()))), + } } /// Make a binary executable (chmod +x). async fn chmod_x(prog: &Path) -> Result<()> { - let metadata = fs::metadata(prog) - .await - .with_context(|| format!("getting metadata for {}", prog.display()))?; - let mut permissions = metadata.permissions(); - permissions.set_mode(permissions.mode() | 0o111); - fs::set_permissions(prog, permissions) - .await - .with_context(|| format!("chmod +x {}", prog.display())) + let metadata = fs::metadata(prog) + .await + .with_context(|| format!("getting metadata for {}", prog.display()))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(permissions.mode() | 0o111); + fs::set_permissions(prog, permissions) + .await + .with_context(|| format!("chmod +x {}", prog.display())) } /// Download and/or cache the tree-sitter CLI binary for the given tag. async fn cli( - build_dir: &Path, - handle: &actors::ProgressAddr, - platform: &str, - repo: &str, - tag: &str, + build_dir: &Path, + handle: &actors::ProgressAddr, + platform: &str, + repo: &str, + tag: &str, ) -> Result { - let cli = format!("tree-sitter-{platform}"); - let res = build_dir.join(format!("{cli}-{tag}")).canon()?; - - let gz_basename = format!("{cli}.gz"); - let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); - - match check_cached_cli(&res, tag).await? { - CliCacheStatus::Hit => { - info!("Using cached tree-sitter CLI at {}", res.display()); - handle.set_outcome_cached().await; - handle.step("cached"); - } - CliCacheStatus::Missing => { - info!("Tree-sitter CLI cache miss for {tag}; downloading from {url}"); - handle.set_outcome_built().await; - handle.step("downloading"); - download_and_install(&url, &res, tag).await?; - } - CliCacheStatus::Invalid(reason) => { - warn!( - "Cached tree-sitter CLI at {} is invalid ({reason}); re-downloading from {url}", - res.display() - ); - handle.msg("cached CLI invalid; re-downloading"); - handle.set_outcome_built().await; - handle.step("downloading"); - download_and_install(&url, &res, tag).await?; - } + let cli = format!("tree-sitter-{platform}"); + let res = build_dir.join(format!("{cli}-{tag}")).canon()?; + + let gz_basename = format!("{cli}.gz"); + let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); + + match check_cached_cli(&res, tag).await? { + CliCacheStatus::Hit => { + info!("Using cached tree-sitter CLI at {}", res.display()); + handle.set_outcome_cached().await; + handle.step("cached"); + } + CliCacheStatus::Missing => { + info!("Tree-sitter CLI cache miss for {tag}; downloading from {url}"); + handle.set_outcome_built().await; + handle.step("downloading"); + download_and_install(&url, &res, tag).await?; } + CliCacheStatus::Invalid(reason) => { + warn!( + "Cached tree-sitter CLI at {} is invalid ({reason}); re-downloading from {url}", + res.display() + ); + handle.msg("cached CLI invalid; re-downloading"); + handle.set_outcome_built().await; + handle.step("downloading"); + download_and_install(&url, &res, tag).await?; + } + } - Ok(res) + Ok(res) } /// Wrap a result, adding context unless it's an Interrupted error. fn context_unless_interrupted(result: Result, message: impl FnOnce() -> String) -> Result { - match result { - Ok(value) => Ok(value), - Err(err @ Error::Interrupted { .. }) => Err(err), - Err(err) => Err(Error::Context { - message: message(), - source: err.into(), - }), - } + match result { + Ok(value) => Ok(value), + Err(err @ Error::Interrupted { .. }) => Err(err), + Err(err) => Err(Error::Context { + message: message(), + source: err.into(), + }), + } } /// Format a tree-sitter version string as a git ref for display purposes. pub(crate) fn display_tree_sitter_ref(version: &str) -> StdResult { - git::Ref::new(normalize_release_ref(version)) + git::Ref::new(normalize_release_ref(version)) } /// Download a file via HTTP GET. async fn download(url: &str, gz: &Path) -> Result<()> { - let response = reqwest::get(url) - .await - .with_context(|| format!("Fetching tree-sitter CLI from {url}"))? - .error_for_status() - .with_context(|| format!("Downloading tree-sitter CLI from {url}"))?; - - let bytes = response - .bytes() - .await - .with_context(|| format!("Reading tree-sitter CLI response body from {url}"))?; - - fs::write(gz, bytes) - .await - .with_context(|| format!("Writing tree-sitter CLI archive to {}", gz.display())) + let response = reqwest::get(url) + .await + .with_context(|| format!("Fetching tree-sitter CLI from {url}"))? + .error_for_status() + .with_context(|| format!("Downloading tree-sitter CLI from {url}"))?; + + let bytes = response + .bytes() + .await + .with_context(|| format!("Reading tree-sitter CLI response body from {url}"))?; + + fs::write(gz, bytes) + .await + .with_context(|| format!("Writing tree-sitter CLI archive to {}", gz.display())) } /// Download a gzipped CLI binary and install it to the final path. async fn download_and_install(url: &str, res: &Path, tag: &str) -> Result<()> { - let gz = temp_path_for(res, ".gz")?; - let cli = temp_path_for(res, ".bin")?; - - info!("Downloading tree-sitter CLI from {url}"); - download(url, gz.as_ref()).await?; - install_downloaded_cli(gz.as_ref(), cli.as_ref(), res, tag).await?; - info!("Installed tree-sitter CLI at {}", res.display()); - Ok(()) + let gz = temp_path_for(res, ".gz")?; + let cli = temp_path_for(res, ".bin")?; + + info!("Downloading tree-sitter CLI from {url}"); + download(url, gz.as_ref()).await?; + install_downloaded_cli(gz.as_ref(), cli.as_ref(), res, tag).await?; + info!("Installed tree-sitter CLI at {}", res.display()); + Ok(()) } /// Get the expected CLI version string from a tag (strips "v" prefix). fn expected_cli_version(tag: &str) -> Option<&str> { - let version = tag.strip_prefix('v').unwrap_or(tag); - is_dotted_numeric_version(version).then_some(version) + let version = tag.strip_prefix('v').unwrap_or(tag); + is_dotted_numeric_version(version).then_some(version) } /// Find a release tag matching the requested version string. fn find_tag( - refs: &HashMap, - version: &str, + refs: &HashMap, + version: &str, ) -> StdResult { - refs.get_key_value(&format!("v{version}")) - .or_else(|| refs.get_key_value(version)) - .map_or_else( - || git::Ref::new(normalize_release_ref(version)).map(git::ResolvedRef::Ref), - |(k, v)| { - trace!("Found! {k} -> {v}"); - Ok(git::ResolvedRef::Tag { - sha: git::Sha::new(v.as_str())?, - label: k.clone(), - }) - }, - ) + refs + .get_key_value(&format!("v{version}")) + .or_else(|| refs.get_key_value(version)) + .map_or_else( + || git::Ref::new(normalize_release_ref(version)).map(git::ResolvedRef::Ref), + |(k, v)| { + trace!("Found! {k} -> {v}"); + Ok(git::ResolvedRef::Tag { + sha: git::Sha::new(v.as_str())?, + label: k.clone(), + }) + }, + ) } /// Get the first line of a multi-line string (for error summaries). fn first_line(message: &str) -> String { - message - .lines() - .next() - .unwrap_or("unknown verification failure") - .to_string() + message + .lines() + .next() + .unwrap_or("unknown verification failure") + .to_string() } /// Decompress a gzip file to the target path. async fn gunzip(gz: &Path, to: &Path) -> Result<()> { - let file = fs::File::open(gz) - .await - .with_context(|| format!("opening {}", gz.display()))?; - let mut decompressor = GzipDecoder::new(tokio::io::BufReader::new(file)); - - let mut file = tokio::fs::File::create(to) - .await - .with_context(|| format!("creating {}", to.display()))?; - - io::copy(&mut decompressor, &mut file) - .await - .map(|_| ()) - .with_context(|| format!("decompressing {}", gz.display()))?; - file.sync_all() - .await - .with_context(|| format!("syncing extracted tree-sitter CLI {}", to.display())) + let file = fs::File::open(gz) + .await + .with_context(|| format!("opening {}", gz.display()))?; + let mut decompressor = GzipDecoder::new(tokio::io::BufReader::new(file)); + + let mut file = tokio::fs::File::create(to) + .await + .with_context(|| format!("creating {}", to.display()))?; + + io::copy(&mut decompressor, &mut file) + .await + .map(|_| ()) + .with_context(|| format!("decompressing {}", gz.display()))?; + file + .sync_all() + .await + .with_context(|| format!("syncing extracted tree-sitter CLI {}", to.display())) } /// Decompress, verify, and install a downloaded CLI binary. async fn install_downloaded_cli(gz: &Path, tmp_cli: &Path, res: &Path, tag: &str) -> Result<()> { - gunzip(gz, tmp_cli).await?; - chmod_x(tmp_cli).await?; - context_unless_interrupted(verify_cli(tmp_cli, tag).await, || { - format!("Verifying downloaded tree-sitter CLI {}", tmp_cli.display()) - })?; - promote_cli(tmp_cli, res).await + gunzip(gz, tmp_cli).await?; + chmod_x(tmp_cli).await?; + context_unless_interrupted(verify_cli(tmp_cli, tag).await, || { + format!("Verifying downloaded tree-sitter CLI {}", tmp_cli.display()) + })?; + promote_cli(tmp_cli, res).await } /// Check if a string looks like a dotted-numeric version. fn is_dotted_numeric_version(value: &str) -> bool { - !value.is_empty() - && value - .split('.') - .all(|part| !part.is_empty() && part.parse::().is_ok()) + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) } /// Normalize a release ref by adding "v" prefix to bare semver. fn normalize_release_ref(value: &str) -> String { - if git::Sha::is_full_sha(value) || value.starts_with('v') { - value.to_string() - } else if is_dotted_numeric_version(value) { - format!("v{value}") - } else { - value.to_string() - } + if git::Sha::is_full_sha(value) || value.starts_with('v') { + value.to_string() + } else if is_dotted_numeric_version(value) { + format!("v{value}") + } else { + value.to_string() + } } /// Parse `git ls-remote --refs --tags` output into a tag→sha map. fn parse_refs(stdout: &str) -> HashMap { - let mut refs = HashMap::new(); + let mut refs = HashMap::new(); - for line in stdout.lines() { - let ref_line = line.split('\t').map(str::trim).collect::>(); - let (sha1, full_ref) = (ref_line[0], ref_line[1]); - let Some(tag) = full_ref.strip_prefix("refs/tags/") else { - continue; - }; - trace!("insert {tag} -> {sha1}"); - refs.insert(tag.to_string(), sha1.to_string()); - } + for line in stdout.lines() { + let ref_line = line.split('\t').map(str::trim).collect::>(); + let (sha1, full_ref) = (ref_line[0], ref_line[1]); + let Some(tag) = full_ref.strip_prefix("refs/tags/") else { + continue; + }; + trace!("insert {tag} -> {sha1}"); + refs.insert(tag.to_string(), sha1.to_string()); + } - refs + refs } /// Download and prepare the tree-sitter CLI binary for the requested version. /// Returns the path to the prepared CLI binary and metadata. pub async fn prepare( - build_dir: &Path, - display: actors::DisplayAddr, - tree_sitter: &args::TreeSitter, + build_dir: &Path, + display: actors::DisplayAddr, + tree_sitter: &args::TreeSitter, ) -> Result { - shutdown::test_delay().await; - shutdown::check()?; - debug!("[prepare] tree-sitter-cli version={}", tree_sitter.version); - - let progress = display - .add_language( - "tree-sitter-cli", - display_tree_sitter_ref(&tree_sitter.version)?, - 2, - ) - .await; - - let repo = Url::parse(&tree_sitter.repo).context("Parsing the tree-sitter URL")?; - let git_ref = &tree_sitter.version; - - progress.step(format!("resolving {git_ref}")); - let tag = match tag(repo.as_str(), git_ref).await { - Ok(tag) => tag, - Err(e) => { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("resolve failed").await; - } - return Err(e); - } - }; - let release_tag = match resolve_release_tag(build_dir, &progress, &tree_sitter.repo, &tag).await - { - Ok(tag) => tag, - Err(e) => { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("resolve failed").await; - } - return Err(e); - } - }; - info!("Resolved tree-sitter CLI ref {git_ref:?} to release tag {release_tag:?}"); - - let cli = match context_unless_interrupted( - cli( - build_dir, - &progress, - &tree_sitter.platform, - &tree_sitter.repo, - &release_tag, - ) - .await, - || { - format!( - "Preparing tree-sitter CLI {release_tag} for platform {}", - tree_sitter.platform - ) - }, - ) { - Ok(cli) => cli, - Err(e) => { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("download failed").await; - } - return Err(e); - } - }; - progress.fin("done").await; - - Ok(PreparedCli { - path: cli, - tree_sitter: args::TreeSitter { - version: release_tag, - platform: tree_sitter.platform.clone(), - repo: tree_sitter.repo.clone(), - }, - }) + shutdown::test_delay().await; + shutdown::check()?; + debug!("[prepare] tree-sitter-cli version={}", tree_sitter.version); + + let progress = display + .add_language( + "tree-sitter-cli", + display_tree_sitter_ref(&tree_sitter.version)?, + 2, + ) + .await; + + let repo = Url::parse(&tree_sitter.repo).context("Parsing the tree-sitter URL")?; + let git_ref = &tree_sitter.version; + + progress.step(format!("resolving {git_ref}")); + let tag = match tag(repo.as_str(), git_ref).await { + Ok(tag) => tag, + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("resolve failed").await; + } + return Err(e); + } + }; + let release_tag = match resolve_release_tag(build_dir, &progress, &tree_sitter.repo, &tag).await { + Ok(tag) => tag, + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("resolve failed").await; + } + return Err(e); + } + }; + info!("Resolved tree-sitter CLI ref {git_ref:?} to release tag {release_tag:?}"); + + let cli = match context_unless_interrupted( + cli( + build_dir, + &progress, + &tree_sitter.platform, + &tree_sitter.repo, + &release_tag, + ) + .await, + || { + format!( + "Preparing tree-sitter CLI {release_tag} for platform {}", + tree_sitter.platform + ) + }, + ) { + Ok(cli) => cli, + Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("download failed").await; + } + return Err(e); + } + }; + progress.fin("done").await; + + Ok(PreparedCli { + path: cli, + tree_sitter: args::TreeSitter { + version: release_tag, + platform: tree_sitter.platform.clone(), + repo: tree_sitter.repo.clone(), + }, + }) } /// Move a temp file to its final destination. async fn promote_cli(tmp_cli: &Path, res: &Path) -> Result<()> { - if let Err(err) = fs::rename(tmp_cli, res).await { - let _ = fs::remove_file(tmp_cli).await; - return Err(err).with_context(|| { - format!( - "Installing tree-sitter CLI {} to {}", - tmp_cli.display(), - res.display() - ) - }); - } - - Ok(()) + if let Err(err) = fs::rename(tmp_cli, res).await { + let _ = fs::remove_file(tmp_cli).await; + return Err(err).with_context(|| { + format!( + "Installing tree-sitter CLI {} to {}", + tmp_cli.display(), + res.display() + ) + }); + } + + Ok(()) } /// Resolve a user-requested tree-sitter ref into a concrete release tag. async fn resolve_release_tag( - build_dir: &Path, - handle: &actors::ProgressAddr, - repo: &str, - resolved_ref: &git::ResolvedRef, + build_dir: &Path, + handle: &actors::ProgressAddr, + repo: &str, + resolved_ref: &git::ResolvedRef, ) -> Result { - let tag = match resolved_ref { - git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), - git::ResolvedRef::Ref(git_ref) => { - handle.msg(format!("resolving exact tag for {resolved_ref}")); - let tree_sitter = build_dir.join("tree-sitter"); - git::clone(repo, &tree_sitter).await?; - Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) - } - }; - Ok(tag.into_owned()) + let tag = match resolved_ref { + git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), + git::ResolvedRef::Ref(git_ref) => { + handle.msg(format!("resolving exact tag for {resolved_ref}")); + let tree_sitter = build_dir.join("tree-sitter"); + git::clone(repo, &tree_sitter).await?; + Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) + } + }; + Ok(tag.into_owned()) } #[allow(clippy::missing_panics_doc)] /// Look up the git ref for a tree-sitter release tag. pub async fn tag(repo: &str, version: &str) -> Result { - let output = Command::new("git") - .args(["ls-remote", "--refs", "--tags", repo]) - .exec() - .await?; - let stdout = String::from_utf8_lossy(&output.stdout); - let refs = parse_refs(&stdout); - find_tag(&refs, version).with_context(|| format!("Parsing tree-sitter git ref {version:?}")) + let output = Command::new("git") + .args(["ls-remote", "--refs", "--tags", repo]) + .exec() + .await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let refs = parse_refs(&stdout); + find_tag(&refs, version).with_context(|| format!("Parsing tree-sitter git ref {version:?}")) } /// Create a temporary file path adjacent to the final path. fn temp_path_for(res: &Path, suffix: &str) -> Result { - let parent = res.parent().ok_or_else(|| Error::Message { - message: format!( - "Could not determine parent directory for tree-sitter CLI path {}", - res.display() - ), - })?; - let name = res.file_name().map_or_else( - || Cow::Borrowed("tree-sitter"), - |name| name.to_string_lossy(), - ); - - tempfile::Builder::new() - .prefix(&format!(".{name}.")) - .suffix(suffix) - .tempfile_in(parent) - .map(tempfile::NamedTempFile::into_temp_path) - .with_context(|| { - format!( - "Creating temporary tree-sitter CLI file in {}", - parent.display() - ) - }) + let parent = res.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not determine parent directory for tree-sitter CLI path {}", + res.display() + ), + })?; + let name = res.file_name().map_or_else( + || Cow::Borrowed("tree-sitter"), + |name| name.to_string_lossy(), + ); + + tempfile::Builder::new() + .prefix(&format!(".{name}.")) + .suffix(suffix) + .tempfile_in(parent) + .map(tempfile::NamedTempFile::into_temp_path) + .with_context(|| { + format!( + "Creating temporary tree-sitter CLI file in {}", + parent.display() + ) + }) } /// Verify that a tree-sitter CLI binary works and matches the expected version. async fn verify_cli(path: &Path, tag: &str) -> Result<()> { - let output = - context_unless_interrupted(Command::new(path).arg("--version").exec().await, || { - format!("Running {} --version", path.display()) - })?; - - let output = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - if let Some(expected) = expected_cli_version(tag) - && !output.contains(expected) - { - return Err(Error::Message { - message: format!( - "tree-sitter CLI version output did not contain expected version {expected:?}: {}", - output.trim() - ), - }); - } + let output = + context_unless_interrupted(Command::new(path).arg("--version").exec().await, || { + format!("Running {} --version", path.display()) + })?; - Ok(()) + let output = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + if let Some(expected) = expected_cli_version(tag) + && !output.contains(expected) + { + return Err(Error::Message { + message: format!( + "tree-sitter CLI version output did not contain expected version {expected:?}: {}", + output.trim() + ), + }); + } + + Ok(()) } #[cfg(test)] mod tests { - use super::*; - use std::fs as std_fs; - - fn write_script(path: &Path, body: &str) { - std_fs::write(path, body).unwrap(); - let mut permissions = std_fs::metadata(path).unwrap().permissions(); - permissions.set_mode(0o755); - std_fs::set_permissions(path, permissions).unwrap(); - } - - #[tokio::test] - async fn cached_cli_missing_is_miss() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("tree-sitter-test-v1.2.3"); - - assert_eq!( - check_cached_cli(&path, "v1.2.3").await.unwrap(), - CliCacheStatus::Missing - ); - } - - #[tokio::test] - async fn cached_cli_non_executable_is_invalid() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("tree-sitter-test-v1.2.3"); - std_fs::write(&path, b"tree-sitter 1.2.3\n").unwrap(); - - match check_cached_cli(&path, "v1.2.3").await.unwrap() { - CliCacheStatus::Invalid(reason) => assert!(reason.contains("not executable")), - status => panic!("expected invalid cache entry, got {status:?}"), - } - } - - #[tokio::test] - async fn cached_cli_valid_version_hits() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("tree-sitter-test-v1.2.3"); - write_script(&path, "#!/bin/sh\necho 'tree-sitter 1.2.3'\n"); - - assert_eq!( - check_cached_cli(&path, "v1.2.3").await.unwrap(), - CliCacheStatus::Hit - ); - } + use super::*; + use std::fs as std_fs; + + fn write_script(path: &Path, body: &str) { + std_fs::write(path, body).unwrap(); + let mut permissions = std_fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + std_fs::set_permissions(path, permissions).unwrap(); + } + + #[tokio::test] + async fn cached_cli_missing_is_miss() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + + assert_eq!( + check_cached_cli(&path, "v1.2.3").await.unwrap(), + CliCacheStatus::Missing + ); + } - #[tokio::test] - async fn cached_cli_wrong_version_is_invalid() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("tree-sitter-test-v1.2.3"); - write_script(&path, "#!/bin/sh\necho 'tree-sitter 9.9.9'\n"); + #[tokio::test] + async fn cached_cli_non_executable_is_invalid() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + std_fs::write(&path, b"tree-sitter 1.2.3\n").unwrap(); - match check_cached_cli(&path, "v1.2.3").await.unwrap() { - CliCacheStatus::Invalid(reason) => assert!(reason.contains("expected version")), - status => panic!("expected invalid cache entry, got {status:?}"), - } + match check_cached_cli(&path, "v1.2.3").await.unwrap() { + CliCacheStatus::Invalid(reason) => assert!(reason.contains("not executable")), + status => panic!("expected invalid cache entry, got {status:?}"), } + } - #[tokio::test] - async fn failed_extraction_does_not_create_final_cli_path() { - let temp = tempfile::tempdir().unwrap(); - let gz = temp.path().join("tree-sitter-test.gz"); - let tmp_cli = temp.path().join("tree-sitter-test.tmp"); - let final_cli = temp.path().join("tree-sitter-test"); - std_fs::write(&gz, b"not a gzip archive").unwrap(); - - let err = install_downloaded_cli(&gz, &tmp_cli, &final_cli, "v1.2.3") - .await - .unwrap_err(); + #[tokio::test] + async fn cached_cli_valid_version_hits() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + write_script(&path, "#!/bin/sh\necho 'tree-sitter 1.2.3'\n"); - assert!(err.to_string().contains("decompressing")); - assert!(!final_cli.exists()); - } + assert_eq!( + check_cached_cli(&path, "v1.2.3").await.unwrap(), + CliCacheStatus::Hit + ); + } - #[test] - fn test_parse_refs_empty() { - let stdout = ""; - let refs = parse_refs(stdout); - assert!(refs.is_empty()); - } + #[tokio::test] + async fn cached_cli_wrong_version_is_invalid() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + write_script(&path, "#!/bin/sh\necho 'tree-sitter 9.9.9'\n"); - #[test] - fn test_parse_refs() { - let stdout = "abc123\trefs/tags/v1.0.0\nuwu456\trefs/tags/release\nxyz789\trefs/tags/v2.0.0\nbranch\trefs/heads/main\nslash\trefs/tags/channel/release"; - let refs = parse_refs(stdout); - assert_eq!(refs.get("v1.0.0"), Some(&"abc123".to_string())); - assert_eq!(refs.get("release"), Some(&"uwu456".to_string())); - assert_eq!(refs.get("v2.0.0"), Some(&"xyz789".to_string())); - assert_eq!(refs.get("channel/release"), Some(&"slash".to_string())); - assert_eq!(refs.get("main"), None); + match check_cached_cli(&path, "v1.2.3").await.unwrap() { + CliCacheStatus::Invalid(reason) => assert!(reason.contains("expected version")), + status => panic!("expected invalid cache entry, got {status:?}"), } - - #[test] - fn test_find_tag_exact() { - let mut refs = HashMap::new(); - refs.insert( - "v1.0.0".to_string(), - "636801770eea172d140e64b691815ff11f6b556f".to_string(), - ); - let tag = find_tag(&refs, "1.0.0").unwrap(); - match tag { - git::ResolvedRef::Tag { sha, label } => { - assert_eq!(sha.as_str(), "636801770eea172d140e64b691815ff11f6b556f"); - assert_eq!(label, "v1.0.0"); - } - git::ResolvedRef::Ref(_) => panic!("Expected git::ResolvedRef::Tag"), - } + } + + #[tokio::test] + async fn failed_extraction_does_not_create_final_cli_path() { + let temp = tempfile::tempdir().unwrap(); + let gz = temp.path().join("tree-sitter-test.gz"); + let tmp_cli = temp.path().join("tree-sitter-test.tmp"); + let final_cli = temp.path().join("tree-sitter-test"); + std_fs::write(&gz, b"not a gzip archive").unwrap(); + + let err = install_downloaded_cli(&gz, &tmp_cli, &final_cli, "v1.2.3") + .await + .unwrap_err(); + + assert!(err.to_string().contains("decompressing")); + assert!(!final_cli.exists()); + } + + #[test] + fn test_parse_refs_empty() { + let stdout = ""; + let refs = parse_refs(stdout); + assert!(refs.is_empty()); + } + + #[test] + fn test_parse_refs() { + let stdout = "abc123\trefs/tags/v1.0.0\nuwu456\trefs/tags/release\nxyz789\trefs/tags/v2.0.0\nbranch\trefs/heads/main\nslash\trefs/tags/channel/release"; + let refs = parse_refs(stdout); + assert_eq!(refs.get("v1.0.0"), Some(&"abc123".to_string())); + assert_eq!(refs.get("release"), Some(&"uwu456".to_string())); + assert_eq!(refs.get("v2.0.0"), Some(&"xyz789".to_string())); + assert_eq!(refs.get("channel/release"), Some(&"slash".to_string())); + assert_eq!(refs.get("main"), None); + } + + #[test] + fn test_find_tag_exact() { + let mut refs = HashMap::new(); + refs.insert( + "v1.0.0".to_string(), + "636801770eea172d140e64b691815ff11f6b556f".to_string(), + ); + let tag = find_tag(&refs, "1.0.0").unwrap(); + match tag { + git::ResolvedRef::Tag { sha, label } => { + assert_eq!(sha.as_str(), "636801770eea172d140e64b691815ff11f6b556f"); + assert_eq!(label, "v1.0.0"); + } + git::ResolvedRef::Ref(_) => panic!("Expected git::ResolvedRef::Tag"), } - - #[test] - fn test_find_tag_ref() { - let refs = HashMap::new(); - let tag = find_tag(&refs, "1.0.0").unwrap(); - match tag { - git::ResolvedRef::Ref(git_ref) => { - assert_eq!(git_ref.as_str(), "v1.0.0"); - } - git::ResolvedRef::Tag { .. } => panic!("Expected git::ResolvedRef::Ref"), - } + } + + #[test] + fn test_find_tag_ref() { + let refs = HashMap::new(); + let tag = find_tag(&refs, "1.0.0").unwrap(); + match tag { + git::ResolvedRef::Ref(git_ref) => { + assert_eq!(git_ref.as_str(), "v1.0.0"); + } + git::ResolvedRef::Tag { .. } => panic!("Expected git::ResolvedRef::Ref"), } + } } diff --git a/src/walk.rs b/src/walk.rs index 762904c..34b73c9 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -11,15 +11,15 @@ use crate::{Result, cache, git, shutdown}; /// Collect grammar.js paths via git ls-files and compute their hashes. pub async fn collect_grammar_paths(root: &Path) -> Result> { - let files = git::list_grammar_files(root).await?; - let mut results = Vec::with_capacity(files.len()); + let files = git::list_grammar_files(root).await?; + let mut results = Vec::with_capacity(files.len()); - for file in files { - shutdown::check()?; - let full_path = root.join(&file); - let hash = cache::hash_file(&full_path).await?; - results.push((full_path, hash)); - } + for file in files { + shutdown::check()?; + let full_path = root.join(&file); + let hash = cache::hash_file(&full_path).await?; + results.push((full_path, hash)); + } - Ok(results) + Ok(results) } diff --git a/tests/cli.rs b/tests/cli.rs index 9ac0688..e043732 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,16 +6,16 @@ use cmd::Sandbox; #[test] fn empty_dir_no_command_shows_help() { - let mut sandbox = Sandbox::new(); - sandbox - .cmd - .assert() - .failure() - .stderr( - p::str::contains(env!("CARGO_PKG_DESCRIPTION")).and(p::str::contains(format!( - "Usage: {} [OPTIONS] ", - env!("CARGO_PKG_NAME") - ))), - ); - assert!(sandbox.is_empty()); + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .assert() + .failure() + .stderr( + p::str::contains(env!("CARGO_PKG_DESCRIPTION")).and(p::str::contains(format!( + "Usage: {} [OPTIONS] ", + env!("CARGO_PKG_NAME") + ))), + ); + assert!(sandbox.is_empty()); } diff --git a/tests/cmd/build.rs b/tests/cmd/build.rs index 8a918d4..62643cd 100644 --- a/tests/cmd/build.rs +++ b/tests/cmd/build.rs @@ -21,8 +21,8 @@ use crate::cmd::Sandbox; #[case::unpinned_rust("rust", "HEAD")] #[case::pinned::cmd::typescript("typescript", "v0.21.0")] fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: &str) { - let config = indoc! { - r#" + let config = indoc! { + r#" [parsers] java = "v0.21.0" json = "0.21.0" @@ -30,37 +30,37 @@ fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: & typescript = { ref = "0.21.0", cmd = "make" } cobol = { ref = "6a469068cacb5e3955bb16ad8dfff0dd792883c9", from = "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } "# - }; - let mut sandbox = Sandbox::new(); - sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); - sandbox - .cmd - .args(["build", language]) - .assert() - .success() - .stdout(p::str::contains(format!("{language:<16} @ {version}"))) - .stdout(p::str::contains(format!( - "{:<16} [4/4] built", - format!("{language}/{language}") - ))); - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); + }; + let mut sandbox = Sandbox::new(); + sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); + sandbox + .cmd + .args(["build", language]) + .assert() + .success() + .stdout(p::str::contains(format!("{language:<16} @ {version}"))) + .stdout(p::str::contains(format!( + "{:<16} [4/4] built", + format!("{language}/{language}") + ))); + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); } #[rstest] fn build_implicit_pinned_and_unpinned() { - let parsers = [ - ("cobol", "6a46906"), - ("java", "v0.21.0"), - ("python", "master"), - ("json", "v0.21.0"), - ("typescript", "v0.21.0"), - ]; - let config = indoc! { - r#" + let parsers = [ + ("cobol", "6a46906"), + ("java", "v0.21.0"), + ("python", "master"), + ("json", "v0.21.0"), + ("typescript", "v0.21.0"), + ]; + let config = indoc! { + r#" [parsers] java = "v0.21.0" json = "0.21.0" @@ -68,74 +68,74 @@ fn build_implicit_pinned_and_unpinned() { typescript = { ref = "0.21.0", cmd = "make" } cobol = { ref = "6a469068cacb5e3955bb16ad8dfff0dd792883c9", from = "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } "# - }; - let mut sandbox = Sandbox::new(); - sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); - let mut out = sandbox.cmd.arg("build").assert().success(); - for (language, _version) in parsers { - out = out - .stdout(p::str::contains(format!("{language}/{language}"))) - .stdout(p::str::contains("[4/4] built")); - } - for (language, _version) in parsers { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } + }; + let mut sandbox = Sandbox::new(); + sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); + let mut out = sandbox.cmd.arg("build").assert().success(); + for (language, _version) in parsers { + out = out + .stdout(p::str::contains(format!("{language}/{language}"))) + .stdout(p::str::contains("[4/4] built")); + } + for (language, _version) in parsers { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } } #[rstest] fn build_plain_progress_numbered_correctly() { - let mut sandbox = Sandbox::new(); - let output = sandbox - .cmd - .args(["build", "json", "--progress=plain"]) - .output() - .unwrap(); + let mut sandbox = Sandbox::new(); + let output = sandbox + .cmd + .args(["build", "json", "--progress=plain"]) + .output() + .unwrap(); - assert!(output.status.success()); + assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = String::from_utf8_lossy(&output.stdout); - // Verify that steps are numbered starting from 1, not 0 - assert!(stdout.contains("[1/"), "stdout should contain [1/"); - assert!(stdout.contains("[2/"), "stdout should contain [2/"); - assert!(stdout.contains("[3/"), "stdout should contain [3/"); + // Verify that steps are numbered starting from 1, not 0 + assert!(stdout.contains("[1/"), "stdout should contain [1/"); + assert!(stdout.contains("[2/"), "stdout should contain [2/"); + assert!(stdout.contains("[3/"), "stdout should contain [3/"); - // Verify no [0/ appears (which was the bug) - assert!( - !stdout.contains("[0/"), - "stdout should not contain [0/ (step numbering started at 0)" - ); + // Verify no [0/ appears (which was the bug) + assert!( + !stdout.contains("[0/"), + "stdout should not contain [0/ (step numbering started at 0)" + ); - // Verify the output artifact was created - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}json.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); + // Verify the output artifact was created + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); } #[rstest] fn build_rejects_malformed_config_file() { - let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(CONFIG_FILE) - .write_str("not valid toml =") - .unwrap(); + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); - sandbox - .cmd - .arg("build") - .assert() - .failure() - .stderr(p::str::contains( - "Resolving build configuration for `build`", - )) - .stderr(p::str::contains("Parsing config file")); + sandbox + .cmd + .arg("build") + .assert() + .failure() + .stderr(p::str::contains( + "Resolving build configuration for `build`", + )) + .stderr(p::str::contains("Parsing config file")); } #[rstest] @@ -144,79 +144,79 @@ fn build_rejects_malformed_config_file() { #[case::native(Some("native"), &[DLL_EXTENSION])] #[cfg_attr(enable_wasm_cases, case::wasm(Some("wasm"), &[WASM_EXTENSION]))] fn build_target(#[case] target: Option<&str>, #[case] exts: &[&str]) { - use std::fmt::Write as _; - - let languages = [("json", "0.21.0")]; - let mut config = String::new(); - writeln!(config, "[parsers]").unwrap(); - for (lang, ver) in languages { - writeln!(config, " {lang} = \"{ver}\"").unwrap(); - } - if let Some(target) = target { - config = format!("target = \"{target}\"\n{config}"); - } - let mut sandbox = Sandbox::new(); - sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); - sandbox.cmd.args(["build"]).assert().success(); - for (lang, _) in languages { - for ext in exts { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{lang}.{ext}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } + use std::fmt::Write as _; + + let languages = [("json", "0.21.0")]; + let mut config = String::new(); + writeln!(config, "[parsers]").unwrap(); + for (lang, ver) in languages { + writeln!(config, " {lang} = \"{ver}\"").unwrap(); + } + if let Some(target) = target { + config = format!("target = \"{target}\"\n{config}"); + } + let mut sandbox = Sandbox::new(); + sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); + sandbox.cmd.args(["build"]).assert().success(); + for (lang, _) in languages { + for ext in exts { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{lang}.{ext}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); } + } } #[rstest] fn multi_parsers_cmd() { - let typescript = "typescript"; - let version = "0.21.0"; - let languages = [typescript, "tsx"]; - let mut sandbox = Sandbox::new(); - let config = formatdoc! { - r#" + let typescript = "typescript"; + let version = "0.21.0"; + let languages = [typescript, "tsx"]; + let mut sandbox = Sandbox::new(); + let config = formatdoc! { + r#" [parsers] typescript = {{ ref = "{version}", cmd = "make" }} "# - }; - sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); - let assert = sandbox.cmd.args(["build", typescript]).assert().success(); - // Check for version in cloning step - // TODO: dig for changes in this test and revert. - _ = assert.stdout(p::str::contains(format!("{typescript:<16} [1/2] cloning"))); - for language in languages { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } + }; + sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); + let assert = sandbox.cmd.args(["build", typescript]).assert().success(); + // Check for version in cloning step + // TODO: dig for changes in this test and revert. + _ = assert.stdout(p::str::contains(format!("{typescript:<16} [1/2] cloning"))); + for language in languages { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } } #[rstest] fn multi_parsers_no_cmd() { - let java = "java"; - let version = "HEAD"; - let languages = [java]; - let mut sandbox = Sandbox::new(); - let mut assert = sandbox - .cmd - .args(["build", java]) - .assert() - .success() - .stdout(p::str::contains(format!("{java:<16} @ {version}"))); - for language in languages { - assert = assert.stdout(p::str::contains(format!("{language:<16} [1/2] cloning"))); - } - for language in languages { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } + let java = "java"; + let version = "HEAD"; + let languages = [java]; + let mut sandbox = Sandbox::new(); + let mut assert = sandbox + .cmd + .args(["build", java]) + .assert() + .success() + .stdout(p::str::contains(format!("{java:<16} @ {version}"))); + for language in languages { + assert = assert.stdout(p::str::contains(format!("{language:<16} [1/2] cloning"))); + } + for language in languages { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } } #[rstest] @@ -224,132 +224,132 @@ fn multi_parsers_no_cmd() { #[case::leading_v("v0.25.6", "v0.25.6", "0.25.6")] #[case::sha1("636801770eea172d140e64b691815ff11f6b556f", "6368017", "0.22.6")] fn no_args_should_build_tree_sitter_with_specific_version( - #[case] requested: &str, - #[case] version: &str, - #[case] cli_version: &str, + #[case] requested: &str, + #[case] version: &str, + #[case] cli_version: &str, ) { - let mut sandbox = Sandbox::new(); + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["build", "--tree-sitter-version", requested]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(format!("tree-sitter-cli @ {version}"))); + let mut tree_sitter_cli = Command::new( sandbox - .cmd - .args(["build", "--tree-sitter-version", requested]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(format!("tree-sitter-cli @ {version}"))); - let mut tree_sitter_cli = Command::new( - sandbox - .tmp - .child(BUILD_DIR) - .child(format!("tree-sitter-{PLATFORM}-v{cli_version}")) - .to_path_buf(), - ); - tree_sitter_cli.arg("--version"); - tree_sitter_cli - .assert() - .success() - .stdout(p::str::contains(format!("tree-sitter {cli_version}"))); + .tmp + .child(BUILD_DIR) + .child(format!("tree-sitter-{PLATFORM}-v{cli_version}")) + .to_path_buf(), + ); + tree_sitter_cli.arg("--version"); + tree_sitter_cli + .assert() + .success() + .stdout(p::str::contains(format!("tree-sitter {cli_version}"))); } #[rstest] fn no_args_should_download_tree_sitter_cli() { - let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build"); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(format!("tree-sitter-cli @ v{VERSION}"))); - assert!(!sandbox.is_empty()); - let tree_sitter_cli = sandbox - .tmp - .child(BUILD_DIR) - .child(format!("tree-sitter-{PLATFORM}-v{VERSION}")); - - tree_sitter_cli - .assert(p::path::exists()) - .assert(p::path::is_file()); - - let tree_sitter_cli = tree_sitter_cli.to_path_buf(); - assert!(tree_sitter_cli.metadata().unwrap().permissions().mode() & 0o111 != 0); - - let gz = tree_sitter_cli.with_extension("gz"); - assert!(!gz.exists()); + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build"); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(format!("tree-sitter-cli @ v{VERSION}"))); + assert!(!sandbox.is_empty()); + let tree_sitter_cli = sandbox + .tmp + .child(BUILD_DIR) + .child(format!("tree-sitter-{PLATFORM}-v{VERSION}")); + + tree_sitter_cli + .assert(p::path::exists()) + .assert(p::path::is_file()); + + let tree_sitter_cli = tree_sitter_cli.to_path_buf(); + assert!(tree_sitter_cli.metadata().unwrap().permissions().mode() & 0o111 != 0); + + let gz = tree_sitter_cli.with_extension("gz"); + assert!(!gz.exists()); } #[rstest] #[case::json(vec!["json"])] #[case::json_rust(vec!["json", "rust"])] fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { - let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build").args(&languages); - let mut assert = sandbox.cmd.assert().success(); - for lang in &languages { - assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); - } - for lang in &languages { - let dylib = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}{lang}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build").args(&languages); + let mut assert = sandbox.cmd.assert().success(); + for lang in &languages { + assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); + } + for lang in &languages { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{lang}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } } #[rstest] fn test_real_parser_error_formatting() { - let mut sandbox = Sandbox::new(); - let output = sandbox.cmd.arg("build").args(["jsonxxx"]).output().unwrap(); - - // Should fail - assert!(!output.status.success()); - - let stderr = String::from_utf8_lossy(&output.stderr); - - // Extract just the error part (after the progress messages) - let error_part = stderr - .lines() - .skip_while(|line| !line.contains("Could not build all parsers")) - .collect::>() - .join("\n"); - - // MacOS needs the canonicalize because tmp by default doesn't have /private as root. - let build_dir = std::fs::canonicalize( - sandbox - .tmp - .path() - .join(BUILD_DIR) - .join("tree-sitter-jsonxxx"), - ) - .unwrap(); + let mut sandbox = Sandbox::new(); + let output = sandbox.cmd.arg("build").args(["jsonxxx"]).output().unwrap(); + + // Should fail + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); - // Define the exact expected error format using multi-line string literal - let expected = format!( - "\ + // Extract just the error part (after the progress messages) + let error_part = stderr + .lines() + .skip_while(|line| !line.contains("Could not build all parsers")) + .collect::>() + .join("\n"); + + // MacOS needs the canonicalize because tmp by default doesn't have /private as root. + let build_dir = std::fs::canonicalize( + sandbox + .tmp + .path() + .join(BUILD_DIR) + .join("tree-sitter-jsonxxx"), + ) + .unwrap(); + + // Define the exact expected error format using multi-line string literal + let expected = format!( + "\ Could not build all parsers. jsonxxx: Could not clone to {}. $ git fetch origin --depth 1 HEAD failed with exit status 128. fatal: could not read Username for 'https://github.com': terminal prompts disabled\ ", - build_dir.display() - ); - - // Cursor for sequential searching because some shells might output noise. - let mut remaining_output = error_part.as_str(); - - for line in expected.lines() { - if line.trim().is_empty() { - continue; - } - - // Find exact line (w/ indentation) within the remaining slice - if let Some(idx) = remaining_output.find(line) { - // Move cursor past the found line to ensure order - remaining_output = &remaining_output[idx + line.len()..]; - } else { - panic!( - "Output mismatch.\n\ + build_dir.display() + ); + + // Cursor for sequential searching because some shells might output noise. + let mut remaining_output = error_part.as_str(); + + for line in expected.lines() { + if line.trim().is_empty() { + continue; + } + + // Find exact line (w/ indentation) within the remaining slice + if let Some(idx) = remaining_output.find(line) { + // Move cursor past the found line to ensure order + remaining_output = &remaining_output[idx + line.len()..]; + } else { + panic!( + "Output mismatch.\n\ Could not find expected line (or it is out of order):\n\ {line:?}\n\ \n\ @@ -358,26 +358,26 @@ Could not build all parsers. \n\ Original full output:\n\ {error_part}" - ); - } + ); } + } } #[rstest] #[case::gringo(vec!["gringo"])] #[case::gringo_bringo(vec!["gringo", "bringo"])] fn unknown_parser_should_fail(#[case] languages: Vec<&str>) { - let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build").args(&languages); - let mut assert = sandbox.cmd.assert().failure(); - for lang in &languages { - assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); - } - for lang in languages { - sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{lang}.{DLL_EXTENSION}")) - .assert(p::path::missing()); - } + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build").args(&languages); + let mut assert = sandbox.cmd.assert().failure(); + for lang in &languages { + assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); + } + for lang in languages { + sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{lang}.{DLL_EXTENSION}")) + .assert(p::path::missing()); + } } diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index aa7b60d..c002830 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -11,270 +11,275 @@ use crate::cmd::Sandbox; #[rstest] fn cache_file_structure() { - let mut sandbox = Sandbox::new(); - - // Build two parsers - sandbox - .cmd - .arg("build") - .args(["json", "python"]) - .assert() - .success(); - - // Read and validate cache file - let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); - cache_file.assert(p::path::exists()); - - let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); - - // Verify TOML structure contains expected entries - assert!( - cache_content.contains("[parsers.\"json/json\"]"), - "Cache should have json entry" - ); - assert!( - cache_content.contains("[parsers.\"python/python\"]"), - "Cache should have python entry" - ); - assert!( - cache_content.contains("hash"), - "Cache should have hash field" - ); - assert!( - cache_content.contains("git_ref"), - "Cache should have git_ref field" - ); - assert!( - cache_content.contains("revision"), - "Cache should have revision identity field" - ); - assert!( - cache_content.contains("spec"), - "Cache should have build spec field" - ); - assert!( - cache_content.contains("outputs"), - "Cache should have outputs field" - ); - assert!( - !cache_content.lines().any(|line| line.starts_with("file")), - "Cache should not serialize its runtime storage path" - ); + let mut sandbox = Sandbox::new(); + + // Build two parsers + sandbox + .cmd + .arg("build") + .args(["json", "python"]) + .assert() + .success(); + + // Read and validate cache file + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); + cache_file.assert(p::path::exists()); + + let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); + + // Verify TOML structure contains expected entries + assert!( + cache_content.contains("[parsers.\"json/json\"]"), + "Cache should have json entry" + ); + assert!( + cache_content.contains("[parsers.\"python/python\"]"), + "Cache should have python entry" + ); + assert!( + cache_content.contains("hash"), + "Cache should have hash field" + ); + assert!( + cache_content.contains("git_ref"), + "Cache should have git_ref field" + ); + assert!( + cache_content.contains("revision"), + "Cache should have revision identity field" + ); + assert!( + cache_content.contains("spec"), + "Cache should have build spec field" + ); + assert!( + cache_content.contains("outputs"), + "Cache should have outputs field" + ); + assert!( + !cache_content.lines().any(|line| line.starts_with("file")), + "Cache should not serialize its runtime storage path" + ); } #[rstest] fn cache_hit_skips_build() { - let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(CONFIG_FILE) - .write_str("[parsers]\njson = \"0.21.0\"\n") - .unwrap(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - let binary = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}json.{DLL_EXTENSION}")); - binary.assert(p::path::exists()).assert(p::path::is_file()); - - // Cache file should exist - let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); - cache_file.assert(p::path::exists()); - - let first_inode = binary.metadata().unwrap().ino(); - - // Second build in same sandbox should hit cache - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.arg("build") - .arg("json") - .assert() - .success() - .stdout(p::str::contains("json/json [4/4] cached")) - .stdout(p::str::contains("cloning").not()); - - let second_inode = binary.metadata().unwrap().ino(); - assert_eq!( - first_inode, second_inode, - "Inode should remain the same on cache hit" - ); + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("[parsers]\njson = \"0.21.0\"\n") + .unwrap(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + let binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + binary.assert(p::path::exists()).assert(p::path::is_file()); + + // Cache file should exist + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); + cache_file.assert(p::path::exists()); + + let first_inode = binary.metadata().unwrap().ino(); + + // Second build in same sandbox should hit cache + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .arg("build") + .arg("json") + .assert() + .success() + .stdout(p::str::contains("json/json [4/4] cached")) + .stdout(p::str::contains("cloning").not()); + + let second_inode = binary.metadata().unwrap().ino(); + assert_eq!( + first_inode, second_inode, + "Inode should remain the same on cache hit" + ); } #[rstest] fn cache_miss_on_grammar_modification() { - let mut sandbox = Sandbox::new(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - // Modify grammar file - let grammar = sandbox - .tmp - .child(BUILD_DIR) - .child("tree-sitter-json") - .child("grammar.js"); - let mut content = std::fs::read_to_string(grammar.path()).unwrap(); - content.push_str("\n// test modification\n"); - grammar.write_str(&content).unwrap(); - - // Second build should miss cache (use --force because binary already exists with different inode) - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--force", "json"]) - .assert() - .success() - .stdout(p::str::contains("json [1/2] cloning")) - .stdout(p::str::contains("json/json [3/4] building")); + let mut sandbox = Sandbox::new(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + // Modify grammar file + let grammar = sandbox + .tmp + .child(BUILD_DIR) + .child("tree-sitter-json") + .child("grammar.js"); + let mut content = std::fs::read_to_string(grammar.path()).unwrap(); + content.push_str("\n// test modification\n"); + grammar.write_str(&content).unwrap(); + + // Second build should miss cache (use --force because binary already exists with different inode) + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .args(["build", "--force", "json"]) + .assert() + .success() + .stdout(p::str::contains("json [1/2] cloning")) + .stdout(p::str::contains("json/json [3/4] building")); } #[rstest] fn force_flag_bypasses_cache() { - let mut sandbox = Sandbox::new(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - let binary = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}json.{DLL_EXTENSION}")); - let first_inode = binary.metadata().unwrap().ino(); - - // Second build with --force - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--force", "json"]) - .assert() - .success() - .stdout(p::str::contains("json [1/2] cloning")) - .stdout(p::str::contains("json/json [3/4] building")); - - let second_inode = binary.metadata().unwrap().ino(); - assert_ne!( - first_inode, second_inode, - "--force should create new binary with different inode" - ); + let mut sandbox = Sandbox::new(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + let binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + let first_inode = binary.metadata().unwrap().ino(); + + // Second build with --force + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .args(["build", "--force", "json"]) + .assert() + .success() + .stdout(p::str::contains("json [1/2] cloning")) + .stdout(p::str::contains("json/json [3/4] building")); + + let second_inode = binary.metadata().unwrap().ino(); + assert_ne!( + first_inode, second_inode, + "--force should create new binary with different inode" + ); } #[rstest] fn force_flag_reinstalls_hardlink() { - let mut sandbox = Sandbox::new(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - let binary = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}json.{DLL_EXTENSION}")); - let build_binary = sandbox - .tmp - .child(BUILD_DIR) - .child("tree-sitter-json") - .child(format!("tsdl-{PLATFORM}-v{VERSION}")) - .child(format!("libtree-sitter-json.{DLL_EXTENSION}")); - - let first_inode_out = binary.metadata().unwrap().ino(); - let first_inode_build = build_binary.metadata().unwrap().ino(); - assert_eq!( - first_inode_out, first_inode_build, - "Hard-link should have same inode" - ); - - // Replace output binary with a copy (different inode) - let content = std::fs::read(binary.path()).unwrap(); - std::fs::remove_file(binary.path()).unwrap(); - std::fs::write(binary.path(), &content).unwrap(); - - let broken_inode_out = binary.metadata().unwrap().ino(); - assert_ne!( - broken_inode_out, first_inode_build, - "Replaced binary should have different inode" - ); - - // Second build with --force should fix the hard-link - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--force", "json"]) - .assert() - .success() - .stdout(p::str::contains("json/json [4/4] installing")); - - let final_inode_out = binary.metadata().unwrap().ino(); - let final_inode_build = build_binary.metadata().unwrap().ino(); - assert_eq!( - final_inode_out, final_inode_build, - "After --force, hard-link should be restored" - ); + let mut sandbox = Sandbox::new(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + let binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + let build_binary = sandbox + .tmp + .child(BUILD_DIR) + .child("tree-sitter-json") + .child(format!("tsdl-{PLATFORM}-v{VERSION}")) + .child(format!("libtree-sitter-json.{DLL_EXTENSION}")); + + let first_inode_out = binary.metadata().unwrap().ino(); + let first_inode_build = build_binary.metadata().unwrap().ino(); + assert_eq!( + first_inode_out, first_inode_build, + "Hard-link should have same inode" + ); + + // Replace output binary with a copy (different inode) + let content = std::fs::read(binary.path()).unwrap(); + std::fs::remove_file(binary.path()).unwrap(); + std::fs::write(binary.path(), &content).unwrap(); + + let broken_inode_out = binary.metadata().unwrap().ino(); + assert_ne!( + broken_inode_out, first_inode_build, + "Replaced binary should have different inode" + ); + + // Second build with --force should fix the hard-link + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .args(["build", "--force", "json"]) + .assert() + .success() + .stdout(p::str::contains("json/json [4/4] installing")); + + let final_inode_out = binary.metadata().unwrap().ino(); + let final_inode_build = build_binary.metadata().unwrap().ino(); + assert_eq!( + final_inode_out, final_inode_build, + "After --force, hard-link should be restored" + ); } #[rstest] fn fresh_flag_clears_build_dir() { - let mut sandbox = Sandbox::new(); + let mut sandbox = Sandbox::new(); - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); - let build_dir = sandbox.tmp.child(BUILD_DIR); - build_dir.assert(p::path::exists()); + let build_dir = sandbox.tmp.child(BUILD_DIR); + build_dir.assert(p::path::exists()); - let cache_file = build_dir.child("cache.toml"); - cache_file.assert(p::path::exists()); + let cache_file = build_dir.child("cache.toml"); + cache_file.assert(p::path::exists()); - let first_binary = sandbox - .tmp - .child(PARSER_OUT_DIR) - .child(format!("{PREFIX}json.{DLL_EXTENSION}")); - let first_inode = first_binary.metadata().unwrap().ino(); + let first_binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + let first_inode = first_binary.metadata().unwrap().ino(); - // Second build with --fresh (need --force to overwrite existing binary) - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--fresh", "--force", "json"]) - .assert() - .success(); + // Second build with --fresh (need --force to overwrite existing binary) + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .args(["build", "--fresh", "--force", "json"]) + .assert() + .success(); - // Cache file should be gone and recreated - cache_file.assert(p::path::exists()); + // Cache file should be gone and recreated + cache_file.assert(p::path::exists()); - let second_inode = first_binary.metadata().unwrap().ino(); + let second_inode = first_binary.metadata().unwrap().ino(); - assert_ne!( - first_inode, second_inode, - "Fresh build should create new binary with different inode" - ); + assert_ne!( + first_inode, second_inode, + "Fresh build should create new binary with different inode" + ); } #[rstest] #[case::json_and_python(vec!["json", "python"])] fn multi_parser_independent_cache(#[case] languages: Vec<&str>) { - let mut sandbox = Sandbox::new(); - - // First build all parsers - sandbox.cmd.arg("build").args(&languages).assert().success(); - - // Verify cache contains both entries - let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); - let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); - for lang in &languages { - assert!( - cache_content.contains(&format!("[parsers.\"{lang}/{lang}\"]")), - "Cache should contain entry for {lang}", - ); - } - - // Second build without modification should hit cache for both - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - let mut output = cmd.arg("build").args(&languages).assert().success(); - - for lang in &languages { - output = output.stdout(p::str::contains(format!( - "{:<16} [4/4] cached", - format!("{lang}/{lang}") - ))); - } + let mut sandbox = Sandbox::new(); + + // First build all parsers + sandbox.cmd.arg("build").args(&languages).assert().success(); + + // Verify cache contains both entries + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); + let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); + for lang in &languages { + assert!( + cache_content.contains(&format!("[parsers.\"{lang}/{lang}\"]")), + "Cache should contain entry for {lang}", + ); + } + + // Second build without modification should hit cache for both + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + let mut output = cmd.arg("build").args(&languages).assert().success(); + + for lang in &languages { + output = output.stdout(p::str::contains(format!( + "{:<16} [4/4] cached", + format!("{lang}/{lang}") + ))); + } } diff --git a/tests/cmd/config.rs b/tests/cmd/config.rs index b2f2caf..57d78c6 100644 --- a/tests/cmd/config.rs +++ b/tests/cmd/config.rs @@ -3,137 +3,137 @@ use indoc::formatdoc; use predicates::{self as p}; use tsdl::{ - args::BuildCommand, - consts::{BUILD_DIR, CONFIG_FILE}, + args::BuildCommand, + consts::{BUILD_DIR, CONFIG_FILE}, }; use crate::cmd::Sandbox; #[test] fn current_rejects_malformed_config_file() { - let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(CONFIG_FILE) - .write_str("not valid toml =") - .unwrap(); + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); - sandbox - .cmd - .args(["config", "current"]) - .assert() - .failure() - .stderr(p::str::contains( - "Resolving build configuration for `config current`", - )) - .stderr(p::str::contains("Parsing config file")); + sandbox + .cmd + .args(["config", "current"]) + .assert() + .failure() + .stderr(p::str::contains( + "Resolving build configuration for `config current`", + )) + .stderr(p::str::contains("Parsing config file")); } #[test] fn current_uses_config_file() { - let build_dir = "build-dir"; - let out_dir = "out-dir"; - let config = formatdoc! { - r#" + let build_dir = "build-dir"; + let out_dir = "out-dir"; + let config = formatdoc! { + r#" build-dir = "{build_dir}" out = "{out_dir}" "# - }; - let mut sandbox = Sandbox::new(); - sandbox.config(&config); - sandbox.cmd.args(["config", "current"]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); - assert!(!sandbox.is_empty()); - sandbox.tmp.child(build_dir).assert(p::path::missing()); + }; + let mut sandbox = Sandbox::new(); + sandbox.config(&config); + sandbox.cmd.args(["config", "current"]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); + assert!(!sandbox.is_empty()); + sandbox.tmp.child(build_dir).assert(p::path::missing()); } #[test] fn current_uses_default() { - let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["config", "current"]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); - assert!(sandbox.is_empty()); + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["config", "current"]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); + assert!(sandbox.is_empty()); } #[test] fn current_uses_explicit_log_file() { - let mut sandbox = Sandbox::new(); - sandbox - .cmd - .args(["--log", "config-current.log", "config", "current"]); - sandbox.cmd.assert().success(); - sandbox - .tmp - .child("config-current.log") - .assert(p::path::exists()) - .assert(p::path::is_file()); - sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["--log", "config-current.log", "config", "current"]); + sandbox.cmd.assert().success(); + sandbox + .tmp + .child("config-current.log") + .assert(p::path::exists()) + .assert(p::path::is_file()); + sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); } #[test] fn default_ignores_malformed_config_file() { - let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(CONFIG_FILE) - .write_str("not valid toml =") - .unwrap(); + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); - sandbox - .cmd - .args(["config", "default"]) - .assert() - .success() - .stdout(p::str::contains( - toml::to_string(&BuildCommand::default()).unwrap(), - )); + sandbox + .cmd + .args(["config", "default"]) + .assert() + .success() + .stdout(p::str::contains( + toml::to_string(&BuildCommand::default()).unwrap(), + )); } #[test] fn default_is_default_toml() { - let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["config", "default"]); - sandbox.cmd.assert().success().stdout(p::str::contains( - toml::to_string(&BuildCommand::default()).unwrap(), - )); - assert!(sandbox.is_empty()); + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["config", "default"]); + sandbox.cmd.assert().success().stdout(p::str::contains( + toml::to_string(&BuildCommand::default()).unwrap(), + )); + assert!(sandbox.is_empty()); } #[test] fn default_uses_explicit_log_file() { - let mut sandbox = Sandbox::new(); - sandbox - .cmd - .args(["--log", "config-default.log", "config", "default"]); - sandbox.cmd.assert().success(); - sandbox - .tmp - .child("config-default.log") - .assert(p::path::exists()) - .assert(p::path::is_file()); - sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["--log", "config-default.log", "config", "default"]); + sandbox.cmd.assert().success(); + sandbox + .tmp + .child("config-default.log") + .assert(p::path::exists()) + .assert(p::path::is_file()); + sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); } #[test] fn no_args_shows_help() { - let mut sandbox = Sandbox::new(); - sandbox - .cmd - .args(["config"]) - .assert() - .failure() - .stderr(p::str::starts_with("Configuration helpers")) - .stderr(p::str::contains(format!( - "Usage: {} config [OPTIONS] ", - env!("CARGO_PKG_NAME") - ))); - assert!(sandbox.is_empty()); + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["config"]) + .assert() + .failure() + .stderr(p::str::starts_with("Configuration helpers")) + .stderr(p::str::contains(format!( + "Usage: {} config [OPTIONS] ", + env!("CARGO_PKG_NAME") + ))); + assert!(sandbox.is_empty()); } diff --git a/tests/cmd/log.rs b/tests/cmd/log.rs index 889ba84..f6bf68e 100644 --- a/tests/cmd/log.rs +++ b/tests/cmd/log.rs @@ -8,20 +8,20 @@ use crate::cmd::Sandbox; #[rstest] fn build_no_args_should_log_to_default_path() { - let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build"); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains("tree-sitter-cli [2/2] done")); - assert!(!sandbox.is_empty()); - sandbox - .tmp - .child(BUILD_DIR) - .child("log") - .assert(p::path::exists()) - .assert(p::path::is_file()); + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build"); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains("tree-sitter-cli [2/2] done")); + assert!(!sandbox.is_empty()); + sandbox + .tmp + .child(BUILD_DIR) + .child("log") + .assert(p::path::exists()) + .assert(p::path::is_file()); } #[rstest] @@ -29,16 +29,16 @@ fn build_no_args_should_log_to_default_path() { #[case::lock_file("tmp/tsdl.lock", "conflicts with a tsdl runtime/build file")] #[case::cache_file("tmp/cache.toml", "conflicts with a tsdl runtime/build file")] fn build_rejects_log_paths_that_conflict_with_fresh_cleanup( - #[case] log: &str, - #[case] expected: &str, + #[case] log: &str, + #[case] expected: &str, ) { - let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["build", "--log", log]); - sandbox - .cmd - .assert() - .failure() - .stderr(p::str::contains(expected)); + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["build", "--log", log]); + sandbox + .cmd + .assert() + .failure() + .stderr(p::str::contains(expected)); } #[rstest] @@ -48,36 +48,36 @@ fn build_rejects_log_paths_that_conflict_with_fresh_cleanup( #[case::absolute("/tmp/tsdl.log")] #[case::parent("../tsdl.log")] fn build_w_specific_log_path(#[case] log: &str) { - let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["build", "--log", log]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains("tree-sitter-cli [2/2] done")); - sandbox - .tmp - .child(log) - .assert(p::path::exists()) - .assert(p::path::is_file()); + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["build", "--log", log]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains("tree-sitter-cli [2/2] done")); + sandbox + .tmp + .child(log) + .assert(p::path::exists()) + .assert(p::path::is_file()); } #[rstest] fn fresh_preserves_root_level_custom_log_and_removes_build_entries() { - let mut sandbox = Sandbox::new(); - let stale = sandbox.tmp.child(BUILD_DIR).child("stale.txt"); - stale.write_str("stale").unwrap(); + let mut sandbox = Sandbox::new(); + let stale = sandbox.tmp.child(BUILD_DIR).child("stale.txt"); + stale.write_str("stale").unwrap(); - sandbox - .cmd - .args(["build", "--fresh", "--log", "tmp/custom.log"]); - sandbox.cmd.assert().success(); + sandbox + .cmd + .args(["build", "--fresh", "--log", "tmp/custom.log"]); + sandbox.cmd.assert().success(); - sandbox - .tmp - .child(BUILD_DIR) - .child("custom.log") - .assert(p::path::exists()) - .assert(p::path::is_file()); - stale.assert(p::path::missing()); + sandbox + .tmp + .child(BUILD_DIR) + .child("custom.log") + .assert(p::path::exists()) + .assert(p::path::is_file()); + stale.assert(p::path::missing()); } diff --git a/tests/cmd/mod.rs b/tests/cmd/mod.rs index ef96e4e..16fe26c 100644 --- a/tests/cmd/mod.rs +++ b/tests/cmd/mod.rs @@ -15,34 +15,34 @@ use assert_fs::TempDir; use tsdl::{args::BuildCommand, config as tsdl_config, consts::CONFIG_FILE}; pub struct Sandbox { - pub build: BuildCommand, - pub cmd: Command, - pub tmp: TempDir, + pub build: BuildCommand, + pub cmd: Command, + pub tmp: TempDir, } impl Sandbox { - pub fn new() -> Self { - let tmp = TempDir::new().unwrap(); - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(tmp.path()); - Sandbox { - build: BuildCommand::default(), - cmd, - tmp, - } + pub fn new() -> Self { + let tmp = TempDir::new().unwrap(); + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(tmp.path()); + Sandbox { + build: BuildCommand::default(), + cmd, + tmp, } + } - pub fn config(&mut self, config: &str) -> &mut Self { - self.config_at(config, &self.tmp.path().join(CONFIG_FILE)) - } + pub fn config(&mut self, config: &str) -> &mut Self { + self.config_at(config, &self.tmp.path().join(CONFIG_FILE)) + } - pub fn config_at(&mut self, config_contents: &str, dst: &Path) -> &mut Self { - fs::write(dst, config_contents).unwrap(); - self.build = tsdl_config::current(dst, None).unwrap(); - self - } + pub fn config_at(&mut self, config_contents: &str, dst: &Path) -> &mut Self { + fs::write(dst, config_contents).unwrap(); + self.build = tsdl_config::current(dst, None).unwrap(); + self + } - pub fn is_empty(&self) -> bool { - fs::read_dir(&self.tmp).is_ok_and(|mut dir| dir.next().is_none()) - } + pub fn is_empty(&self) -> bool { + fs::read_dir(&self.tmp).is_ok_and(|mut dir| dir.next().is_none()) + } } diff --git a/tests/config.rs b/tests/config.rs index 815bc9c..2ff67de 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -7,138 +7,138 @@ use indoc::{formatdoc, indoc}; use pretty_assertions::{assert_eq, assert_ne}; use tsdl::{ - args::{self, BuildCommand, Target}, - config::{self, Source}, - consts::{BUILD_DIR, FRESH, PARSER_OUT_DIR, PLATFORM, PREFIX, REPO, SHOW_CONFIG, VERSION}, + args::{self, BuildCommand, Target}, + config::{self, Source}, + consts::{BUILD_DIR, FRESH, PARSER_OUT_DIR, PLATFORM, PREFIX, REPO, SHOW_CONFIG, VERSION}, }; static ENV_LOCK: Mutex<()> = Mutex::new(()); struct EnvVarGuard { - key: &'static str, - previous: Option, + key: &'static str, + previous: Option, } impl Drop for EnvVarGuard { - fn drop(&mut self) { - // SAFETY: The caller holds ENV_LOCK, serializing all env access - // within these tests. - match &self.previous { - Some(value) => unsafe { env::set_var(self.key, value) }, - None => unsafe { env::remove_var(self.key) }, - } + fn drop(&mut self) { + // SAFETY: The caller holds ENV_LOCK, serializing all env access + // within these tests. + match &self.previous { + Some(value) => unsafe { env::set_var(self.key, value) }, + None => unsafe { env::remove_var(self.key) }, } + } } impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = env::var_os(key); - // SAFETY: The caller holds ENV_LOCK, serializing all env access - // within these tests. - unsafe { env::set_var(key, value) }; - Self { key, previous } - } + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var_os(key); + // SAFETY: The caller holds ENV_LOCK, serializing all env access + // within these tests. + unsafe { env::set_var(key, value) }; + Self { key, previous } + } } fn current_with_cli(config: &assert_fs::fixture::ChildPath, argv: &[&str]) -> BuildCommand { - let _lock = ENV_LOCK.lock().unwrap(); - let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); - let build_matches = args::build_matches(&matches); - config::current(config.path(), build_matches).unwrap() + let _lock = ENV_LOCK.lock().unwrap(); + let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); + let build_matches = args::build_matches(&matches); + config::current(config.path(), build_matches).unwrap() } fn current_with_cli_provenance( - config: &assert_fs::fixture::ChildPath, - argv: &[&str], + config: &assert_fs::fixture::ChildPath, + argv: &[&str], ) -> (BuildCommand, config::BuildProvenance) { - let _lock = ENV_LOCK.lock().unwrap(); - let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); - let build_matches = args::build_matches(&matches); - config::current_with_provenance(config.path(), build_matches).unwrap() + let _lock = ENV_LOCK.lock().unwrap(); + let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); + let build_matches = args::build_matches(&matches); + config::current_with_provenance(config.path(), build_matches).unwrap() } #[test] fn boolean_env_can_override_config_file() -> Result<()> { - let _lock = ENV_LOCK.lock().unwrap(); - let _force = EnvVarGuard::set("FORCE", "false"); + let _lock = ENV_LOCK.lock().unwrap(); + let _force = EnvVarGuard::set("FORCE", "false"); - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("force = true\n")?; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("force = true\n")?; - let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); - let build_matches = args::build_matches(&matches); - let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; - assert!(!cmd.force); - assert_eq!(prov.force, Source::Environment); - Ok(()) + assert!(!cmd.force); + assert_eq!(prov.force, Source::Environment); + Ok(()) } #[test] fn cli_can_override_config_to_builtin_default_value() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("target = \"wasm\"\n")?; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("target = \"wasm\"\n")?; - let resolved = current_with_cli(&generated, &["tsdl", "build", "--target", "native"]); + let resolved = current_with_cli(&generated, &["tsdl", "build", "--target", "native"]); - assert_eq!(resolved.target, Target::Native); - Ok(()) + assert_eq!(resolved.target, Target::Native); + Ok(()) } #[test] fn cli_can_override_tree_sitter_version_to_builtin_default_value() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("[tree-sitter]\nversion = \"0.25.0\"\n")?; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("[tree-sitter]\nversion = \"0.25.0\"\n")?; - let resolved = current_with_cli( - &generated, - &["tsdl", "build", "--tree-sitter-version", VERSION], - ); + let resolved = current_with_cli( + &generated, + &["tsdl", "build", "--tree-sitter-version", VERSION], + ); - assert_eq!(resolved.tree_sitter.version, VERSION); - Ok(()) + assert_eq!(resolved.tree_sitter.version, VERSION); + Ok(()) } #[test] fn cli_explicit_default_value_overrides_config_file() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("build-dir = \"/custom\"\n")?; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("build-dir = \"/custom\"\n")?; - let (cmd, prov) = - current_with_cli_provenance(&generated, &["tsdl", "build", "--build-dir", "tmp"]); + let (cmd, prov) = + current_with_cli_provenance(&generated, &["tsdl", "build", "--build-dir", "tmp"]); - assert_eq!(cmd.build_dir, PathBuf::from("tmp")); - assert_eq!(prov.build_dir, Source::CommandLine); - Ok(()) + assert_eq!(cmd.build_dir, PathBuf::from("tmp")); + assert_eq!(prov.build_dir, Source::CommandLine); + Ok(()) } #[test] fn cli_has_precedence_over_env() -> Result<()> { - let _lock = ENV_LOCK.lock().unwrap(); - let _target = EnvVarGuard::set("TSDL_TARGET", "wasm"); + let _lock = ENV_LOCK.lock().unwrap(); + let _target = EnvVarGuard::set("TSDL_TARGET", "wasm"); - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.touch()?; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; - let (_parsed_args, matches) = - config::try_parse_from_with_matches(["tsdl", "build", "--target", "native"]).unwrap(); - let build_matches = args::build_matches(&matches); - let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + let (_parsed_args, matches) = + config::try_parse_from_with_matches(["tsdl", "build", "--target", "native"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; - assert_eq!(cmd.target, Target::Native); - assert_eq!(prov.target, Source::CommandLine); - Ok(()) + assert_eq!(cmd.target, Target::Native); + assert_eq!(prov.target, Source::CommandLine); + Ok(()) } #[test] fn current_default_is_default() -> Result<()> { - let config_contents = formatdoc! { - r#" + let config_contents = formatdoc! { + r#" build-dir = "{}" fresh = {} out-dir = "{}" @@ -149,47 +149,47 @@ fn current_default_is_default() -> Result<()> { repo = "{}" platform = "{}" "#, - BUILD_DIR, - FRESH, - PARSER_OUT_DIR, - SHOW_CONFIG, - VERSION, - REPO, - PLATFORM, - }; - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.write_str(&config_contents)?; - assert_eq!(def, config::current(generated.path(), None).unwrap()); - assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); - Ok(()) + BUILD_DIR, + FRESH, + PARSER_OUT_DIR, + SHOW_CONFIG, + VERSION, + REPO, + PLATFORM, + }; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.write_str(&config_contents)?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); + Ok(()) } #[test] fn current_from_empty() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.touch()?; - assert_eq!(def, config::current(generated.path(), None).unwrap()); - Ok(()) + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.touch()?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + Ok(()) } #[test] fn current_from_generated_default() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.write_str(&toml::to_string(&def)?)?; - assert_eq!(def, config::current(generated.path(), None).unwrap()); - Ok(()) + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.write_str(&toml::to_string(&def)?)?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + Ok(()) } #[test] fn current_overrides_default() -> Result<()> { - let config_contents = indoc! { - r#" + let config_contents = indoc! { + r#" build-dir = "/root" fresh = true out-dir = "tree-sitter-parsers" @@ -200,94 +200,94 @@ fn current_overrides_default() -> Result<()> { repo = "https://gitlab.com/tree-sitter/tree-sitter" platform = "linux-arm64" "# - }; - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.write_str(config_contents)?; - generated.assert(config_contents); - assert_ne!(def, config::current(generated.path(), None).unwrap()); - assert_ne!(def, current_with_cli(&generated, &["tsdl", "build"])); - Ok(()) + }; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.write_str(config_contents)?; + generated.assert(config_contents); + assert_ne!(def, config::current(generated.path(), None).unwrap()); + assert_ne!(def, current_with_cli(&generated, &["tsdl", "build"])); + Ok(()) } #[test] fn current_preserves_cli_languages() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.touch()?; - - let def = BuildCommand::default(); - assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); - - let mut expected = BuildCommand { - languages: Some(vec!["rust".to_string()]), - ..BuildCommand::default() - }; - assert_eq!( - expected, - current_with_cli(&generated, &["tsdl", "build", "rust"]) - ); - - expected.languages = Some(vec!["rust".to_string(), "ruby".to_string()]); - assert_eq!( - expected, - current_with_cli(&generated, &["tsdl", "build", "rust", "ruby"]) - ); - Ok(()) + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; + + let def = BuildCommand::default(); + assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); + + let mut expected = BuildCommand { + languages: Some(vec!["rust".to_string()]), + ..BuildCommand::default() + }; + assert_eq!( + expected, + current_with_cli(&generated, &["tsdl", "build", "rust"]) + ); + + expected.languages = Some(vec!["rust".to_string(), "ruby".to_string()]); + assert_eq!( + expected, + current_with_cli(&generated, &["tsdl", "build", "rust", "ruby"]) + ); + Ok(()) } #[test] fn env_can_override_config_to_builtin_default_value() -> Result<()> { - let _lock = ENV_LOCK.lock().unwrap(); - let _prefix = EnvVarGuard::set("PREFIX", PREFIX); + let _lock = ENV_LOCK.lock().unwrap(); + let _prefix = EnvVarGuard::set("PREFIX", PREFIX); - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("prefix = \"\"\n")?; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("prefix = \"\"\n")?; - let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); - let build_matches = args::build_matches(&matches); - let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; - assert_eq!(cmd.prefix, PREFIX); - assert_eq!(prov.prefix, Source::Environment); - Ok(()) + assert_eq!(cmd.prefix, PREFIX); + assert_eq!(prov.prefix, Source::Environment); + Ok(()) } #[test] fn negative_boolean_flag_overrides_positive() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.touch()?; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; - let (cmd, prov) = - current_with_cli_provenance(&generated, &["tsdl", "build", "--force=true", "--no-force"]); + let (cmd, prov) = + current_with_cli_provenance(&generated, &["tsdl", "build", "--force=true", "--no-force"]); - assert!(!cmd.force); - assert_eq!(prov.force, Source::CommandLine); - Ok(()) + assert!(!cmd.force); + assert_eq!(prov.force, Source::CommandLine); + Ok(()) } #[test] fn negative_boolean_flags_override_config_file() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - generated.write_str("force = true\nfresh = true\nshow-config = true\n")?; - - let resolved = current_with_cli( - &generated, - &[ - "tsdl", - "build", - "--no-force", - "--no-fresh", - "--no-show-config", - ], - ); - - assert!(!resolved.force); - assert!(!resolved.fresh); - assert!(!resolved.show_config); - Ok(()) + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("force = true\nfresh = true\nshow-config = true\n")?; + + let resolved = current_with_cli( + &generated, + &[ + "tsdl", + "build", + "--no-force", + "--no-fresh", + "--no-show-config", + ], + ); + + assert!(!resolved.force); + assert!(!resolved.fresh); + assert!(!resolved.show_config); + Ok(()) } From b08ccaecc095ea10834a32771c089edec6783af5 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Fri, 29 May 2026 20:09:06 +0200 Subject: [PATCH 85/88] fmt: match always have pipes + sort --- rustfmt.toml | 1 + src/actors/cache.rs | 56 ++++---- src/actors/display.rs | 288 ++++++++++++++++++++++-------------------- src/actors/mod.rs | 30 ++--- src/app.rs | 27 ++-- src/args.rs | 30 ++--- src/build.rs | 45 ++++--- src/cache.rs | 171 +++++++++++++------------ src/columns.rs | 12 +- src/config.rs | 12 +- src/display.rs | 79 ++++++------ src/error.rs | 33 ++--- src/git.rs | 18 +-- src/lib.rs | 12 +- src/lock.rs | 50 ++++---- src/logging.rs | 20 +-- src/main.rs | 28 ++-- src/parser.rs | 32 ++--- src/selfupdate.rs | 18 +-- src/sh.rs | 8 +- src/shutdown.rs | 8 +- src/tree_sitter.rs | 56 ++++---- tests/config.rs | 4 +- 23 files changed, 535 insertions(+), 503 deletions(-) diff --git a/rustfmt.toml b/rustfmt.toml index b196eaa..c0d10f6 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1 +1,2 @@ +match_arm_leading_pipes = "Always" tab_spaces = 2 diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 1dade2d..36d6be0 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -110,8 +110,33 @@ impl CacheActor { /// Process incoming cache messages until the channel closes. async fn run(mut self) { while let Some(msg) = self.rx.recv().await { + use CacheMessage::*; + match msg { - CacheMessage::NeedsRebuild { + | Get { name, tx } => { + Response { + tx, + kind: ResponseKind::CacheGet { name: &name }, + } + .send(self.db.get(&name).cloned()); + } + + | HasCompatibleEntries { language, spec, tx } => { + Response { + tx, + kind: ResponseKind::HasCompatibleEntries { + language: &language, + }, + } + .send( + !self.force + && self + .db + .has_compatible_entry_for_language(&language, spec.as_ref()), + ); + } + + | NeedsRebuild { hash, name, spec, @@ -137,11 +162,7 @@ impl CacheActor { .send(decision); } - CacheMessage::Update { entry, name } => { - self.db.set(name, entry); - } - - CacheMessage::Save { tx } => { + | Save { tx } => { Response { tx, kind: ResponseKind::SaveComplete, @@ -149,27 +170,8 @@ impl CacheActor { .send(self.store.save(&self.db).await); } - CacheMessage::HasCompatibleEntries { language, spec, tx } => { - Response { - tx, - kind: ResponseKind::HasCompatibleEntries { - language: &language, - }, - } - .send( - !self.force - && self - .db - .has_compatible_entry_for_language(&language, spec.as_ref()), - ); - } - - CacheMessage::Get { name, tx } => { - Response { - tx, - kind: ResponseKind::CacheGet { name: &name }, - } - .send(self.db.get(&name).cloned()); + | Update { entry, name } => { + self.db.set(name, entry); } } } diff --git a/src/actors/display.rs b/src/actors/display.rs index bf4309c..6820698 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -195,15 +195,17 @@ fn draw_lines_in_viewport( /// Transition an item state to `Done`. Returns an error if the state is not /// `InProgress(Some(_))`. fn finish_item(state: display::ItemState) -> Result { + use display::ItemState::*; + match state { - display::ItemState::InProgress(Some(outcome)) => Ok(display::ItemState::Done(outcome)), - display::ItemState::New | display::ItemState::InProgress(None) => invalid_finish( + | Cancelled | Done(_) | Failed => { + invalid_finish("finish update received for terminal item state", state) + } + | InProgress(Some(outcome)) => Ok(Done(outcome)), + | New | InProgress(None) => invalid_finish( "finish update received before cached/built path was set", state, ), - display::ItemState::Done(_) | display::ItemState::Cancelled | display::ItemState::Failed => { - invalid_finish("finish update received for terminal item state", state) - } } } @@ -243,54 +245,44 @@ fn invalid_finish(reason: &str, state: display::ItemState) -> Result display::ItemState { + use display::ItemState::*; + match state { - display::ItemState::New | display::ItemState::InProgress(_) => { - display::ItemState::InProgress(Some(outcome)) - } - display::ItemState::Done(_) | display::ItemState::Cancelled | display::ItemState::Failed => { - state - } + | Cancelled | Done(_) | Failed => state, + | New | InProgress(_) => InProgress(Some(outcome)), } } /// Format a plain-text message for a grammar row. fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { + use UpdateKind::*; + use display::{ItemState::*, SuccessOutcome}; + match kind { - UpdateKind::Err => format!("failed: {msg}"), - UpdateKind::Cached => "cached".to_string(), - UpdateKind::Fin => match state { - display::ItemState::Done(display::SuccessOutcome::Cached) => "cached".to_string(), - display::ItemState::Done(display::SuccessOutcome::Built) => "built".to_string(), - display::ItemState::New - | display::ItemState::InProgress(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => msg.to_string(), + | Cached => "cached".to_string(), + | Cancel | Msg | SetOutcomeBuilt | SetOutcomeCached | Step => msg.to_string(), + | Err => format!("failed: {msg}"), + | Fin => match state { + | Done(SuccessOutcome::Cached) => "cached".to_string(), + | Done(SuccessOutcome::Built) => "built".to_string(), + | New | InProgress(_) | Cancelled | Failed => msg.to_string(), }, - UpdateKind::SetOutcomeBuilt - | UpdateKind::SetOutcomeCached - | UpdateKind::Cancel - | UpdateKind::Msg - | UpdateKind::Step => msg.to_string(), } } /// Format a plain-text message for a repo row. fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { + use UpdateKind::*; + use display::ItemState::*; + match kind { - UpdateKind::Err => format!("failed: {msg}"), - UpdateKind::Cached => "cached".to_string(), - UpdateKind::Fin => match state { - display::ItemState::Done(_) => "done".to_string(), - display::ItemState::New - | display::ItemState::InProgress(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => msg.to_string(), + | Cached => "cached".to_string(), + | Err => format!("failed: {msg}"), + | Fin => match state { + | Cancelled | Failed | New | InProgress(_) => msg.to_string(), + | Done(_) => "done".to_string(), }, - UpdateKind::SetOutcomeBuilt - | UpdateKind::SetOutcomeCached - | UpdateKind::Cancel - | UpdateKind::Msg - | UpdateKind::Step => msg.to_string(), + | Cancel | Msg | SetOutcomeBuilt | SetOutcomeCached | Step => msg.to_string(), } } @@ -324,12 +316,12 @@ fn split_lines_for_viewport( /// Transition `New` or `InProgress` into `InProgress(None)`. fn start_item(state: display::ItemState) -> display::ItemState { + use display::ItemState::*; + match state { - display::ItemState::New => display::ItemState::InProgress(None), - display::ItemState::InProgress(outcome) => display::ItemState::InProgress(outcome), - display::ItemState::Done(_) | display::ItemState::Cancelled | display::ItemState::Failed => { - state - } + | Cancelled | Done(_) | Failed => state, + | InProgress(outcome) => InProgress(outcome), + | New => InProgress(None), } } @@ -359,6 +351,8 @@ impl DisplayActor { &self, repo_id: display::ItemId, ) -> Option { + use display::{ItemState::*, SuccessOutcome::*}; + let mut saw_cached = false; for grammar in self @@ -368,18 +362,15 @@ impl DisplayActor { .filter(|g| g.repo_id == Some(repo_id)) { match grammar.state { - display::ItemState::Done(display::SuccessOutcome::Built) => { - return Some(display::SuccessOutcome::Built); + | Done(Built) => { + return Some(Built); } - display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, - display::ItemState::New - | display::ItemState::InProgress(_) - | display::ItemState::Cancelled - | display::ItemState::Failed => {} + | Done(Cached) => saw_cached = true, + | New | InProgress(_) | Cancelled | Failed => {} } } - saw_cached.then_some(display::SuccessOutcome::Cached) + saw_cached.then_some(Cached) } /// Aggregate the live outcome of still-active grammar children for a repo. @@ -396,17 +387,15 @@ impl DisplayActor { .values() .filter(|g| g.repo_id == Some(repo_id)) { + use display::{ItemState::*, SuccessOutcome::*}; + match grammar.state { - display::ItemState::InProgress(Some(display::SuccessOutcome::Built)) - | display::ItemState::Done(display::SuccessOutcome::Built) => { - return Some(display::SuccessOutcome::Built); - } - display::ItemState::InProgress(Some(display::SuccessOutcome::Cached)) - | display::ItemState::Done(display::SuccessOutcome::Cached) => saw_cached = true, - display::ItemState::New | display::ItemState::InProgress(None) => { - saw_unknown = true; + | Cancelled | Failed => {} + | Done(Built) | InProgress(Some(Built)) => { + return Some(Built); } - display::ItemState::Cancelled | display::ItemState::Failed => {} + | Done(Cached) | InProgress(Some(Cached)) => saw_cached = true, + | InProgress(None) | New => saw_unknown = true, } } @@ -426,49 +415,56 @@ impl DisplayActor { } match kind { - UpdateKind::Msg => { - grammar.msg = msg; - } - UpdateKind::Step => { - grammar.state = start_item(grammar.state); - grammar.step += 1; + | UpdateKind::Cached => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Done(display::SuccessOutcome::Cached); + grammar.step = grammar.total; grammar.msg = msg; } - UpdateKind::SetOutcomeCached => { - grammar.state = mark_success(grammar.state, display::SuccessOutcome::Cached); - } - UpdateKind::Cancel => { + + | UpdateKind::Cancel => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); grammar.state = display::ItemState::Cancelled; grammar.step = grammar.total; grammar.msg = msg; } - UpdateKind::SetOutcomeBuilt => { - grammar.state = mark_success(grammar.state, display::SuccessOutcome::Built); - } - UpdateKind::Cached => { + + | UpdateKind::Err => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Done(display::SuccessOutcome::Cached); - grammar.step = grammar.total; + grammar.state = display::ItemState::Failed; grammar.msg = msg; } - UpdateKind::Fin => { + + | UpdateKind::Fin => { grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); match finish_item(grammar.state) { - Ok(state) => { + | Ok(state) => { grammar.state = state; grammar.msg = msg; } - Err(message) => { + | Err(message) => { grammar.state = display::ItemState::Failed; grammar.msg = message.into(); } } grammar.step = grammar.total; } - UpdateKind::Err => { - grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); - grammar.state = display::ItemState::Failed; + + | UpdateKind::Msg => { + grammar.msg = msg; + } + + | UpdateKind::SetOutcomeCached => { + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Cached); + } + + | UpdateKind::SetOutcomeBuilt => { + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Built); + } + + | UpdateKind::Step => { + grammar.state = start_item(grammar.state); + grammar.step += 1; grammar.msg = msg; } } @@ -480,46 +476,42 @@ impl DisplayActor { return; } + use display::ItemState::*; + use display::SuccessOutcome::*; + match kind { - UpdateKind::Msg => { - repo.msg = msg; - } - UpdateKind::Step => { - repo.state = start_item(repo.state); - repo.step += 1; - repo.msg = msg; - } - UpdateKind::SetOutcomeCached => { - repo.state = mark_success(repo.state, display::SuccessOutcome::Cached); - } - UpdateKind::Cancel => { + | UpdateKind::Cached => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Cancelled; + repo.state = Done(Cached); if repo.total > 0 { repo.step = repo.total; } repo.msg = msg; } - UpdateKind::SetOutcomeBuilt => { - repo.state = mark_success(repo.state, display::SuccessOutcome::Built); - } - UpdateKind::Cached => { + | UpdateKind::Cancel => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Done(display::SuccessOutcome::Cached); + repo.state = Cancelled; if repo.total > 0 { repo.step = repo.total; } repo.msg = msg; } - UpdateKind::Fin => { + + | UpdateKind::Err => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = Failed; + repo.msg = msg; + } + + | UpdateKind::Fin => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); match finish_item(repo.state) { - Ok(state) => { + | Ok(state) => { repo.state = state; repo.msg = msg; } - Err(message) => { - repo.state = display::ItemState::Failed; + | Err(message) => { + repo.state = Failed; repo.msg = message.into(); } } @@ -527,9 +519,22 @@ impl DisplayActor { repo.step = repo.total; } } - UpdateKind::Err => { - repo.frozen_elapsed = Some(repo.started_at.elapsed()); - repo.state = display::ItemState::Failed; + + | UpdateKind::Msg => { + repo.msg = msg; + } + + | UpdateKind::SetOutcomeCached => { + repo.state = mark_success(repo.state, Cached); + } + + | UpdateKind::SetOutcomeBuilt => { + repo.state = mark_success(repo.state, Built); + } + + | UpdateKind::Step => { + repo.state = start_item(repo.state); + repo.step += 1; repo.msg = msg; } } @@ -548,15 +553,12 @@ impl DisplayActor { if let Some(grammar) = self.state.grammars.get_mut(&id) { Self::apply_grammar_update(grammar, kind, msg); self.grid.mark_dirty(id); + + use UpdateKind::*; + if matches!( kind, - UpdateKind::Cached - | UpdateKind::Err - | UpdateKind::Fin - | UpdateKind::SetOutcomeBuilt - | UpdateKind::SetOutcomeCached - | UpdateKind::Cancel - | UpdateKind::Step + Cached | Cancel | Err | Fin | SetOutcomeBuilt | SetOutcomeCached | Step ) { maybe_parent_id = grammar.repo_id; } @@ -618,18 +620,18 @@ impl DisplayActor { final_lines.push(Line::from("")); match render_final_report(terminal, final_lines) { - Ok(()) => { + | Err(err) => { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display final report failed: {err}"); + } + | Ok(()) => { ratatui::restore(); if let Err(err) = clear_from_cursor_down() { println!(); eprintln!("tsdl: fancy display cleanup failed: {err}"); } } - Err(err) => { - ratatui::restore(); - println!(); - eprintln!("tsdl: fancy display final report failed: {err}"); - } } let _ = tx.send(()); @@ -637,8 +639,10 @@ impl DisplayActor { /// Process a single message from the channel. fn handle_message(&mut self, msg: Message) { + use Message::*; + match msg { - Message::RegisterLanguage { + | RegisterLanguage { git_ref, name, num_tasks, @@ -647,7 +651,8 @@ impl DisplayActor { let addr = self.register_repo(name, git_ref, num_tasks); let _ = tx.send(addr); } - Message::RegisterGrammar { + + | RegisterGrammar { git_ref, language, name, @@ -657,16 +662,19 @@ impl DisplayActor { let addr = self.register_grammar(language, name, git_ref, num_tasks); let _ = tx.send(addr); } - Message::RegisterReference { .. } => {} - Message::Update { id, kind, msg } => { - self.apply_update(id, kind, msg); - } - Message::Shutdown { interrupted, tx } => { + + | RegisterReference { .. } => {} + + | Shutdown { interrupted, tx } => { if interrupted { self.cancel_live_rows(); } let _ = tx.send(()); } + + | Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + } } } @@ -937,8 +945,8 @@ impl DisplayActor { viewport: ratatui::Viewport::Inline(viewport_height), }, ) { - Ok(terminal) => terminal, - Err(err) => { + | Ok(terminal) => terminal, + | Err(err) => { eprintln!("tsdl: fancy display unavailable; falling back to plain progress: {err}"); self.run_plain().await; return; @@ -978,11 +986,11 @@ impl DisplayActor { // ── drain any messages that queued up concurrently ───── while let Ok(msg) = self.rx.try_recv() { match msg { - Message::Shutdown { interrupted, tx } => { + | Message::Shutdown { interrupted, tx } => { shutdown = Some((interrupted, tx)); break; } - other => self.handle_message(other), + | other => self.handle_message(other), } } @@ -1015,8 +1023,11 @@ impl DisplayActor { self.print_plain_metadata(); while let Some(msg) = self.rx.recv().await { + use Message::*; + use UpdateKind::*; + match msg { - Message::RegisterLanguage { + | RegisterLanguage { git_ref, name, num_tasks, @@ -1025,7 +1036,7 @@ impl DisplayActor { let addr = self.register_repo(name, git_ref, num_tasks); let _ = tx.send(addr); } - Message::RegisterGrammar { + | RegisterGrammar { git_ref, language, name, @@ -1037,22 +1048,19 @@ impl DisplayActor { let addr = self.register_grammar(language, name, git_ref, num_tasks); let _ = tx.send(addr); } - Message::RegisterReference { git_ref, name } => { + | RegisterReference { git_ref, name } => { self.print_plain_ref(&name, git_ref.short()); } - Message::Update { id, kind, msg } => { + | Update { id, kind, msg } => { self.apply_update(id, kind, msg); - if matches!( - kind, - UpdateKind::Msg | UpdateKind::SetOutcomeCached | UpdateKind::SetOutcomeBuilt - ) { + if matches!(kind, Msg | SetOutcomeCached | SetOutcomeBuilt) { continue; } if let Some(line) = self.plain_progress_line(id, kind) { self.print_plain_progress(&line); } } - Message::Shutdown { interrupted, tx } => { + | Shutdown { interrupted, tx } => { if interrupted { self.cancel_live_rows(); } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index e07c378..5325c24 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -98,8 +98,7 @@ async fn discover_grammars( // Also concurrent with CLI preparation. progress.step("scanning"); let grammars = match language.discover_grammars().await { - Ok(grammars) => grammars, - Err(e) => { + | Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { progress.cancel().await; } else { @@ -107,6 +106,7 @@ async fn discover_grammars( } return Err(e); } + | Ok(grammars) => grammars, }; progress.fin("done").await; @@ -185,7 +185,15 @@ async fn resolve_revision( progress.set_outcome_built().await; progress.step("cloning"); match language.checkout().await { - Ok(checkout) => { + | Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("clone failed").await; + } + Err(e) + } + | Ok(checkout) => { info!( "Resolved parser {} git ref {} to commit {}", language.name, @@ -194,14 +202,6 @@ async fn resolve_revision( ); Ok(crate::cache::Revision::moving(checkout.commit)) } - Err(e) => { - if shutdown::current().is_some_and(|s| s.is_cancelled()) { - progress.cancel().await; - } else { - progress.err("clone failed").await; - } - Err(e) - } } } else { let has_compatible_entries = cache @@ -238,14 +238,14 @@ pub async fn run( tree_sitter: &args::TreeSitter, ) -> Result<()> { let tree_sitter_ref = match tree_sitter::display_tree_sitter_ref(&tree_sitter.version) { - Ok(git_ref) => git_ref, - Err(err) => { + | Err(err) => { display.shutdown(false).await; return Err(Error::Context { message: format!("Parsing tree-sitter git ref {:?}", tree_sitter.version), source: err.into(), }); } + | Ok(git_ref) => git_ref, }; display.reference("tree-sitter-cli", tree_sitter_ref).await; @@ -396,10 +396,10 @@ async fn wait_for_prepared( loop { if let Some(result) = rx.borrow().as_ref() { return match result { - Ok(prepared) => Ok(prepared.clone()), - Err(e) => Err(Error::Message { + | Err(e) => Err(Error::Message { message: format!("tree-sitter CLI preparation failed: {e}"), }), + | Ok(prepared) => Ok(prepared.clone()), }; } if rx.changed().await.is_err() { diff --git a/src/app.rs b/src/app.rs index 432e937..f3218e9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -61,19 +61,16 @@ fn resolve_build( /// map cli args + matches into a [`resolvedcommand`] (build, config, or self-update). fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { + use args::{Command::*, ConfigCommand::*}; + match &args.command { - args::Command::Build => resolve_build(&args.config, args::build_matches(matches), "`build`") + | Build => resolve_build(&args.config, args::build_matches(matches), "`build`") .map(ResolvedCommand::Build), - - args::Command::Config { - command: args::ConfigCommand::Current, - } => resolve_build(&args.config, None, "`config current`").map(ResolvedCommand::ConfigCurrent), - - args::Command::Config { - command: args::ConfigCommand::Default, - } => Ok(ResolvedCommand::ConfigDefault), - - args::Command::Selfupdate { force, target } => Ok(ResolvedCommand::Selfupdate { + | Config { command: Current } => { + resolve_build(&args.config, None, "`config current`").map(ResolvedCommand::ConfigCurrent) + } + | Config { command: Default } => Ok(ResolvedCommand::ConfigDefault), + | Selfupdate { force, target } => Ok(ResolvedCommand::Selfupdate { force: *force, target: target.clone(), }), @@ -108,14 +105,16 @@ pub fn setup() -> Result { impl ResolvedCommand { /// Return a logging policy based on the command type. fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { + use logging::{Implicit::*, Policy}; + let implicit = match self { - Self::Build(build) => logging::Implicit::BuildDir { + | Self::Build(build) => BuildDir { dir: &build.command.build_dir, }, - _ => logging::Implicit::None, + | _ => logging::Implicit::None, }; - logging::Policy { explicit, implicit } + Policy { explicit, implicit } } } diff --git a/src/args.rs b/src/args.rs index 4e7d7db..0f5d946 100644 --- a/src/args.rs +++ b/src/args.rs @@ -388,21 +388,21 @@ impl Target { /// Check whether `self` covers the requested target (e.g. All covers everything). #[must_use] pub fn covers(&self, other: Target) -> bool { - matches!( - (self, other), - (Target::All, _) | (Target::Native, Target::Native) | (Target::Wasm, Target::Wasm) - ) + use Target::*; + + matches!((self, other), (All, _) | (Native, Native) | (Wasm, Wasm)) } /// Combine two targets into the broadest coverage. #[must_use] pub fn union(self, other: Self) -> Self { match (self, other) { - (Self::Native, Self::Native) => Self::Native, - (Self::Wasm, Self::Wasm) => Self::Wasm, - (Self::All, _) | (_, Self::All) | (Self::Native, Self::Wasm) | (Self::Wasm, Self::Native) => { - Self::All - } + | (Self::Native, Self::Native) => Self::Native, + | (Self::Wasm, Self::Wasm) => Self::Wasm, + | (Self::All, _) + | (_, Self::All) + | (Self::Native, Self::Wasm) + | (Self::Wasm, Self::Native) => Self::All, } } @@ -422,9 +422,9 @@ impl Target { #[must_use] pub fn to_lowercase(&self) -> &'static str { match self { - Target::Native => "native", - Target::Wasm => "wasm", - Target::All => "all", + | Target::Native => "native", + | Target::Wasm => "wasm", + | Target::All => "all", } } } @@ -432,9 +432,9 @@ impl Target { impl fmt::Display for VersionBump { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Major => write!(f, "major"), - Self::Minor => write!(f, "minor"), - Self::Patch => write!(f, "patch"), + | Self::Major => write!(f, "major"), + | Self::Minor => write!(f, "minor"), + | Self::Patch => write!(f, "patch"), } } } diff --git a/src/build.rs b/src/build.rs index fc98d80..463fca6 100644 --- a/src/build.rs +++ b/src/build.rs @@ -72,26 +72,28 @@ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result return Ok(guard), + | Acquired(guard) => return Ok(guard), - lock::Status::Cyclic => { + | Cyclic => { info!("lock::Lock already held by this process (cyclic)."); return Err(Error::Message { message: "1+ lock acquisition".into(), }); } - lock::Status::LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { - Ok(guard) => return Ok(guard), - Err(ref err) if err.is_retryable() => { + | LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { + | Err(ref err) if err.is_retryable() => { info!("{err}; re-checking lock status..."); // continue to the next loop iteration } - Err(err) => return Err(err.into()), + | Err(err) => return Err(err.into()), + | Ok(guard) => return Ok(guard), }, - lock::Status::Unknown { pid, reason } => { + | Unknown { pid, reason } => { if let Some(pid) = pid { info!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"); } else { @@ -158,24 +160,28 @@ fn get_language_coords( ) -> Result<(Url, parser::Ref, Option)> { let config = defined_parsers.and_then(|parsers| parsers.get(language)); + use args::ParserConfig::*; + match config { - Some(args::ParserConfig::Ref(git_ref)) => Ok(( + | None => Ok((default_repo(language)?, parser::Ref::head(), None)), + + | Some(Ref(git_ref)) => Ok(( default_repo(language)?, parser::Ref::parse(git_ref) .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, None, )), - Some(args::ParserConfig::Full { + | Some(Full { build_script, git_ref, from, }) => { let repo = match from { - Some(url_str) => { + | Some(url_str) => { Url::parse(url_str).with_context(|| format!("Parsing {url_str} for {language}"))? } - None => default_repo(language)?, + | None => default_repo(language)?, }; Ok(( @@ -185,8 +191,6 @@ fn get_language_coords( build_script.clone(), )) } - - None => Ok((default_repo(language)?, parser::Ref::head(), None)), } } @@ -292,8 +296,8 @@ fn unique_languages( let defined_parsers = command.parsers.as_ref(); let final_languages = match requested_languages { - Some(langs) if !langs.is_empty() => langs.clone(), - _ => defined_parsers + | Some(langs) if !langs.is_empty() => langs.clone(), + | _ => defined_parsers .map(|parsers| parsers.keys().cloned().collect()) .unwrap_or_default(), }; @@ -303,7 +307,12 @@ fn unique_languages( for language in unique { let result = match get_language_coords(&language, defined_parsers) { - Ok((repo, git_ref, build_script)) => Ok(parser::LanguageBuild::new( + | Err(err) => Err(Error::Language { + name: language, + source: err.into(), + }), + + | Ok((repo, git_ref, build_script)) => Ok(parser::LanguageBuild::new( Context { overwrite_output: command.force, }, @@ -327,10 +336,6 @@ fn unique_languages( target: command.target, }), )), - Err(err) => Err(Error::Language { - name: language, - source: err.into(), - }), }; results.push(result); } diff --git a/src/cache.rs b/src/cache.rs index 196faac..9e5c5a8 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -184,16 +184,19 @@ pub async fn verify_artifacts(artifacts: Vec) -> Decision { let mut reasons = Vec::new(); for path in artifacts { + use MissReason::*; + use io::ErrorKind::NotFound; + match tokio::fs::metadata(&path).await { - Ok(metadata) if metadata.is_file() => {} - Ok(_) => reasons.push(MissReason::ArtifactNotFile { path }), - Err(err) if err.kind() == io::ErrorKind::NotFound => { - reasons.push(MissReason::ArtifactMissing { path }); + | Err(err) if err.kind() == NotFound => { + reasons.push(ArtifactMissing { path }); } - Err(err) => reasons.push(MissReason::ArtifactInaccessible { + | Err(err) => reasons.push(ArtifactInaccessible { path, error: err.to_string(), }), + | Ok(metadata) if metadata.is_file() => {} + | Ok(_) => reasons.push(ArtifactNotFile { path }), } } @@ -324,8 +327,8 @@ impl Db { spec: &build::Spec, ) -> Decision { let decision = match self.get(name) { - None => Decision::miss(MissReason::MissingEntry), - Some(entry) => entry.rebuild_decision(hash, revision, spec), + | None => Decision::miss(MissReason::MissingEntry), + | Some(entry) => entry.rebuild_decision(hash, revision, spec), }; debug!("Cache decision for {name}: {decision}"); @@ -379,8 +382,8 @@ impl Decision { #[must_use] pub fn short_message(&self) -> String { match self { - Self::Hit => "cache hit".to_string(), - Self::Miss(miss) => miss.short_message(), + | Self::Hit => "cache hit".to_string(), + | Self::Miss(miss) => miss.short_message(), } } } @@ -471,8 +474,8 @@ impl Entry { impl fmt::Display for Decision { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Decision::Hit => write!(f, "cache hit"), - Decision::Miss(miss) => write!(f, "cache miss: {miss}"), + | Decision::Hit => write!(f, "cache hit"), + | Decision::Miss(miss) => write!(f, "cache miss: {miss}"), } } } @@ -504,24 +507,50 @@ impl fmt::Display for Miss { impl fmt::Display for MissReason { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::MissingEntry => write!(f, "missing cache entry"), - Self::CacheIgnored => write!(f, "cache ignored"), - Self::HashChanged { cached, current } => { + | Self::ArtifactInaccessible { path, error } => { + write!( + f, + "artifact inaccessible path={} error={error}", + path.display() + ) + } + | Self::ArtifactMissing { path } => { + write!(f, "artifact missing path={}", path.display()) + } + | Self::ArtifactNotFile { path } => { + write!(f, "artifact is not a regular file path={}", path.display()) + } + | Self::BuildScriptChanged => write!(f, "build script changed"), + | Self::CacheIgnored => write!(f, "cache ignored"), + | Self::HashChanged { cached, current } => { write!(f, "grammar hash changed cached={cached} current={current}") } - Self::RepoChanged { cached, current } => { - write!(f, "repo changed cached={cached} current={current}") + | Self::MissingEntry => write!(f, "missing cache entry"), + | Self::OutputsMissing { + available, + requested, + } => { + write!( + f, + "requested output not cached available={available:?} requested={requested:?}" + ) } - Self::RefChanged { cached, current } => write!( + | Self::PrefixChanged { cached, current } => { + write!(f, "prefix changed cached={cached:?} current={current:?}") + } + | Self::RefChanged { cached, current } => write!( f, "git ref changed cached={} current={}", cached.requested().as_str(), current.requested().as_str() ), - Self::RevisionChanged { cached, current } => { + | Self::RepoChanged { cached, current } => { + write!(f, "repo changed cached={cached} current={current}") + } + | Self::RevisionChanged { cached, current } => { write!(f, "revision changed cached={cached} current={current}") } - Self::TreeSitterChanged { cached, current } => write!( + | Self::TreeSitterChanged { cached, current } => write!( f, "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", cached.version, @@ -531,32 +560,6 @@ impl fmt::Display for MissReason { current.repo, current.platform ), - Self::BuildScriptChanged => write!(f, "build script changed"), - Self::PrefixChanged { cached, current } => { - write!(f, "prefix changed cached={cached:?} current={current:?}") - } - Self::OutputsMissing { - available, - requested, - } => { - write!( - f, - "requested output not cached available={available:?} requested={requested:?}" - ) - } - Self::ArtifactMissing { path } => { - write!(f, "artifact missing path={}", path.display()) - } - Self::ArtifactNotFile { path } => { - write!(f, "artifact is not a regular file path={}", path.display()) - } - Self::ArtifactInaccessible { path, error } => { - write!( - f, - "artifact inaccessible path={} error={error}", - path.display() - ) - } } } } @@ -564,8 +567,8 @@ impl fmt::Display for MissReason { impl fmt::Display for Revision { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Stable => write!(f, "stable"), - Self::Moving { commit } => write!(f, "moving commit={}", commit.as_str()), + | Self::Stable => write!(f, "stable"), + | Self::Moving { commit } => write!(f, "moving commit={}", commit.as_str()), } } } @@ -627,9 +630,9 @@ impl Miss { #[must_use] pub fn short_message(&self) -> String { match self.reasons.as_slice() { - [] => "cache changed".to_string(), - [reason] => reason.short_message().to_string(), - reasons => { + | [] => "cache changed".to_string(), + | [reason] => reason.short_message().to_string(), + | reasons => { let labels = reasons .iter() .map(MissReason::short_label) @@ -646,19 +649,19 @@ impl MissReason { #[must_use] pub fn short_label(&self) -> &'static str { match self { - Self::MissingEntry => "missing", - Self::CacheIgnored => "ignored", - Self::HashChanged { .. } => "hash", - Self::RepoChanged { .. } => "repo", - Self::RefChanged { .. } => "ref", - Self::RevisionChanged { .. } => "revision", - Self::TreeSitterChanged { .. } => "tree-sitter", - Self::BuildScriptChanged => "script", - Self::PrefixChanged { .. } => "prefix", - Self::OutputsMissing { .. } => "outputs", - Self::ArtifactMissing { .. } - | Self::ArtifactNotFile { .. } - | Self::ArtifactInaccessible { .. } => "artifact", + | Self::ArtifactInaccessible { .. } + | Self::ArtifactMissing { .. } + | Self::ArtifactNotFile { .. } => "artifact", + | Self::BuildScriptChanged => "script", + | Self::CacheIgnored => "ignored", + | Self::HashChanged { .. } => "hash", + | Self::MissingEntry => "missing", + | Self::OutputsMissing { .. } => "outputs", + | Self::PrefixChanged { .. } => "prefix", + | Self::RefChanged { .. } => "ref", + | Self::RepoChanged { .. } => "repo", + | Self::RevisionChanged { .. } => "revision", + | Self::TreeSitterChanged { .. } => "tree-sitter", } } @@ -666,19 +669,19 @@ impl MissReason { #[must_use] pub fn short_message(&self) -> &'static str { match self { - Self::MissingEntry => "not cached", - Self::CacheIgnored => "cache ignored", - Self::HashChanged { .. } => "grammar changed", - Self::RepoChanged { .. } => "repo changed", - Self::RefChanged { .. } => "git ref changed", - Self::RevisionChanged { .. } => "git ref resolved commit changed", - Self::TreeSitterChanged { .. } => "tree-sitter changed", - Self::BuildScriptChanged => "build script changed", - Self::PrefixChanged { .. } => "prefix changed", - Self::OutputsMissing { .. } => "requested output not cached", - Self::ArtifactMissing { .. } => "artifact missing", - Self::ArtifactNotFile { .. } => "artifact invalid", - Self::ArtifactInaccessible { .. } => "artifact inaccessible", + | Self::ArtifactInaccessible { .. } => "artifact inaccessible", + | Self::ArtifactMissing { .. } => "artifact missing", + | Self::ArtifactNotFile { .. } => "artifact invalid", + | Self::BuildScriptChanged => "build script changed", + | Self::CacheIgnored => "cache ignored", + | Self::HashChanged { .. } => "grammar changed", + | Self::MissingEntry => "not cached", + | Self::OutputsMissing { .. } => "requested output not cached", + | Self::PrefixChanged { .. } => "prefix changed", + | Self::RefChanged { .. } => "git ref changed", + | Self::RepoChanged { .. } => "repo changed", + | Self::RevisionChanged { .. } => "git ref resolved commit changed", + | Self::TreeSitterChanged { .. } => "tree-sitter changed", } } } @@ -715,17 +718,17 @@ impl Store { /// Delete the cache file from disk. pub async fn delete(&self) -> Result<()> { match tokio::fs::metadata(&self.file).await { - Ok(_) => { + | Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + | Err(err) => { + return Err(err) + .with_context(|| format!("Reading cache file metadata at {}", self.file.display())); + } + | Ok(_) => { tokio::fs::remove_file(&self.file) .await .with_context(|| format!("Deleting cache file at {}", self.file.display()))?; debug!("Cache file deleted"); } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => { - return Err(err) - .with_context(|| format!("Reading cache file metadata at {}", self.file.display())); - } } Ok(()) @@ -826,8 +829,8 @@ mod tests { fn assert_miss(decision: Decision, expected: &[MissReason]) { match decision { - Decision::Hit => panic!("expected cache miss"), - Decision::Miss(miss) => assert_eq!(miss.reasons, expected), + | Decision::Hit => panic!("expected cache miss"), + | Decision::Miss(miss) => assert_eq!(miss.reasons, expected), } } diff --git a/src/columns.rs b/src/columns.rs index 63e8783..9e7f4fc 100644 --- a/src/columns.rs +++ b/src/columns.rs @@ -182,18 +182,18 @@ const fn is_last_cell_in_row( x: usize, ) -> bool { match layout { - Layout::Column => item_index + rows >= item_count, - Layout::Row => x == cols - 1 || item_index == item_count - 1, - Layout::Plain => unreachable!(), + | Layout::Column => item_index + rows >= item_count, + | Layout::Plain => unreachable!(), + | Layout::Row => x == cols - 1 || item_index == item_count - 1, } } /// Convert grid coordinates to a linear index based on the layout direction. const fn linear_index(cols: usize, layout: Layout, rows: usize, x: usize, y: usize) -> usize { match layout { - Layout::Column => x * rows + y, - Layout::Row => y * cols + x, - Layout::Plain => unreachable!(), + | Layout::Column => x * rows + y, + | Layout::Plain => unreachable!(), + | Layout::Row => y * cols + x, } } diff --git a/src/config.rs b/src/config.rs index 577b4ce..5c0b5da 100644 --- a/src/config.rs +++ b/src/config.rs @@ -550,9 +550,11 @@ fn resolve_bool( /// Run a config command (current or default). pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { + use args::ConfigCommand::*; + match command { - args::ConfigCommand::Current => print_current(¤t(config_path, None)?), - args::ConfigCommand::Default => print_default(), + | Current => print_current(¤t(config_path, None)?), + | Default => print_default(), } } @@ -619,9 +621,9 @@ impl Source { /// Convert a clap `ValueSource` to a config Source. fn from_value_source(source: ValueSource) -> Option { match source { - ValueSource::CommandLine => Some(Self::CommandLine), - ValueSource::EnvVariable => Some(Self::Environment), - _ => None, + | ValueSource::CommandLine => Some(Self::CommandLine), + | ValueSource::EnvVariable => Some(Self::Environment), + | _ => None, } } } diff --git a/src/display.rs b/src/display.rs index adb4576..0a72e3d 100644 --- a/src/display.rs +++ b/src/display.rs @@ -320,12 +320,15 @@ fn count_item_state( failed: &mut usize, cancelled: &mut usize, ) { + use ItemState::*; + use SuccessOutcome::*; + match state { - ItemState::New | ItemState::InProgress(_) => *building += 1, - ItemState::Done(SuccessOutcome::Cached) => *cached += 1, - ItemState::Done(SuccessOutcome::Built) => *built += 1, - ItemState::Cancelled => *cancelled += 1, - ItemState::Failed => *failed += 1, + | Cancelled => *cancelled += 1, + | Done(Built) => *built += 1, + | Done(Cached) => *cached += 1, + | Failed => *failed += 1, + | InProgress(_) | New => *building += 1, } } @@ -377,15 +380,15 @@ fn format_elapsed_duration(dur: Duration) -> String { #[must_use] pub fn mode_from_args(progress: &ProgressStyle, verbose: &Verbosity) -> Mode { let mut mode = match progress { - ProgressStyle::Auto => { + | ProgressStyle::Auto => { if atty::is(atty::Stream::Stdout) { Mode::Fancy } else { Mode::Plain } } - ProgressStyle::Fancy => Mode::Fancy, - ProgressStyle::Plain => Mode::Plain, + | ProgressStyle::Fancy => Mode::Fancy, + | ProgressStyle::Plain => Mode::Plain, }; if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { @@ -596,32 +599,32 @@ impl ItemInfo<'_> { /// Get the current item state. pub(crate) fn state(&self) -> ItemState { match self { - ItemInfo::Grammar(g) => g.state, - ItemInfo::Repo(r) => r.state, + | ItemInfo::Grammar(g) => g.state, + | ItemInfo::Repo(r) => r.state, } } /// Get the current message string. pub(crate) fn msg(&self) -> &str { match self { - ItemInfo::Grammar(g) => &g.msg, - ItemInfo::Repo(r) => &r.msg, + | ItemInfo::Grammar(g) => &g.msg, + | ItemInfo::Repo(r) => &r.msg, } } /// Get the current step index (0-based). pub(crate) fn step(&self) -> usize { match self { - ItemInfo::Grammar(g) => g.step, - ItemInfo::Repo(r) => r.step, + | ItemInfo::Grammar(g) => g.step, + | ItemInfo::Repo(r) => r.step, } } /// Get the total number of steps. pub(crate) fn total(&self) -> usize { match self { - ItemInfo::Grammar(g) => g.total, - ItemInfo::Repo(r) => r.total, + | ItemInfo::Grammar(g) => g.total, + | ItemInfo::Repo(r) => r.total, } } @@ -629,16 +632,16 @@ impl ItemInfo<'_> { /// Get the git ref for this item. pub(crate) fn git_ref(&self) -> &git::Ref { match self { - ItemInfo::Grammar(g) => &g.git_ref, - ItemInfo::Repo(r) => &r.git_ref, + | ItemInfo::Grammar(g) => &g.git_ref, + | ItemInfo::Repo(r) => &r.git_ref, } } /// Get the elapsed time for this item. pub(crate) fn elapsed(&self) -> Duration { match self { - ItemInfo::Grammar(g) => g.elapsed(), - ItemInfo::Repo(r) => r.elapsed(), + | ItemInfo::Grammar(g) => g.elapsed(), + | ItemInfo::Repo(r) => r.elapsed(), } } } @@ -654,38 +657,38 @@ impl ItemState { #[must_use] pub fn success_outcome(self) -> Option { match self { - Self::InProgress(outcome) => outcome, - Self::Done(outcome) => Some(outcome), - Self::New | Self::Cancelled | Self::Failed => None, + | Self::Cancelled | Self::Failed | Self::New => None, + | Self::Done(outcome) => Some(outcome), + | Self::InProgress(outcome) => outcome, } } /// Return the status icon character. fn icon(self) -> &'static str { match self { - Self::New | Self::InProgress(_) => "●", - Self::Done(_) => "✓", - Self::Cancelled | Self::Failed => "✗", + | Self::Cancelled | Self::Failed => "✗", + | Self::Done(_) => "✓", + | Self::New | Self::InProgress(_) => "●", } } /// Return the colour for the name text. fn name_color(self) -> Color { match self { - Self::Failed => Color::Red, - Self::Cancelled => Color::Yellow, - Self::New | Self::InProgress(None) => Color::DarkGray, - Self::InProgress(Some(outcome)) | Self::Done(outcome) => outcome.color(), + | Self::Cancelled => Color::Yellow, + | Self::Done(outcome) | Self::InProgress(Some(outcome)) => outcome.color(), + | Self::Failed => Color::Red, + | Self::New | Self::InProgress(None) => Color::DarkGray, } } /// Return the colour for the status indicator. fn indicator_color(self) -> Color { match self { - Self::Done(_) => Color::Green, - Self::Cancelled => Color::Yellow, - Self::Failed => Color::Red, - Self::New | Self::InProgress(_) => Color::DarkGray, + | Self::Done(_) => Color::Green, + | Self::Cancelled => Color::Yellow, + | Self::Failed => Color::Red, + | Self::New | Self::InProgress(_) => Color::DarkGray, } } } @@ -819,13 +822,13 @@ impl State { /// Get the item info for a row spec. pub fn get_item_info(&self, spec: &RowSpec) -> ItemInfo<'_> { match spec.kind { - RowKind::Grammar => ItemInfo::Grammar( + | RowKind::Grammar => ItemInfo::Grammar( self .grammars .get(&spec.id) .expect("grammar not found for row spec"), ), - RowKind::Repo => ItemInfo::Repo( + | RowKind::Repo => ItemInfo::Repo( self .repos .get(&spec.id) @@ -910,8 +913,8 @@ impl SuccessOutcome { /// Return the display colour for entries with this outcome. fn color(self) -> Color { match self { - SuccessOutcome::Built => Color::Blue, - SuccessOutcome::Cached => Color::Yellow, + | SuccessOutcome::Built => Color::Blue, + | SuccessOutcome::Cached => Color::Yellow, } } } diff --git a/src/error.rs b/src/error.rs index d73d6b1..7871a52 100644 --- a/src/error.rs +++ b/src/error.rs @@ -181,8 +181,8 @@ fn format_language_collection( write!(w, ", ")?; } match error { - Error::Language { name, .. } => write!(w, "{name}")?, - _ => error.format(w, 0)?, + | Error::Language { name, .. } => write!(w, "{name}")?, + | _ => error.format(w, 0)?, } } @@ -216,16 +216,16 @@ impl fmt::Display for Error { impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Error::Build { .. } + | Error::Build { .. } | Error::Command { .. } | Error::Config { .. } | Error::Interrupted { .. } | Error::LanguageCollection { .. } | Error::Message { .. } => None, - Error::Context { source, .. } + | Error::Context { source, .. } | Error::Language { source, .. } | Error::Step { source, .. } => Some(source.as_error()), - Error::Io { source } => Some(source), + | Error::Io { source } => Some(source), } } } @@ -241,16 +241,19 @@ impl Error { /// Recursively format the error tree with per-level indentation. fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { + use Error::*; + let prefix = " ".repeat(indent); + match self { - Error::Build { errors } => format_build_errors(w, errors, indent), - Error::Command { + | Build { errors } => format_build_errors(w, errors, indent), + | Command { msg, stderr, stdout, } => format_command(w, indent, msg, stdout, stderr), - Error::Config { message } => write!(w, "{prefix}Configuration error: {message}"), - Error::Context { message, source } => { + | Config { message } => write!(w, "{prefix}Configuration error: {message}"), + | Context { message, source } => { write!( w, "{}{}\n{}", @@ -259,9 +262,9 @@ impl Error { source.as_error().format_indent(indent + 2) ) } - Error::Io { source } => write!(w, "{prefix}IO error: {source}"), - Error::Interrupted { signal } => write!(w, "{prefix}Interrupted by {signal}"), - Error::Language { name, source } => { + | Io { source } => write!(w, "{prefix}IO error: {source}"), + | Interrupted { signal } => write!(w, "{prefix}Interrupted by {signal}"), + | Language { name, source } => { write!( w, "{}{}\n{}", @@ -270,9 +273,9 @@ impl Error { source.as_error().format_indent(indent + 2) ) } - Error::LanguageCollection { related } => format_language_collection(w, related, indent), - Error::Message { message } => write!(w, "{prefix}{message}"), - Error::Step { name, kind, source } => { + | LanguageCollection { related } => format_language_collection(w, related, indent), + | Message { message } => write!(w, "{prefix}{message}"), + | Step { name, kind, source } => { write!( w, "{}{}: {}.\n{}", diff --git a/src/git.rs b/src/git.rs index 49381da..279963c 100644 --- a/src/git.rs +++ b/src/git.rs @@ -441,23 +441,23 @@ impl fmt::Display for Ref { impl fmt::Display for RefError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::EmptyRef => write!(f, "git ref cannot be empty"), - Self::InvalidRefCharacter { index, character } => { + | Self::EmptyRef => write!(f, "git ref cannot be empty"), + | Self::InvalidRefCharacter { index, character } => { write!( f, "git ref contains invalid character at byte {index}: {character:?}" ) } - Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), - Self::InvalidShaLength { actual } => { - write!(f, "git SHA must be exactly 40 hex characters, got {actual}") - } - Self::InvalidShaHex { index, character } => { + | Self::InvalidShaHex { index, character } => { write!( f, "git SHA contains non-hex character at byte {index}: {character:?}" ) } + | Self::InvalidShaLength { actual } => { + write!(f, "git SHA must be exactly 40 hex characters, got {actual}") + } + | Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), } } } @@ -465,8 +465,8 @@ impl fmt::Display for RefError { impl fmt::Display for ResolvedRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Tag { label, .. } => write!(f, "{label}"), - Self::Ref(git_ref) => write!(f, "{git_ref}"), + | Self::Ref(git_ref) => write!(f, "{git_ref}"), + | Self::Tag { label, .. } => write!(f, "{label}"), } } } diff --git a/src/lib.rs b/src/lib.rs index 14ef914..07ebf5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,14 +147,16 @@ fn normalize_components(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { + use Component::*; + match component { - Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), - Component::RootDir => normalized.push(component.as_os_str()), - Component::CurDir => {} - Component::ParentDir => { + | Prefix(prefix) => normalized.push(prefix.as_os_str()), + | RootDir => normalized.push(component.as_os_str()), + | CurDir => {} + | ParentDir => { normalized.pop(); } - Component::Normal(part) => normalized.push(part), + | Normal(part) => normalized.push(part), } } diff --git a/src/lock.rs b/src/lock.rs index 5331b3b..568203b 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -145,10 +145,10 @@ fn same_owner(current: &Owner, previous: &Owner) -> bool { impl fmt::Display for Observation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::LockedBy(owner) => { + | Self::LockedBy(owner) => { write!(f, "lock is held by PID {} ({})", owner.pid, owner.name) } - Self::Unknown { pid, reason } => { + | Self::Unknown { pid, reason } => { if let Some(pid) = pid { write!( f, @@ -199,38 +199,38 @@ impl fmt::Display for Owner { impl fmt::Display for TakeoverError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::OwnerDisappeared { previous } => write!( + | Self::OwnerDisappeared { previous } => write!( f, "Lock owner PID {} ({}) is no longer running; retry lock acquisition", previous.pid, previous.name ), - Self::OwnerChanged { previous, current } => write!( + | Self::OwnerChanged { previous, current } => write!( f, "Lock owner changed while taking over: previous PID {} ({}), current PID {} ({})", previous.pid, previous.name, current.pid, current.name ), - Self::Cyclic => write!(f, "Lock became cyclic while waiting for release"), - Self::Unknown { pid, reason } => { + | Self::Cyclic => write!(f, "Lock became cyclic while waiting for release"), + | Self::Unknown { pid, reason } => { if let Some(pid) = pid { write!(f, "Could not identify build lock owner PID {pid}: {reason}") } else { write!(f, "Could not identify build lock owner: {reason}") } } - Self::SignalFailed { owner } => { + | Self::SignalFailed { owner } => { write!( f, "Failed to send SIGTERM to lock owner PID {} ({})", owner.pid, owner.name ) } - Self::SignalUnsupported { .. } => { + | Self::SignalUnsupported { .. } => { write!( f, "SIGTERM is not supported on this platform; cannot terminate lock owner" ) } - Self::Timeout { + | Self::Timeout { previous, timeout, last_observation, @@ -241,7 +241,7 @@ impl fmt::Display for TakeoverError { previous.pid, previous.name ), - Self::Source(err) => write!(f, "{err}"), + | Self::Source(err) => write!(f, "{err}"), } } } @@ -343,9 +343,9 @@ impl Lock { let file = self.open_lock_file()?; match file.try_lock_exclusive() { - Ok(()) => self.activate(file).map(Status::Acquired), - Err(err) if is_lock_contention(&err) => Ok(self.lock_status()), - Err(err) => Err(Error::Context { + | Ok(()) => self.activate(file).map(Status::Acquired), + | Err(err) if is_lock_contention(&err) => Ok(self.lock_status()), + | Err(err) => Err(Error::Context { message: format!("Acquiring build lock {}", self.lock_path.display()), source: err.into(), }), @@ -377,14 +377,14 @@ impl Lock { } match process.kill_with(Signal::Term) { - Some(true) => { + | Some(true) => { info!("Sent SIGTERM to lock owner PID {}", owner.pid); Ok(()) } - Some(false) => Err(TakeoverError::SignalFailed { + | Some(false) => Err(TakeoverError::SignalFailed { owner: Box::new(owner.clone()), }), - None => Err(TakeoverError::SignalUnsupported { + | None => Err(TakeoverError::SignalUnsupported { owner: Box::new(owner.clone()), }), } @@ -420,12 +420,14 @@ impl Lock { }); } + use Status::*; + match self.try_acquire()? { - Status::Acquired(guard) => return Ok(guard), + | Acquired(guard) => return Ok(guard), - Status::Cyclic => return Err(TakeoverError::Cyclic), + | Cyclic => return Err(TakeoverError::Cyclic), - Status::LockedBy(current) => { + | LockedBy(current) => { if same_owner(¤t, owner) { last_observation = Observation::LockedBy(Box::new(current)); } else { @@ -436,7 +438,7 @@ impl Lock { } } - Status::Unknown { pid, reason } => { + | Unknown { pid, reason } => { // If metadata now points at a different PID that we cannot inspect, // stop waiting on the previous owner. Otherwise keep polling until // timeout so transient metadata reads or inherited lock handles have @@ -482,13 +484,13 @@ impl Lock { /// Helper for checking process status and determining lock conflicts. fn lock_status(&self) -> Status { let lock_pid = match self.read_pid() { - Ok(pid) => pid, - Err(err) => { + | Err(err) => { return Status::Unknown { pid: None, reason: format!("lock is held, but owner metadata could not be read: {err}"), }; } + | Ok(pid) => pid, }; if lock_pid == self.current_pid { @@ -496,12 +498,12 @@ impl Lock { } match Self::owner_for_pid(lock_pid) { - Some(owner) => Status::LockedBy(owner), - None => Status::Unknown { + | None => Status::Unknown { pid: Some(lock_pid), reason: "lock is held, but the metadata PID is not running or cannot be inspected" .to_string(), }, + | Some(owner) => Status::LockedBy(owner), } } diff --git a/src/logging.rs b/src/logging.rs index e6d374f..db4a37c 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -80,22 +80,24 @@ pub fn init( log_color: args::LogColor, verbose: clap_verbosity_flag::Verbosity, ) -> Result { + use args::LogColor::*; + let color = match log_color { - args::LogColor::Auto => atty::is(atty::Stream::Stdout), - args::LogColor::No => false, - args::LogColor::Yes => true, + | Auto => atty::is(atty::Stream::Stdout), + | No => false, + | Yes => true, }; console::set_colors_enabled(color); let filter = verbose.log_level_filter().as_trace(); let path = resolve_log_path(policy)?; let (writer, guard) = match path.as_ref() { - Some(path) => { + | None => (None, None), + | Some(path) => { let file = open_log_file(path)?; let (writer, guard) = tracing_appender::non_blocking(file); (Some(writer), Some(Guard(guard))) } - None => (None, None), }; init_tracing(writer, color, filter); @@ -137,12 +139,12 @@ fn open_log_file(log: &Path) -> Result { /// Resolve the log path from a policy (explicit + implicit fallback). fn resolve_log_path(policy: Policy<'_>) -> Result> { match (policy.explicit, policy.implicit) { - (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), - (Some(log), Implicit::None) => validate_standalone_log_path(log).map(Some), - (None, Implicit::BuildDir { dir }) => { + | (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), + | (Some(log), Implicit::None) => validate_standalone_log_path(log).map(Some), + | (None, Implicit::BuildDir { dir }) => { validate_log_path(dir, &dir.join(consts::LOG_FILE)).map(Some) } - (None, Implicit::None) => Ok(None), + | (None, Implicit::None) => Ok(None), } } diff --git a/src/main.rs b/src/main.rs index 9dfbf0d..27cb3c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,43 +7,43 @@ use tsdl::{Error, Result, app}; fn main() -> ExitCode { set_panic_hook(); let app = match app::setup() { - Ok(app) => app, - Err(e) => { + | Err(e) => { eprintln!("{e}"); return ExitCode::FAILURE; } + | Ok(app) => app, }; info!("Starting"); match run(&app) { - Err(Error::Interrupted { signal }) => ExitCode::from(signal.shell_exit_code()), - Err(e) => { + | Err(Error::Interrupted { signal }) => ExitCode::from(signal.shell_exit_code()), + | Err(e) => { eprintln!("{e}"); ExitCode::FAILURE } - Ok(()) => ExitCode::SUCCESS, + | Ok(()) => ExitCode::SUCCESS, } } fn run(app: &app::App) -> Result<()> { + use app::ResolvedCommand::*; + match &app.command { - app::ResolvedCommand::Build(build) => { + | Build(build) => { let (result, duration) = time(|| tsdl::build::run(&build.command, app)); match &result { - Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), - Err(Error::Interrupted { signal }) => println!( + | Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), + | Err(Error::Interrupted { signal }) => println!( "{}", style(format!("Interrupted by {signal} after {duration}")).yellow() ), - Err(_) => println!("{}", style(format!("Done in {duration}")).red()), + | Err(_) => println!("{}", style(format!("Done in {duration}")).red()), } result } - app::ResolvedCommand::ConfigCurrent(build) => tsdl::config::print_current(&build.command), - app::ResolvedCommand::ConfigDefault => tsdl::config::print_default(), - app::ResolvedCommand::Selfupdate { force, target } => { - tsdl::selfupdate::run(*force, target.as_str()) - } + | ConfigCurrent(build) => tsdl::config::print_current(&build.command), + | ConfigDefault => tsdl::config::print_default(), + | Selfupdate { force, target } => tsdl::selfupdate::run(*force, target.as_str()), } } diff --git a/src/parser.rs b/src/parser.rs index 0a10c13..2f1cccc 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -300,8 +300,8 @@ impl ArtifactKind { #[must_use] fn extension(self) -> &'static str { match self { - Self::Native => DLL_EXTENSION, - Self::Wasm => WASM_EXTENSION, + | Self::Native => DLL_EXTENSION, + | Self::Wasm => WASM_EXTENSION, } } @@ -609,10 +609,10 @@ impl GrammarBuild { } match (exact_match, candidates.len()) { - (Some(path), _) => Ok(path), - (None, 0) => Err(self.missing_parser_error(ext)), - (None, 1) => Ok(candidates.into_iter().next().unwrap()), - (None, _) => Err(self.multiple_parsers_error(ext, &candidates)), + | (None, 0) => Err(self.missing_parser_error(ext)), + | (None, 1) => Ok(candidates.into_iter().next().unwrap()), + | (None, _) => Err(self.multiple_parsers_error(ext, &candidates)), + | (Some(path), _) => Ok(path), } } @@ -656,17 +656,17 @@ impl GrammarBuild { .with_context(|| format!("Reading {}", src.display()))?; let dst_link_metadata = match fs::symlink_metadata(&dst).await { - Ok(metadata) => metadata, - Err(err) if err.kind() == io::ErrorKind::NotFound => { + | Err(err) if err.kind() == io::ErrorKind::NotFound => { self.create_hardlink(&src, &dst).await?; return Ok(()); } - Err(err) => { + | Err(err) => { return Err(Error::Context { message: format!("Reading {}", dst.display()), source: err.into(), }); } + | Ok(metadata) => metadata, }; self @@ -758,15 +758,15 @@ impl GrammarBuild { } match fs::metadata(dst).await { - Ok(dst_metadata) if same_file_identity(&src_metadata, &dst_metadata) => return Ok(()), - Ok(_) => {} - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - Err(err) => { + | Err(err) if err.kind() == io::ErrorKind::NotFound => {} + | Err(err) => { return Err(Error::Context { message: format!("Reading {}", dst.display()), source: err.into(), }); } + | Ok(dst_metadata) if same_file_identity(&src_metadata, &dst_metadata) => return Ok(()), + | Ok(_) => {} } self.replace_with_hardlink(src, dst).await @@ -1018,7 +1018,7 @@ impl Ref { #[must_use] pub fn requested(&self) -> &git::Ref { match self { - Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, + | Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, } } } @@ -1175,7 +1175,7 @@ mod tests { let err = language.discover_grammars().await.unwrap_err(); match err { - Error::Step { name, kind, source } => { + | Error::Step { name, kind, source } => { assert_eq!(name.as_ref(), "empty"); assert!(matches!(kind, error::ParserOp::Discover { dir } if dir == build_dir)); let message = source.as_error().to_string(); @@ -1183,7 +1183,7 @@ mod tests { assert!(message.contains("https://example.com/tree-sitter-empty")); assert!(message.contains("v1.0.0")); } - other => panic!("expected discovery step error, got {other:?}"), + | other => panic!("expected discovery step error, got {other:?}"), } } diff --git a/src/selfupdate.rs b/src/selfupdate.rs index 6f4301a..3a548b9 100644 --- a/src/selfupdate.rs +++ b/src/selfupdate.rs @@ -46,10 +46,10 @@ fn download_and_replace(asset_name: &str, download_url: &str, version: &Version) fn parse_target(raw: &str) -> Result { match raw { - "major" => Ok(UpdateTarget::Relative(VersionBump::Major)), - "minor" => Ok(UpdateTarget::Relative(VersionBump::Minor)), - "patch" => Ok(UpdateTarget::Relative(VersionBump::Patch)), - other => Version::parse(other) + | "major" => Ok(UpdateTarget::Relative(VersionBump::Major)), + | "minor" => Ok(UpdateTarget::Relative(VersionBump::Minor)), + | "patch" => Ok(UpdateTarget::Relative(VersionBump::Patch)), + | other => Version::parse(other) .map(UpdateTarget::Exact) .context("expected 'patch', 'minor', 'major', or a semver like '2.5.0'"), } @@ -78,7 +78,7 @@ pub fn run(force: bool, target: &str) -> Result<()> { } let (release, version) = match update_target { - UpdateTarget::Exact(target_version) => { + | UpdateTarget::Exact(target_version) => { let Some(found_release) = releases .iter() .find(|r| Version::parse(&r.version).is_ok_and(|v| v == target_version)) @@ -108,7 +108,7 @@ pub fn run(force: bool, target: &str) -> Result<()> { (found_release.clone(), target_version) } - UpdateTarget::Relative(bump) => { + | UpdateTarget::Relative(bump) => { let compatible: Vec<_> = releases .iter() .filter(|r| { @@ -119,9 +119,9 @@ pub fn run(force: bool, target: &str) -> Result<()> { return false; } match bump { - VersionBump::Major => true, - VersionBump::Minor => rel_ver.major == current_version.major, - VersionBump::Patch => { + | VersionBump::Major => true, + | VersionBump::Minor => rel_ver.major == current_version.major, + | VersionBump::Patch => { rel_ver.major == current_version.major && rel_ver.minor == current_version.minor } } diff --git a/src/sh.rs b/src/sh.rs index c6454ba..5f3b202 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -70,8 +70,8 @@ impl Exec for Command { let base = self.display()?; match cwd { - Some(path) => Ok(format!("[{}] {}", path.display(), base)), - None => Ok(base), + | None => Ok(base), + | Some(path) => Ok(format!("[{}] {}", path.display(), base)), } } @@ -108,11 +108,11 @@ impl Exec for Command { debug!("spawned pid={child_pid:?} cmd={cmd_short}"); let pgid_guard = match (pgid, shutdown::current()) { - (Some(pgid), Some(shutdown)) => { + | (Some(pgid), Some(shutdown)) => { debug!("registered pgid={pgid}"); Some(shutdown.register_pgid(pgid)) } - _ => None, + | _ => None, }; // Test hook: pause while the child is running so an external diff --git a/src/shutdown.rs b/src/shutdown.rs index ceb884c..2ed30b8 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -82,8 +82,8 @@ pub struct Signal { /// Wait for the shutdown signal from the current task-local handle. pub async fn cancelled() -> Signal { match current() { - Some(shutdown) => shutdown.cancelled().await, - None => std::future::pending::().await, + | None => std::future::pending::().await, + | Some(shutdown) => shutdown.cancelled().await, } } @@ -171,10 +171,10 @@ impl fmt::Display for PgId { impl fmt::Display for PgIdError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Zero => write!(f, "process group id cannot be zero"), - Self::OutOfRange(value) => { + | Self::OutOfRange(value) => { write!(f, "process group id {value} does not fit in libc::pid_t") } + | Self::Zero => write!(f, "process group id cannot be zero"), } } } diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index c4acad6..c72fce3 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -50,14 +50,14 @@ pub struct PreparedCli { /// Check whether a cached tree-sitter CLI binary is still valid. async fn check_cached_cli(path: &Path, tag: &str) -> Result { let metadata = match fs::symlink_metadata(path).await { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + | Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return Ok(CliCacheStatus::Missing); } - Err(err) => { + | Err(err) => { return Err(err) .with_context(|| format!("Inspecting cached tree-sitter CLI {}", path.display())); } + | Ok(metadata) => metadata, }; let file_type = metadata.file_type(); @@ -89,9 +89,9 @@ async fn check_cached_cli(path: &Path, tag: &str) -> Result { } match verify_cli(path, tag).await { - Ok(()) => Ok(CliCacheStatus::Hit), - Err(Error::Interrupted { signal }) => Err(Error::Interrupted { signal }), - Err(err) => Ok(CliCacheStatus::Invalid(first_line(&err.to_string()))), + | Err(Error::Interrupted { signal }) => Err(Error::Interrupted { signal }), + | Err(err) => Ok(CliCacheStatus::Invalid(first_line(&err.to_string()))), + | Ok(()) => Ok(CliCacheStatus::Hit), } } @@ -122,18 +122,18 @@ async fn cli( let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); match check_cached_cli(&res, tag).await? { - CliCacheStatus::Hit => { + | CliCacheStatus::Hit => { info!("Using cached tree-sitter CLI at {}", res.display()); handle.set_outcome_cached().await; handle.step("cached"); } - CliCacheStatus::Missing => { + | CliCacheStatus::Missing => { info!("Tree-sitter CLI cache miss for {tag}; downloading from {url}"); handle.set_outcome_built().await; handle.step("downloading"); download_and_install(&url, &res, tag).await?; } - CliCacheStatus::Invalid(reason) => { + | CliCacheStatus::Invalid(reason) => { warn!( "Cached tree-sitter CLI at {} is invalid ({reason}); re-downloading from {url}", res.display() @@ -151,12 +151,12 @@ async fn cli( /// Wrap a result, adding context unless it's an Interrupted error. fn context_unless_interrupted(result: Result, message: impl FnOnce() -> String) -> Result { match result { - Ok(value) => Ok(value), - Err(err @ Error::Interrupted { .. }) => Err(err), - Err(err) => Err(Error::Context { + | Err(err @ Error::Interrupted { .. }) => Err(err), + | Err(err) => Err(Error::Context { message: message(), source: err.into(), }), + | Ok(value) => Ok(value), } } @@ -321,8 +321,7 @@ pub async fn prepare( progress.step(format!("resolving {git_ref}")); let tag = match tag(repo.as_str(), git_ref).await { - Ok(tag) => tag, - Err(e) => { + | Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { progress.cancel().await; } else { @@ -330,10 +329,10 @@ pub async fn prepare( } return Err(e); } + | Ok(tag) => tag, }; let release_tag = match resolve_release_tag(build_dir, &progress, &tree_sitter.repo, &tag).await { - Ok(tag) => tag, - Err(e) => { + | Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { progress.cancel().await; } else { @@ -341,6 +340,7 @@ pub async fn prepare( } return Err(e); } + | Ok(tag) => tag, }; info!("Resolved tree-sitter CLI ref {git_ref:?} to release tag {release_tag:?}"); @@ -360,8 +360,7 @@ pub async fn prepare( ) }, ) { - Ok(cli) => cli, - Err(e) => { + | Err(e) => { if shutdown::current().is_some_and(|s| s.is_cancelled()) { progress.cancel().await; } else { @@ -369,6 +368,7 @@ pub async fn prepare( } return Err(e); } + | Ok(cli) => cli, }; progress.fin("done").await; @@ -406,13 +406,13 @@ async fn resolve_release_tag( resolved_ref: &git::ResolvedRef, ) -> Result { let tag = match resolved_ref { - git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), - git::ResolvedRef::Ref(git_ref) => { + | git::ResolvedRef::Ref(git_ref) => { handle.msg(format!("resolving exact tag for {resolved_ref}")); let tree_sitter = build_dir.join("tree-sitter"); git::clone(repo, &tree_sitter).await?; Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) } + | git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), }; Ok(tag.into_owned()) } @@ -512,8 +512,8 @@ mod tests { std_fs::write(&path, b"tree-sitter 1.2.3\n").unwrap(); match check_cached_cli(&path, "v1.2.3").await.unwrap() { - CliCacheStatus::Invalid(reason) => assert!(reason.contains("not executable")), - status => panic!("expected invalid cache entry, got {status:?}"), + | CliCacheStatus::Invalid(reason) => assert!(reason.contains("not executable")), + | status => panic!("expected invalid cache entry, got {status:?}"), } } @@ -536,8 +536,8 @@ mod tests { write_script(&path, "#!/bin/sh\necho 'tree-sitter 9.9.9'\n"); match check_cached_cli(&path, "v1.2.3").await.unwrap() { - CliCacheStatus::Invalid(reason) => assert!(reason.contains("expected version")), - status => panic!("expected invalid cache entry, got {status:?}"), + | CliCacheStatus::Invalid(reason) => assert!(reason.contains("expected version")), + | status => panic!("expected invalid cache entry, got {status:?}"), } } @@ -584,11 +584,11 @@ mod tests { ); let tag = find_tag(&refs, "1.0.0").unwrap(); match tag { - git::ResolvedRef::Tag { sha, label } => { + | git::ResolvedRef::Ref(_) => panic!("Expected git::ResolvedRef::Tag"), + | git::ResolvedRef::Tag { sha, label } => { assert_eq!(sha.as_str(), "636801770eea172d140e64b691815ff11f6b556f"); assert_eq!(label, "v1.0.0"); } - git::ResolvedRef::Ref(_) => panic!("Expected git::ResolvedRef::Tag"), } } @@ -597,10 +597,10 @@ mod tests { let refs = HashMap::new(); let tag = find_tag(&refs, "1.0.0").unwrap(); match tag { - git::ResolvedRef::Ref(git_ref) => { + | git::ResolvedRef::Ref(git_ref) => { assert_eq!(git_ref.as_str(), "v1.0.0"); } - git::ResolvedRef::Tag { .. } => panic!("Expected git::ResolvedRef::Ref"), + | git::ResolvedRef::Tag { .. } => panic!("Expected git::ResolvedRef::Ref"), } } } diff --git a/tests/config.rs b/tests/config.rs index 2ff67de..4bb42f7 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -24,8 +24,8 @@ impl Drop for EnvVarGuard { // SAFETY: The caller holds ENV_LOCK, serializing all env access // within these tests. match &self.previous { - Some(value) => unsafe { env::set_var(self.key, value) }, - None => unsafe { env::remove_var(self.key) }, + | None => unsafe { env::remove_var(self.key) }, + | Some(value) => unsafe { env::set_var(self.key, value) }, } } } From 9f7e2d152f75341312a4507132357c9def609e8e Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Sat, 8 Aug 2026 10:27:54 +0200 Subject: [PATCH 86/88] cargo: update --- Cargo.lock | 66 +++++++++++++++++++++++++++--------------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6269e15..74bc9e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,9 +172,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -182,9 +182,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -346,7 +346,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -360,9 +360,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -1654,7 +1654,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -1703,9 +1703,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1720,7 +1720,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2328,7 +2328,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -2351,7 +2351,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -2467,7 +2467,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -3330,11 +3330,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -3350,9 +3350,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -3591,7 +3591,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -3901,9 +3901,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -3914,9 +3914,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -3924,9 +3924,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3934,9 +3934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -3947,18 +3947,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -4424,7 +4424,7 @@ checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" dependencies = [ "base64", "ed25519-dalek", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] From a66296ce3391185134e2505171169e76aedec1ed Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 10 Aug 2026 10:13:12 +0200 Subject: [PATCH 87/88] clipy: fix --- src/actors/cache.rs | 2 +- src/actors/display.rs | 43 ++++++++++++++++++++++++++----------------- src/app.rs | 7 +++++-- src/args.rs | 2 +- src/build.rs | 6 +++--- src/cache.rs | 2 +- src/config.rs | 2 +- src/display.rs | 4 ++-- src/error.rs | 4 +++- src/lib.rs | 8 ++++---- src/lock.rs | 4 ++-- src/logging.rs | 2 +- src/main.rs | 2 +- 13 files changed, 51 insertions(+), 37 deletions(-) diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 36d6be0..e8a87b0 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -110,7 +110,7 @@ impl CacheActor { /// Process incoming cache messages until the channel closes. async fn run(mut self) { while let Some(msg) = self.rx.recv().await { - use CacheMessage::*; + use CacheMessage::{Get, HasCompatibleEntries, NeedsRebuild, Save, Update}; match msg { | Get { name, tx } => { diff --git a/src/actors/display.rs b/src/actors/display.rs index 6820698..4fc7b3b 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -195,7 +195,7 @@ fn draw_lines_in_viewport( /// Transition an item state to `Done`. Returns an error if the state is not /// `InProgress(Some(_))`. fn finish_item(state: display::ItemState) -> Result { - use display::ItemState::*; + use display::ItemState::{Cancelled, Done, Failed, InProgress, New}; match state { | Cancelled | Done(_) | Failed => { @@ -245,7 +245,7 @@ fn invalid_finish(reason: &str, state: display::ItemState) -> Result display::ItemState { - use display::ItemState::*; + use display::ItemState::{Cancelled, Done, Failed, InProgress, New}; match state { | Cancelled | Done(_) | Failed => state, @@ -255,8 +255,11 @@ fn mark_success(state: display::ItemState, outcome: display::SuccessOutcome) -> /// Format a plain-text message for a grammar row. fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { - use UpdateKind::*; - use display::{ItemState::*, SuccessOutcome}; + use UpdateKind::{Cached, Cancel, Err, Fin, Msg, SetOutcomeBuilt, SetOutcomeCached, Step}; + use display::{ + ItemState::{Cancelled, Done, Failed, InProgress, New}, + SuccessOutcome, + }; match kind { | Cached => "cached".to_string(), @@ -272,8 +275,8 @@ fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) /// Format a plain-text message for a repo row. fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { - use UpdateKind::*; - use display::ItemState::*; + use UpdateKind::{Cached, Cancel, Err, Fin, Msg, SetOutcomeBuilt, SetOutcomeCached, Step}; + use display::ItemState::{Cancelled, Done, Failed, InProgress, New}; match kind { | Cached => "cached".to_string(), @@ -316,7 +319,7 @@ fn split_lines_for_viewport( /// Transition `New` or `InProgress` into `InProgress(None)`. fn start_item(state: display::ItemState) -> display::ItemState { - use display::ItemState::*; + use display::ItemState::{Cancelled, Done, Failed, InProgress, New}; match state { | Cancelled | Done(_) | Failed => state, @@ -351,7 +354,10 @@ impl DisplayActor { &self, repo_id: display::ItemId, ) -> Option { - use display::{ItemState::*, SuccessOutcome::*}; + use display::{ + ItemState::{Cancelled, Done, Failed, InProgress, New}, + SuccessOutcome::{Built, Cached}, + }; let mut saw_cached = false; @@ -387,7 +393,10 @@ impl DisplayActor { .values() .filter(|g| g.repo_id == Some(repo_id)) { - use display::{ItemState::*, SuccessOutcome::*}; + use display::{ + ItemState::{Cancelled, Done, Failed, InProgress, New}, + SuccessOutcome::{Built, Cached}, + }; match grammar.state { | Cancelled | Failed => {} @@ -472,13 +481,13 @@ impl DisplayActor { /// Apply a state update to a repo entry. fn apply_repo_update(repo: &mut display::RepoEntry, kind: UpdateKind, msg: Arc) { + use display::ItemState::{Cancelled, Done, Failed}; + use display::SuccessOutcome::{Built, Cached}; + if !repo.state.is_live() { return; } - use display::ItemState::*; - use display::SuccessOutcome::*; - match kind { | UpdateKind::Cached => { repo.frozen_elapsed = Some(repo.started_at.elapsed()); @@ -551,11 +560,11 @@ impl DisplayActor { let mut maybe_parent_id: Option = None; if let Some(grammar) = self.state.grammars.get_mut(&id) { + use UpdateKind::{Cached, Cancel, Err, Fin, SetOutcomeBuilt, SetOutcomeCached, Step}; + Self::apply_grammar_update(grammar, kind, msg); self.grid.mark_dirty(id); - use UpdateKind::*; - if matches!( kind, Cached | Cancel | Err | Fin | SetOutcomeBuilt | SetOutcomeCached | Step @@ -639,7 +648,7 @@ impl DisplayActor { /// Process a single message from the channel. fn handle_message(&mut self, msg: Message) { - use Message::*; + use Message::{RegisterGrammar, RegisterLanguage, RegisterReference, Shutdown, Update}; match msg { | RegisterLanguage { @@ -1023,8 +1032,8 @@ impl DisplayActor { self.print_plain_metadata(); while let Some(msg) = self.rx.recv().await { - use Message::*; - use UpdateKind::*; + use Message::{RegisterGrammar, RegisterLanguage, RegisterReference, Shutdown, Update}; + use UpdateKind::{Msg, SetOutcomeBuilt, SetOutcomeCached}; match msg { | RegisterLanguage { diff --git a/src/app.rs b/src/app.rs index f3218e9..c45ef1b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -61,7 +61,10 @@ fn resolve_build( /// map cli args + matches into a [`resolvedcommand`] (build, config, or self-update). fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { - use args::{Command::*, ConfigCommand::*}; + use args::{ + Command::{Build, Config, Selfupdate}, + ConfigCommand::{Current, Default}, + }; match &args.command { | Build => resolve_build(&args.config, args::build_matches(matches), "`build`") @@ -105,7 +108,7 @@ pub fn setup() -> Result { impl ResolvedCommand { /// Return a logging policy based on the command type. fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { - use logging::{Implicit::*, Policy}; + use logging::{Implicit::BuildDir, Policy}; let implicit = match self { | Self::Build(build) => BuildDir { diff --git a/src/args.rs b/src/args.rs index 0f5d946..3503192 100644 --- a/src/args.rs +++ b/src/args.rs @@ -388,7 +388,7 @@ impl Target { /// Check whether `self` covers the requested target (e.g. All covers everything). #[must_use] pub fn covers(&self, other: Target) -> bool { - use Target::*; + use Target::{All, Native, Wasm}; matches!((self, other), (All, _) | (Native, Native) | (Wasm, Wasm)) } diff --git a/src/build.rs b/src/build.rs index 463fca6..549c308 100644 --- a/src/build.rs +++ b/src/build.rs @@ -72,7 +72,7 @@ fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result return Ok(guard), @@ -158,9 +158,9 @@ fn get_language_coords( language: &str, defined_parsers: Option<&BTreeMap>, ) -> Result<(Url, parser::Ref, Option)> { - let config = defined_parsers.and_then(|parsers| parsers.get(language)); + use args::ParserConfig::{Full, Ref}; - use args::ParserConfig::*; + let config = defined_parsers.and_then(|parsers| parsers.get(language)); match config { | None => Ok((default_repo(language)?, parser::Ref::head(), None)), diff --git a/src/cache.rs b/src/cache.rs index 9e5c5a8..d8598f0 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -184,7 +184,7 @@ pub async fn verify_artifacts(artifacts: Vec) -> Decision { let mut reasons = Vec::new(); for path in artifacts { - use MissReason::*; + use MissReason::{ArtifactInaccessible, ArtifactMissing, ArtifactNotFile}; use io::ErrorKind::NotFound; match tokio::fs::metadata(&path).await { diff --git a/src/config.rs b/src/config.rs index 5c0b5da..9f5dfd2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -550,7 +550,7 @@ fn resolve_bool( /// Run a config command (current or default). pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { - use args::ConfigCommand::*; + use args::ConfigCommand::{Current, Default}; match command { | Current => print_current(¤t(config_path, None)?), diff --git a/src/display.rs b/src/display.rs index 0a72e3d..e19a427 100644 --- a/src/display.rs +++ b/src/display.rs @@ -320,8 +320,8 @@ fn count_item_state( failed: &mut usize, cancelled: &mut usize, ) { - use ItemState::*; - use SuccessOutcome::*; + use ItemState::{Cancelled, Done, Failed, InProgress, New}; + use SuccessOutcome::{Built, Cached}; match state { | Cancelled => *cancelled += 1, diff --git a/src/error.rs b/src/error.rs index 7871a52..00b22d7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -241,7 +241,9 @@ impl Error { /// Recursively format the error tree with per-level indentation. fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - use Error::*; + use Error::{ + Build, Command, Config, Context, Interrupted, Io, Language, LanguageCollection, Message, Step, + }; let prefix = " ".repeat(indent); diff --git a/src/lib.rs b/src/lib.rs index 07ebf5b..008e4da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,16 +147,16 @@ fn normalize_components(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { - use Component::*; + use Component::{CurDir, Normal, ParentDir, Prefix, RootDir}; match component { - | Prefix(prefix) => normalized.push(prefix.as_os_str()), - | RootDir => normalized.push(component.as_os_str()), | CurDir => {} + | Normal(part) => normalized.push(part), | ParentDir => { normalized.pop(); } - | Normal(part) => normalized.push(part), + | Prefix(prefix) => normalized.push(prefix.as_os_str()), + | RootDir => normalized.push(component.as_os_str()), } } diff --git a/src/lock.rs b/src/lock.rs index 568203b..09b3d59 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -400,6 +400,8 @@ impl Lock { owner: &Owner, timeout: Duration, ) -> StdResult { + use Status::{Acquired, Cyclic, LockedBy, Unknown}; + info!( "Waiting up to {} for lock release from PID {}", crate::format_duration(timeout), @@ -420,8 +422,6 @@ impl Lock { }); } - use Status::*; - match self.try_acquire()? { | Acquired(guard) => return Ok(guard), diff --git a/src/logging.rs b/src/logging.rs index db4a37c..7cf2efa 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -80,7 +80,7 @@ pub fn init( log_color: args::LogColor, verbose: clap_verbosity_flag::Verbosity, ) -> Result { - use args::LogColor::*; + use args::LogColor::{Auto, No, Yes}; let color = match log_color { | Auto => atty::is(atty::Stream::Stdout), diff --git a/src/main.rs b/src/main.rs index 27cb3c0..9592890 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ fn main() -> ExitCode { } fn run(app: &app::App) -> Result<()> { - use app::ResolvedCommand::*; + use app::ResolvedCommand::{Build, ConfigCurrent, ConfigDefault, Selfupdate}; match &app.command { | Build(build) => { From 809dd8837847078f0343e351c60c8f1f05ea7bf0 Mon Sep 17 00:00:00 2001 From: Firas al-Khalil Date: Mon, 10 Aug 2026 10:30:43 +0200 Subject: [PATCH 88/88] github: remove dependabot; it's just noise --- .github/dependabot.yml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 8004f80..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 -updates: - # Maintain dependencies for Cargo - - package-ecosystem: "cargo" - directory: "/" - schedule: - interval: "daily" - # Disables regular version updates while allowing security PRs - open-pull-requests-limit: 0 - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 0