From 3801e8c1347068113cd73984a055684bcfbb3262 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sun, 13 Sep 2026 02:42:49 -0700 Subject: [PATCH 1/6] Call the fold hook over reqwest instead of jsonrpsee The fold hook is still a JSON-RPC 2.0 server on loopback HTTP with the same `summarize` method, params and errors. The client is now a plain reqwest POST with a hand-built request and response envelope, so the later built-in summarizer shares one HTTP stack. The hook's interface to `DiffResult` is unchanged. Written with AI assistance (Claude Code). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0de57442-ad9b-4c99-960e-4974e629b71d Agent-Session: e046556c-20f0-4bba-832b-b6784a7783c5 Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 --- Cargo.lock | 723 ++++++++++++++++++++++++++++++---------------------- Cargo.toml | 2 +- src/hook.rs | 120 ++++++--- 3 files changed, 506 insertions(+), 339 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1cba9554e..bbe262c46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,17 +82,6 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -105,12 +94,41 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" @@ -152,18 +170,29 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clap" version = "4.5.23" @@ -192,6 +221,15 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -224,6 +262,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.5" @@ -276,7 +323,6 @@ dependencies = [ "humansize", "ignore", "imara-diff", - "jsonrpsee", "lazy_static", "libc", "line-numbers", @@ -288,6 +334,7 @@ dependencies = [ "radix-heap", "rayon", "regex", + "reqwest", "rustc-hash", "serde", "serde_json", @@ -382,6 +429,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.10.0" @@ -418,12 +471,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.8" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -474,6 +527,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -508,7 +567,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", - "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -521,8 +579,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -692,7 +766,6 @@ dependencies = [ "http", "hyper", "hyper-util", - "log", "rustls", "tokio", "tokio-rustls", @@ -705,13 +778,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -857,6 +933,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-terminal" version = "0.4.12" @@ -882,27 +964,32 @@ checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.106", ] [[package]] @@ -934,86 +1021,14 @@ dependencies = [ ] [[package]] -name = "jsonrpsee" -version = "0.26.0" +name = "js-sys" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ - "jsonrpsee-core", - "jsonrpsee-http-client", - "jsonrpsee-proc-macros", - "jsonrpsee-types", - "tracing", -] - -[[package]] -name = "jsonrpsee-core" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "316c96719901f05d1137f19ba598b5fe9c9bc39f4335f67f6be8613921946480" -dependencies = [ - "async-trait", - "bytes", + "cfg-if", "futures-util", - "http", - "http-body", - "http-body-util", - "jsonrpsee-types", - "pin-project", - "serde", - "serde_json", - "thiserror 2.0.20", - "tokio", - "tower", - "tracing", -] - -[[package]] -name = "jsonrpsee-http-client" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790bedefcec85321e007ff3af84b4e417540d5c87b3c9779b9e247d1bcc3dab8" -dependencies = [ - "base64", - "http-body", - "hyper", - "hyper-rustls", - "hyper-util", - "jsonrpsee-core", - "jsonrpsee-types", - "rustls", - "rustls-platform-verifier", - "serde", - "serde_json", - "thiserror 2.0.20", - "tokio", - "tower", - "url", -] - -[[package]] -name = "jsonrpsee-proc-macros" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07" -dependencies = [ - "heck", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "jsonrpsee-types" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc88ff4688e43cc3fa9883a8a95c6fa27aa2e76c96e610b737b6554d650d7fd5" -dependencies = [ - "http", - "serde", - "serde_json", - "thiserror 2.0.20", + "wasm-bindgen", ] [[package]] @@ -1082,6 +1097,12 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.3" @@ -1158,26 +1179,6 @@ dependencies = [ "indexmap", ] -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1250,21 +1251,69 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "proc-macro2" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ - "toml_edit 0.25.14+spec-1.1.0", + "unicode-ident", ] [[package]] -name = "proc-macro2" -version = "1.0.101" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "unicode-ident", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -1276,12 +1325,44 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix-heap" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ffec9df464013295b499298811e6a3de31bf8128092135826517db12dee601" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.10.0" @@ -1331,6 +1412,44 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1339,7 +1458,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1351,6 +1470,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.34" @@ -1370,9 +1498,8 @@ version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ - "log", + "aws-lc-rs", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1397,14 +1524,15 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] [[package]] name = "rustls-platform-verifier" -version = "0.5.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -1417,8 +1545,8 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", + "webpki-root-certs", + "windows-sys 0.61.2", ] [[package]] @@ -1433,11 +1561,18 @@ version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.17" @@ -1485,6 +1620,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1544,6 +1685,22 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1638,6 +1795,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1675,33 +1835,13 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "thiserror-impl", ] [[package]] @@ -1745,6 +1885,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -1791,8 +1946,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_datetime", + "toml_edit", ] [[package]] @@ -1804,15 +1959,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -1822,29 +1968,8 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime 0.6.11", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.14+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2195eec204e2764644a4ea619704f9fbe5e0673038eded55ad9956f24fca0cc" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow 1.0.4", + "toml_datetime", + "winnow", ] [[package]] @@ -1857,10 +1982,29 @@ dependencies = [ "futures-util", "pin-project-lite", "sync_wrapper", + "tokio", "tower-layer", "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1880,21 +2024,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -2616,12 +2748,78 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "webpki-root-certs" -version = "0.26.11" +name = "wasm-bindgen" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ - "webpki-root-certs 1.0.9", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", ] [[package]] @@ -2648,22 +2846,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" 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]] @@ -2672,7 +2861,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -2684,67 +2873,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" 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_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "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", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2757,48 +2913,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[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_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[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_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2814,15 +2946,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index c1605e89a..8c35eb2bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,6 @@ ignore = ">= 0.4, < 0.4.30" owo-colors = "4.2.3" imara-diff = { version = "0.2.0", default-features = false } rayon = "1.7.0" -jsonrpsee = { version = "0.26", default-features = false, features = ["http-client", "macros"] } tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time"] } tree_magic_mini = "3.2.0" @@ -135,6 +134,7 @@ tree-sitter-yaml = "0.7.0" tree-sitter-zig = "1.1.2" ts-parser-perl = "1.2.0" toml = { version = "0.8", default-features = false, features = ["parse"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "http2"] } [target.'cfg(not(any(windows, target_os = "illumos", target_os = "freebsd")))'.dependencies] diff --git a/src/hook.rs b/src/hook.rs index 742caca34..7d7cb851c 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -10,15 +10,13 @@ use crate::config::HookConfig; use crate::parse::folds::FoldMatch; use crate::review::wire; use crate::summary::{DiffResult, FileContent, FileFormat}; -use jsonrpsee::core::ClientError; -use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; -use jsonrpsee::proc_macros::rpc; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -31,25 +29,44 @@ pub(crate) struct RequestFold { placeholder: String, } -/// The interface every hook implements. Params are sent by name; the result -/// maps fold ids, as strings, to replacement text. -#[rpc(client)] -trait FoldHook { - #[method(name = "summarize", param_kind = map)] - async fn summarize( - &self, - path: String, - language: Option, - src: String, - folds: Vec, - ) -> jsonrpsee::core::RpcResult>; +/// The one method every hook implements, `summarize`. Params are sent by +/// name; the result maps fold ids, as strings, to replacement text. +#[derive(Serialize)] +struct Params<'a> { + path: &'a str, + language: Option<&'a str>, + src: &'a str, + folds: Vec, +} + +#[derive(Serialize)] +struct Request<'a> { + jsonrpc: &'static str, + id: u64, + method: &'static str, + params: Params<'a>, +} + +#[derive(Deserialize)] +struct Response { + #[serde(default)] + result: Option>, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct RpcError { + message: String, } pub(crate) struct Hook { config: HookConfig, child: Mutex, runtime: tokio::runtime::Runtime, - client: HttpClient, + client: reqwest::Client, + url: String, + next_id: AtomicU64, } impl Hook { @@ -76,16 +93,53 @@ impl Hook { .thread_name("diffr-hook-client") .enable_all() .build()?; - let client = runtime.block_on(async { - HttpClientBuilder::default() - .request_timeout(Duration::from_millis(config.timeout_ms)) - .build(format!("http://127.0.0.1:{port}")) - })?; + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(config.timeout_ms)) + .build()?; Ok(Self { config: config.clone(), child: Mutex::new(child), runtime, client, + url: format!("http://127.0.0.1:{port}/"), + next_id: AtomicU64::new(1), + }) + } + + /// One JSON-RPC 2.0 call over loopback HTTP, blocking the worker on its reply. + fn call(&self, params: Params<'_>) -> Result, String> { + let request = Request { + jsonrpc: "2.0", + id: self.next_id.fetch_add(1, Ordering::Relaxed), + method: "summarize", + params, + }; + self.runtime.block_on(async { + let response = self + .client + .post(&self.url) + .json(&request) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + format!("fold hook timed out after {}ms", self.config.timeout_ms) + } else { + format!("fold hook: {error}") + } + })?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("fold hook: {error}"))?; + let response: Response = serde_json::from_str(&body) + .map_err(|error| format!("fold hook: invalid response ({status}): {error}"))?; + match (response.result, response.error) { + (_, Some(error)) => Err(format!("fold hook reported: {}", error.message)), + (Some(result), None) => Ok(result), + (None, None) => Err("fold hook: response has neither result nor error".to_owned()), + } }) } @@ -123,22 +177,12 @@ impl Hook { } }) .collect(); - let texts = self - .runtime - .block_on(FoldHookClient::summarize( - &self.client, - diff.display_path.clone(), - language, - src.clone(), - folds, - )) - .map_err(|error| match error { - ClientError::Call(error) => format!("fold hook reported: {}", error.message()), - ClientError::RequestTimeout => { - format!("fold hook timed out after {}ms", self.config.timeout_ms) - } - other => format!("fold hook: {other}"), - })?; + let texts = self.call(Params { + path: &diff.display_path, + language: language.as_deref(), + src, + folds, + })?; for (key, text) in texts { let index: usize = key .parse() From 28d5aeb026dbf56ffc0c2bd5d8beb89eb89e7903 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sun, 13 Sep 2026 02:50:23 -0700 Subject: [PATCH 2/6] Stream diffs as wire v3 region trees `--format ndjson` now writes protocol v3: a start record with per-side file identity (path, oid, mode), one file record per diff holding each side's text and a strict tree of regions, and a complete footer. Leaves come from the row alignment, folds from the parse. Each fold records the fold the matcher paired it with both ways, if any, and the projection numbers regions densely as it builds them: a region whose partner is already numbered shares that id. `Pairing` moves to its own module so line layout can use it for run sides without depending on the protocol. The previous stream, its writer, and `--format json` with the domain-object encoding behind it are removed; the fold hook has no caller until it returns as a fold mutation. Errors stay `anyhow::Error` until the writer turns one into a wire record. In this layer `visible` equals `textual`, `aborted` is never set, `syntax` is always empty, and `fold_state_id` equals `alignment_id`. The Python streaming checker is removed. Written with AI assistance (Claude Code). Agent-Session: 8837929e-0118-489c-9648-e47c1831ee8c Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 4a8f6ee6-1693-4de3-9993-b9f5914cdd09 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 8761954a-0023-463e-996c-7c9fbabdef69 Agent-Session: 30956777-ac82-4a2b-9b52-dbfb517b52ee --- Cargo.lock | 7 + Cargo.toml | 1 + docs/cli.md | 6 +- docs/streaming.md | 334 ++++++--- examples/review/viewer/build.py | 32 +- src/cli.rs | 21 +- src/config.rs | 6 +- src/display/line_layout.rs | 159 +++++ src/git.rs | 190 ++++- src/hook.rs | 17 +- src/lines.rs | 4 +- src/main.rs | 5 +- src/pairing.rs | 96 +++ src/parse/folds.rs | 257 ++++++- src/parse/syntax.rs | 5 +- src/protocol/mod.rs | 502 +++++++++++++ src/protocol/project.rs | 1190 +++++++++++++++++++++++++++++++ src/protocol/stream.rs | 206 ++++++ src/review/mod.rs | 1 - src/review/render.rs | 31 +- src/review/tests.rs | 97 ++- src/review/wire.rs | 154 ---- src/stream.rs | 229 ------ tests/streaming/check.py | 246 ------- 24 files changed, 2914 insertions(+), 882 deletions(-) create mode 100644 src/pairing.rs create mode 100644 src/protocol/mod.rs create mode 100644 src/protocol/project.rs create mode 100644 src/protocol/stream.rs delete mode 100644 src/review/wire.rs delete mode 100644 src/stream.rs delete mode 100755 tests/streaming/check.py diff --git a/Cargo.lock b/Cargo.lock index bbe262c46..9ac24f86e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "assert_cmd" version = "2.0.17" @@ -312,6 +318,7 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" name = "difftastic" version = "0.71.0" dependencies = [ + "anyhow", "assert_cmd", "bumpalo", "cc", diff --git a/Cargo.toml b/Cargo.toml index 8c35eb2bb..0f4398008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ hashbrown = "0.17.0" humansize = "2.1.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +anyhow = "1.0" line-numbers = "0.3.0" smallvec = "1.13.2" streaming-iterator = "0.1.9" diff --git a/docs/cli.md b/docs/cli.md index cfe066595..2776ff2ca 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,9 +27,9 @@ Text output does not implement Git's path quoting or compact rename formatting. for changes; ordinary output exits 0. Errors exit 2. `--no-index` supports two files, implies change exit status, and does not yet support metadata options. -Structural output uses the existing terminal renderer. `--format json` emits -one domain object per line. `--format ndjson` emits a buffered event stream, -diffing `--jobs N` files at once (default 16) and emitting each as it finishes; +Structural output uses the existing terminal renderer. `--format ndjson` emits +a buffered event stream, diffing `--jobs N` files at once (default 16) and +emitting each as it finishes; `--format snapshot` is the fixture text adapter. `-U N` selects ordinary context padding. Matching limits, `--ignore-comments`, color, width and inline/split display remain configurable; see `--help`. diff --git a/docs/streaming.md b/docs/streaming.md index 2a0062344..a4461d164 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -2,22 +2,29 @@ ```sh diffr main HEAD --format ndjson -diffr --cached --format ndjson --order source,test -- src/ +diffr --cached --format ndjson --syntax --order source,test -- src/ +diffr --no-index --format ndjson -- before.rs after.rs ``` Spawn one process per comparison and consume stdout line by line. The comparison arguments are the same as the [ordinary CLI](cli.md). There is no HTTP server. -`--format json` remains the bare domain-object output; `ndjson` adds file metadata, -progress, recoverable errors and a completion record. NDJSON currently requires a -repository comparison; it rejects `--no-index`, `--quiet` and metadata output flags. +`--format ndjson` is the stream described here. It requires a repository +comparison or `--no-index`; it rejects `--quiet` and metadata output flags. + +The Rust types behind this document are `src/protocol/mod.rs`; the projection +from the internal diff is `src/protocol/project.rs`. ## Configuration and ordering -Each invocation loads repository-root `diffr.toml` and compiles it once. -`--config PATH` selects another file instead. Omitted keys retain bundled defaults; -query strings replace whole values, and empty queries disable that feature. +Each invocation resolves one configuration from the bundled defaults, the +global file, the repository's `diffr.toml`, `DIFFR_*` variables and `--set` +overrides, then compiles it once; see [config.md](config.md). Omitted keys +retain defaults; query strings replace whole values, and empty queries disable +that feature. -File classes come from the current workspace's Git attributes: +File categories come from `diffr-classify` and `linguist-generated` Git +attributes, then built-in path rules for lockfiles, build output, tests and +docs: ```gitattributes * diffr-classify=source @@ -26,95 +33,259 @@ docs/** diffr-classify=docs **/*.lock diffr-classify=generated ``` -`--order source,test,docs` prioritizes those classes. It can also be repeated. +`--order source,test,docs` prioritizes those categories. It can also be repeated. Unlisted and unclassified files follow, with path order breaking ties. Without `--order`, use path order. Renames classify by new path; deletions by old path. -Only string attributes assign classes. Normal Git attribute precedence applies. Paths after `--` accept libgit2 directory prefixes and wildcard patterns, not Git magic pathspecs. Only changed files are selected. An unmatched path yields an empty stream. Filtering precedes rename detection, so selecting one side of a rename can appear as an addition or deletion. `--no-renames` skips rename detection. -## Output contract +## Conventions + +Stdout carries UTF-8 newline-delimited JSON, one record per line. Read complete +lines; pipe reads can split records or contain several. + +- Every enum is tagged: `type` on records, snapshots and diffs; `kind` on regions. + Tags and enum values are `snake_case`. +- Optional, empty, and default fields are omitted, never `null`. Read a missing + `visibility` as open, a missing `tags` as none, a missing `changed` as none. +- Consumers ignore unknown fields and tolerate unknown enum strings. Additions + within a `version` never change the meaning of existing fields. +- Sides are `lhs` (before) and `rhs` (after). Anything that can exist on one side + only is a *pairing*, written by presence: `{"lhs": …, "rhs": …}`, `{"lhs": …}`, + or `{"rhs": …}`. +- Lines are 0-based and split on `\n` only: an empty file has zero lines, and a + file without a trailing newline still counts its last line. Columns are 0-based + byte offsets into the UTF-8 text on the wire. Ranges are half-open. -Stdout contains only UTF-8 newline-delimited JSON records. Read complete lines; -pipe reads can split records or contain several. +## Records -| Event | Fields | Consumer action | +| Record | Fields | Consumer action | | --- | --- | --- | -| `start` | `version: 1`, `before`, `after`, `total`, `files` | Lay out every file up front. | -| `file` | `file`, `diff`, optional `hook_error` | Render a result. | -| `file_error` | `file`, `message` | Report failure and keep reading. | -| `complete` | `succeeded`, `failed` | Mark complete, including partial failures. | - -`before` and `after` identify the operands: `{"kind":"revision","ref":""}`, -`{"kind":"index"}`, `{"kind":"working_tree"}`, or `{"kind":"empty_tree"}`. -Revision refs resolve once. Index source is pinned by blob ID during discovery. -Worktree files are read as results are computed, not as an atomic snapshot. - -The file descriptor contains nullable `old_path`, `new_path`, `class`, and `status` -(added/deleted/modified/renamed/type_changed/conflicted). `diff` is the existing -domain JSON: complete sources, token correspondence, folds and context hunks. -There is no display layout in the response. - -`files` lists every selected file descriptor in priority order. File results -arrive in completion order, not manifest order, since files are diffed -concurrently (`--jobs`, default 16). Match results to the manifest by identity. - -At completion, `succeeded + failed == total`; every selected file has one result -or file error. EOF without `complete` means interrupted/incomplete output. -Setup failures write to stderr and exit 2 before producing any records. -Per-file failures emit `file_error`, allow subsequent results, and finish with -`complete` and exit 2. Success exits 0, or 1 with `--exit-code` if changes exist. -Unexpected computation or output failures can terminate without `complete`. - -Regular UTF-8 text files are supported. Binary/non-UTF-8 files, symlinks, -submodules and unmerged index entries produce per-file errors. Non-UTF-8 paths -fail discovery. +| `start` | `version: 3`, `lhs`, `rhs`, `files` | Lay out every file up front. | +| `file` | `file`, then `diff` or `error` | Render a result, or mark the file failed. | +| `complete` | `succeeded`, `failed`, optional `aborted` | Mark complete; `aborted` means the run stopped early. | + +`lhs` and `rhs` on `start` say what is being compared: `{"type":"revision","rev":""}`, +`{"type":"index"}`, `{"type":"working_tree"}`, `{"type":"empty_tree"}` for an unborn +branch, or `{"type":"path","path":"…"}` for `--no-index`. Revisions resolve once. +Index content is pinned by blob id during discovery; working-tree files are read +as results are computed, not as an atomic snapshot. + +`files` lists every selected file in priority order: + +```jsonc +{"file": {"lhs": {"path": "src/a.rs", "oid": "3b18…", "mode": "100644"}, + "rhs": {"path": "src/a.rs", "oid": "9be2…", "mode": "100644"}}, + "status": "modified", // added | deleted | modified | renamed | copied | type_changed + "category": "source", // from diffr-classify; absent when unset + "language": "Rust", // guessed from the path; absent when unknown + "visibility": {"collapsed": true, "label": "Generated file"}} // absent when open +``` + +`file` is git's delta: a deleted file has `lhs` only, an added file `rhs` only. The +path pair is the file's identity; each `file` record repeats it verbatim so the +record can be matched back to the manifest. Working-tree sides carry git's +all-zero oid. A `--no-index` comparison has empty `oid` and `mode`. + +Results arrive in completion order, not manifest order, since files are diffed +concurrently (`--jobs`, default 16). `--jobs 1` restores priority order. At +completion, `succeeded + failed` equals the manifest length unless `aborted` is +present. EOF without `complete` means the output was cut off. + +### Errors + +One shape everywhere: `{"code": "", "message": ""}`. + +- On a `file` record, `error` replaces `diff` and the run continues. Codes: + `binary`, `not_utf8`, `unsupported_file_type` (symlinks, submodules), + `unmerged`, `read_failed`, `fold_pairing`, or `internal` for a failure diffr + did not classify. +- On `complete`, `aborted` reports a run-level failure: `summarizer_failed` or + `hook_failed`. diffr stops pulling files, lets the ones in flight finish, and + exits 2. Every `file` record already written stays valid. +- Setup failures (bad revision, unreadable config, hook that never starts) + write to stderr and exit 2 before any record. + +Exit status is 0 on success, 1 with `--exit-code` when there are changes, 2 when +any file failed or the run aborted. + +## The diff + +```jsonc +{"type": "text", + "lhs": {"text": "…", "syntax": [...], "regions": [...]}, + "rhs": {"text": "…", "syntax": [...], "regions": [...]}, + "stats": {"textual": {"added": 4, "removed": 1}, + "visible": {"added": 2, "removed": 1}}} // + "fallback": {code, message} on a line diff +``` + +A `binary` diff carries only `{"lhs": {"size": n}, "rhs": {"size": n}}`; either +side being binary makes the whole diff binary. Text sides are a pairing too: a +deleted file has `lhs` only. + +`text` is the complete source. `syntax` is present only with `--syntax`: every +token as `{line, start_column, end_column, capture}`, where `capture` is the +tree-sitter highlight capture name (`keyword`, `function.method`, …). Spans are +per line, sorted, and non-overlapping; where captures nest, the innermost wins. +Files that fell back to a line diff have no syntax. + +`stats.textual` counts lines with any byte change. `stats.visible` counts the +changed lines still on screen under the default visibility: a line that carries +a `changed` span, or any line of a leaf that exists on one side only, unless it +sits inside a region that starts collapsed. It is computed after the mutations +run, so configuration and hooks change it, and with nothing collapsed it matches +`textual` up to the blank lines of a paired changed run. A frontend that lets the +reader fold and unfold recomputes the same rule locally; the wire value is the +starting point. + +`stats.fallback` is present when the AST match did not run: `unsupported_language`, +`too_large`, `too_complex`, `parse_error`. A fallback diff is aligned by a line +diff and its `changed` spans are word-level, but the parse still stands: folds +and the enclosing-header context are present whenever the language parsed +(`too_complex`, `parse_error`), paired through that alignment exactly as they are +for a structural diff. Only `unsupported_language` and `too_large` produce +leaves alone. + +### Regions + +Each side carries a tree of regions. A region is a line range on that side with +two identities. `alignment_id` says what the region is across sides: the +same value on the other side marks its counterpart, one-to-one. `fold_state_id` +says what it moves with: regions sharing it open and close together, on either +side. For an ordinary region the two hold the same number; a mutation that +bundles regions (a docstring with its function, say) gives them one +`fold_state_id` while each keeps its own `alignment_id`. Consumers key the row +zip by `alignment_id` and collapse state by `fold_state_id`, never the reverse. +Today the only bundle is a docstring: leaves tagged `docstring` carry the +`fold_state_id` of the `function` fold they document, and start collapsed, +with an empty label, whenever that function does. + +```jsonc +{"alignment_id": 7, "fold_state_id": 7, "kind": "fold", + "start": {"line": 18, "column": 4}, "end": {"line": 52, "column": 33}, + "tags": ["body"], + "visibility": {"collapsed": false, "label": "Body"}, + "children": [ + {"alignment_id": 8, "fold_state_id": 8, "kind": "leaf", "start": {"line": 18, "column": 0}, "end": {"line": 30, "column": 0}}, + {"alignment_id": 9, "fold_state_id": 9, "kind": "leaf", "start": {"line": 30, "column": 0}, "end": {"line": 31, "column": 0}, + "changed": [{"line": 30, "start_column": 8, "end_column": 9}]}, + {"alignment_id": 10, "fold_state_id": 10, "kind": "leaf", "start": {"line": 31, "column": 0}, "end": {"line": 53, "column": 0}}]} +``` + +**Leaves** tile the file: read in order, their line ranges cover every line once. +They always start and end at column 0. A leaf with an `alignment_id` on both sides is +paired: the two have the same line count and their rows pair line for line. A +leaf on one side only has no counterpart, and the other side shows blank rows +against it. The row table is the walk over both sides' leaves, zipped by +`alignment_id`. +A paired leaf whose counterpart lies behind the reading cursor is a move; the +frontend chooses how to show it. + +`changed` holds the byte ranges inside a leaf that should be painted as changed: +a line that is entirely new carries one span covering it, a changed word inside +an otherwise matching line carries just that word. A line with no span in a leaf +that has spans is a changed line whose tokens all matched elsewhere. Blank +changed lines carry no span. + +**Folds** are regions with `children`. Their `start` and `end` are the hull of +their children, which tile it exactly, so a fold's range is whole lines: the +first line is its header and stays visible when it collapses. Regions form a +strict tree: every child lies inside its parent's range and siblings never +overlap. When the parser hands over two folds that cross on one line, such as a +collection whose closer sits on the line that opens the next body, the earlier +fold gives that line to the later one. A fold with an `alignment_id` on both sides is the +same syntax node on both sides; its contents may differ. A syntactic region that +spans a single line is not a region: it hides nothing. + +`tags` name what a region is (`body`, `import`, `test`, `unchanged`, or tags a +hook adds). `visibility` is how it starts out: `collapsed` and the `label` to +show while collapsed, a placeholder or pseudocode summary for a fold, or the +count for a context gap. Absent means open. + +**Context** is expressed as leaves. The rows difftastic's hunks display, +which are the `-U` padding around every change plus the enclosing syntax +context such as the header of the function a change sits in, stay open. +Every other stretch of unchanged rows that is at least three lines long +collapses into a leaf tagged `unchanged` with a label such as +`"142 unchanged lines"`; shorter stretches stay open because a fold row +would save nothing. A file with no change is one collapsed leaf however +short. Folds that lie entirely inside a gap are not regions. A fold edge +never cuts a gap into two collapsed leaves back to back: a fold that ends +inside a gap extends to the gap's end, and a fold whose header would be +hidden inside one is dropped. A cut leaving a sliver under three lines +leaves that sliver open. The grouping mutation merges adjacent gaps again +where both sides agree. + +**Groups** come from the last built-in mutation: a run of two or more +sibling folds that start collapsed, such as several deleted or summarized +functions in a row, is wrapped in one new fold tagged `group` whose label +counts them (`"3 functions removed"`, `"3 functions summarized"`, or +`"3 folded regions"`). It starts collapsed; expanding it reveals each +child's own collapsed row. The wrapped children are untouched. + +Ids are per file and dense from 0. The projection assigns them as it builds +the regions, in lhs preorder then rhs preorder; a region paired with one on +the other side takes that region's id instead of a new one. Mutations take +ids above every id already in the file. Ids mean nothing across files or +runs. ## Computation and output Discovery and rename detection finish before `start`; syntax matching is lazy. A pool of `--jobs` workers pulls files from the iterator: each worker reads the next file's sources under a lock, then diffs them while other workers pull -further files. The calling thread serializes, writes and flushes each event. -A bounded queue holds one ready event, so computation overlaps slow writes +further files. The calling thread serializes, writes and flushes each record. +A bounded queue holds one ready record, so computation overlaps slow writes without collecting the entire comparison. In-flight files are bounded by the -pool size, not their individual size. `--jobs 1` restores priority order. +pool size, not their individual size. Closing stdout stops production once the files in flight finish. Terminate the process to cancel immediately. The CLI also retains its normal SIGPIPE behavior on Unix. -## Fold hooks +Regular UTF-8 text files are supported. Binary and non-UTF-8 files, symlinks, +submodules and unmerged index entries produce per-file errors. Non-UTF-8 paths +fail discovery. + +## Mutations and fold hooks + +After projection, mutations set what starts collapsed and what its label says: +generated and test files in the manifest, deleted function bodies, test +bodies on both sides, the middle of large removed stretches, docstrings +bundled with their functions, and summaries for large new function bodies +from the built-in summarizer. [config.md](config.md) lists them and +their order. Their output is ordinary `visibility` on files and regions; +frontends need no knowledge of which mutation produced it. -A trusted hook can replace fold placeholders with richer text, such as -pseudocode, before each `file` event is emitted. A hook is a JSON-RPC 2.0 -server over HTTP that diffr starts once per invocation and calls on loopback: +A trusted hook can add its own summaries. It is a JSON-RPC 2.0 server over +HTTP that diffr starts once per invocation, calls on loopback, and runs after +the built-in summarizer: ```toml [folds.hook] command = ["uv", "run", "--script", "examples/hooks/summarize.py"] -tags = ["body"] # optional; any listed tag qualifies. Omit to send every fold. -min_lines = 12 # optional; default 0 +tags = ["body"] # optional; any listed tag qualifies. Omit to send every tagged fold. +min_lines = 12 # optional; defaults to folds.min_lines timeout_ms = 5000 # optional; per call startup_timeout_ms = 30000 # optional; time allowed to start listening ``` The command starts with the caller's environment plus `DIFFR_HOOK_PORT`, the loopback port it must listen on, and `DIFFR_WORKSPACE`, the diffed repository's -root. It runs in the directory containing the config file, so relative paths in -`command` resolve against the config wherever it lives, including one given by -`--config` outside the repository. Its stdout is discarded because diffr's own -stdout carries the event stream; log to stderr. diffr polls the port until the -hook accepts connections, exits 2 before `start` if the hook exits or misses -`startup_timeout_ms`, and kills the hook when the comparison ends. - -Only novel folds on the after side qualify: bodies that exist in the after -source with no counterpart in the before source. Files with no qualifying fold -never reach the hook. Streaming is the only output mode that runs hooks; the -terminal frontend streams, so it does too. +root. It runs in the directory of the file that configured it, so relative +paths in `command` resolve against that file wherever it lives. Its stdout is +discarded because diffr's own stdout carries the stream; log to stderr. diffr +polls the port until the hook accepts connections, exits 2 before `start` if +the hook exits or misses `startup_timeout_ms`, and kills the hook when the +comparison ends. + +Only new fold regions on the after side qualify: folds whose `alignment_id` has no +counterpart on the before side, of at least `min_lines` lines. Files with no +qualifying fold never reach the hook. Streaming is the only output mode that +runs mutations. One call per file, method `summarize`, params by name. The worker diffing that file blocks on the reply; other workers keep calling, so a hook must serve @@ -124,36 +295,29 @@ requests concurrently rather than one at a time. // diffr -> hook POST / with a JSON-RPC 2.0 request {"jsonrpc": "2.0", "id": 7, "method": "summarize", "params": { "path": "src/auth.py", "language": "Python", "src": "", - "folds": [{"id": 0, "range": {"start": {"line": 40, "byte_column": 0}, "end": {"line": 88, "byte_column": 1}}, + "folds": [{"id": 3, "start": {"line": 40, "column": 0}, "end": {"line": 88, "column": 1}, "tags": ["body"], "placeholder": "Body"}]}} // hook -> diffr -{"jsonrpc": "2.0", "id": 7, "result": {"0": "def refresh_token(session):\n ..."}} -{"jsonrpc": "2.0", "id": 8, "error": {"code": -32000, "message": "rate limited"}} +{"jsonrpc": "2.0", "id": 7, "result": {"3": "def refresh_token(session):\n ..."}} ``` -`language` is null for plain text. A fold `id` indexes `rhs_folds` in that file's -`diff`; the matching fold gains a non-null `summary` while `placeholder` is -unchanged. Folds missing from the result keep a null `summary`. An error -object, a timeout, an unknown fold id, or an invalid response leaves every -summary in that file null and adds `hook_error` to its `file` event. +A fold `id` in the request is the region's `alignment_id` in that file's +`rhs.regions`. The matching +region starts collapsed with the returned text as its label, behind a +`# pseudocode` comment line in the file's own syntax. Folds missing from the +result keep their placeholder. An error object, a timeout, an unknown fold id, +or an invalid response is a run-level failure: the stream ends with +`complete.aborted` set to `hook_failed` and diffr exits 2. `examples/hooks/summarize.py` is a reference hook: an aiohttp server that hands -each request to jsonrpcserver and asks Gemini 3.8 Flash, with thinking disabled, +each request to jsonrpcserver and asks Gemini Flash, with thinking disabled, for Python-style pseudocode. It needs `GOOGLE_API_KEY` and answers up to 16 files at once on one asyncio loop with a shared httpx client. `uv run --script` -installs its dependencies on first use. `tests/hooks/rpc_server.py` is a -dependency-free hook used by the tests. +installs its dependencies on first use. The built-in summarizer does the same +job without a subprocess. `tests/hooks/rpc_server.py` is a dependency-free hook +used by the tests. ## Fixture viewer -The viewer reads captured CLI streams from static files: - -```sh -cargo build --locked -python3 examples/review/viewer/build.py -python3 -m http.server 4176 --bind 127.0.0.1 --directory examples/review/viewer -python3 tests/streaming/check.py -``` - -Rebuild captures after backend changes. Static HTTP serves the example assets -only; it does not compute diffs. +The static viewer under `examples/review/viewer` predates this stream and does +not read it. Its build script captures the pinned Git patches only. diff --git a/examples/review/viewer/build.py b/examples/review/viewer/build.py index ee7466ac6..a5356eb03 100644 --- a/examples/review/viewer/build.py +++ b/examples/review/viewer/build.py @@ -19,11 +19,7 @@ def git(repo, *args, input=None): return subprocess.check_output(['git', '-C', str(repo), *args], input=input, env=ENV).decode().strip() -# Build pinned comparisons and capture the CLI stream for the static viewer. -REPO = OUT / "workspace" -REPO.mkdir(exist_ok=True) -git(REPO, 'init', '-q') -sides = [[], []] +# Build pinned comparisons for the static viewer. index = [] for directory in sorted((ROOT / 'examples/review/real').iterdir()): provenance = json.loads((directory / 'provenance.json').read_text()) @@ -31,12 +27,6 @@ def git(repo, *args, input=None): sources = provenance['sources'] path = sources['rhs']['path'] or sources['lhs']['path'] served_path = directory.name + "/" + path - for i, side in enumerate(('lhs', 'rhs')): - entry = sources[side] - if entry['path'] is not None: - data = (directory / entry['file']).read_bytes() - blob = git(REPO, 'hash-object', '-w', '--stdin', input=data) - sides[i].append((served_path, blob)) with tempfile.TemporaryDirectory(prefix='difft-viewer-') as repo: git(repo, 'init', '-q') commits = [] @@ -66,23 +56,3 @@ def git(repo, *args, input=None): index.append(meta) print(directory.name) (OUT / 'index.json').write_text(json.dumps(index)) - -commits = [] -for files in sides: - git(REPO, 'read-tree', '--empty') - for path, blob in files: - git(REPO, 'update-index', '--add', '--cacheinfo', '100644', blob, path) - tree = git(REPO, 'write-tree') - parent = ['-p', commits[0]] if commits else [] - commits.append(git(REPO, 'commit-tree', tree, '-m', 'Fixture stream snapshot', *parent)) -for meta in index: - path = OUT / (meta['id'] + '.json') - view = json.loads(path.read_text()) - paths = view['request']['files']['paths'] - stream_path = OUT / (meta['id'] + '.ndjson') - with stream_path.open('wb') as output: - subprocess.run([str(ROOT / 'target/debug/diffr'), '--repo', str(REPO), - *commits, '--format', 'ndjson', '--', *paths], - stdout=output, env=ENV, check=True) - view['request'] = 'data/' + stream_path.name - path.write_text(json.dumps(view)) diff --git a/src/cli.rs b/src/cli.rs index c67e44727..e4be8109a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,7 +1,6 @@ //! Git-style CLI input; rendering and NDJSON remain adapters over the same engine. use crate::config::Config; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; -use crate::hook::Hook; use crate::options::{DiffOptions, DisplayMode, DisplayOptions}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use git2::{DiffStatsFormat, Repository}; @@ -60,7 +59,7 @@ pub(crate) fn run() -> Result { .arg(flag("no-renames")) .arg(flag("find-renames").short('M').conflicts_with("no-renames")) .arg(Arg::new("unified").short('U').long("unified").default_value("3").value_parser(clap::value_parser!(u32))) - .arg(Arg::new("format").long("format").value_parser(["text", "json", "ndjson", "snapshot"]).default_value("text")) + .arg(Arg::new("format").long("format").value_parser(["text", "ndjson", "snapshot"]).default_value("text")) .arg(Arg::new("display").long("display").value_parser(["inline", "side-by-side", "side-by-side-show-both"]).default_value("side-by-side")) .arg(Arg::new("color").long("color").num_args(0..=1).require_equals(true).default_missing_value("always").default_value("auto").value_parser(["auto", "always", "never"])) .arg(flag("no-color")) @@ -166,7 +165,6 @@ pub(crate) fn run() -> Result { let params = Arc::new( Config::load(workspace, args.get_one::("config").map(Path::new))?.compile()?, ); - let hook = fold_hook(¶ms, workspace)?; let mut session = DiffSession::open(workspace, comparison, params, &files)?; session.context_lines = display.num_context_lines; session.diff_options = diff_options; @@ -176,7 +174,7 @@ pub(crate) fn run() -> Result { if jobs == 0 { return Err("--jobs must be at least 1".into()); } - let failed = crate::stream::write(session, jobs, hook, &mut io::stdout().lock())?; + let failed = crate::protocol::stream::write(session, jobs, &mut io::stdout().lock())?; return Ok(if failed { 2 } else { @@ -200,7 +198,6 @@ fn render( display: &DisplayOptions, ) -> Result<()> { match args.get_one::("format").unwrap().as_str() { - "json" => println!("{}", diff.domain_json()), "snapshot" => print!("{}", diff.snapshot()), _ => crate::print_diff_result(display, diff), } @@ -461,12 +458,11 @@ fn no_index( ) }; if args.get_one::("format").map(String::as_str) == Some("ndjson") { - let hook = fold_hook(&config, Path::new(args.get_one::("repo").unwrap()))?; - crate::stream::write_file( + crate::protocol::stream::write_file( &paths[0].to_string_lossy(), &paths[1].to_string_lossy(), + (before.len() as u64, after.len() as u64), compute, - hook.as_deref(), &mut io::stdout().lock(), )?; Ok(i32::from(changed && args.get_flag("exit-code"))) @@ -476,15 +472,6 @@ fn no_index( } } -/// Streaming output summarizes large novel folds through the configured hook. -fn fold_hook(params: &crate::config::Params, workspace: &Path) -> Result>> { - params - .hook - .as_ref() - .map(|config| Hook::spawn(config, workspace).map(Arc::new)) - .transpose() -} - /// Explicit machine/text modes and redirected output must never enter the alternate screen. fn opens_tui(explicit_format: bool, metadata_or_quiet: bool, terminal: bool) -> bool { terminal && !explicit_format && !metadata_or_quiet diff --git a/src/config.rs b/src/config.rs index fe29ee81c..0b8a77893 100644 --- a/src/config.rs +++ b/src/config.rs @@ -70,6 +70,8 @@ impl std::error::Error for ConfigError {} pub(crate) struct Params { languages: DftHashMap>>, + // Unread until the hook returns as a fold mutation. + #[allow(dead_code)] pub(crate) hook: Option, } @@ -483,7 +485,6 @@ mod tag_tests { #[test] fn test_bodies_keep_both_tags_and_remain_paired() { - use crate::parse::folds::FoldMatch; let params = Params::default(); let result = DiffResult::from_sources_with_params( "a.rs", @@ -495,7 +496,6 @@ mod tag_tests { assert_eq!(result.rhs_folds.len(), 1); assert_eq!(result.lhs_folds[0].tags, ["body", "test"]); assert_eq!(result.rhs_folds[0].tags, ["body", "test"]); - assert!(matches!(&result.lhs_folds[0].match_kind, - FoldMatch::Unchanged { opposite } if *opposite == result.rhs_folds[0].range)); + assert!(result.lhs_folds[0].counterpart(&result.rhs_folds).is_some()); } } diff --git a/src/display/line_layout.rs b/src/display/line_layout.rs index 89ed43c5a..fd33c5327 100644 --- a/src/display/line_layout.rs +++ b/src/display/line_layout.rs @@ -1,6 +1,7 @@ //! Line alignment, ordinary context padding, and indentation classification. use super::hunks::Hunk; use crate::display::context::all_matched_lines_filled; +use crate::pairing::Pairing; use crate::parse::syntax::MatchedPos; use crate::summary::{DiffResult, FileContent}; use std::collections::BTreeSet; @@ -135,6 +136,164 @@ pub(crate) fn reindented_pairs(diff: &DiffResult) -> BTreeSet<(usize, usize)> { pairs } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RunKind { + Unchanged, + Novel, +} + +/// A maximal run of aligned rows of one kind before fold splitting. Each +/// side present holds a half-open line span. +#[derive(Clone, Debug)] +pub(crate) struct Run { + pub(crate) kind: RunKind, + pub(crate) sides: Pairing<(usize, usize)>, + pub(crate) collapsed: bool, +} + +impl Run { + pub(crate) fn len(&self) -> usize { + let (start, end) = match self.sides { + Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => lhs, + Pairing::RightOnly { rhs } => rhs, + }; + end - start + } +} + +/// Rows in order, run-length encoded by kind and side presence. +pub(crate) fn runs( + rows: &[Row], + lhs_novel: &BTreeSet, + rhs_novel: &BTreeSet, +) -> Vec { + let mut runs: Vec = Vec::new(); + for &row in rows { + let row = match row { + (Some(lhs), Some(rhs)) => Pairing::Both { lhs, rhs }, + (Some(lhs), None) => Pairing::LeftOnly { lhs }, + (None, Some(rhs)) => Pairing::RightOnly { rhs }, + (None, None) => unreachable!("aligned_rows only emits rows with a line on some side"), + }; + let kind = match row { + Pairing::Both { lhs, rhs } + if !lhs_novel.contains(&lhs) && !rhs_novel.contains(&rhs) => + { + RunKind::Unchanged + } + _ => RunKind::Novel, + }; + let last = runs.last_mut().filter(|run| run.kind == kind); + match (last.map(|run| &mut run.sides), row) { + ( + Some(Pairing::Both { + lhs: (_, lhs_end), + rhs: (_, rhs_end), + }), + Pairing::Both { lhs, rhs }, + ) if *lhs_end == lhs && *rhs_end == rhs => { + *lhs_end += 1; + *rhs_end += 1; + } + (Some(Pairing::LeftOnly { lhs: (_, end) }), Pairing::LeftOnly { lhs: line }) + | (Some(Pairing::RightOnly { rhs: (_, end) }), Pairing::RightOnly { rhs: line }) + if *end == line => + { + *end += 1; + } + _ => runs.push(Run { + kind, + sides: row.map(|line| (line, line + 1)), + collapsed: false, + }), + } + } + runs +} + +/// The lines difftastic's hunks display on each side: the `-U` padding +/// around every change plus the enclosing syntax context, such as the +/// header of the function a change sits in. +pub(crate) fn shown_lines(hunks: &[Hunk]) -> (BTreeSet, BTreeSet) { + let mut lhs = BTreeSet::new(); + let mut rhs = BTreeSet::new(); + for hunk in hunks { + for &(l, r) in &hunk.lines { + lhs.extend(l.map(|line| line.as_usize())); + rhs.extend(r.map(|line| line.as_usize())); + } + } + (lhs, rhs) +} + +/// Collapsing fewer lines than this saves nothing worth a fold row. +pub(crate) const MIN_GAP: usize = 3; + +/// Collapse every maximal stretch of an unchanged run that no hunk shows +/// and that is at least `MIN_GAP` lines long. A file with no change has no +/// hunks, so it becomes one collapsed run. +pub(crate) fn trim_context( + runs: Vec, + lhs_shown: &BTreeSet, + rhs_shown: &BTreeSet, +) -> Vec { + let no_change = lhs_shown.is_empty() && rhs_shown.is_empty(); + let mut out: Vec = Vec::new(); + for run in runs { + if run.kind != RunKind::Unchanged { + out.push(run); + continue; + } + let Pairing::Both { + lhs: (lhs_start, _), + rhs: (rhs_start, _), + } = run.sides + else { + unreachable!("unchanged runs are paired"); + }; + let shown = |offset: usize| { + lhs_shown.contains(&(lhs_start + offset)) || rhs_shown.contains(&(rhs_start + offset)) + }; + let len = run.len(); + let mut from = 0; + while from < len { + let hidden = !shown(from); + let mut to = from + 1; + while to < len && shown(to) != hidden { + to += 1; + } + // A file with no change at all is one gap however short. + let collapsed = hidden && (to - from >= MIN_GAP || no_change); + let piece = Run { + kind: RunKind::Unchanged, + sides: Pairing::Both { + lhs: (lhs_start + from, lhs_start + to), + rhs: (rhs_start + from, rhs_start + to), + }, + collapsed, + }; + match out.last_mut() { + // Open pieces of one run stay one leaf. + Some(Run { + kind: RunKind::Unchanged, + sides: + Pairing::Both { + lhs: (_, lhs_end), + rhs: (_, rhs_end), + }, + collapsed: false, + }) if !collapsed && *lhs_end == lhs_start + from => { + *lhs_end = lhs_start + to; + *rhs_end = rhs_start + to; + } + _ => out.push(piece), + } + from = to; + } + } + out +} + #[cfg(test)] mod full_file_tests { use super::*; diff --git a/src/git.rs b/src/git.rs index 2d4c9ec6c..aa3277abf 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,16 +1,20 @@ //! Git comparison selection and lazy source loading used by the CLI and its stdout stream. use crate::config::Params; +use crate::pairing::Pairing; +use crate::protocol; use crate::summary::DiffResult; +use anyhow::Context as _; use git2::{AttrCheckFlags, AttrValue, Delta, Diff, DiffFindOptions, DiffOptions, Oid, Repository}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::{ + fmt, path::{Path, PathBuf}, sync::Arc, }; pub(crate) type Result = std::result::Result>; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub(crate) enum Operand { Revision { r#ref: String }, @@ -113,8 +117,7 @@ impl Comparison { } } -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "snake_case")] +#[derive(Clone, Debug)] pub(crate) enum FileStatus { Added, Deleted, @@ -124,12 +127,14 @@ pub(crate) enum FileStatus { Conflicted, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug)] pub(crate) struct FileChange { pub(crate) old_path: Option, pub(crate) new_path: Option, pub(crate) status: FileStatus, pub(crate) class: Option, + /// Git's delta sides. + pub(crate) sides: Pairing, } impl FileChange { @@ -139,6 +144,107 @@ impl FileChange { .or(self.old_path.as_deref()) .expect("changed file has a path") } + + /// A standalone comparison of two paths, outside any repository. + pub(crate) fn standalone(before: &str, after: &str) -> Self { + let file_ref = |path: &str| protocol::FileRef { + path: path.to_owned(), + oid: String::new(), + mode: String::new(), + }; + let old_path = (before != "/dev/null").then(|| before.to_owned()); + let new_path = (after != "/dev/null").then(|| after.to_owned()); + let (status, sides) = match (&old_path, &new_path) { + (Some(old), Some(new)) => ( + FileStatus::Modified, + Pairing::Both { + lhs: file_ref(old), + rhs: file_ref(new), + }, + ), + (Some(old), None) => ( + FileStatus::Deleted, + Pairing::LeftOnly { lhs: file_ref(old) }, + ), + (None, Some(new)) => (FileStatus::Added, Pairing::RightOnly { rhs: file_ref(new) }), + (None, None) => panic!("a standalone comparison needs at least one path"), + }; + Self { + old_path, + new_path, + status, + class: None, + sides, + } + } + + pub(crate) fn manifest_entry(&self) -> protocol::FileChange { + protocol::FileChange { + file: self.sides.clone(), + status: match self.status { + FileStatus::Added => protocol::FileStatus::Added, + FileStatus::Deleted => protocol::FileStatus::Deleted, + FileStatus::Modified => protocol::FileStatus::Modified, + FileStatus::Renamed => protocol::FileStatus::Renamed, + FileStatus::TypeChanged => protocol::FileStatus::TypeChanged, + // Both sides exist; the file record carries the unmerged error. + FileStatus::Conflicted => protocol::FileStatus::Modified, + }, + visibility: protocol::Visibility::default(), + } + } +} + +/// Why one file could not be diffed. Loading attaches it to the error, and +/// the stream turns it into the record's `code`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FileError { + UnsupportedFileType, + ReadFailed, + Binary, + NotUtf8, + Unmerged, +} + +impl FileError { + pub(crate) fn code(self) -> &'static str { + match self { + Self::UnsupportedFileType => "unsupported_file_type", + Self::ReadFailed => "read_failed", + Self::Binary => "binary", + Self::NotUtf8 => "not_utf8", + Self::Unmerged => "unmerged", + } + } +} + +impl fmt::Display for FileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::UnsupportedFileType => { + "structural diffs currently require regular text files (not symlinks or submodules)" + } + Self::ReadFailed => "could not read the source", + Self::Binary => "structural diffs currently support text files only", + Self::NotUtf8 => "the source is not valid UTF-8", + Self::Unmerged => { + "unmerged index entry: resolve the conflict before requesting a structural diff" + } + }) + } +} + +impl std::error::Error for FileError {} + +impl From<&Operand> for protocol::Snapshot { + fn from(operand: &Operand) -> Self { + match operand { + Operand::Revision { r#ref } => Self::Revision { rev: r#ref.clone() }, + Operand::Index => Self::Index, + Operand::WorkingTree => Self::WorkingTree, + Operand::EmptyTree => Self::EmptyTree, + } + } } #[derive(Debug, Deserialize)] @@ -191,23 +297,27 @@ impl Source { }) } - fn read(&self, repo: &Repository) -> Result { + fn read(&self, repo: &Repository) -> anyhow::Result { let mode = match self { Self::Absent => return Ok(String::new()), Self::Blob { mode, .. } | Self::WorkingFile { mode, .. } => mode, }; if !matches!(mode, git2::FileMode::Blob | git2::FileMode::BlobExecutable) { - return Err("structural diffs currently require regular text files (not symlinks or submodules)".into()); + return Err(FileError::UnsupportedFileType.into()); } let bytes = match self { - Self::Blob { id, .. } => repo.find_blob(*id)?.content().to_vec(), - Self::WorkingFile { path, .. } => std::fs::read(path)?, + Self::Blob { id, .. } => repo + .find_blob(*id) + .context(FileError::ReadFailed)? + .content() + .to_vec(), + Self::WorkingFile { path, .. } => std::fs::read(path).context(FileError::ReadFailed)?, Self::Absent => unreachable!(), }; if bytes.contains(&0) { - return Err("structural diffs currently support text files only".into()); + return Err(FileError::Binary.into()); } - Ok(String::from_utf8(bytes)?) + String::from_utf8(bytes).context(FileError::NotUtf8) } } @@ -267,19 +377,40 @@ impl DiffSession { .ok_or("non-UTF-8 Git paths are unsupported")? .to_owned()) }; - let mut file = FileChange { - old_path: if delta.status() == Delta::Added { - None - } else { - Some(path(delta.old_file())?) + let old_path = if delta.status() == Delta::Added { + None + } else { + Some(path(delta.old_file())?) + }; + let new_path = if delta.status() == Delta::Deleted { + None + } else { + Some(path(delta.new_file())?) + }; + let file_ref = |file: git2::DiffFile<'_>, path: &str| protocol::FileRef { + path: path.to_owned(), + oid: file.id().to_string(), + mode: format!("{:o}", u32::from(file.mode())), + }; + let sides = match (&old_path, &new_path) { + (Some(old), Some(new)) => Pairing::Both { + lhs: file_ref(delta.old_file(), old), + rhs: file_ref(delta.new_file(), new), }, - new_path: if delta.status() == Delta::Deleted { - None - } else { - Some(path(delta.new_file())?) + (Some(old), None) => Pairing::LeftOnly { + lhs: file_ref(delta.old_file(), old), }, + (None, Some(new)) => Pairing::RightOnly { + rhs: file_ref(delta.new_file(), new), + }, + (None, None) => unreachable!("a delta has a path"), + }; + let mut file = FileChange { + old_path, + new_path, status, class: None, + sides, }; file.class = match AttrValue::from_string(repo.get_attr( Path::new(file.path()), @@ -334,12 +465,16 @@ pub(crate) struct LoadedFile { pub(crate) file: FileChange, before: String, after: String, - params: Arc, - context_lines: u32, + pub(crate) params: Arc, + pub(crate) context_lines: u32, diff_options: crate::options::DiffOptions, } impl LoadedFile { + pub(crate) fn sizes(&self) -> (u64, u64) { + (self.before.len() as u64, self.after.len() as u64) + } + pub(crate) fn diff(&self) -> DiffResult { DiffResult::from_sources_with_options( self.file.path(), @@ -357,11 +492,11 @@ impl LoadedFile { impl DiffSession { /// Read the next file's sources without diffing them. - pub(crate) fn load(&mut self) -> Option<(FileChange, Result)> { + pub(crate) fn load(&mut self) -> Option<(FileChange, anyhow::Result)> { let pending = self.files.next()?; let result = (|| { if matches!(pending.file.status, FileStatus::Conflicted) { - return Err("unmerged index entry: resolve the conflict before requesting a structural diff".into()); + return Err(FileError::Unmerged.into()); } Ok(LoadedFile { before: pending.before.read(&self.repo)?, @@ -380,6 +515,11 @@ impl Iterator for DiffSession { type Item = (FileChange, Result); fn next(&mut self) -> Option { let (file, loaded) = self.load()?; - Some((file, loaded.map(|loaded| loaded.diff()))) + Some(( + file, + loaded + .map(|loaded| loaded.diff()) + .map_err(|error| format!("{error:#}").into()), + )) } } diff --git a/src/hook.rs b/src/hook.rs index 7d7cb851c..b6c22911e 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -7,11 +7,10 @@ //! every in-flight request on a small tokio runtime, so files still stream out //! as each worker finishes. use crate::config::HookConfig; -use crate::parse::folds::FoldMatch; -use crate::review::wire; +use crate::parse::folds::Fold; use crate::summary::{DiffResult, FileContent, FileFormat}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use std::collections::BTreeMap; use std::net::{TcpListener, TcpStream}; use std::path::Path; @@ -150,7 +149,7 @@ impl Hook { .rhs_folds .iter() .enumerate() - .filter(|(_, fold)| self.qualifies(fold)) + .filter(|(_, fold)| self.qualifies(fold, &diff.lhs_folds)) .map(|(index, _)| index) .collect(); if selected.is_empty() { @@ -171,7 +170,10 @@ impl Hook { let fold = &diff.rhs_folds[index]; RequestFold { id: index, - range: wire::range(&fold.range), + range: json!({ + "start": {"line": fold.range.start.line.0, "byte_column": fold.range.start.byte_column}, + "end": {"line": fold.range.end.line.0, "byte_column": fold.range.end.byte_column}, + }), tags: fold.tags.clone(), placeholder: fold.placeholder.clone(), } @@ -194,8 +196,9 @@ impl Hook { Ok(()) } - fn qualifies(&self, fold: &crate::parse::folds::Fold) -> bool { - if !matches!(fold.match_kind, FoldMatch::Novel) { + /// Only folds new on the rhs qualify: those without a lhs counterpart. + fn qualifies(&self, fold: &Fold, lhs_folds: &[Fold]) -> bool { + if fold.counterpart(lhs_folds).is_some() { return false; } let lines = (fold.range.end.line.0 - fold.range.start.line.0 + 1) as usize; diff --git a/src/lines.rs b/src/lines.rs index d46bacfa3..a5fce0246 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -61,13 +61,13 @@ pub(crate) fn is_all_whitespace(s: &str) -> bool { /// A nonempty source interval with an exclusive end. Coordinates must be /// ordered, in bounds, and on UTF-8 boundaries in the associated text source. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct SourceRange { pub(crate) start: SourcePosition, pub(crate) end: SourcePosition, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct SourcePosition { pub(crate) line: LineNumber, /// Zero-based UTF-8 byte offset within the line, not a display column. diff --git a/src/main.rs b/src/main.rs index c1576de45..43f1c5b32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,13 +54,16 @@ mod files; mod git; mod gitattributes; mod hash; +// The fold hook has no caller until it returns as a fold mutation. +#[allow(dead_code)] mod hook; mod line_parser; mod lines; mod options; +mod pairing; mod parse; +pub(crate) mod protocol; mod review; -mod stream; mod summary; mod version; mod words; diff --git a/src/pairing.rs b/src/pairing.rs new file mode 100644 index 000000000..701a5588e --- /dev/null +++ b/src/pairing.rs @@ -0,0 +1,96 @@ +//! Which sides of a comparison a thing exists on. +//! +//! Sides are always `lhs` (before) and `rhs` (after). On the wire a +//! `Pairing` serializes by presence: `{lhs, rhs}`, `{lhs}`, or `{rhs}`. + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Which sides a thing exists on. Serializes by presence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pairing { + Both { lhs: T, rhs: T }, + LeftOnly { lhs: T }, + RightOnly { rhs: T }, +} + +impl Pairing { + /// The same sides, each value transformed by `f`. + pub(crate) fn map(self, mut f: impl FnMut(T) -> U) -> Pairing { + match self { + Self::Both { lhs, rhs } => Pairing::Both { + lhs: f(lhs), + rhs: f(rhs), + }, + Self::LeftOnly { lhs } => Pairing::LeftOnly { lhs: f(lhs) }, + Self::RightOnly { rhs } => Pairing::RightOnly { rhs: f(rhs) }, + } + } +} + +#[derive(Serialize, Deserialize)] +struct PairingRepr { + #[serde(skip_serializing_if = "Option::is_none")] + lhs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rhs: Option, +} + +impl Serialize for Pairing { + fn serialize(&self, serializer: S) -> Result { + let repr = match self { + Self::Both { lhs, rhs } => PairingRepr { + lhs: Some(lhs), + rhs: Some(rhs), + }, + Self::LeftOnly { lhs } => PairingRepr { + lhs: Some(lhs), + rhs: None, + }, + Self::RightOnly { rhs } => PairingRepr { + lhs: None, + rhs: Some(rhs), + }, + }; + repr.serialize(serializer) + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Pairing { + fn deserialize>(deserializer: D) -> Result { + match PairingRepr::deserialize(deserializer)? { + PairingRepr { + lhs: Some(lhs), + rhs: Some(rhs), + } => Ok(Self::Both { lhs, rhs }), + PairingRepr { + lhs: Some(lhs), + rhs: None, + } => Ok(Self::LeftOnly { lhs }), + PairingRepr { + lhs: None, + rhs: Some(rhs), + } => Ok(Self::RightOnly { rhs }), + PairingRepr { + lhs: None, + rhs: None, + } => Err(D::Error::custom("a pairing needs at least one side")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_pairing_needs_a_side() { + let error = serde_json::from_value::>(json!({})).unwrap_err(); + assert!(error.to_string().contains("at least one side")); + assert_eq!( + serde_json::from_value::>(json!({"rhs": 7})).unwrap(), + Pairing::RightOnly { rhs: 7 } + ); + } +} diff --git a/src/parse/folds.rs b/src/parse/folds.rs index 8714add33..bddac6de6 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -1,10 +1,12 @@ //! Fold metadata is attached during parsing; pairing reuses syntax identity. use super::query::node_range; use crate::config::query::AnnotationQuery; -use crate::diff::changes::ChangeKind; +use crate::diff::changes::{ChangeKind, ChangeMap}; +use crate::display::line_layout::MIN_GAP; use crate::hash::{DftHashMap, DftHashSet}; use crate::lines::{SourcePosition, SourceRange}; -use crate::parse::syntax::{FoldMetadata, Syntax}; +use crate::parse::syntax::{FoldMetadata, Syntax, SyntaxId}; +use std::collections::BTreeSet; use streaming_iterator::StreamingIterator as _; use tree_sitter::{QueryCursor, Tree}; @@ -13,20 +15,25 @@ pub(crate) struct Fold { pub(crate) tags: Vec, /// Source on this side; may span multiple syntax nodes. pub(crate) range: SourceRange, - pub(crate) match_kind: FoldMatch, + /// The syntax node the fold was built on. Engine-internal identity: + /// syntax ids are unique across both sides of a file and never reach + /// the wire, where the projection numbers regions itself. + pub(crate) syntax_id: SyntaxId, + /// The `syntax_id` of the corresponding fold on the other side, when + /// there is one. Pairs are mutual: the partner records this fold back. + pub(crate) partner: Option, /// Text shown in place of the source, including supplied pseudocode. pub(crate) placeholder: String, /// Longer replacement text supplied by a configured fold hook. pub(crate) summary: Option, } -#[derive(Debug, Clone)] -pub(crate) enum FoldMatch { - /// A corresponding fold, whose contents may differ. Collapsed state is client-owned. - Unchanged { - opposite: SourceRange, - }, - Novel, +impl Fold { + /// The fold on the other side that pairs with this one, if any. + pub(crate) fn counterpart<'a>(&self, other_side: &'a [Fold]) -> Option<&'a Fold> { + let partner = self.partner?; + other_side.iter().find(|other| other.syntax_id == partner) + } } /// Interpret configurable fold captures in their own query traversal. @@ -144,24 +151,153 @@ fn range(node: &Syntax<'_>) -> Option { Some(region) } -/// Project a side-local annotation using the same correspondence as MatchedPos. -pub(crate) fn project(node: &Syntax<'_>, change: ChangeKind<'_>) -> Option { - let own = node.info().fold.borrow().clone()?; - let own_range = range(node)?; - let opposite = match change { +/// Half-open whole lines a fold touches, clamped to the file. +pub(crate) fn line_span(fold: &Fold, line_count: usize) -> (usize, usize) { + let range = &fold.range; + let start = range.start.line.as_usize(); + let end = if range.end.byte_column == 0 { + range.end.line.as_usize() + } else { + range.end.line.as_usize() + 1 + }; + ( + start.min(line_count), + end.min(line_count).max(start.min(line_count)), + ) +} + +/// Make fold line spans a strict tree: nested or disjoint, never crossing. +/// +/// Leaves tile whole lines, so two folds can share a line only if one +/// contains the other. The parser can hand over folds that cross on one +/// line, such as a collection whose closer sits on the line that opens the +/// next body (`for x in [ … ] {`). The rule: the earlier fold gives the +/// shared line to the later one, so its span ends where the later fold +/// starts. A span left with fewer than two lines hides nothing and is +/// dropped (`None`). Output is in input order. +pub(crate) fn nested_spans(spans: &[(usize, usize)]) -> Vec> { + let mut order: Vec = (0..spans.len()).collect(); + order.sort_by_key(|&index| (spans[index].0, std::cmp::Reverse(spans[index].1))); + let mut out: Vec> = spans.iter().map(|&span| Some(span)).collect(); + let mut open: Vec = Vec::new(); + let closed = |out: &[Option<(usize, usize)>], top: usize, start: usize| { + out[top].is_none_or(|(_, top_end)| top_end <= start) + }; + for index in order { + let (start, end) = spans[index]; + while open.last().is_some_and(|&top| closed(&out, top, start)) { + open.pop(); + } + // Every open fold that ends before this one does crosses it: clip + // each to hand over the shared lines. + for &top in open.iter().rev() { + let Some((top_start, top_end)) = out[top] else { + continue; + }; + if top_end >= end { + break; + } + out[top] = (start > top_start).then_some((top_start, start)); + } + while open.last().is_some_and(|&top| closed(&out, top, start)) { + open.pop(); + } + open.push(index); + } + for span in &mut out { + if span.is_some_and(|(start, end)| end - start < 2) { + *span = None; + } + } + out +} + +/// Every line where a span starts or ends: where leaves must split. +pub(crate) fn split_lines(spans: impl IntoIterator) -> BTreeSet { + spans + .into_iter() + .flat_map(|(start, end)| [start, end]) + .collect() +} + +/// Whether a fold's line span lies entirely inside one collapsed gap. +fn inside_gap(lines: (usize, usize), gaps: &[(usize, usize)]) -> bool { + gaps.iter() + .any(|&(start, end)| start <= lines.0 && lines.1 <= end) +} + +/// A collapsed gap is never split into two collapsed leaves back to back. +/// A fold entirely inside a gap is dropped: it would be hidden anyway. When +/// a fold edge would cut a gap into two pieces that are each long enough to +/// collapse, the fold gives way: a fold whose header starts inside such a +/// gap is dropped (its header would be hidden), and a fold that ends inside +/// one extends to the gap's end, so the gap stays one leaf inside it. Any +/// ancestor ending in the same gap extends to the same line, so nesting +/// holds. A cut leaving a sliver shorter than `MIN_GAP` needs nothing: the +/// sliver stays open. Gaps are aligned runs, so both sides agree. +pub(crate) fn fit_to_gaps(span: (usize, usize), gaps: &[(usize, usize)]) -> Option<(usize, usize)> { + if inside_gap(span, gaps) { + return None; + } + let (start, end) = span; + let splits = |gap_start: usize, cut: usize, gap_end: usize| { + gap_start < cut && cut < gap_end && cut - gap_start >= MIN_GAP && gap_end - cut >= MIN_GAP + }; + if gaps + .iter() + .any(|&(gap_start, gap_end)| splits(gap_start, start, gap_end.min(end))) + { + return None; + } + match gaps + .iter() + .find(|&&(gap_start, gap_end)| splits(gap_start.max(start), end, gap_end)) + { + Some(&(_, gap_end)) => Some((start, gap_end)), + None => Some(span), + } +} + +/// The node the matcher paired `node` with, when the pairing is mutual. +/// +/// Every matcher step records a pair on both nodes, but the nested slider +/// fixes run one side at a time and move a pair's record from a list onto +/// its child (or parent) on that side only: afterwards the child names the +/// opposite list, while the opposite list still names the original. Such a +/// one-way record is not a pair. +pub(crate) fn partner<'a>( + node: &Syntax<'_>, + change: ChangeKind<'a>, + change_map: &ChangeMap<'a>, +) -> Option<&'a Syntax<'a>> { + let other = match change { ChangeKind::Unchanged(other) | ChangeKind::ReplacedComment(_, other) - | ChangeKind::ReplacedString(_, other) => range(other), - _ => None, + | ChangeKind::ReplacedString(_, other) => other, + ChangeKind::IgnoredPunctuation | ChangeKind::Novel => return None, }; - let match_kind = match opposite { - Some(opposite) => FoldMatch::Unchanged { opposite }, - None => FoldMatch::Novel, + let back = match change_map + .get(other) + .expect("the matcher records a change on every node") + { + ChangeKind::Unchanged(back) + | ChangeKind::ReplacedComment(_, back) + | ChangeKind::ReplacedString(_, back) => back, + ChangeKind::IgnoredPunctuation | ChangeKind::Novel => return None, }; + (back.id() == node.id()).then_some(other) +} + +/// Build the fold annotated on `node`, if any, recording the `partner` the +/// matcher paired `node` with (see `partner`). +pub(crate) fn project(node: &Syntax<'_>, partner: Option<&Syntax<'_>>) -> Option { + let own = node.info().fold.borrow().clone()?; + let own_range = range(node)?; Some(Fold { tags: own.tags.clone(), range: own_range, - match_kind, + syntax_id: node.id(), + partner: partner.map(Syntax::id), placeholder: own .tags .first() @@ -176,3 +312,82 @@ pub(crate) fn project(node: &Syntax<'_>, change: ChangeKind<'_>) -> Option summary: None, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::diff::sliders::fix_all_sliders; + use crate::parse::guess_language::Language; + use crate::parse::syntax::{init_all_info, AtomKind}; + use line_numbers::SingleLineSpan; + use typed_arena::Arena; + + #[test] + fn a_pair_moved_by_a_one_sided_slider_fix_is_not_a_pair() { + let span = |line: u32| { + vec![SingleLineSpan { + line: line.into(), + start_col: 0, + end_col: 1, + }] + }; + let arena = Arena::new(); + // lhs `((x))`, rhs `(x)`: the matcher pairs the outer lists, and the + // lhs inner list is novel. + let lhs_x = Syntax::new_atom(&arena, span(0), "x".to_owned(), AtomKind::Normal); + let lhs_inner = Syntax::new_list(&arena, "(", span(0), vec![lhs_x], ")", span(0)); + let lhs_outer = Syntax::new_list(&arena, "(", span(0), vec![lhs_inner], ")", span(0)); + let rhs_x = Syntax::new_atom(&arena, span(1), "x".to_owned(), AtomKind::Normal); + let rhs_outer = Syntax::new_list(&arena, "(", span(1), vec![rhs_x], ")", span(1)); + init_all_info(&[lhs_outer], &[rhs_outer]); + let mut change_map = ChangeMap::default(); + change_map.insert(lhs_outer, ChangeKind::Unchanged(rhs_outer)); + change_map.insert(rhs_outer, ChangeKind::Unchanged(lhs_outer)); + change_map.insert(lhs_inner, ChangeKind::Novel); + change_map.insert(lhs_x, ChangeKind::Unchanged(rhs_x)); + change_map.insert(rhs_x, ChangeKind::Unchanged(lhs_x)); + fn partner_of<'a>(node: &'a Syntax<'a>, change_map: &ChangeMap<'a>) -> Option { + partner(node, change_map.get(node).unwrap(), change_map).map(Syntax::id) + } + assert_eq!(partner_of(lhs_outer, &change_map), Some(rhs_outer.id())); + assert_eq!(partner_of(rhs_outer, &change_map), Some(lhs_outer.id())); + + // Lisp prefers the outer delimiter: on the lhs only, the record moves + // from the outer list onto the inner one. + fix_all_sliders(Language::EmacsLisp, &[lhs_outer], &mut change_map); + assert_eq!( + change_map.get(lhs_inner), + Some(ChangeKind::Unchanged(rhs_outer)) + ); + assert_eq!( + change_map.get(rhs_outer), + Some(ChangeKind::Unchanged(lhs_outer)) + ); + assert_eq!(partner_of(lhs_inner, &change_map), None); + assert_eq!(partner_of(rhs_outer, &change_map), None); + assert_eq!(partner_of(lhs_outer, &change_map), None); + } + + #[test] + fn crossing_folds_give_the_shared_line_to_the_later_fold() { + // A collection closing on line 5 where a body opens: `for x in [ … ] {`. + let spans = [(0, 6), (5, 9), (7, 8)]; + assert_eq!( + nested_spans(&spans), + vec![Some((0, 5)), Some((5, 9)), None], + "the earlier fold ends where the later starts; one-line spans go" + ); + // Clipping cascades through every open ancestor that would cross. + assert_eq!( + nested_spans(&[(0, 10), (2, 6), (4, 8)]), + vec![Some((0, 10)), Some((2, 4)), Some((4, 8))] + ); + // An ancestor that is clipped down to its header line disappears. + assert_eq!(nested_spans(&[(3, 5), (4, 9)]), vec![None, Some((4, 9))]); + // Nested and disjoint spans are untouched, whatever their input order. + assert_eq!( + nested_spans(&[(5, 9), (0, 4), (1, 3)]), + vec![Some((5, 9)), Some((0, 4)), Some((1, 3))] + ); + } +} diff --git a/src/parse/syntax.rs b/src/parse/syntax.rs index d5ea6d722..ac849cb71 100644 --- a/src/parse/syntax.rs +++ b/src/parse/syntax.rs @@ -1088,7 +1088,10 @@ fn change_positions_<'a>( .get(node) .unwrap_or_else(|| panic!("Should have changes set in all nodes: {:#?}", node)); - folds.extend(folds::project(node, change)); + folds.extend(folds::project( + node, + folds::partner(node, change, change_map), + )); if matches!(change, ChangeKind::Unchanged(_)) { *seen_unchanged = true; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 000000000..a78f1fdef --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,502 @@ +//! The NDJSON wire protocol between diffr and its frontends. +//! +//! One `Event` per line: a `start` header, one `file` record per changed +//! file in completion order, and a `complete` footer. Every enum on the +//! wire is internally tagged with a `type` (or `kind`) key in snake_case. +//! Optional, empty, and default fields are omitted, never `null`. Consumers +//! ignore unknown fields and tolerate unknown enum strings. +//! +//! Coordinates: lines are 0-based and split on `\n` only, so an empty file +//! has zero lines and a file without a trailing newline still counts its +//! last line. Columns are 0-based byte offsets into the UTF-8 text on the +//! wire. All ranges are half-open. +//! +//! Sides are always `lhs` (before) and `rhs` (after). A `Pairing` says which +//! sides exist and serializes by presence: `{lhs, rhs}`, `{lhs}`, or `{rhs}`. + +use serde::{Deserialize, Serialize}; + +use crate::pairing::Pairing; + +pub(crate) mod project; +pub(crate) mod stream; + +/// The current wire version. Changes within a version are additive. +pub const VERSION: u32 = 3; + +// ── stream ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(clippy::large_enum_variant)] +pub enum Event { + /// The header. Sent once, before any result, so a frontend can lay out + /// every file up front. + Start { + version: u32, + lhs: Snapshot, + rhs: Snapshot, + files: Vec, + }, + /// One result. `file` is byte-identical to the manifest entry it + /// answers; the path pair is the identity. + File { + file: Pairing, + #[serde(flatten)] + outcome: Outcome, + }, + /// The footer. `aborted` is present when a run-level failure stopped + /// the comparison early; every file already emitted stays valid. + Complete { + succeeded: u32, + failed: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + aborted: Option, + }, +} + +/// Exactly one of `diff` or `error` appears on a `file` record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Outcome { + Diff { diff: Diff }, + Error { error: Problem }, +} + +/// What one end of the comparison is. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Snapshot { + Revision { + rev: String, + }, + Index, + WorkingTree, + EmptyTree, + /// A file on disk, for `--no-index`. + Path { + path: String, + }, +} + +/// The one error shape, used for a file failure, a run abort, and a +/// structural fallback. `code` is an open snake_case set. Internal code +/// carries `anyhow::Error`; the stream writer builds this record from one +/// just before serializing it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Problem { + pub code: String, + pub message: String, +} + +// ── manifest entry ──────────────────────────────────────────────────────── + +/// One changed file, known before any diffing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileChange { + /// `LeftOnly` is a deletion, `RightOnly` an addition. + pub file: Pairing, + pub status: FileStatus, + #[serde(default, skip_serializing_if = "Visibility::is_unset")] + pub visibility: Visibility, +} + +/// libgit2's delta status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileStatus { + Added, + Deleted, + Modified, + Renamed, + Copied, + TypeChanged, +} + +/// One side of a git delta. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileRef { + pub path: String, + pub oid: String, + /// Git's octal mode text, e.g. `100644`. + pub mode: String, +} + +/// How a file or region starts out. `label` is shown while collapsed: a +/// reason for a file, a placeholder or pseudocode summary for a region. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Visibility { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub label: String, +} + +impl Visibility { + /// Open and unlabelled, which is how a missing `visibility` reads. + pub fn is_unset(&self) -> bool { + !self.collapsed && self.label.is_empty() + } +} + +// ── per-file result ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Diff { + Text { + #[serde(flatten)] + sides: Pairing, + stats: Stats, + }, + /// Either side being binary makes the whole diff binary. + Binary { + #[serde(flatten)] + sides: Pairing, + }, +} + +/// One side's text, colors, and regions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Source { + pub text: String, + /// Every token with its tree-sitter capture name. Per line, sorted, + /// non-overlapping. Empty unless the run asked for syntax. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub syntax: Vec, + /// The largest regions, in order. Leaves tile the file. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub regions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BinaryRef { + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyntaxSpan { + pub line: u32, + pub start_column: u32, + pub end_column: u32, + /// A tree-sitter capture name such as `keyword` or `function.method`. + pub capture: String, +} + +/// A range on one side, carrying two identities that must never be +/// conflated. `alignment_id` says what the region *is* across sides: +/// the same value on the other side means the two regions are aligned +/// visually, one-to-one. Two leaves correspond when the line alignment +/// pairs their lines. Two folds correspond when the alignment pairs their +/// header lines, or, failing that, when one contains the counterpart of a +/// leaf the other contains, whichever engine produced the alignment. +/// A paired leaf has the same line count on both sides and its rows pair +/// line for line; a paired leaf whose counterpart is behind the reading +/// cursor is a move, and the frontend chooses how to show it. +/// `fold_state_id` says what the region *moves with*: regions sharing it +/// open and close together. It may span sides (a paired region has the +/// same value on both) and may bundle several same-side regions. For an +/// ordinary region both fields hold the same number. Consumers key the +/// row zip by `alignment_id` and collapse state by `fold_state_id`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Region { + pub alignment_id: u32, + pub fold_state_id: u32, + #[serde(flatten)] + pub range: SourceRange, + /// `body`, `import`, `test`, `unchanged`, or hook-supplied tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// A collapsed leaf is a context gap. A collapsed fold is a folded body. + #[serde(default, skip_serializing_if = "Visibility::is_unset")] + pub visibility: Visibility, + #[serde(flatten)] + pub node: Node, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Node { + /// Tiles the file. `changed` holds the byte ranges painted as changed + /// within it; a fully new line carries one span covering it. + Leaf { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + changed: Vec, + }, + /// A foldable region. Its range is the hull of its children. + Fold { children: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Span { + pub line: u32, + pub start_column: u32, + pub end_column: u32, +} + +/// Half-open. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceRange { + pub start: SourcePos, + pub end: SourcePos, +} + +impl SourceRange { + /// The lines this range touches, half-open. A range ending at column + /// zero does not touch its end line. + #[cfg(test)] + pub fn lines(&self) -> std::ops::Range { + let end = if self.end.column == 0 { + self.end.line + } else { + self.end.line + 1 + }; + self.start.line..end + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePos { + pub line: u32, + pub column: u32, +} + +/// Line counts for one file. `fallback` is present exactly when the AST +/// match did not run and the alignment is a line diff, carrying why: +/// `too_complex`, `too_large`, `unsupported_language`, `parse_error`. +/// Folds are still present on a fallback whenever the language parsed, +/// paired through that alignment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Stats { + /// Lines with any byte change. + pub textual: LineCounts, + /// Changed lines still on screen under the default visibility: a + /// changed line inside a region that starts collapsed, or under one, + /// is not counted. Computed after mutations run, so hooks and config + /// change it. + pub visible: LineCounts, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LineCounts { + pub added: u32, + pub removed: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn pos(line: u32, column: u32) -> SourcePos { + SourcePos { line, column } + } + + fn leaf(id: u32, start: u32, end: u32, changed: Vec) -> Region { + Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: pos(start, 0), + end: pos(end, 0), + }, + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { changed }, + } + } + + /// A paired function with one changed word: `fn f() { 1 }` became + /// `fn f() { 1 + 2 }`. + fn example_file() -> Event { + let file_ref = |oid: &str| FileRef { + path: "src/lib.rs".to_owned(), + oid: oid.to_owned(), + mode: "100644".to_owned(), + }; + let side = |text: &str, changed: Vec| Source { + text: text.to_owned(), + syntax: vec![], + regions: vec![Region { + alignment_id: 1, + fold_state_id: 1, + range: SourceRange { + start: pos(0, 0), + end: pos(3, 0), + }, + tags: vec!["body".to_owned()], + visibility: Visibility::default(), + node: Node::Fold { + children: vec![ + leaf(2, 0, 1, vec![]), + leaf(3, 1, 2, changed), + leaf(4, 2, 3, vec![]), + ], + }, + }], + }; + Event::File { + file: Pairing::Both { + lhs: file_ref("3b18e5"), + rhs: file_ref("9be2c1"), + }, + outcome: Outcome::Diff { + diff: Diff::Text { + sides: Pairing::Both { + lhs: side("fn f() {\n 1\n}\n", vec![]), + rhs: side( + "fn f() {\n 1 + 2\n}\n", + vec![Span { + line: 1, + start_column: 5, + end_column: 9, + }], + ), + }, + stats: Stats { + textual: LineCounts { + added: 1, + removed: 1, + }, + visible: LineCounts { + added: 1, + removed: 1, + }, + fallback: None, + }, + }, + }, + } + } + + #[test] + fn file_record_serializes_to_the_documented_shape() { + let region = |changed: serde_json::Value| { + let mut middle = json!({"alignment_id": 3, "fold_state_id": 3, "kind": "leaf", "start": {"line": 1, "column": 0}, "end": {"line": 2, "column": 0}}); + if let Some(spans) = changed.as_array().filter(|spans| !spans.is_empty()) { + middle["changed"] = json!(spans); + } + json!({ + "alignment_id": 1, "fold_state_id": 1, "kind": "fold", "tags": ["body"], + "start": {"line": 0, "column": 0}, "end": {"line": 3, "column": 0}, + "children": [ + {"alignment_id": 2, "fold_state_id": 2, "kind": "leaf", "start": {"line": 0, "column": 0}, "end": {"line": 1, "column": 0}}, + middle, + {"alignment_id": 4, "fold_state_id": 4, "kind": "leaf", "start": {"line": 2, "column": 0}, "end": {"line": 3, "column": 0}}, + ], + }) + }; + let expected = json!({ + "type": "file", + "file": { + "lhs": {"path": "src/lib.rs", "oid": "3b18e5", "mode": "100644"}, + "rhs": {"path": "src/lib.rs", "oid": "9be2c1", "mode": "100644"}, + }, + "diff": { + "type": "text", + "lhs": {"text": "fn f() {\n 1\n}\n", "regions": [region(json!([]))]}, + "rhs": {"text": "fn f() {\n 1 + 2\n}\n", + "regions": [region(json!([{"line": 1, "start_column": 5, "end_column": 9}]))]}, + "stats": {"textual": {"added": 1, "removed": 1}, "visible": {"added": 1, "removed": 1}}, + }, + }); + assert_eq!(serde_json::to_value(example_file()).unwrap(), expected); + } + + #[test] + fn every_event_round_trips() { + let events = vec![ + Event::Start { + version: VERSION, + lhs: Snapshot::Revision { + rev: "main".to_owned(), + }, + rhs: Snapshot::WorkingTree, + files: vec![FileChange { + file: Pairing::RightOnly { + rhs: FileRef { + path: "gen/schema.json".to_owned(), + oid: "0e1f2a".to_owned(), + mode: "100644".to_owned(), + }, + }, + status: FileStatus::Added, + visibility: Visibility { + collapsed: true, + label: "Generated file · hidden by default".to_owned(), + }, + }], + }, + example_file(), + Event::File { + file: Pairing::LeftOnly { + lhs: FileRef { + path: "old.bin".to_owned(), + oid: "aaaaaa".to_owned(), + mode: "100644".to_owned(), + }, + }, + outcome: Outcome::Diff { + diff: Diff::Binary { + sides: Pairing::LeftOnly { + lhs: BinaryRef { size: 4096 }, + }, + }, + }, + }, + Event::File { + file: Pairing::Both { + lhs: FileRef { + path: "a.txt".to_owned(), + oid: "bbbbbb".to_owned(), + mode: "100644".to_owned(), + }, + rhs: FileRef { + path: "a.txt".to_owned(), + oid: "cccccc".to_owned(), + mode: "100644".to_owned(), + }, + }, + outcome: Outcome::Error { + error: Problem { + code: "not_utf8".to_owned(), + message: "a.txt is not valid UTF-8".to_owned(), + }, + }, + }, + Event::Complete { + succeeded: 2, + failed: 1, + aborted: Some(Problem { + code: "hook_failed".to_owned(), + message: "summarizer returned 503 after 4 attempts".to_owned(), + }), + }, + ]; + for event in events { + let line = serde_json::to_string(&event).unwrap(); + assert_eq!( + serde_json::from_str::(&line).unwrap(), + event, + "{line}" + ); + } + } + + #[test] + fn defaults_are_omitted() { + let line = serde_json::to_string(&example_file()).unwrap(); + for absent in [ + "null", + "visibility", + "collapsed", + "syntax", + "fallback", + "\"changed\":[]", + ] { + assert!(!line.contains(absent), "{absent} appeared in {line}"); + } + } +} diff --git a/src/protocol/project.rs b/src/protocol/project.rs new file mode 100644 index 000000000..7909556b8 --- /dev/null +++ b/src/protocol/project.rs @@ -0,0 +1,1190 @@ +//! Projection from the internal `DiffResult` onto the wire types. +//! +//! Leaves come from the full-file row alignment: consecutive rows of one +//! kind (paired unchanged, paired novel, one-sided) become one leaf on each +//! side they touch, and a paired leaf shares its id across sides. Folds +//! come from the per-side fold lists; a fold whose partner (see +//! `folds::partner`) is also a region shares its id. Ids are numbered +//! densely as the regions are built, lhs first. Leaves are split wherever a +//! fold starts or ends so that every fold's children tile its line span +//! exactly, and a split on one side of a paired leaf is mirrored on the +//! other so paired leaves stay equal in length. Unchanged rows that no hunk +//! shows, which is everything outside the `-U` padding and the enclosing +//! syntax context difftastic already selected, become collapsed leaves +//! tagged `unchanged`. Folds that lie entirely inside such a gap are +//! dropped: they would be hidden anyway, and keeping them would only +//! fragment the gap. +use super::{ + BinaryRef, Diff, FileRef, LineCounts, Node, Problem, Region, Source, SourcePos, SourceRange, + Span, Stats, SyntaxSpan, Visibility, +}; +use crate::display::line_layout::{ + aligned_rows, novel_lines, runs, shown_lines, trim_context, Run, MIN_GAP, +}; +use crate::hash::DftHashMap; +use crate::line_parser; +use crate::pairing::Pairing; +use crate::parse::folds::{self, Fold}; +use crate::parse::syntax::{MatchKind, MatchedPos, SyntaxId}; +use crate::summary::{DiffResult, FileContent, FileFormat}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Everything the projection needs besides the diff itself. +pub(crate) struct Inputs<'a> { + /// Which sides the file exists on; a one-sided file gets one source. + pub(crate) file: &'a Pairing, + /// Byte length of each side's content, for binary files. + pub(crate) sizes: (u64, u64), + /// Highlight spans per side; empty when the run did not ask for syntax. + pub(crate) syntax: (Vec, Vec), +} + +pub(crate) fn diff(result: &DiffResult, inputs: Inputs<'_>) -> Diff { + let (lhs_src, rhs_src) = match (&result.lhs_src, &result.rhs_src) { + (FileContent::Text(lhs), FileContent::Text(rhs)) => (lhs.as_str(), rhs.as_str()), + _ => { + let sides = pair( + inputs.file, + BinaryRef { + size: inputs.sizes.0, + }, + BinaryRef { + size: inputs.sizes.1, + }, + ); + return Diff::Binary { sides }; + } + }; + let (lhs_regions, rhs_regions) = regions(result, lhs_src, rhs_src); + let (lhs_syntax, rhs_syntax) = inputs.syntax; + let sides = pair( + inputs.file, + Source { + text: lhs_src.to_owned(), + syntax: lhs_syntax, + regions: lhs_regions, + }, + Source { + text: rhs_src.to_owned(), + syntax: rhs_syntax, + regions: rhs_regions, + }, + ); + Diff::Text { + sides, + stats: stats(result, lhs_src, rhs_src), + } +} + +fn pair(file: &Pairing, lhs: T, rhs: T) -> Pairing { + match file { + Pairing::Both { .. } => Pairing::Both { lhs, rhs }, + Pairing::LeftOnly { .. } => Pairing::LeftOnly { lhs }, + Pairing::RightOnly { .. } => Pairing::RightOnly { rhs }, + } +} + +fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { + let (lhs_lines, rhs_lines) = line_parser::change_positions(lhs_src, rhs_src); + let textual = LineCounts { + added: novel_lines(&rhs_lines).len() as u32, + removed: novel_lines(&lhs_lines).len() as u32, + }; + let fallback = match &result.file_format { + FileFormat::SupportedLanguage(_) => None, + FileFormat::PlainText => Some(Problem { + code: "unsupported_language".to_owned(), + message: "no tree-sitter grammar for this file".to_owned(), + }), + FileFormat::TextFallback { reason } => Some(Problem { + code: fallback_code(reason).to_owned(), + message: reason.clone(), + }), + FileFormat::Binary => unreachable!("binary files never reach text stats"), + }; + Stats { + textual, + // Before any mutation runs nothing starts collapsed except context + // gaps, which hold no changed lines; the stream recounts after + // mutations. + visible: textual, + fallback, + } +} + +/// difftastic reports its fallbacks as prose; the wire wants a code. +fn fallback_code(reason: &str) -> &'static str { + if reason.contains("DFT_BYTE_LIMIT") { + "too_large" + } else if reason.contains("DFT_GRAPH_LIMIT") { + "too_complex" + } else if reason.contains("parse error") { + "parse_error" + } else { + "text_fallback" + } +} + +// ── regions ─────────────────────────────────────────────────────────────── + +/// A leaf after splitting. `key` identifies its counterpart on the other +/// side, when it has one. +#[derive(Clone, Debug)] +struct Leaf { + lines: (usize, usize), + key: Option, + collapsed: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct LeafKey { + run: usize, + piece: usize, +} + +struct SideFold<'a> { + fold: &'a Fold, + lines: (usize, usize), +} + +fn regions(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> (Vec, Vec) { + let lhs_lines: Vec<&str> = lhs_src.split_terminator('\n').collect(); + let rhs_lines: Vec<&str> = rhs_src.split_terminator('\n').collect(); + let lhs_novel = novel_lines(&result.lhs_positions); + let rhs_novel = novel_lines(&result.rhs_positions); + let sources = (lhs_src, rhs_src); + let positions = ( + result.lhs_positions.as_slice(), + result.rhs_positions.as_slice(), + ); + let rows = aligned_rows(sources, positions); + let (lhs_shown, rhs_shown) = shown_lines(&result.hunks); + let runs = trim_context(runs(&rows, &lhs_novel, &rhs_novel), &lhs_shown, &rhs_shown); + let lhs_gaps: Vec<(usize, usize)> = runs + .iter() + .filter(|run| run.collapsed) + .filter_map(|run| match run.sides { + Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => Some(lhs), + Pairing::RightOnly { .. } => None, + }) + .collect(); + let rhs_gaps: Vec<(usize, usize)> = runs + .iter() + .filter(|run| run.collapsed) + .filter_map(|run| match run.sides { + Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs } => Some(rhs), + Pairing::LeftOnly { .. } => None, + }) + .collect(); + + let lhs_folds = side_folds(&result.lhs_folds, &lhs_lines, &lhs_gaps); + let rhs_folds = side_folds(&result.rhs_folds, &rhs_lines, &rhs_gaps); + let lhs_splits = folds::split_lines(lhs_folds.iter().map(|fold| fold.lines)); + let rhs_splits = folds::split_lines(rhs_folds.iter().map(|fold| fold.lines)); + let (lhs_leaves, rhs_leaves) = split_runs(&runs, &lhs_splits, &rhs_splits); + + let mut ids = Ids::default(); + let lhs = tree( + &lhs_folds, + &lhs_leaves, + &result.lhs_positions, + &lhs_novel, + &lhs_lines, + &mut ids, + ); + let rhs = tree( + &rhs_folds, + &rhs_leaves, + &result.rhs_positions, + &rhs_novel, + &rhs_lines, + &mut ids, + ); + #[cfg(debug_assertions)] + for side in [&lhs, &rhs] { + assert_eq!(tree_violation(side), None); + } + (lhs, rhs) +} + +/// The first way `regions` fails to be a strict tree, if any: a child +/// outside its parent's byte range, siblings out of order or overlapping, +/// or a fold with no children. +#[cfg(any(test, debug_assertions))] +pub(crate) fn tree_violation(regions: &[Region]) -> Option { + fn pos(position: SourcePos) -> (u32, u32) { + (position.line, position.column) + } + fn walk(regions: &[Region], parent: Option<&Region>) -> Option { + let mut previous_end = None; + for region in regions { + let (start, end) = (pos(region.range.start), pos(region.range.end)); + if let Some(parent) = parent { + if start < pos(parent.range.start) || end > pos(parent.range.end) { + return Some(format!( + "region {} outside its parent {}", + region.alignment_id, parent.alignment_id + )); + } + } + if previous_end.is_some_and(|previous| start < previous) { + return Some(format!( + "region {} overlaps its previous sibling", + region.alignment_id + )); + } + previous_end = Some(end); + if let Node::Fold { children } = ®ion.node { + if children.is_empty() { + return Some(format!("fold {} has no children", region.alignment_id)); + } + if let Some(problem) = walk(children, Some(region)) { + return Some(problem); + } + } + } + None + } + walk(regions, None) +} + +/// One side's folds as nested line spans fitted around the collapsed gaps. +/// +/// A fold dropped here is never numbered, so its partner on the other side +/// takes an id of its own (see `Ids::fold`). +fn side_folds<'a>(side: &'a [Fold], lines: &[&str], gaps: &[(usize, usize)]) -> Vec> { + let spans: Vec<(usize, usize)> = side + .iter() + .map(|fold| folds::line_span(fold, lines.len())) + .collect(); + side.iter() + .zip(folds::nested_spans(&spans)) + // A fold on a single line hides nothing; it is not a region. + .filter_map(|(fold, span)| { + Some(SideFold { + fold, + lines: folds::fit_to_gaps(span?, gaps)?, + }) + }) + .collect() +} + +/// Split every run at its side's fold boundaries, mirroring splits across +/// paired runs so both sides keep equal-length pieces. +fn split_runs( + runs: &[Run], + lhs_splits: &BTreeSet, + rhs_splits: &BTreeSet, +) -> (Vec, Vec) { + let mut lhs_leaves = Vec::new(); + let mut rhs_leaves = Vec::new(); + for (index, run) in runs.iter().enumerate() { + let len = run.len(); + let (lhs, rhs, paired) = match run.sides { + Pairing::Both { lhs, rhs } => (Some(lhs), Some(rhs), true), + Pairing::LeftOnly { lhs } => (Some(lhs), None, false), + Pairing::RightOnly { rhs } => (None, Some(rhs), false), + }; + let mut offsets: BTreeSet = BTreeSet::new(); + if let Some((start, end)) = lhs { + offsets.extend(lhs_splits.range(start + 1..end).map(|line| line - start)); + } + if let Some((start, end)) = rhs { + offsets.extend(rhs_splits.range(start + 1..end).map(|line| line - start)); + } + let mut at = 0; + for (piece, cut) in offsets.into_iter().chain([len]).enumerate() { + let key = paired.then_some(LeafKey { run: index, piece }); + // A sliver cut off a gap by a fold edge stays open: the same + // offsets apply to both sides, so paired pieces agree. + let collapsed = run.collapsed && (cut - at >= MIN_GAP || cut - at == len); + if let Some((start, _)) = lhs { + lhs_leaves.push(Leaf { + lines: (start + at, start + cut), + key, + collapsed, + }); + } + if let Some((start, _)) = rhs { + rhs_leaves.push(Leaf { + lines: (start + at, start + cut), + key, + collapsed, + }); + } + at = cut; + } + } + (lhs_leaves, rhs_leaves) +} + +/// Wire id allocation, in the order regions are built: lhs preorder, then +/// rhs preorder. Ids are dense from 0, and a region paired with one already +/// numbered takes that region's id. +#[derive(Default)] +struct Ids { + next: u32, + /// Paired leaves share an id through their `LeafKey`. + leaves: DftHashMap, + /// The id of every fold numbered so far, by the syntax node it was + /// built on. + folds: DftHashMap, +} + +impl Ids { + fn fresh(&mut self) -> u32 { + let id = self.next; + self.next += 1; + id + } + + fn leaf(&mut self, key: Option) -> u32 { + match key { + Some(key) => match self.leaves.get(&key) { + Some(&id) => id, + None => { + let id = self.fresh(); + self.leaves.insert(key, id); + id + } + }, + None => self.fresh(), + } + } + + /// A fold whose partner is already numbered shares its id. A partner + /// that is never numbered, because its fold was dropped from its side, + /// leaves the survivor a fresh id no region on the other side carries. + fn fold(&mut self, fold: &Fold) -> u32 { + let shared = fold + .partner + .and_then(|partner| self.folds.get(&partner).copied()); + let id = match shared { + Some(id) => id, + None => self.fresh(), + }; + self.folds.insert(fold.syntax_id, id); + id + } +} + +enum Item<'a> { + Fold(&'a SideFold<'a>), + Leaf(&'a Leaf), +} + +impl Item<'_> { + fn lines(&self) -> (usize, usize) { + match self { + Self::Fold(fold) => fold.lines, + Self::Leaf(leaf) => leaf.lines, + } + } + + /// Outer before inner: earlier start, then later end, then folds before + /// leaves, then the wider byte range. + fn order( + &self, + ) -> ( + usize, + std::cmp::Reverse, + u8, + (usize, usize), + std::cmp::Reverse<(usize, usize)>, + ) { + let (start, end) = self.lines(); + match self { + Self::Fold(fold) => ( + start, + std::cmp::Reverse(end), + 0, + ( + fold.fold.range.start.line.as_usize(), + fold.fold.range.start.byte_column, + ), + std::cmp::Reverse(( + fold.fold.range.end.line.as_usize(), + fold.fold.range.end.byte_column, + )), + ), + Self::Leaf(_) => ( + start, + std::cmp::Reverse(end), + 1, + (0, 0), + std::cmp::Reverse((0, 0)), + ), + } + } +} + +/// Nest folds and leaves by containment on line spans and number them in +/// document order, outer before inner. +fn tree( + folds: &[SideFold<'_>], + leaves: &[Leaf], + positions: &[MatchedPos], + novel: &BTreeSet, + lines: &[&str], + ids: &mut Ids, +) -> Vec { + let mut items: Vec> = folds + .iter() + .map(Item::Fold) + .chain(leaves.iter().map(Item::Leaf)) + .collect(); + items.sort_by_key(Item::order); + let by_line = positions_by_line(positions); + + // Build bottom-up with an explicit stack of open folds. + struct Open<'a> { + fold: &'a SideFold<'a>, + id: u32, + children: Vec, + } + let mut root: Vec = Vec::new(); + let mut stack: Vec> = Vec::new(); + let close = |stack: &mut Vec>, root: &mut Vec| { + let open = stack.pop().expect("closing an open fold"); + let fold = open.fold.fold; + // The wire range is the hull of the children, which tile whole + // lines; the parser's byte columns inside the header line are not + // carried, since nothing narrower than a line can be hidden. + let (Some(first), Some(last)) = (open.children.first(), open.children.last()) else { + return; + }; + let range = SourceRange { + start: first.range.start, + end: last.range.end, + }; + let region = Region { + alignment_id: open.id, + fold_state_id: open.id, + range, + tags: fold.tags.clone(), + visibility: Visibility { + collapsed: false, + label: fold + .summary + .clone() + .unwrap_or_else(|| fold.placeholder.clone()), + }, + node: Node::Fold { + children: open.children, + }, + }; + match stack.last_mut() { + Some(parent) => parent.children.push(region), + None => root.push(region), + } + }; + for item in items { + let (start, _) = item.lines(); + while stack.last().is_some_and(|open| open.fold.lines.1 <= start) { + close(&mut stack, &mut root); + } + match item { + Item::Fold(fold) => { + let id = ids.fold(fold.fold); + stack.push(Open { + fold, + id, + children: Vec::new(), + }); + } + Item::Leaf(leaf) => { + if leaf.lines.0 == leaf.lines.1 { + continue; + } + let region = leaf_region(leaf, ids, &by_line, novel, lines); + match stack.last_mut() { + Some(parent) => parent.children.push(region), + None => root.push(region), + } + } + } + } + while !stack.is_empty() { + close(&mut stack, &mut root); + } + root +} + +fn leaf_region( + leaf: &Leaf, + ids: &mut Ids, + by_line: &BTreeMap>, + novel: &BTreeSet, + lines: &[&str], +) -> Region { + let (start, end) = leaf.lines; + let mut changed = Vec::new(); + for line in novel.range(start..end) { + let tokens = by_line.get(line).map(Vec::as_slice).unwrap_or(&[]); + let all_novel = tokens.iter().all(|token| { + matches!( + token.kind, + MatchKind::Novel { .. } | MatchKind::NovelWord { .. } + ) + }); + if all_novel { + // A blank novel line has nothing to paint. + if !lines[*line].is_empty() { + changed.push(Span { + line: *line as u32, + start_column: 0, + end_column: lines[*line].len() as u32, + }); + } + continue; + } + for token in tokens { + if !matches!( + token.kind, + MatchKind::Novel { .. } | MatchKind::NovelWord { .. } + ) { + continue; + } + let span = Span { + line: *line as u32, + start_column: token.pos.start_col, + end_column: token.pos.end_col, + }; + match changed.last_mut() { + Some(last) if last.line == span.line && last.end_column == span.start_column => { + last.end_column = span.end_column; + } + _ => changed.push(span), + } + } + } + let (tags, visibility) = if leaf.collapsed { + ( + vec!["unchanged".to_owned()], + Visibility { + collapsed: true, + label: match end - start { + 1 => "1 unchanged line".to_owned(), + count => format!("{count} unchanged lines"), + }, + }, + ) + } else { + (Vec::new(), Visibility::default()) + }; + let id = ids.leaf(leaf.key); + Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: SourcePos { + line: start as u32, + column: 0, + }, + end: SourcePos { + line: end as u32, + column: 0, + }, + }, + tags, + visibility, + node: Node::Leaf { changed }, + } +} + +fn positions_by_line(positions: &[MatchedPos]) -> BTreeMap> { + let mut by_line: BTreeMap> = BTreeMap::new(); + for position in positions { + by_line + .entry(position.pos.line.as_usize()) + .or_default() + .push(position); + } + for tokens in by_line.values_mut() { + tokens.sort_by_key(|token| token.pos.start_col); + } + by_line +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Params; + use crate::options::{DiffOptions, DisplayOptions}; + + fn refs(lhs: bool, rhs: bool) -> Pairing { + let file_ref = FileRef { + path: "a.py".to_owned(), + oid: String::new(), + mode: String::new(), + }; + match (lhs, rhs) { + (true, true) => Pairing::Both { + lhs: file_ref.clone(), + rhs: file_ref, + }, + (true, false) => Pairing::LeftOnly { lhs: file_ref }, + (false, true) => Pairing::RightOnly { rhs: file_ref }, + (false, false) => panic!("a file has a side"), + } + } + + fn project(path: &str, lhs: &str, rhs: &str, context: usize) -> Diff { + project_with(path, lhs, rhs, context, DiffOptions::default()) + } + + /// `graph_limit: 1` forces the text-diff fallback for any real change. + fn project_with( + path: &str, + lhs: &str, + rhs: &str, + context: usize, + options: DiffOptions, + ) -> Diff { + let result = DiffResult::from_sources_with_options( + path, + lhs, + rhs, + &Params::default(), + &DisplayOptions { + num_context_lines: context as u32, + ..DisplayOptions::default() + }, + &options, + ); + diff( + &result, + Inputs { + file: &refs(!lhs.is_empty(), !rhs.is_empty()), + sizes: (lhs.len() as u64, rhs.len() as u64), + syntax: (Vec::new(), Vec::new()), + }, + ) + } + + fn sources(diff: &Diff) -> (Option<&Source>, Option<&Source>) { + match diff { + Diff::Text { sides, .. } => match sides { + Pairing::Both { lhs, rhs } => (Some(lhs), Some(rhs)), + Pairing::LeftOnly { lhs } => (Some(lhs), None), + Pairing::RightOnly { rhs } => (None, Some(rhs)), + }, + Diff::Binary { .. } => panic!("text diff"), + } + } + + fn leaves(regions: &[Region]) -> Vec<&Region> { + let mut out = Vec::new(); + for region in regions { + match ®ion.node { + Node::Leaf { .. } => out.push(region), + Node::Fold { children } => out.extend(leaves(children)), + } + } + out + } + + fn all(regions: &[Region]) -> Vec<&Region> { + let mut out = Vec::new(); + for region in regions { + out.push(region); + if let Node::Fold { children } = ®ion.node { + out.extend(all(children)); + } + } + out + } + + fn line_count(text: &str) -> u32 { + text.split_terminator('\n').count() as u32 + } + + fn assert_tiles(source: &Source) { + let mut at = 0; + for leaf in leaves(&source.regions) { + assert_eq!(leaf.range.start.line, at, "gap before {leaf:?}"); + assert_eq!(leaf.range.start.column, 0); + assert_eq!(leaf.range.end.column, 0); + assert!(leaf.range.end.line > at, "empty leaf {leaf:?}"); + at = leaf.range.end.line; + } + assert_eq!(at, line_count(&source.text)); + } + + fn assert_folds_hold_children(regions: &[Region]) { + assert_eq!(tree_violation(regions), None); + for region in regions { + if let Node::Fold { children } = ®ion.node { + assert!(!children.is_empty(), "fold without children {region:?}"); + let (start, end) = region.range.lines_spanned(); + let mut at = start; + for child in children { + let (child_start, child_end) = child.range.lines_spanned(); + assert_eq!(child_start, at, "hole inside {region:?}"); + at = child_end; + } + assert_eq!(at, end, "fold {region:?} not tiled by its children"); + assert_folds_hold_children(children); + } + } + } + + impl SourceRange { + fn lines_spanned(&self) -> (u32, u32) { + let range = self.lines(); + (range.start, range.end) + } + } + + const RUST_LHS: &str = "fn f(a: u32) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n"; + const RUST_RHS: &str = "fn f(a: u32, b: u32) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n\nfn added() -> u32 {\n let p = 3;\n let q = 4;\n p + q\n}\n"; + + fn fold_ids(source: &Source) -> BTreeMap { + all(&source.regions) + .into_iter() + .filter(|r| matches!(r.node, Node::Fold { .. })) + .map(|r| (r.range.start.line, r.alignment_id)) + .collect() + } + + #[test] + fn a_fold_ending_inside_a_gap_does_not_split_it() { + // The inner block's closer sits inside a long unchanged stretch that + // continues in the enclosing function: one gap, not two back to back. + let body = (1..=7) + .map(|n| format!(" u{n}();\n")) + .collect::>() + .concat(); + let tail = (1..=5) + .map(|n| format!(" v{n}();\n")) + .collect::>() + .concat(); + let lhs = format!("fn f() {{\n if a {{\n x();\n{body} }}\n{tail}}}\n"); + let rhs = format!("fn f() {{\n if a {{\n y();\n{body} }}\n{tail}}}\n"); + let diff = project("a.rs", &lhs, &rhs, 1); + let (lhs_src, rhs_src) = sources(&diff); + for source in [lhs_src.unwrap(), rhs_src.unwrap()] { + assert_tiles(source); + let gaps: Vec<&Region> = all(&source.regions) + .into_iter() + .filter(|r| { + matches!(r.node, Node::Leaf { .. }) + && r.visibility.collapsed + && r.tags.iter().any(|t| t == "unchanged") + }) + .collect(); + assert_eq!(gaps.len(), 1, "one gap: {gaps:?}"); + assert!(gaps[0].range.end.line - gaps[0].range.start.line >= 10); + } + } + + #[test] + fn a_closer_and_an_opener_on_one_line_yield_a_strict_tree() { + let lhs = "fn f() {\n for x in [\n 1,\n 2,\n ] {\n use_it(x);\n more(x);\n }\n}\n"; + let rhs = "fn f() {\n for x in [\n 1,\n 2,\n 3,\n ] {\n use_it(x);\n more(x);\n }\n}\n"; + let diff = project("a.rs", lhs, rhs, 3); + let (lhs, rhs) = sources(&diff); + for source in [lhs.unwrap(), rhs.unwrap()] { + assert_tiles(source); + assert_folds_hold_children(&source.regions); + let folds: Vec<&Region> = all(&source.regions) + .into_iter() + .filter(|region| matches!(region.node, Node::Fold { .. })) + .collect(); + let collection = folds + .iter() + .find(|fold| fold.tags.iter().any(|tag| tag == "collection")) + .expect("the array is a fold"); + let body = folds + .iter() + .find(|fold| fold.tags.iter().any(|tag| tag == "body") && fold.range.start.line > 0) + .expect("the loop body is a fold"); + assert_eq!( + collection.range.end.line, body.range.start.line, + "the collection gives its closing line to the body that opens there" + ); + assert_eq!(body.range.start.column, 0); + assert_eq!(collection.range.end.column, 0); + } + } + + #[test] + fn structural_folds_pair_exactly_as_the_matcher_recorded() { + let result = DiffResult::from_sources_with_options( + "a.rs", + RUST_LHS, + RUST_RHS, + &Params::default(), + &DisplayOptions::default(), + &DiffOptions::default(), + ); + let diff = project("a.rs", RUST_LHS, RUST_RHS, 3); + let (lhs, rhs) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs.unwrap()), fold_ids(rhs.unwrap())); + let rhs_ids: BTreeSet = rhs_folds.values().copied().collect(); + for fold in &result.lhs_folds { + let id = lhs_folds[&(fold.range.start.line.as_usize() as u32)]; + let matcher_paired = fold.counterpart(&result.rhs_folds).is_some(); + assert_eq!(rhs_ids.contains(&id), matcher_paired, "{:?}", fold.range); + } + } + + #[test] + fn a_fold_dropped_on_one_side_leaves_its_partner_unshared() { + // The matcher pairs the two arrays, but the lhs array sits on one + // line: it hides nothing and is not a region. + let lhs = "fn f() {\n let v = [1, 2];\n work(v);\n}\n"; + let rhs = "fn f() {\n let v = [\n 1,\n 2,\n ];\n work(v);\n}\n"; + let result = DiffResult::from_sources("a.rs", lhs, rhs); + let collection = |folds: &[Fold]| -> (SyntaxId, Option) { + let fold = folds + .iter() + .find(|fold| fold.tags.iter().any(|tag| tag == "collection")) + .expect("the array is a fold"); + (fold.syntax_id, fold.partner) + }; + let (lhs_array, lhs_partner) = collection(&result.lhs_folds); + let (rhs_array, rhs_partner) = collection(&result.rhs_folds); + assert_eq!( + (lhs_partner, rhs_partner), + (Some(rhs_array), Some(lhs_array)), + "the matcher pairs the arrays" + ); + let diff = project("a.rs", lhs, rhs, 3); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + let lhs_ids: BTreeSet = all(&lhs.regions).iter().map(|r| r.alignment_id).collect(); + let array = all(&rhs.regions) + .into_iter() + .find(|r| r.tags.iter().any(|tag| tag == "collection")) + .expect("the rhs array is a region"); + assert!( + !lhs_ids.contains(&array.alignment_id), + "no lhs region claims the dropped fold's id" + ); + } + + #[test] + fn leaves_tile_both_sides_and_paired_leaves_share_ids() { + let lhs = "import os\n\ndef f():\n x = 1\n return x\n"; + let rhs = + "import os\n\ndef f():\n x = 2\n return x\n\ndef g():\n y = 3\n return y\n"; + let diff = project("a.py", lhs, rhs, 3); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + assert_folds_hold_children(&lhs.regions); + assert_folds_hold_children(&rhs.regions); + let lhs_ids: BTreeSet = all(&lhs.regions).iter().map(|r| r.alignment_id).collect(); + let rhs_ids: BTreeSet = all(&rhs.regions).iter().map(|r| r.alignment_id).collect(); + // Ids are dense and assigned lhs first. + let max = lhs_ids.iter().chain(&rhs_ids).max().copied().unwrap(); + assert_eq!( + lhs_ids.union(&rhs_ids).copied().collect::>(), + (0..=max).collect::>() + ); + for (lhs_leaf, rhs_leaf) in leaves(&lhs.regions).iter().zip(leaves(&rhs.regions)) { + if lhs_leaf.alignment_id == rhs_leaf.alignment_id { + assert_eq!( + lhs_leaf.range.lines_spanned().1 - lhs_leaf.range.lines_spanned().0, + rhs_leaf.range.lines_spanned().1 - rhs_leaf.range.lines_spanned().0, + "paired leaves have equal length" + ); + } + } + // The new function exists on the rhs only. + let rhs_only: Vec<_> = rhs_ids.difference(&lhs_ids).collect(); + assert!(!rhs_only.is_empty()); + // Python's body fold is the block, which starts on the line after `def`. + let new_fold = all(&rhs.regions) + .into_iter() + .find(|r| matches!(r.node, Node::Fold { .. }) && r.range.start.line == 7) + .expect("the added function is a fold"); + assert!(rhs_only.contains(&&new_fold.alignment_id)); + assert_eq!(new_fold.tags, vec!["body", "function"]); + assert_eq!(new_fold.visibility.label, "Body"); + } + + #[test] + fn the_changed_body_is_a_paired_fold_with_a_novel_leaf_inside() { + let lhs = "def f():\n a = 1\n b = 2\n return a\n"; + let rhs = "def f():\n a = 1\n b = 3\n return a\n"; + let diff = project("a.py", lhs, rhs, 3); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + let fold = |source: &Source| { + let folds: Vec<_> = all(&source.regions) + .into_iter() + .filter(|r| matches!(r.node, Node::Fold { .. })) + .collect(); + assert_eq!(folds.len(), 1); + folds[0].clone() + }; + let lhs_fold = fold(lhs); + let rhs_fold = fold(rhs); + assert_eq!(lhs_fold.alignment_id, rhs_fold.alignment_id); + assert_eq!(lhs_fold.range.lines_spanned(), (1, 4)); + let novel: Vec<_> = leaves(&rhs.regions) + .into_iter() + .filter(|leaf| matches!(&leaf.node, Node::Leaf { changed } if !changed.is_empty())) + .collect(); + assert_eq!(novel.len(), 1); + assert_eq!(novel[0].range.lines_spanned(), (2, 3)); + let Node::Leaf { changed } = &novel[0].node else { + unreachable!() + }; + // Only the changed token is painted, not the whole line. + assert_eq!( + changed, + &[Span { + line: 2, + start_column: 8, + end_column: 9 + }] + ); + } + + #[test] + fn a_fully_new_line_is_painted_whole_and_blank_lines_not_at_all() { + let diff = project("a.py", "x = 1\n", "x = 1\n\ny = 2\n", 3); + let (_, rhs) = sources(&diff); + let changed: Vec = leaves(&rhs.unwrap().regions) + .into_iter() + .filter_map(|leaf| match &leaf.node { + Node::Leaf { changed } => Some(changed.clone()), + Node::Fold { .. } => None, + }) + .flatten() + .collect(); + assert_eq!( + changed, + vec![Span { + line: 2, + start_column: 0, + end_column: 5 + }] + ); + } + + #[test] + fn long_unchanged_runs_collapse_to_the_context_width() { + let body = (0..20) + .map(|i| format!("x{i} = {i}\n")) + .collect::>() + .concat(); + let lhs = format!("{body}changed = 1\n{body}"); + let rhs = format!("{body}changed = 2\n{body}"); + let diff = project("a.py", &lhs, &rhs, 3); + let (lhs, _) = sources(&diff); + let leaves = leaves(&lhs.unwrap().regions); + let spans: Vec<_> = leaves + .iter() + .map(|leaf| { + ( + leaf.range.lines_spanned(), + leaf.visibility.collapsed, + leaf.tags.clone(), + ) + }) + .collect(); + assert_eq!( + spans, + vec![ + ((0, 17), true, vec!["unchanged".to_owned()]), + ((17, 20), false, vec![]), + ((20, 21), false, vec![]), + ((21, 24), false, vec![]), + ((24, 41), true, vec!["unchanged".to_owned()]), + ] + ); + assert_eq!(leaves[0].visibility.label, "17 unchanged lines"); + } + + #[test] + fn folds_inside_a_gap_are_dropped_and_the_gap_stays_whole() { + // Three unchanged functions sit between two changes. They would be + // hidden inside the gap, so they are not regions and do not split it. + let unchanged = (0..3) + .map(|i| format!("def f{i}():\n a = {i}\n b = {i}\n return a + b\n\n")) + .collect::>() + .concat(); + let lhs = format!("first = 1\n\n{unchanged}last = 1\n"); + let rhs = format!("first = 2\n\n{unchanged}last = 2\n"); + let diff = project("a.py", &lhs, &rhs, 1); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert!(all(&lhs.regions) + .iter() + .all(|r| matches!(r.node, Node::Leaf { .. }))); + let gaps: Vec<_> = leaves(&rhs.regions) + .into_iter() + .filter(|leaf| leaf.visibility.collapsed) + .map(|leaf| leaf.range.lines_spanned()) + .collect(); + assert_eq!(gaps, vec![(2, 16)]); + assert_tiles(lhs); + assert_tiles(rhs); + } + + #[test] + fn slivers_cut_from_a_gap_by_a_fold_edge_stay_open() { + // The changed function's fold edge lands one line into the gap + // before it; that one line is not worth a fold row. + let head = (0..8) + .map(|i| format!("x{i} = {i}\n")) + .collect::>() + .concat(); + let lhs = format!("{head}\ndef f():\n a = 1\n b = 1\n c = 1\n return 1\n"); + let rhs = format!("{head}\ndef f():\n a = 1\n b = 1\n c = 1\n return 2\n"); + let diff = project("a.py", &lhs, &rhs, 1); + let (_, rhs) = sources(&diff); + let rhs = rhs.unwrap(); + for leaf in leaves(&rhs.regions) { + let (start, end) = leaf.range.lines_spanned(); + assert!( + !leaf.visibility.collapsed || end - start >= MIN_GAP as u32, + "collapsed sliver {start}..{end}" + ); + } + assert_tiles(rhs); + } + + #[test] + fn the_enclosing_header_stays_open_above_a_deep_change() { + // A change ten lines into a function: the `def` line is syntax + // context, so it is shown even though it is far outside the padding. + let body = (0..10) + .map(|i| format!(" a{i} = {i}\n")) + .collect::>() + .concat(); + let lhs = format!("def outer():\n{body} return 1\n"); + let rhs = format!("def outer():\n{body} return 2\n"); + let diff = project("a.py", &lhs, &rhs, 1); + let (_, rhs) = sources(&diff); + let rhs = rhs.unwrap(); + let open_lines: Vec = leaves(&rhs.regions) + .into_iter() + .filter(|leaf| !leaf.visibility.collapsed) + .flat_map(|leaf| { + let (start, end) = leaf.range.lines_spanned(); + start..end + }) + .collect(); + assert!( + open_lines.contains(&0), + "header line hidden: {open_lines:?}" + ); + assert!( + !open_lines.contains(&5), + "middle of the body should be a gap" + ); + assert_tiles(rhs); + } + + #[test] + fn a_fold_edge_splits_paired_leaves_on_both_sides() { + // The unchanged function inside the gap forces a split on both sides + // at the same offset, even though only the rhs shifted. + let lhs = "a = 1\n\ndef f():\n return 1\n\nz = 1\n"; + let rhs = "a = 2\n\ndef f():\n return 1\n\nz = 1\n"; + let diff = project("a.py", lhs, rhs, 1); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + assert_folds_hold_children(&lhs.regions); + assert_folds_hold_children(&rhs.regions); + let lhs_leaves: Vec<_> = leaves(&lhs.regions) + .iter() + .map(|l| (l.alignment_id, l.range.lines_spanned())) + .collect(); + let rhs_leaves: Vec<_> = leaves(&rhs.regions) + .iter() + .map(|l| (l.alignment_id, l.range.lines_spanned())) + .collect(); + assert_eq!(lhs_leaves, rhs_leaves); + } + + #[test] + fn stats_count_text_and_syntax_lines_separately() { + let diff = project("a.py", "x = 1\n", "x = 1 # same\n", 3); + let Diff::Text { stats, .. } = &diff else { + panic!("text") + }; + assert_eq!( + stats.textual, + LineCounts { + added: 1, + removed: 1 + } + ); + assert!(stats.fallback.is_none()); + let diff = project("a.unknownext", "x\n", "y\n", 3); + let Diff::Text { stats, .. } = &diff else { + panic!("text") + }; + assert_eq!( + stats.fallback.as_ref().unwrap().code, + "unsupported_language" + ); + } + + #[test] + fn one_sided_files_have_one_source_and_no_shared_ids() { + let diff = project("a.py", "", "def f():\n return 1\n", 3); + let (lhs, rhs) = sources(&diff); + assert!(lhs.is_none()); + let rhs = rhs.unwrap(); + assert_tiles(rhs); + assert!(leaves(&rhs.regions) + .iter() + .all(|leaf| !leaf.visibility.collapsed)); + } + + #[test] + fn identical_files_are_one_collapsed_leaf() { + let diff = project("a.py", "x = 1\ny = 2\n", "x = 1\ny = 2\n", 3); + let (lhs, rhs) = sources(&diff); + let lhs = lhs.unwrap(); + assert_eq!(leaves(&lhs.regions).len(), 1); + assert!(leaves(&lhs.regions)[0].visibility.collapsed); + assert_eq!( + lhs.regions[0].alignment_id, + rhs.unwrap().regions[0].alignment_id + ); + } + + #[test] + fn binary_sides_carry_sizes() { + let result = DiffResult { + display_path: "a.bin".to_owned(), + extra_info: None, + file_format: FileFormat::Binary, + lhs_src: FileContent::Binary, + rhs_src: FileContent::Binary, + hunks: vec![], + lhs_folds: vec![], + rhs_folds: vec![], + lhs_positions: vec![], + rhs_positions: vec![], + has_byte_changes: Some((3, 5)), + has_syntactic_changes: false, + }; + let diff = diff( + &result, + Inputs { + file: &refs(true, true), + sizes: (3, 5), + syntax: (Vec::new(), Vec::new()), + }, + ); + let Diff::Binary { + sides: Pairing::Both { lhs, rhs }, + } = diff + else { + panic!("a binary diff with both sides: {diff:?}"); + }; + assert_eq!((lhs.size, rhs.size), (3, 5)); + } +} diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs new file mode 100644 index 000000000..b61adad38 --- /dev/null +++ b/src/protocol/stream.rs @@ -0,0 +1,206 @@ +//! The stdout stream: manifest, one record per file as it finishes, footer. +use super::project::{self, Inputs}; +use super::{Event, FileChange, Outcome, Problem, Snapshot, VERSION}; +use crate::git::{DiffSession, FileError, LoadedFile}; +use crate::summary::DiffResult; +use anyhow::anyhow; +use rayon::iter::{ParallelBridge, ParallelIterator}; +use std::io::{BufWriter, Write}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::sync::Arc; +use std::thread; + +/// Returns whether any file failed. Files are diffed on `jobs` workers and +/// emitted as they finish. The queue holds at most one ready record, so +/// computation overlaps output without retaining the whole comparison. +pub(crate) fn write( + session: DiffSession, + jobs: usize, + output: &mut impl Write, +) -> anyhow::Result { + let manifest = manifest(&session); + let (sender, receiver) = sync_channel(1); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(jobs) + .thread_name(|index| format!("diffr-worker-{index}")) + .build()?; + let worker = thread::spawn(move || { + // A disconnected consumer cancels production after the files in flight. + let _ = produce(session, manifest, &pool, sender); + }); + let mut output = BufWriter::new(output); + let result: anyhow::Result = (|| { + let mut failed = false; + for event in &receiver { + if let Event::Complete { failed: count, .. } = &event { + failed = *count > 0; + } + serde_json::to_writer(&mut output, &event)?; + output.write_all(b"\n")?; + output.flush()?; + } + Ok(failed) + })(); + // Wake a producer blocked on a full queue if writing failed. + drop(receiver); + let joined = worker.join(); + let failed = result?; + joined.map_err(|_| anyhow!("diff computation thread panicked"))?; + Ok(failed) +} + +/// The consumer went away; production stops after the files in flight. +struct Disconnected; + +fn manifest(session: &DiffSession) -> Vec { + session + .file_manifest() + .iter() + .map(crate::git::FileChange::manifest_entry) + .collect() +} + +fn produce( + session: DiffSession, + manifest: Vec, + pool: &rayon::ThreadPool, + sender: SyncSender, +) -> Result<(), Disconnected> { + let send = |event: Event| sender.send(event).map_err(|_| Disconnected); + send(Event::Start { + version: VERSION, + lhs: Snapshot::from(&session.comparison.before), + rhs: Snapshot::from(&session.comparison.after), + files: manifest, + })?; + let succeeded = AtomicU32::new(0); + let failed = AtomicU32::new(0); + let cancelled = Arc::new(AtomicBool::new(false)); + let loader = Loader { + session, + cancelled: Arc::clone(&cancelled), + }; + pool.install(|| { + loader.par_bridge().for_each(|(file, loaded)| { + let outcome = match loaded { + Ok(loaded) => Outcome::Diff { + diff: file_diff(&loaded), + }, + Err(error) => Outcome::Error { + error: wire_error(&error), + }, + }; + match &outcome { + Outcome::Diff { .. } => succeeded.fetch_add(1, Ordering::Relaxed), + Outcome::Error { .. } => failed.fetch_add(1, Ordering::Relaxed), + }; + let event = Event::File { + file: file.sides, + outcome, + }; + if sender.send(event).is_err() { + cancelled.store(true, Ordering::Relaxed); + } + }); + }); + send(Event::Complete { + succeeded: succeeded.into_inner(), + failed: failed.into_inner(), + aborted: None, + }) +} + +/// The wire record for an error, built as it is written. The code comes from +/// the typed cause attached where the error arose; an error nothing +/// classified is `internal`. +fn wire_error(error: &anyhow::Error) -> Problem { + let code = match error.downcast_ref::() { + Some(kind) => kind.code(), + None => "internal", + }; + Problem { + code: code.to_owned(), + message: format!("{error:#}"), + } +} + +fn file_diff(loaded: &LoadedFile) -> super::Diff { + let inputs = Inputs { + file: &loaded.file.sides, + sizes: loaded.sizes(), + syntax: (Vec::new(), Vec::new()), + }; + project::diff(&loaded.diff(), inputs) +} + +/// Reads sources serially on whichever worker pulls next; diffing then +/// proceeds on that worker while others pull further files. +struct Loader { + session: DiffSession, + cancelled: Arc, +} + +impl Iterator for Loader { + type Item = (crate::git::FileChange, anyhow::Result); + fn next(&mut self) -> Option { + if self.cancelled.load(Ordering::Relaxed) { + return None; + } + self.session.load() + } +} + +/// A standalone two-path comparison through the same three records. +pub(crate) fn write_file( + before: &str, + after: &str, + sizes: (u64, u64), + compute: impl FnOnce() -> DiffResult, + output: &mut impl Write, +) -> anyhow::Result<()> { + let file = crate::git::FileChange::standalone(before, after); + let mut output = BufWriter::new(output); + serde_json::to_writer( + &mut output, + &Event::Start { + version: VERSION, + lhs: Snapshot::Path { + path: before.to_owned(), + }, + rhs: Snapshot::Path { + path: after.to_owned(), + }, + files: vec![file.manifest_entry()], + }, + )?; + output.write_all(b"\n")?; + output.flush()?; + let diff = project::diff( + &compute(), + Inputs { + file: &file.sides, + sizes, + syntax: (Vec::new(), Vec::new()), + }, + ); + serde_json::to_writer( + &mut output, + &Event::File { + file: file.sides, + outcome: Outcome::Diff { diff }, + }, + )?; + output.write_all(b"\n")?; + serde_json::to_writer( + &mut output, + &Event::Complete { + succeeded: 1, + failed: 0, + aborted: None, + }, + )?; + output.write_all(b"\n")?; + output.flush()?; + Ok(()) +} diff --git a/src/review/mod.rs b/src/review/mod.rs index 8ce09f194..efc896159 100644 --- a/src/review/mod.rs +++ b/src/review/mod.rs @@ -3,4 +3,3 @@ mod render; #[cfg(test)] mod tests; -pub(crate) mod wire; diff --git a/src/review/render.rs b/src/review/render.rs index 797823913..0ea1e80db 100644 --- a/src/review/render.rs +++ b/src/review/render.rs @@ -107,6 +107,7 @@ impl SnapshotWriter { #[cfg(test)] mod tests { + use crate::parse::syntax::MatchKind; use crate::summary::DiffResult; #[test] fn matched_rename_to_is_reindented_not_deleted_and_added() { @@ -121,28 +122,22 @@ mod tests { .collect(); assert_eq!(assignment.len(), 2); assert!(assignment.iter().all(|line| line.as_bytes()[10] == b'~')); - let domain = review.domain_json(); - assert_eq!(domain["lhs_src"]["Text"], lhs); - assert_eq!(domain["rhs_src"]["Text"], rhs); - let token = domain["lhs_positions"] - .as_array() - .unwrap() + let token = review + .lhs_positions .iter() - .find(|p| p["pos"]["line"] == 247 && p["pos"]["start_col"] == 6) + .find(|p| p.pos.line.0 == 247 && p.pos.start_col == 6) .unwrap(); + let MatchKind::UnchangedToken { opposite_pos, .. } = &token.kind else { + panic!( + "the renamed binding is an unchanged token: {:?}", + token.kind + ); + }; assert_eq!( - token["kind"]["UnchangedToken"]["opposite_pos"][0]["line"], - 253 - ); - assert_eq!( - token["kind"]["UnchangedToken"]["opposite_pos"][0]["start_col"], - 8 - ); - assert!(domain["lhs_folds"][0]["match_kind"]["Unchanged"].is_object()); - assert!( - domain.get("layout").is_none(), - "layout is not part of the domain" + (opposite_pos[0].line.0, opposite_pos[0].start_col), + (253, 8) ); + assert!(review.lhs_folds[0].counterpart(&review.rhs_folds).is_some()); } #[test] diff --git a/src/review/tests.rs b/src/review/tests.rs index be201f4fc..04f84622e 100644 --- a/src/review/tests.rs +++ b/src/review/tests.rs @@ -20,18 +20,24 @@ fn text<'a>(source: &'a str, range: &SourceRange) -> &'a str { mod folds { use super::text; use crate::lines::SourceRange; - use crate::parse::folds::{Fold, FoldMatch}; + use crate::parse::folds::Fold; use crate::summary::DiffResult; use std::fmt::Write as _; - fn paired(fold: &Fold) -> Option<(&SourceRange, &SourceRange)> { - match &fold.match_kind { - FoldMatch::Unchanged { opposite } => Some((&fold.range, opposite)), - FoldMatch::Novel => None, - } + /// A fold's range and its counterpart's, when it has one. + fn paired<'a>( + fold: &'a Fold, + other_side: &'a [Fold], + ) -> Option<(&'a SourceRange, &'a SourceRange)> { + fold.counterpart(other_side) + .map(|other| (&fold.range, &other.range)) } - fn added(fold: &Fold) -> Option<&SourceRange> { - matches!(fold.match_kind, FoldMatch::Novel).then_some(&fold.range) + /// A fold's range, when it has no counterpart. + fn added<'a>(fold: &'a Fold, other_side: &[Fold]) -> Option<&'a SourceRange> { + match fold.counterpart(other_side) { + Some(_) => None, + None => Some(&fold.range), + } } #[test] @@ -40,7 +46,7 @@ mod folds { let rhs = "fn execute() {\n old_work();\n new_work();\n}\n"; let diff = DiffResult::from_sources("a.rs", lhs, rhs); assert!(diff.lhs_folds.iter().any(|f| { - paired(f).is_some_and(|(l, r)| { + paired(f, &diff.rhs_folds).is_some_and(|(l, r)| { l.start.line.as_usize() == 0 && l.end.line.as_usize() == 2 && r.end.line.as_usize() == 3 @@ -67,7 +73,8 @@ mod folds { .rhs_folds .iter() .any(|fold| fold.placeholder == "String" - && added(fold).is_some_and(|r| text(src, r) == "\"\"\"first\nsecond\"\"\""))); + && added(fold, &result.lhs_folds) + .is_some_and(|r| text(src, r) == "\"\"\"first\nsecond\"\"\""))); } #[test] @@ -79,7 +86,8 @@ mod folds { assert_eq!(result.rhs_folds.len(), 1); let fold = &result.lhs_folds[0]; assert_eq!(fold.tags, ["body", "test"]); - let (left, right) = paired(fold).expect("reuse the replaced-string correspondence"); + let (left, right) = + paired(fold, &result.rhs_folds).expect("reuse the replaced-string correspondence"); assert_eq!(text(lhs, left), "\"\"\"some shared words before\"\"\""); assert_eq!(text(rhs, right), "\"\"\"some shared words after\"\"\""); } @@ -96,8 +104,10 @@ mod folds { (source, ""), ] { let diff = DiffResult::from_sources("a.py", lhs, rhs); - for (folds, own, opposite) in [(&diff.lhs_folds, lhs, rhs), (&diff.rhs_folds, rhs, lhs)] - { + for (folds, own, opposite, opposite_folds) in [ + (&diff.lhs_folds, lhs, rhs, &diff.rhs_folds), + (&diff.rhs_folds, rhs, lhs, &diff.lhs_folds), + ] { if own.is_empty() { assert!(folds.is_empty()); continue; @@ -106,11 +116,9 @@ mod folds { for (fold, expected) in folds.iter().zip(["import os", "import sys"]) { assert_eq!(fold.tags, ["import"]); assert_eq!(text(own, &fold.range), expected); - match &fold.match_kind { - FoldMatch::Unchanged { opposite: range } => { - assert_eq!(text(opposite, range), expected) - } - FoldMatch::Novel => assert!(opposite.is_empty()), + match fold.counterpart(opposite_folds) { + Some(other) => assert_eq!(text(opposite, &other.range), expected), + None => assert!(opposite.is_empty()), } } } @@ -138,13 +146,19 @@ mod folds { let added_diff = DiffResult::from_sources("a.py", "", source); assert!(added_diff.lhs_folds.is_empty()); assert_eq!( - text(source, added(&added_diff.rhs_folds[0]).unwrap()), + text( + source, + added(&added_diff.rhs_folds[0], &added_diff.lhs_folds).unwrap() + ), "import os" ); let deleted_diff = DiffResult::from_sources("a.py", source, ""); assert!(deleted_diff.rhs_folds.is_empty()); assert_eq!( - text(source, added(&deleted_diff.lhs_folds[0]).unwrap()), + text( + source, + added(&deleted_diff.lhs_folds[0], &deleted_diff.rhs_folds).unwrap() + ), "import os" ); } @@ -162,7 +176,7 @@ mod folds { let expected_rhs = expected.replace("work(1)", "work(2)"); assert!( review.lhs_folds.iter().any(|f| { - paired(f).is_some_and(|(l, r)| { + paired(f, &review.rhs_folds).is_some_and(|(l, r)| { text(lhs, l) == expected && text(&rhs, r) == expected_rhs }) }), @@ -181,7 +195,7 @@ mod folds { .position(|l| l.contains("fn max_depth_does_not_load_unreachable_ignore_files()")) .unwrap(); assert!(review.rhs_folds.iter().any(|f| { - added(f).is_some_and(|r| { + added(f, &review.lhs_folds).is_some_and(|r| { r.start.line.as_usize() == signature && text(rhs, r).contains("let td = tmpdir();") && rhs.lines().nth(r.end.line.as_usize()).unwrap().trim() == "}" @@ -208,7 +222,7 @@ mod folds { review .rhs_folds .iter() - .any(|f| added(f).is_some_and(|r| text(src, r) == expected)), + .any(|f| added(f, &review.lhs_folds).is_some_and(|r| text(src, r) == expected)), "missing body fold in {path}" ); } @@ -220,7 +234,7 @@ mod folds { let rhs = "const x = [\"☕\", newValue];\n"; let review = DiffResult::from_sources("a.ts", lhs, rhs); assert!(review.lhs_folds.iter().any(|f| { - paired(f).is_some_and(|(l, r)| { + paired(f, &review.rhs_folds).is_some_and(|(l, r)| { text(lhs, l) == "\"☕\", oldValue" && text(rhs, r) == "\"☕\", newValue" }) })); @@ -321,10 +335,6 @@ mod syntax_tests { assert!(selected.contains(&own)); assert!(!selected.contains(&other)); } - let domain = review.domain_json(); - assert!(domain.get("context").is_none()); - assert!(domain["hunks"][0]["lines"].is_array()); - assert!(domain["hunks"][0].get("context").is_none()); } #[test] @@ -423,7 +433,6 @@ mod syntax_tests { mod hunk_tests { use super::text; use crate::display::line_layout as layout; - use crate::parse::folds::FoldMatch; use crate::summary::DiffResult; use crate::summary::FileFormat; #[test] @@ -471,10 +480,13 @@ mod hunk_tests { let source = "fn unchanged() {\n work();\n}\n"; let diff = DiffResult::from_sources("a.rs", source, source); assert!(diff.hunks.is_empty()); - let viewer = diff.viewer_json(); + let rows = layout::aligned_rows( + layout::sources(&diff), + (&diff.lhs_positions, &diff.rhs_positions), + ); assert_eq!( - viewer["layout"]["rows"], - serde_json::json!([[0, 0], [1, 1], [2, 2]]) + rows, + [(Some(0), Some(0)), (Some(1), Some(1)), (Some(2), Some(2))] ); } @@ -514,12 +526,21 @@ mod hunk_tests { ] { for fold in folds { text(own_src, &fold.range); - if let FoldMatch::Unchanged { opposite } = &fold.match_kind { - text(opposite_src, opposite); - assert!(opposite_folds.iter().any(|other| { - other.range == *opposite - && matches!(&other.match_kind, FoldMatch::Unchanged { opposite: back } if *back == fold.range) - }), "matched folds must be reciprocal"); + let counterparts: Vec<_> = opposite_folds + .iter() + .filter(|other| fold.partner == Some(other.syntax_id)) + .collect(); + assert!( + counterparts.len() <= 1, + "a fold has at most one counterpart" + ); + for other in counterparts { + text(opposite_src, &other.range); + assert_eq!( + other.partner, + Some(fold.syntax_id), + "matched folds must be reciprocal" + ); } } } diff --git a/src/review/wire.rs b/src/review/wire.rs deleted file mode 100644 index ce3fc9780..000000000 --- a/src/review/wire.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Lossless JSON encoding of the domain model, not Difftastic's display JSON. -use crate::display::line_layout as layout; -use crate::lines::SourceRange; -use crate::parse::folds::{Fold, FoldMatch}; -use crate::parse::syntax::{MatchKind, MatchedPos}; -use crate::summary::DiffResult; -use crate::summary::{FileContent, FileFormat}; -use line_numbers::SingleLineSpan; -use serde_json::{json, Value}; - -fn span(position: &SingleLineSpan) -> Value { - json!({ - "line": position.line.0, - "start_col": position.start_col, - "end_col": position.end_col, - }) -} - -fn spans(positions: &[SingleLineSpan]) -> Value { - Value::Array(positions.iter().map(span).collect()) -} - -fn position(position: &MatchedPos) -> Value { - let kind = match &position.kind { - MatchKind::UnchangedToken { - highlight, - self_pos, - opposite_pos, - } => json!({ - "UnchangedToken": { - "highlight": highlight, - "self_pos": spans(self_pos), - "opposite_pos": spans(opposite_pos), - }, - }), - MatchKind::UnchangedPartOfNovelItem { - highlight, - self_pos, - opposite_pos, - } => json!({ - "UnchangedPartOfNovelItem": { - "highlight": highlight, - "self_pos": span(self_pos), - "opposite_pos": spans(opposite_pos), - }, - }), - MatchKind::Novel { highlight } => json!({"Novel": {"highlight": highlight}}), - MatchKind::NovelWord { highlight } => json!({"NovelWord": {"highlight": highlight}}), - MatchKind::Ignored { highlight } => json!({"Ignored": {"highlight": highlight}}), - }; - json!({"pos": span(&position.pos), "kind": kind}) -} - -pub(crate) fn range(range: &SourceRange) -> Value { - json!({ - "start": { - "line": range.start.line.0, - "byte_column": range.start.byte_column, - }, - "end": { - "line": range.end.line.0, - "byte_column": range.end.byte_column, - }, - }) -} - -fn fold(fold: &Fold) -> Value { - let match_kind = match &fold.match_kind { - FoldMatch::Unchanged { opposite } => json!({"Unchanged": {"opposite": range(opposite)}}), - FoldMatch::Novel => json!("Novel"), - }; - json!({ - "tags": fold.tags, - "range": range(&fold.range), - "match_kind": match_kind, - "placeholder": fold.placeholder, - "summary": fold.summary, - }) -} - -fn content(content: &FileContent) -> Value { - match content { - FileContent::Text(source) => json!({"Text": source}), - FileContent::Binary => json!("Binary"), - } -} - -fn file_format(format: &FileFormat) -> Value { - match format { - FileFormat::SupportedLanguage(language) => json!({ - "SupportedLanguage": crate::parse::guess_language::language_name(*language), - }), - FileFormat::PlainText => json!("PlainText"), - FileFormat::Binary => json!("Binary"), - FileFormat::TextFallback { reason } => json!({"TextFallback": {"reason": reason}}), - } -} - -fn hunk(hunk: &crate::display::hunks::Hunk) -> Value { - let mut novel_lhs: Vec<_> = hunk.novel_lhs.iter().map(|line| line.0).collect(); - let mut novel_rhs: Vec<_> = hunk.novel_rhs.iter().map(|line| line.0).collect(); - novel_lhs.sort_unstable(); - novel_rhs.sort_unstable(); - let lines: Vec<_> = hunk - .lines - .iter() - .map(|(lhs, rhs)| (lhs.map(|line| line.0), rhs.map(|line| line.0))) - .collect(); - json!({ - "novel_lhs": novel_lhs, "novel_rhs": novel_rhs, "lines": lines, - }) -} - -impl DiffResult { - pub(crate) fn domain_json(&self) -> Value { - let hunks: Vec<_> = self.hunks.iter().map(hunk).collect(); - let aligned_rows = match (&self.lhs_src, &self.rhs_src) { - (FileContent::Text(lhs), FileContent::Text(rhs)) => { - layout::aligned_rows((lhs, rhs), (&self.lhs_positions, &self.rhs_positions)) - } - _ => vec![], - }; - json!({ - "display_path": self.display_path, - "extra_info": self.extra_info, - "file_format": file_format(&self.file_format), - "lhs_src": content(&self.lhs_src), - "rhs_src": content(&self.rhs_src), - "lhs_positions": self.lhs_positions.iter().map(position).collect::>(), - "rhs_positions": self.rhs_positions.iter().map(position).collect::>(), - "hunks": hunks, - "aligned_rows": aligned_rows, - "has_byte_changes": self.has_byte_changes, - "has_syntactic_changes": self.has_syntactic_changes, - "lhs_folds": self.lhs_folds.iter().map(fold).collect::>(), - "rhs_folds": self.rhs_folds.iter().map(fold).collect::>(), - }) - } - - /// Adapt the existing alignment and selected rows for the fixture viewer. - #[cfg(test)] - pub(crate) fn viewer_json(&self) -> Value { - let rows = layout::aligned_rows( - layout::sources(self), - (&self.lhs_positions, &self.rhs_positions), - ); - let baseline = layout::LineSelection::from_hunks(&self.hunks); - let reindented = layout::reindented_pairs(self); - json!({ - "domain": self.domain_json(), - "layout": {"rows": rows, "baseline": (&baseline.lhs, &baseline.rhs), "reindented": reindented}, - }) - } -} diff --git a/src/stream.rs b/src/stream.rs deleted file mode 100644 index 4dd6d502e..000000000 --- a/src/stream.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Incremental stdout protocol over the shared file iterator. -use crate::git::{DiffSession, FileChange, LoadedFile, Operand, Result}; -use crate::hook::Hook; -use crate::summary::DiffResult; -use rayon::iter::{ParallelBridge, ParallelIterator}; -use serde::Serialize; -use serde_json::Value; -use std::io::{BufWriter, Write}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc::{sync_channel, SendError, SyncSender}; -use std::sync::Arc; -use std::thread; - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum Event { - Start { - version: u32, - before: Operand, - after: Operand, - total: usize, - files: Vec, - }, - File { - file: FileChange, - diff: Value, - /// The fold hook failed for this file; its folds keep their placeholders. - #[serde(skip_serializing_if = "Option::is_none")] - hook_error: Option, - }, - FileError { - file: FileChange, - message: String, - }, - Complete { - succeeded: usize, - failed: usize, - }, -} - -/// Returns whether any file failed. Files are diffed on `jobs` workers and -/// emitted as they finish, so results arrive in completion order. The queue -/// holds at most one ready event; computation can overlap output without -/// retaining the whole diff. -pub(crate) fn write( - session: DiffSession, - jobs: usize, - hook: Option>, - output: &mut impl Write, -) -> Result { - let (sender, receiver) = sync_channel(1); - let pool = rayon::ThreadPoolBuilder::new() - .num_threads(jobs) - .thread_name(|index| format!("diffr-worker-{index}")) - .build()?; - let worker = thread::spawn(move || { - // A disconnected consumer cancels production after the files in flight. - let _ = produce(session, &pool, hook.as_deref(), sender); - }); - let mut output = BufWriter::new(output); - let result: Result = (|| { - let mut failed = false; - for event in &receiver { - if let Event::Complete { failed: count, .. } = &event { - failed = *count > 0; - } - serde_json::to_writer(&mut output, &event)?; - output.write_all(b"\n")?; - output.flush()?; - } - Ok(failed) - })(); - // Wake a producer blocked on a full queue if writing failed. - drop(receiver); - let joined = worker.join(); - let failed = result?; - joined.map_err(|_| "diff computation thread panicked")?; - Ok(failed) -} - -fn produce( - session: DiffSession, - pool: &rayon::ThreadPool, - hook: Option<&Hook>, - sender: SyncSender, -) -> std::result::Result<(), SendError> { - sender.send(Event::Start { - version: 1, - before: session.comparison.before.clone(), - after: session.comparison.after.clone(), - total: session.remaining(), - files: session.file_manifest(), - })?; - let succeeded = AtomicUsize::new(0); - let failed = AtomicUsize::new(0); - let cancelled = Arc::new(AtomicBool::new(false)); - let loader = Loader { - session, - cancelled: Arc::clone(&cancelled), - }; - pool.install(|| { - loader.par_bridge().for_each(|(file, loaded)| { - let event = match loaded { - Ok(loaded) => { - succeeded.fetch_add(1, Ordering::Relaxed); - file_event(file, loaded.diff(), hook) - } - Err(error) => { - failed.fetch_add(1, Ordering::Relaxed); - Event::FileError { - file, - message: error.to_string(), - } - } - }; - if sender.send(event).is_err() { - cancelled.store(true, Ordering::Relaxed); - } - }); - }); - sender.send(Event::Complete { - succeeded: succeeded.into_inner(), - failed: failed.into_inner(), - }) -} - -/// Summaries are filled in before the event so clients never see a fold change. -fn file_event(file: FileChange, mut diff: DiffResult, hook: Option<&Hook>) -> Event { - let hook_error = hook.and_then(|hook| hook.summarize(&mut diff).err()); - Event::File { - file, - diff: diff.domain_json(), - hook_error, - } -} - -/// Reads sources serially on whichever worker pulls next; diffing then -/// proceeds on that worker while others pull further files. -struct Loader { - session: DiffSession, - cancelled: Arc, -} - -impl Iterator for Loader { - type Item = (FileChange, crate::git::Result); - fn next(&mut self) -> Option { - if self.cancelled.load(Ordering::Relaxed) { - return None; - } - self.session.load() - } -} - -/// Stream a standalone file comparison through the same file/completion events. -/// File operands identify paths rather than repository revisions. -pub(crate) fn write_file( - before: &str, - after: &str, - compute: impl FnOnce() -> DiffResult, - hook: Option<&Hook>, - output: &mut impl Write, -) -> Result<()> { - let file = FileChange { - old_path: (before != "/dev/null").then(|| before.into()), - new_path: (after != "/dev/null").then(|| after.into()), - status: if before == "/dev/null" { - crate::git::FileStatus::Added - } else if after == "/dev/null" { - crate::git::FileStatus::Deleted - } else { - crate::git::FileStatus::Modified - }, - class: None, - }; - let mut output = BufWriter::new(output); - serde_json::to_writer( - &mut output, - &serde_json::json!({ - "type": "start", "version": 1, "total": 1, "files": [&file], - "before": {"kind": "file", "path": before}, - "after": {"kind": "file", "path": after} - }), - )?; - output.write_all(b"\n")?; - output.flush()?; - serde_json::to_writer(&mut output, &file_event(file, compute(), hook))?; - output.write_all(b"\n")?; - serde_json::to_writer( - &mut output, - &Event::Complete { - succeeded: 1, - failed: 0, - }, - )?; - output.write_all(b"\n")?; - output.flush()?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn standalone_manifest_is_flushed_before_computation() { - struct Disconnected(Vec); - impl Write for Disconnected { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.extend_from_slice(bytes); - Ok(bytes.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)) - } - } - let mut output = Disconnected(vec![]); - let result = write_file( - "before.rs", - "after.rs", - || panic!("must not compute after manifest flush fails"), - None, - &mut output, - ); - assert!(result.is_err()); - let start: Value = serde_json::from_slice(&output.0).unwrap(); - assert_eq!(start["type"], "start"); - assert_eq!(start["files"][0]["new_path"], "after.rs"); - } -} diff --git a/tests/streaming/check.py b/tests/streaming/check.py deleted file mode 100755 index db57aea86..000000000 --- a/tests/streaming/check.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise the CLI stream against real Git repositories and partial staging.""" - -import json -import os -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -EXE = ROOT / "target/debug/diffr" -ENV = dict( - os.environ, - GIT_CONFIG_GLOBAL="/dev/null", - GIT_CONFIG_NOSYSTEM="1", - GIT_AUTHOR_NAME="Test", - GIT_AUTHOR_EMAIL="test@example.invalid", - GIT_COMMITTER_NAME="Test", - GIT_COMMITTER_EMAIL="test@example.invalid", -) - - -def git(repo, *args): - return ( - subprocess.check_output(["git", "-C", str(repo), *args], env=ENV) - .decode() - .strip() - ) - - -def commit(repo, message): - git(repo, "add", ".") - git(repo, "commit", "-qm", message) - return git(repo, "rev-parse", "HEAD") - - -def cli(repo, *args): - return subprocess.run( - [str(EXE), "--repo", str(repo), *args], - capture_output=True, - env=ENV, - check=False, - ) - - -def stream(repo, *args, code=0): - with subprocess.Popen( - [str(EXE), "--repo", str(repo), "--format", "ndjson", *args], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=ENV, - ) as process: - first = json.loads(process.stdout.readline()) - assert first["type"] == "start" and first["version"] == 1 - assert len(first["files"]) == first["total"] - events = [first, *[json.loads(line) for line in process.stdout]] - stderr = process.stderr.read() - assert process.wait(timeout=30) == code, stderr - assert events[-1]["type"] == "complete" - succeeded = sum(e["type"] == "file" for e in events) - failed = sum(e["type"] == "file_error" for e in events) - assert events[-1] == {"type": "complete", "succeeded": succeeded, "failed": failed} - assert succeeded + failed == first["total"] - assert all("layout" not in e for e in events) - return events - - -with tempfile.TemporaryDirectory(prefix="diffr-stream-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - (repo / "a.rs").write_text("fn run() { old(); }\n") - (repo / "remove.py").write_text("print('remove')\n") - (repo / "rename.py").write_text("print('same content')\n") - base = commit(repo, "base") - (repo / "a.rs").write_text("fn run() { old(); new(); }\n") - (repo / "remove.py").unlink() - (repo / "rename.py").rename(repo / "renamed.py") - (repo / "binary.bin").write_bytes(b"a\0b") - (repo / "z.py").write_text("print('new')\n") - head = commit(repo, "head") - (repo / ".gitattributes").write_text( - "*.rs diffr-classify=source\n*.py diffr-classify=test\n*.bin diffr-classify=generated\n" - ) - (repo / "diffr.toml").write_text('[languages.rust]\nfolds = ""\n') - events = stream(repo, base, head, "--order", "test,source,generated", code=2) - assert events[0]["before"] == {"kind": "revision", "ref": base} - assert events[0]["after"] == {"kind": "revision", "ref": head} - # Results arrive in completion order; --order governs computation priority only. - assert sorted(e["file"]["class"] for e in events[1:-1]) == [ - "generated", - "source", - "test", - "test", - "test", - ] - renamed = next(e["file"] for e in events[1:-1] if e["file"]["status"] == "renamed") - assert renamed["old_path"] == "rename.py" and renamed["new_path"] == "renamed.py" - rust = next(e for e in events[1:-1] if e["file"]["new_path"] == "a.rs") - assert rust["diff"]["rhs_folds"] == [] - # An early file failure must not prevent the later successes. - events = stream(repo, base, head, "--order", "generated", code=2) - assert ( - sum(e["type"] == "file_error" for e in events) == 1 - and events[-1]["succeeded"] == 4 - ) - events = stream(repo, base, head, "--", "a.rs", "z.py") - assert sorted(e["file"]["new_path"] for e in events[1:-1]) == ["a.rs", "z.py"] - events = stream(repo, base, head, "--jobs", "1", "--", "a.rs", "z.py") - assert [e["file"]["new_path"] for e in events[1:-1]] == ["a.rs", "z.py"] - assert len(stream(repo, head, head)) == 2 - assert len(stream(repo, base, head, "--", "missing.rs")) == 2 - stream(repo, base, head, "--exit-code", "--", "a.rs", code=1) - for args in ( - ["bad-ref", head], - [base, head, "--quiet"], - ["--no-index", "a", "b"], - [base, head, "--stat"], - ): - result = cli(repo, "--format", "ndjson", *args) - assert result.returncode == 2 and not result.stdout and result.stderr - (repo / "diffr.toml").write_text("invalid toml") - assert cli(repo, "--format", "ndjson", base, head).returncode == 2 - custom = repo / "custom.toml" - custom.write_text("") - events = stream(repo, base, head, "--config", str(custom), "--", "a.rs") - assert events[1]["diff"]["rhs_folds"] - -with tempfile.TemporaryDirectory(prefix="diffr-operands-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - source = repo / "a.rs" - initial = "fn run() { initial(); }\n" - staged = "fn run() { staged(); }\n" - working = "fn run() { working(); }\n" - source.write_text(initial) - base = commit(repo, "initial") - source.write_text(staged) - git(repo, "add", "a.rs") - source.write_text(working) - for selection, left, right in ( - ([], staged, working), - (["--cached"], initial, staged), - ([base], initial, working), - ): - for reverse in (False, True): - args = [*selection, *(["-R"] if reverse else [])] - for output in ("--name-only", "--numstat"): - actual = cli(repo, *args, output) - expected = subprocess.check_output( - ["git", "-C", str(repo), "diff", *args, output], env=ENV - ) - assert actual.returncode == 0 and actual.stdout == expected - diff = stream(repo, *args)[1]["diff"] - assert diff["lhs_src"]["Text"] == (right if reverse else left) - assert diff["rhs_src"]["Text"] == (left if reverse else right) - assert cli(repo, "--quiet").returncode == 1 - assert cli(repo, base, "HEAD", "--exit-code").returncode == 0 - git(repo, "rm", "-f", "a.rs") - source.write_text(working) - assert stream(repo, base)[1]["file"]["status"] == "deleted" - assert cli(repo, base, "--name-status").stdout == b"D\ta.rs\n" - -with tempfile.TemporaryDirectory(prefix="diffr-unborn-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - (repo / "new.rs").write_text("fn new() {}\n") - git(repo, "add", ".") - events = stream(repo, "--cached") - assert events[0]["before"] == {"kind": "empty_tree"} - assert events[1]["file"]["status"] == "added" -# A configured fold hook fills summaries before each file event; its failures -# are reported per file without losing the diff. -with tempfile.TemporaryDirectory(prefix="diffr-hook-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - git(repo, "commit", "--allow-empty", "-qm", "empty") - base = git(repo, "rev-parse", "HEAD") - rpc_server = ROOT / "tests/hooks/rpc_server.py" - large = "def f():\n a()\n b()\n c()\n\ndef g():\n d()\n" - (repo / "good.py").write_text(large) - (repo / "bad.py").write_text(large) - (repo / "small.py").write_text("def h():\n e()\n") - head = commit(repo, "additions") - - def hook_config(mode, *extra): - command = [sys.executable, str(rpc_server), mode, *extra] - return f"[folds.hook]\ncommand = {json.dumps(command)}\ntags = ['body']\nmin_lines = 3\n" - - (repo / "diffr.toml").write_text(hook_config("echo")) - events = {e["file"]["new_path"]: e for e in stream(repo, base, head)[1:-1]} - good = events["good.py"] - assert "hook_error" not in good - assert [ - f["summary"] for f in good["diff"]["rhs_folds"] if f["tags"] == ["body"] - ] == ["pseudo Body", None] - assert all(f["summary"] is None for f in events["small.py"]["diff"]["rhs_folds"]) - (repo / "diffr.toml").write_text(hook_config("error")) - events = {e["file"]["new_path"]: e for e in stream(repo, base, head)[1:-1]} - assert events["bad.py"]["hook_error"] == "fold hook reported: declined" - assert all(f["summary"] is None for f in events["good.py"]["diff"]["rhs_folds"]) - (repo / "diffr.toml").write_text("[folds.hook]\ncommand = ['./missing-hook']\n") - assert cli(repo, "--format", "ndjson", base, head).returncode == 2 - (repo / "diffr.toml").write_text(hook_config("exit")) - assert cli(repo, "--format", "ndjson", base, head).returncode == 2 - # Relative hook paths resolve against the config file, not the repository. - with tempfile.TemporaryDirectory(prefix="diffr-hook-config-") as elsewhere: - shutil.copy(rpc_server, Path(elsewhere) / "hook.py") - (Path(elsewhere) / "hook.toml").write_text( - f"[folds.hook]\ncommand = [{json.dumps(sys.executable)}, 'hook.py', 'cwd', {json.dumps(str(repo.resolve()) + os.sep)}]\ntags = ['body']\n" - ) - events = stream( - repo, - base, - head, - "--config", - str(Path(elsewhere) / "hook.toml"), - "--", - "good.py", - ) - assert events[1]["diff"]["rhs_folds"][0]["summary"] == str( - Path(elsewhere).resolve() - ) - -# Closing the pipe while a multi-file producer is active must not leave it -# blocked forever on a full queue. Unix CLI output retains normal SIGPIPE behavior. -if os.name == "posix": - with tempfile.TemporaryDirectory(prefix="diffr-cancel-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - git(repo, "commit", "--allow-empty", "-qm", "empty") - base = git(repo, "rev-parse", "HEAD") - for index in range(8): - (repo / f"{index}.txt").write_text("some new text\n" * 4096) - head = commit(repo, "large additions") - with subprocess.Popen( - [str(EXE), "--repo", str(repo), base, head, "--format", "ndjson"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - env=ENV, - ) as process: - assert json.loads(process.stdout.readline())["type"] == "start" - process.stdout.close() - assert process.wait(timeout=30) != 0 -print("CLI streaming, cancellation and Git operand checks passed") From f26e15f46d46a4f55b1a52048cb19c31faf7735c Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sun, 13 Sep 2026 02:50:50 -0700 Subject: [PATCH 3/6] Keep folds when a diff falls back to lines When the structural matcher gives up on a parsed file (graph limit or parse-error limit), the engine still collects each side's folds and enclosing headers from the parse before taking the line diff. Folds on such a fallback pair through the line alignment: by aligned header line with matching tags, then by the most aligned body lines without crossing an existing pair. Fallback folds are collected with no partner, and both folds of each pair then record the other as partner, just as the syntax matcher's pairs do, so the projection numbers every file's pairs the same way. The parse-error fallback numbers both sides' syntax together, since partners are keyed by syntax id and the parser leaves every node the same placeholder id. The projection lays fallback rows out on the same alignment, with each changed block re-paired from whichever end reads alike, so an edited signature sits beside the old one. Written with AI assistance (Claude Code). Agent-Session: 8837929e-0118-489c-9648-e47c1831ee8c Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 4a8f6ee6-1693-4de3-9993-b9f5914cdd09 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 8761954a-0023-463e-996c-7c9fbabdef69 Agent-Session: 30956777-ac82-4a2b-9b52-dbfb517b52ee --- src/engine.rs | 45 +++++++ src/line_folds.rs | 275 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/parse/folds.rs | 12 ++ src/protocol/project.rs | 257 ++++++++++++++++++++++++++++++++++++- 5 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 src/line_folds.rs diff --git a/src/engine.rs b/src/engine.rs index cb9f32439..100e9a71d 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -8,9 +8,11 @@ use crate::diff::unchanged; use crate::display; use crate::display::context::opposite_positions; use crate::display::hunks::{matched_pos_to_hunks, merge_adjacent}; +use crate::line_folds; use crate::line_parser; use crate::lines::MaxLine; use crate::options::{DiffOptions, DisplayOptions, FileArgument}; +use crate::parse::folds; use crate::parse::guess_language::{guess, language_name, LanguageOverride}; use crate::parse::syntax::{self, init_next_prev}; use crate::parse::tree_sitter_parser as tsp; @@ -218,6 +220,14 @@ pub(crate) fn diff_file_content( } if exceeded_graph_limit { + // The parse still stands: folds and enclosing + // context come from it, and the line diff + // supplies the alignment they hang off. + folds::unmatched(&lhs, &mut lhs_folds); + folds::unmatched(&rhs, &mut rhs_folds); + annotations = display::syntax_context::SyntaxAnnotations::collect( + (&lhs, &rhs), + ); let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); ( @@ -296,6 +306,30 @@ pub(crate) fn diff_file_content( ); } + // The trees parsed, only with too many errors to + // match on. Folds and context still come from them. + let (lhs, _) = tsp::to_syntax( + &lhs_tree, + lhs_src, + &arena, + lang_config, + diff_options.ignore_comments, + ); + let (rhs, _) = tsp::to_syntax( + &rhs_tree, + rhs_src, + &arena, + lang_config, + diff_options.ignore_comments, + ); + // Fold partners are keyed by syntax id, which + // only exists once both sides are numbered. + syntax::init_all_info(&lhs, &rhs); + folds::unmatched(&lhs, &mut lhs_folds); + folds::unmatched(&rhs, &mut rhs_folds); + annotations = + display::syntax_context::SyntaxAnnotations::collect((&lhs, &rhs)); + let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); (file_format, lhs_positions, rhs_positions) @@ -356,6 +390,17 @@ pub(crate) fn diff_file_content( Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len())) }; + // A structural diff's folds already record the partners the matcher + // paired them with. A line-diff fallback's folds arrive unpaired; pair + // them through the line alignment instead. + if !matches!(file_format, FileFormat::SupportedLanguage(_)) { + line_folds::pair( + &line_folds::fallback_rows((lhs_src, rhs_src), (&lhs_positions, &rhs_positions)), + (lhs_src, rhs_src), + (&mut lhs_folds, &mut rhs_folds), + ); + } + DiffResult { extra_info, display_path: display_path.to_owned(), diff --git a/src/line_folds.rs b/src/line_folds.rs new file mode 100644 index 000000000..e15013aa7 --- /dev/null +++ b/src/line_folds.rs @@ -0,0 +1,275 @@ +//! Fold pairing and row alignment for files that fell back to a line diff. +//! +//! A structural diff pairs folds through the syntax matcher (see +//! `parse::folds::project`). A line diff has no matcher, so folds pair +//! through the line alignment instead: first by header line, then through +//! the lines they hold. The same alignment, with changed blocks re-paired +//! from whichever end reads alike, is what the projection lays rows out on. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::display::line_layout::{aligned_rows, novel_lines, Row}; +use crate::hash::DftHashMap; +use crate::parse::folds::{self, Fold}; +use crate::parse::syntax::MatchedPos; + +/// The row alignment of a line-diff fallback: the line diff's rows, with +/// each changed block re-paired by `align_changed_blocks`. +pub(crate) fn fallback_rows( + (lhs_src, rhs_src): (&str, &str), + (lhs_positions, rhs_positions): (&[MatchedPos], &[MatchedPos]), +) -> Vec { + let lhs_lines: Vec<&str> = lhs_src.split_terminator('\n').collect(); + let rhs_lines: Vec<&str> = rhs_src.split_terminator('\n').collect(); + let rows = aligned_rows((lhs_src, rhs_src), (lhs_positions, rhs_positions)); + align_changed_blocks( + &rows, + &novel_lines(lhs_positions), + &novel_lines(rhs_positions), + (&lhs_lines, &rhs_lines), + ) +} + +/// Pair the folds of a line-diff fallback: both folds of a pair record the +/// other as their `partner`. +/// +/// Rule 1: two folds pair when the alignment pairs their header lines and +/// their tags agree. A changed signature is still a paired row, so a +/// function whose header was edited keeps its counterpart. Folds sharing a +/// header line on one side pair in order of span length. +/// +/// Rule 2, for a lhs fold rule 1 left unpaired: the free rhs fold with +/// equal tags that holds the most aligned counterparts of the lhs fold's +/// lines (ties by the nearest header in row order), provided the pair does +/// not cross an existing pair. This keeps a function whose signature was +/// reflowed onto more lines paired with itself. +pub(crate) fn pair( + rows: &[Row], + (lhs_src, rhs_src): (&str, &str), + (lhs_folds, rhs_folds): (&mut [Fold], &mut [Fold]), +) { + let lhs_spans = spans(lhs_folds, lhs_src.split_terminator('\n').count()); + let rhs_spans = spans(rhs_folds, rhs_src.split_terminator('\n').count()); + let mut lhs_pair: Vec> = vec![None; lhs_folds.len()]; + let mut rhs_pair: Vec> = vec![None; rhs_folds.len()]; + + let aligned: DftHashMap = rows + .iter() + .filter_map(|&(lhs, rhs)| Some((lhs?, rhs?))) + .collect(); + let by_header = |spans: &[Option<(usize, usize)>]| { + let mut map: BTreeMap> = BTreeMap::new(); + for (index, span) in spans.iter().enumerate() { + if let Some((start, _)) = span { + map.entry(*start).or_default().push(index); + } + } + for candidates in map.values_mut() { + candidates.sort_by_key(|&index| { + let (start, end) = spans[index].expect("indexed spans exist"); + std::cmp::Reverse(end - start) + }); + } + map + }; + let rhs_by_header = by_header(&rhs_spans); + for (header, own) in by_header(&lhs_spans) { + let Some(other) = aligned + .get(&header) + .and_then(|line| rhs_by_header.get(line)) + else { + continue; + }; + for lhs_index in own { + let tags = &lhs_folds[lhs_index].tags; + let Some(&rhs_index) = other + .iter() + .find(|&&index| rhs_pair[index].is_none() && rhs_folds[index].tags == *tags) + else { + continue; + }; + lhs_pair[lhs_index] = Some(rhs_index); + rhs_pair[rhs_index] = Some(lhs_index); + } + } + + // Row index of each side's line, for crossing checks and tie-breaks. + let mut row_of_lhs: DftHashMap = DftHashMap::default(); + let mut row_of_rhs: DftHashMap = DftHashMap::default(); + for (index, &(lhs, rhs)) in rows.iter().enumerate() { + if let Some(line) = lhs { + row_of_lhs.entry(line).or_insert(index); + } + if let Some(line) = rhs { + row_of_rhs.entry(line).or_insert(index); + } + } + let header_row = |rows_of: &DftHashMap, span: Option<(usize, usize)>| { + span.and_then(|(start, _)| rows_of.get(&start).copied()) + }; + let mut pairs: Vec<(usize, usize)> = lhs_pair + .iter() + .enumerate() + .filter_map(|(lhs_index, rhs_index)| { + Some(( + header_row(&row_of_lhs, lhs_spans[lhs_index])?, + header_row(&row_of_rhs, rhs_spans[(*rhs_index)?])?, + )) + }) + .collect(); + for lhs_index in 0..lhs_folds.len() { + let Some((lhs_start, lhs_end)) = lhs_spans[lhs_index] else { + continue; + }; + if lhs_pair[lhs_index].is_some() { + continue; + } + let Some(lhs_row) = row_of_lhs.get(&lhs_start).copied() else { + continue; + }; + let mut score: DftHashMap = DftHashMap::default(); + for &(lhs, rhs) in rows { + let (Some(l), Some(r)) = (lhs, rhs) else { + continue; + }; + if l < lhs_start || l >= lhs_end { + continue; + } + for (rhs_index, span) in rhs_spans.iter().enumerate() { + let Some((rhs_start, rhs_end)) = span else { + continue; + }; + if rhs_pair[rhs_index].is_none() + && rhs_folds[rhs_index].tags == lhs_folds[lhs_index].tags + && *rhs_start <= r + && r < *rhs_end + { + *score.entry(rhs_index).or_default() += 1; + } + } + } + let mut candidates: Vec<(usize, usize, usize)> = score + .into_iter() + .filter_map(|(rhs_index, lines)| { + Some(( + rhs_index, + lines, + header_row(&row_of_rhs, rhs_spans[rhs_index])?, + )) + }) + .collect(); + candidates.sort_by_key(|&(rhs_index, lines, rhs_row)| { + ( + std::cmp::Reverse(lines), + rhs_row.abs_diff(lhs_row), + rhs_index, + ) + }); + let Some(&(rhs_index, _, rhs_row)) = candidates + .iter() + .find(|&&(_, _, rhs_row)| !pairs.iter().any(|&(a, b)| (a < lhs_row) != (b < rhs_row))) + else { + continue; + }; + lhs_pair[lhs_index] = Some(rhs_index); + rhs_pair[rhs_index] = Some(lhs_index); + pairs.push((lhs_row, rhs_row)); + } + + for (lhs_fold, rhs_index) in lhs_folds.iter_mut().zip(lhs_pair) { + let Some(rhs_index) = rhs_index else { + continue; + }; + let rhs_fold = &mut rhs_folds[rhs_index]; + lhs_fold.partner = Some(rhs_fold.syntax_id); + rhs_fold.partner = Some(lhs_fold.syntax_id); + } +} + +/// The whole lines each fold covers, or `None` for a fold that hides +/// nothing (a single line). +fn spans(folds: &[Fold], line_count: usize) -> Vec> { + folds + .iter() + .map(|fold| { + let (start, end) = folds::line_span(fold, line_count); + (end - start >= 2).then_some((start, end)) + }) + .collect() +} + +/// Re-pair the lines of each changed block, a maximal stretch of rows +/// between unchanged rows, when its two sides differ in length. A line diff +/// pairs a block's first lines with each other; when the block's last lines +/// read more alike, pair those instead, so a changed signature sits next to +/// the old one right above a body that still aligns. Blocks that are +/// one-sided, equal in length, or read no better from the bottom keep their +/// rows. +pub(crate) fn align_changed_blocks( + rows: &[Row], + lhs_novel: &BTreeSet, + rhs_novel: &BTreeSet, + (lhs_lines, rhs_lines): (&[&str], &[&str]), +) -> Vec { + let unchanged = |&(lhs, rhs): &Row| matches!((lhs, rhs), (Some(l), Some(r)) if !lhs_novel.contains(&l) && !rhs_novel.contains(&r)); + let mut out = Vec::with_capacity(rows.len()); + let mut at = 0; + while at < rows.len() { + if unchanged(&rows[at]) { + out.push(rows[at]); + at += 1; + continue; + } + let end = rows[at..] + .iter() + .position(unchanged) + .map_or(rows.len(), |offset| at + offset); + let block = &rows[at..end]; + let lhs: Vec = block.iter().filter_map(|&(l, _)| l).collect(); + let rhs: Vec = block.iter().filter_map(|&(_, r)| r).collect(); + let shared = lhs.len().min(rhs.len()); + let similarity = |lhs_from: usize, rhs_from: usize| -> f64 { + (0..shared) + .map(|offset| { + line_similarity( + lhs_lines[lhs[lhs_from + offset]], + rhs_lines[rhs[rhs_from + offset]], + ) + }) + .sum() + }; + let bottom_reads_better = shared > 0 + && lhs.len() != rhs.len() + && similarity(lhs.len() - shared, rhs.len() - shared) > similarity(0, 0); + if bottom_reads_better { + out.extend(lhs[..lhs.len() - shared].iter().map(|&l| (Some(l), None))); + out.extend(rhs[..rhs.len() - shared].iter().map(|&r| (None, Some(r)))); + out.extend( + lhs[lhs.len() - shared..] + .iter() + .zip(&rhs[rhs.len() - shared..]) + .map(|(&l, &r)| (Some(l), Some(r))), + ); + } else { + out.extend_from_slice(block); + } + at = end; + } + out +} + +/// How alike two lines read: the Dice coefficient of their identifier and +/// number tokens, 0 when neither has any. +fn line_similarity(lhs: &str, rhs: &str) -> f64 { + let tokens = |line: &str| -> BTreeSet { + line.split(|c: char| !(c.is_alphanumeric() || c == '_')) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .collect() + }; + let (lhs, rhs) = (tokens(lhs), tokens(rhs)); + if lhs.is_empty() && rhs.is_empty() { + return 0.0; + } + 2.0 * lhs.intersection(&rhs).count() as f64 / (lhs.len() + rhs.len()) as f64 +} diff --git a/src/main.rs b/src/main.rs index 43f1c5b32..cec0f186a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,7 @@ mod hash; // The fold hook has no caller until it returns as a fold mutation. #[allow(dead_code)] mod hook; +mod line_folds; mod line_parser; mod lines; mod options; diff --git a/src/parse/folds.rs b/src/parse/folds.rs index bddac6de6..e4c0da62a 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -258,6 +258,18 @@ pub(crate) fn fit_to_gaps(span: (usize, usize), gaps: &[(usize, usize)]) -> Opti } } +/// Every fold in one parsed side, each unpaired. Used when the AST match +/// did not run: `line_folds::pair` then records partners through the line +/// alignment. +pub(crate) fn unmatched(nodes: &[&Syntax<'_>], folds: &mut Vec) { + for node in nodes { + folds.extend(project(node, None)); + if let Syntax::List { children, .. } = node { + unmatched(children, folds); + } + } +} + /// The node the matcher paired `node` with, when the pairing is mutual. /// /// Every matcher step records a pair on both nodes, but the nested slider diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 7909556b8..a5bb5ed14 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -22,6 +22,7 @@ use crate::display::line_layout::{ aligned_rows, novel_lines, runs, shown_lines, trim_context, Run, MIN_GAP, }; use crate::hash::DftHashMap; +use crate::line_folds; use crate::line_parser; use crate::pairing::Pairing; use crate::parse::folds::{self, Fold}; @@ -157,7 +158,12 @@ fn regions(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> (Vec, V result.lhs_positions.as_slice(), result.rhs_positions.as_slice(), ); - let rows = aligned_rows(sources, positions); + // The structural matcher anchors its rows on matched tokens; a line + // diff's changed blocks are re-paired from whichever end reads alike. + let rows = match result.file_format { + FileFormat::SupportedLanguage(_) => aligned_rows(sources, positions), + _ => line_folds::fallback_rows(sources, positions), + }; let (lhs_shown, rhs_shown) = shown_lines(&result.hunks); let runs = trim_context(runs(&rows, &lhs_novel, &rhs_novel), &lhs_shown, &rhs_shown); let lhs_gaps: Vec<(usize, usize)> = runs @@ -612,6 +618,64 @@ mod tests { use crate::config::Params; use crate::options::{DiffOptions, DisplayOptions}; + #[test] + fn a_changed_block_pairs_its_last_lines_when_they_read_alike() { + // A changed block: an old doc comment and body, then the old + // signature; the new side has only the new signature. The unchanged + // row after it is the function body. + let lhs_lines = [ + "/// Fill in summaries.", + "fn summarize() {", + "}", + "fn qualifies(fold: Fold) -> bool {", + " true", + ]; + let rhs_lines = ["fn qualifies(region: Region) -> bool {", " true"]; + let rows = vec![ + (Some(0), Some(0)), + (Some(1), None), + (Some(2), None), + (Some(3), None), + (Some(4), Some(1)), + ]; + let lhs_novel: BTreeSet = [0, 1, 2, 3].into_iter().collect(); + let rhs_novel: BTreeSet = [0].into_iter().collect(); + let realigned = crate::line_folds::align_changed_blocks( + &rows, + &lhs_novel, + &rhs_novel, + (&lhs_lines, &rhs_lines), + ); + assert_eq!( + realigned, + vec![ + (Some(0), None), + (Some(1), None), + (Some(2), None), + (Some(3), Some(0)), + (Some(4), Some(1)), + ] + ); + } + + #[test] + fn a_changed_block_keeps_its_rows_when_the_top_reads_as_well() { + let lhs_lines = ["let a = 1;", "let b = 2;", "done();"]; + let rhs_lines = ["let a = 3;", "done();"]; + let rows = vec![(Some(0), Some(0)), (Some(1), None), (Some(2), Some(1))]; + let lhs_novel: BTreeSet = [0, 1].into_iter().collect(); + let rhs_novel: BTreeSet = [0].into_iter().collect(); + assert_eq!( + crate::line_folds::align_changed_blocks( + &rows, + &lhs_novel, + &rhs_novel, + (&lhs_lines, &rhs_lines) + ), + rows + ); + } + fn refs(lhs: bool, rhs: bool) -> Pairing { let file_ref = FileRef { path: "a.py".to_owned(), @@ -808,6 +872,84 @@ mod tests { } } + #[test] + fn a_parse_error_fallback_numbers_its_folds() { + // Both sides hold a stray `)`, so the parse-error limit of zero sends + // the file to a line diff; the folds still come from the parse. + let lhs = format!("{RUST_LHS})\n"); + let rhs = format!("{RUST_RHS})\n"); + let diff = project_with( + "a.rs", + &lhs, + &rhs, + 3, + DiffOptions { + parse_error_limit: 0, + ..DiffOptions::default() + }, + ); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.fallback.as_ref().unwrap().code, "parse_error"); + let (lhs, rhs) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs.unwrap()), fold_ids(rhs.unwrap())); + let lhs_ids: BTreeSet = lhs_folds.values().copied().collect(); + let rhs_ids: BTreeSet = rhs_folds.values().copied().collect(); + assert_eq!( + lhs_ids.len(), + lhs_folds.len(), + "lhs folds have distinct ids" + ); + assert_eq!( + rhs_ids.len(), + rhs_folds.len(), + "rhs folds have distinct ids" + ); + assert_eq!( + lhs_folds[&0], rhs_folds[&0], + "f pairs across a changed header" + ); + assert!(!lhs_ids.contains(&rhs_folds[&12]), "added is rhs-only"); + } + + #[test] + fn folds_pair_on_either_engine() { + for options in [ + DiffOptions::default(), + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ] { + let structural = options.graph_limit != 1; + let diff = project_with("a.rs", RUST_LHS, RUST_RHS, 3, options); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.fallback.is_none(), structural); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + let (lhs_folds, rhs_folds) = (fold_ids(lhs), fold_ids(rhs)); + // `f` changed its signature and stays paired: through the matcher + // on the structural path, through its aligned header line on the + // fallback. `keep` is untouched. `added` is rhs-only. + assert_eq!( + lhs_folds[&0], rhs_folds[&0], + "f pairs across a changed header" + ); + assert_eq!(lhs_folds[&6], rhs_folds[&6], "keep pairs"); + assert!( + !lhs_folds.values().any(|id| *id == rhs_folds[&12]), + "added is rhs-only" + ); + assert_eq!(lhs_folds.len(), 2); + assert_eq!(rhs_folds.len(), 3); + } + } + #[test] fn structural_folds_pair_exactly_as_the_matcher_recorded() { let result = DiffResult::from_sources_with_options( @@ -864,6 +1006,119 @@ mod tests { ); } + #[test] + fn a_line_diff_pairs_a_reflowed_signature_through_its_body() { + // The signature moves onto three lines, so the header rows no + // longer align, but the body is untouched: still the same function. + let lhs = + "fn f(a: u32, b: u32) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n"; + let rhs = "fn f(\n a: u32,\n b: u32,\n) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n"; + let diff = project_with( + "a.rs", + lhs, + rhs, + 3, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); + let (lhs_src, rhs_src) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); + assert_eq!(lhs_folds.len(), 1); + assert_eq!(rhs_folds.len(), 1); + assert_eq!( + lhs_folds.values().next(), + rhs_folds.values().next(), + "the reflowed function keeps one alignment id" + ); + } + + #[test] + fn line_diff_content_pairing_picks_the_candidate_with_the_most_paired_lines() { + // Both rhs functions have reflowed signatures and both hold a line + // `f` also holds (`let x = ...`), but only `g` holds `f`'s long + // body. Order is preserved so nothing crosses. + let lhs = "fn h(b: u32) -> u32 {\n let x = b;\n b\n}\n\nfn f(a: u32) -> u32 {\n let x = a;\n let y = x * 2;\n let z = y * 3;\n x + y + z\n}\n"; + let rhs = "fn h(\n b: u32,\n) -> u32 {\n let x = b;\n b\n}\n\nfn g(\n a: u32,\n) -> u32 {\n let x = a;\n let y = x * 2;\n let z = y * 3;\n x + y + z\n}\n"; + let diff = project_with( + "a.rs", + lhs, + rhs, + 3, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); + let (lhs_src, rhs_src) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); + // Fold headers sit on the `{` line: lhs h=0, f=5; rhs h=2, g=9. + assert_eq!(lhs_folds[&0], rhs_folds[&2], "h pairs with h"); + assert_eq!(lhs_folds[&5], rhs_folds[&9], "f pairs with g by its body"); + } + + #[test] + fn line_diff_content_pairing_never_crosses_an_existing_pair() { + // `keep` pairs by header on both sides. `f` moved below `keep` on + // the rhs with a reflowed signature; pairing it would cross `keep`, + // so it stays one-sided on each side. + let lhs = "fn f(a: u32) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n"; + let rhs = "fn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n\nfn f(\n a: u32,\n) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n"; + let diff = project_with( + "a.rs", + lhs, + rhs, + 3, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); + let (lhs_src, rhs_src) = sources(&diff); + let (lhs_folds, rhs_folds) = (fold_ids(lhs_src.unwrap()), fold_ids(rhs_src.unwrap())); + assert_eq!(lhs_folds[&6], rhs_folds[&0], "keep pairs by header"); + assert_ne!( + lhs_folds[&0], rhs_folds[&8], + "f would cross keep, so it stays unpaired" + ); + } + + #[test] + fn the_fallback_keeps_folds_and_enclosing_context() { + let diff = project_with( + "a.rs", + RUST_LHS, + RUST_RHS, + 1, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.fallback.as_ref().unwrap().code, "too_complex"); + let (_, rhs) = sources(&diff); + let rhs = rhs.unwrap(); + let bodies: Vec<_> = all(&rhs.regions) + .into_iter() + .filter(|r| r.tags.iter().any(|tag| tag == "body")) + .map(|r| r.range.start.line) + .collect(); + // `keep` is untouched and lies inside a collapsed gap, so it is not a + // region; the changed `f` and the new `added` are folds. + assert_eq!(bodies, vec![0, 12]); + // The change inside `f` keeps its header line open, not in a gap. + let gaps: Vec<_> = leaves(&rhs.regions) + .into_iter() + .filter(|r| r.visibility.collapsed) + .map(|r| r.range.lines_spanned()) + .collect(); + assert!(gaps.iter().all(|(start, _)| *start != 0), "{gaps:?}"); + } + #[test] fn leaves_tile_both_sides_and_paired_leaves_share_ids() { let lhs = "import os\n\ndef f():\n x = 1\n return x\n"; From 8f1d64572f9fe0b52e355613851cb2ef62f1971e Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sun, 13 Sep 2026 02:52:54 -0700 Subject: [PATCH 4/6] Layer configuration and add `diffr config` Configuration now resolves through figment: bundled defaults, the global file (`$XDG_CONFIG_HOME/diffr/config.toml` or `--config`), the repository's `diffr.toml`, `DIFFR_*` variables, then `--set key=value`. A schemars JSON Schema describes every setting. `diffr config schema`, `show` and `set` print or edit it; bare `diffr config` launches the terminal frontend's settings screen. The `[diff]` table holds the engine limits (with `DFT_*` variables and flags on top), and `[theme]` names the frontend theme. Fallback reasons point at the config keys. Written with AI assistance (Claude Code). Agent-Session: 0de57442-ad9b-4c99-960e-4974e629b71d Agent-Session: e046556c-20f0-4bba-832b-b6784a7783c5 Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 8837929e-0118-489c-9648-e47c1831ee8c Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 4a8f6ee6-1693-4de3-9993-b9f5914cdd09 Agent-Session: 8761954a-0023-463e-996c-7c9fbabdef69 Agent-Session: 30956777-ac82-4a2b-9b52-dbfb517b52ee Agent-Session: 01a09f1b-2525-74e3-99d4-f1bdcbec53e6 --- Cargo.lock | 409 ++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 7 +- README.md | 2 +- docs/cli.md | 17 +- docs/config.md | 169 +++++++++++++++++ src/cli.rs | 168 +++++++++++++---- src/config.rs | 360 ++++++++++++++++++++++++++++++++--- src/config/store.rs | 149 +++++++++++++++ src/engine.rs | 10 +- src/protocol/project.rs | 4 +- 10 files changed, 1206 insertions(+), 89 deletions(-) create mode 100644 docs/config.md create mode 100644 src/config/store.rs diff --git a/Cargo.lock b/Cargo.lock index 9ac24f86e..dc89e79bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -158,6 +167,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.12.1" @@ -323,7 +338,9 @@ dependencies = [ "bumpalo", "cc", "clap", + "dirs", "encoding_rs", + "figment", "git2", "glob", "hashbrown 0.17.1", @@ -343,16 +360,19 @@ dependencies = [ "regex", "reqwest", "rustc-hash", + "schemars", "serde", "serde_json", "smallvec", "streaming-iterator", "strsim", "strum", + "tempfile", "terminal_size", "tikv-jemallocator", "tokio", "toml", + "toml_edit 0.25.15+spec-1.1.0", "tree-sitter", "tree-sitter-ada", "tree-sitter-asm", @@ -419,6 +439,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -442,6 +483,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.10.0" @@ -486,6 +533,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -940,6 +1007,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "ipnet" version = "2.12.2" @@ -981,7 +1054,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -1068,6 +1141,15 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + [[package]] name = "libz-sys" version = "1.1.29" @@ -1092,6 +1174,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1163,12 +1251,41 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[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.106", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1266,6 +1383,19 @@ 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.106", + "version_check", + "yansi", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1280,7 +1410,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -1303,7 +1433,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -1390,6 +1520,37 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.12.2" @@ -1495,10 +1656,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.44" @@ -1604,6 +1778,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1663,6 +1862,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -1817,6 +2027,19 @@ dependencies = [ "syn 2.0.106", ] +[[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 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -1832,7 +2055,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ - "rustix", + "rustix 0.38.34", "windows-sys 0.59.0", ] @@ -1842,13 +2065,33 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -1953,8 +2196,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -1966,6 +2209,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -1975,10 +2227,45 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", - "winnow", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", ] +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -2666,6 +2953,15 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[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.12" @@ -2853,13 +3149,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2868,7 +3173,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2880,34 +3185,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "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_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2920,24 +3258,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2953,6 +3315,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 0f4398008..1834b4996 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,7 +134,11 @@ tree-sitter-xml = "0.7.0" tree-sitter-yaml = "0.7.0" tree-sitter-zig = "1.1.2" ts-parser-perl = "1.2.0" -toml = { version = "0.8", default-features = false, features = ["parse"] } +toml = { version = "0.8", default-features = false, features = ["display", "parse"] } +figment = { version = "0.10.19", features = ["toml", "env"] } +schemars = { version = "1.2.2", features = ["preserve_order"] } +dirs = "5" +toml_edit = "0.25.15" reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "http2"] } @@ -146,6 +150,7 @@ assert_cmd = "2.0.17" predicates = "3.1.3" pretty_assertions = "1.3.0" +tempfile = "3.27.0" [build-dependencies] # TODO: enable parallel mode, see discussion in diff --git a/README.md b/README.md index 8781fdfe2..81b68f655 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ diffr main HEAD --format ndjson ``` See the [CLI reference](docs/cli.md), [streaming API](docs/streaming.md) -including fold hooks, and [fixture viewer](examples/review/viewer/README.md). +including [configuration](docs/config.md), fold hooks, and [fixture viewer](examples/review/viewer/README.md). The executable is `diffr`; the Cargo package remains `difftastic`. The upstream installation commands below install upstream Difftastic, not this fork. diff --git a/docs/cli.md b/docs/cli.md index 2776ff2ca..e7735ecf2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,11 +28,18 @@ for changes; ordinary output exits 0. Errors exit 2. `--no-index` supports two files, implies change exit status, and does not yet support metadata options. Structural output uses the existing terminal renderer. `--format ndjson` emits -a buffered event stream, diffing `--jobs N` files at once (default 16) and -emitting each as it finishes; -`--format snapshot` is the fixture text adapter. -`-U N` selects ordinary context padding. Matching limits, `--ignore-comments`, -color, width and inline/split display remain configurable; see `--help`. +the event stream described in [streaming.md](streaming.md), diffing `--jobs N` +files at once (default 16) and emitting each as it finishes; `--syntax` adds +every token's highlight capture to it. `--format snapshot` is the fixture text +adapter. +`-U N` selects context padding, defaulting to `folds.context_lines`. Matching +limits, `--ignore-comments`, color, width and inline/split display remain +configurable; see `--help`. + +Configuration is layered from the global file, the repository's `diffr.toml`, +`DIFFR_*` variables and `--set key=value`; `--config PATH` replaces the global +file. `diffr config` opens the settings screen, and `diffr config schema`, +`show` and `set` are the commands frontends use. See [config.md](config.md). This is a subset of git diff, not full flag parity: unsupported options and Git magic pathspecs fail explicitly. Untracked files are excluded as in git diff. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 000000000..aaa14858a --- /dev/null +++ b/docs/config.md @@ -0,0 +1,169 @@ +# Configuration + +One configuration, layered. Later layers override earlier ones, key by key: + +1. Bundled defaults. +2. The global file, `$XDG_CONFIG_HOME/diffr/config.toml` (usually + `~/.config/diffr/config.toml`). `--config PATH` replaces it and must exist. +3. The repository's `diffr.toml`, when there is one. +4. Environment variables `DIFFR___`, for example + `DIFFR_SUMMARIZE__API_KEY=...` or `DIFFR_FOLDS__MIN_LINES=8`. +5. `--set key=value`, repeatable, for one run. + +Most users need nothing beyond the global file, and `diffr config` writes it. +A repository file is for maintainers: language queries, or a rule the whole +team wants. + +## Commands + +```sh +diffr config # settings screen in the terminal frontend +diffr config summarize # the same, searching for "summarize" +diffr config schema # JSON Schema: description and default per key +diffr config show [--json] # the resolved configuration; api_key redacted +diffr config show --reveal # ...with the key +diffr config set folds.min_lines 20 +diffr config set summarize.api_key "$KEY" +``` + +`set` writes one key into the global file (or the `--config` file), keeping +everything else in it as written. Values are TOML: `true`, `12`, `1.5`, +`["a", "b"]`; anything that is not valid TOML is taken as a string, and a +number given to a string key stays a string. Unknown keys and wrong types are +rejected before anything is written. Frontends drive their settings pages +through these three commands; the schema is the only contract. + +## Keys + +```toml +[folds] +min_lines = 12 # bodies shorter than this are never collapsed by a rule +collapse_deleted = true # deleted function bodies start collapsed, header visible +collapse_test_bodies = true # test bodies start collapsed on both sides, header visible +bundle_docstrings = true # a docstring opens and closes with its function +collapse_removed_lines = 5 # removed stretches this long, in unpaired code, collapse in the middle; 0 disables +collapse_generated = true # generated files start hidden +collapse_tests = true # test files start hidden +collapse_deleted_files = true # deleted files start hidden +context_lines = 3 # unchanged lines kept around a change; -U overrides + +[summarize] +enabled = true # off without an API key, silently +provider = "gemini" +model = "gemini-3.8-flash" +min_lines = 20 # new function bodies shorter than this are shown as code +api_key = "..." # or GEMINI_API_KEY / GOOGLE_API_KEY in the environment +endpoint = "https://..." # optional base URL override, for proxies and tests +timeout_ms = 60000 +max_concurrency = 16 # requests in flight across files +retries = 3 # on timeouts, rate limits and server errors + +[diff] +byte_limit = 1000000 # larger files on either side get a line diff +graph_limit = 3000000 # the largest AST matching graph explored per file +parse_error_limit = 0 # more tree-sitter parse errors than this: line diff + +[theme] +name = "default-dark" # a bundled terminal theme +path = "/path/to/theme.toml" # or a Helix-style theme file + +[languages.rust] # tree-sitter queries; see src/config/README.md +folds = '''...''' +context = '''...''' + +[folds.hook] # an external JSON-RPC summarizer; see streaming.md +command = ["uv", "run", "--script", "examples/hooks/summarize.py"] +tags = ["body"] +min_lines = 12 # optional; defaults to folds.min_lines +timeout_ms = 5000 +startup_timeout_ms = 30000 +``` + +`languages` and `folds.hook` are not in the schema, so the settings screen +does not show them; `config set` still accepts their keys. + +When a file exceeds a `[diff]` limit it falls back to a line diff: no folds, +no collapse rules, no summaries, and the file's `stats` carries a `fallback` +with code `too_large`, `too_complex` or `parse_error` and a message naming +the key to raise. `DFT_BYTE_LIMIT`, `DFT_GRAPH_LIMIT` and +`DFT_PARSE_ERROR_LIMIT` override the file for one run, and `--byte-limit`, +`--graph-limit` and `--parse-error-limit` override both. The defaults are +difftastic's. A large rewrite can exceed the graph limit and fall back to a +line diff; raising the limit trades memory for it across every file diffed in +parallel, so prefer narrowing the comparison or leaving the fallback. + +## File categories + +Every file in the manifest carries a `category`: `source`, `test`, +`generated`, `docs`, or whatever a repository assigns. In order of precedence: + +1. A `diffr-classify` git attribute. +2. A set `linguist-generated` attribute, which means `generated`. +3. Built-in path rules: lockfiles and `dist/`, `build/`, `vendor/`, + `node_modules/`, `__generated__/`, `*.min.js`, `*.pb.go`, `*.generated.*` + are generated; `tests/`, `test/`, `__tests__/`, `spec/`, `*_test.go`, + `*.test.*`, `*.spec.*`, `test_*.py`, `*_test.py`, `conftest.py`, `tests.rs`, + `test.rs`, `*_test.rs`, `*_tests.rs` are tests; + `docs/` and `*.md` are docs. + +```gitattributes +web/schema.json diffr-classify=generated +fixtures/** diffr-classify=test +``` + +## Mutations + +After each file is diffed and projected onto the wire, mutations adjust what +starts collapsed and what the collapsed label says. They never touch the diff +itself. In order: + +1. `collapse_deleted_files`, `collapse_generated`, `collapse_tests`: the file's `visibility` in the + manifest, before `start` is written. +2. `collapse_deleted`: deleted function bodies (folds tagged `function`) of + at least `min_lines` lines, labelled `" lines removed"`. A body is + deleted only when nothing under it is paired with the after side; a + function whose header moved but whose lines still align is a rewrite + and stays open. +3. `collapse_test_bodies`: bodies of test functions (folds tagged `test`) of + three or more lines, on both sides, labelled `"test body"`. A whole test + module such as a Rust `#[cfg(test)] mod tests` is one `test` fold too, + labelled `"test module"`, with the test bodies still foldable inside it. +4. `collapse_removed_lines`: removed stretches with no counterpart on the + after side and at least that many lines keep their first and last line + open and collapse the middle, tagged `removed` and labelled + `" lines removed"`. Only unpaired code qualifies: the nearest + enclosing `function` fold (or, outside any function, the nearest + enclosing fold) must itself have nothing paired under it, so a rewritten + function shows its removed lines in place. Stretches at the top level + always qualify. Stretches under a fold that already starts collapsed are + left alone. +5. `bundle_docstrings`: a function's docstring is the run of comment lines + just above it, with only its signature and blank lines between; in + Python it is instead a string that is the body's first statement. Its + lines become leaves tagged `docstring` that share the function's + `fold_state_id`, so they open and close together. A comment separated + from the function by code does not count, a docstring already inside a + collapsed leaf is left alone, and a paired docstring keeps its bundle + only when the other side bundles it with the same function. +6. The built-in summarizer: new function bodies (folds tagged `function`, + never a test, never one nested inside another selected body) of at least + `summarize.min_lines` lines on the after side become python-flavored + pseudocode. When the body has a docstring its text goes with the request, + and the model may return one sentence from it; that sentence is kept only + when it really occurs in the docstring, and then leads the label. A + summary whose pseudocode has more than half the body's non-blank lines + is discarded and the body stays open. The label starts with a comment + line in the file's own syntax, `# pseudocode` or `// pseudocode`, then + the quoted sentence if any, then the pseudocode. +7. `folds.hook`, when configured, over the same selection with its own tags + and threshold; its text gets the same comment line. +8. With `bundle_docstrings`, each docstring whose function now starts + collapsed collapses too, with an empty label. +9. Grouping, always on: adjacent context gaps merge into one, and a run of + two or more sibling folds that start collapsed is wrapped in one `group` + fold labelled `" functions removed"`, `" functions summarized"`, or + `" folded regions"`. Expanding it reveals each child's own row. + +A mutation that fails after its retries ends the run: the stream finishes with +`complete.aborted` (`summarizer_failed` or `hook_failed`) and diffr exits 2. +Files already written stay valid. diff --git a/src/cli.rs b/src/cli.rs index e4be8109a..a522f5d03 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,5 @@ //! Git-style CLI input; rendering and NDJSON remain adapters over the same engine. -use crate::config::Config; +use crate::config::{self, Config, Sources}; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; use crate::options::{DiffOptions, DisplayMode, DisplayOptions}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; @@ -33,7 +33,8 @@ pub(crate) fn run() -> Result { .version(env!("CARGO_PKG_VERSION")) .about("Structural diffs with Git-style comparison inputs") .arg(Arg::new("repo").long("repo").default_value(".")) - .arg(Arg::new("config").long("config")) + .arg(Arg::new("config_file").long("config").value_name("PATH").help("Replace the global configuration file")) + .arg(Arg::new("set").long("set").value_name("KEY=VALUE").action(ArgAction::Append).help("Override one configuration key for this run")) .arg( Arg::new("jobs") .long("jobs") @@ -69,14 +70,35 @@ pub(crate) fn run() -> Result { .arg(Arg::new("graph-limit").long("graph-limit").value_parser(clap::value_parser!(usize))) .arg(Arg::new("parse-error-limit").long("parse-error-limit").value_parser(clap::value_parser!(usize))) .arg(Arg::new("items").num_args(0..).value_parser(clap::value_parser!(OsString))) + .subcommand( + Command::new("config") + .about("Show, edit, or open the settings screen for diffr's configuration") + .arg(Arg::new("query").help("Initial search in the settings screen")) + .subcommand(Command::new("schema").about("Print the configuration's JSON Schema")) + .subcommand( + Command::new("show") + .about("Print the resolved configuration") + .arg(flag("json")) + .arg(flag("reveal").help("Do not redact the API key")), + ) + .subcommand( + Command::new("set") + .about("Write one key to the global configuration file") + .arg(Arg::new("key").required(true)) + .arg(Arg::new("value").required(true)), + ), + ) .after_help("Examples:\n diffr\n diffr --cached\n diffr main...HEAD -- src/\n diffr --no-index -- before.rs after.rs\n diffr main HEAD --format ndjson\n\nUnsupported Git flags are rejected; this is not a complete git diff implementation.") .get_matches_from(argv); + if let Some(("config", sub)) = args.subcommand() { + return run_config(&args, sub); + } if opens_tui( args.value_source("format") == Some(clap::parser::ValueSource::CommandLine), args.get_flag("quiet") || args.contains_id("metadata"), io::stdin().is_terminal() && io::stdout().is_terminal(), ) { - return launch_tui(&frontend_args); + return launch_tui(&frontend_args, true); } let streaming = args.get_one::("format").unwrap() == "ndjson"; if streaming && (args.get_flag("quiet") || args.contains_id("metadata")) { @@ -109,25 +131,11 @@ pub(crate) fn run() -> Result { }, ..DisplayOptions::default() }; - let mut diff_options = DiffOptions { - ignore_comments: args.get_flag("ignore-comments"), - ..DiffOptions::default() - }; - if let Some(limit) = args.get_one::("byte-limit") { - diff_options.byte_limit = *limit; - } - if let Some(limit) = args.get_one::("graph-limit") { - diff_options.graph_limit = *limit; - } - if let Some(limit) = args.get_one::("parse-error-limit") { - diff_options.parse_error_limit = *limit; - } if args.get_flag("no-index") { return no_index( &args, items.into_iter().chain(explicit_paths).collect(), &display, - &diff_options, ); } if args.get_flag("null") && !args.get_flag("name-only") && !args.get_flag("name-status") { @@ -162,9 +170,8 @@ pub(crate) fn run() -> Result { } changed } else { - let params = Arc::new( - Config::load(workspace, args.get_one::("config").map(Path::new))?.compile()?, - ); + let params = Arc::new(load_config(&args, workspace)?.compile()?); + let diff_options = diff_options(&args, ¶ms)?; let mut session = DiffSession::open(workspace, comparison, params, &files)?; session.context_lines = display.num_context_lines; session.diff_options = diff_options; @@ -402,12 +409,7 @@ fn print_metadata(diff: &git2::Diff<'_>, args: &ArgMatches, width: usize) -> Res Ok(()) } -fn no_index( - args: &ArgMatches, - paths: Vec, - display: &DisplayOptions, - options: &DiffOptions, -) -> Result { +fn no_index(args: &ArgMatches, paths: Vec, display: &DisplayOptions) -> Result { if paths.len() != 2 { return Err("--no-index requires two file paths".into()); } @@ -434,11 +436,9 @@ fn no_index( if args.get_flag("quiet") { return Ok(i32::from(changed)); } - let config = Config::load( - Path::new(args.get_one::("repo").unwrap()), - args.get_one::("config").map(Path::new), - )? - .compile()?; + let workspace = Path::new(args.get_one::("repo").unwrap()); + let config = load_config(args, workspace)?.compile()?; + let options = &diff_options(args, &config)?; let lhs = crate::options::FileArgument::from_path_argument(&paths[0]); let rhs = crate::options::FileArgument::from_path_argument(&paths[1]); let compute = || { @@ -472,12 +472,96 @@ fn no_index( } } +/// The engine limits: the configured `[diff]` table, then the `DFT_*` +/// variables, then the command-line flags. +fn diff_options(args: &ArgMatches, params: &config::Params) -> Result { + let mut options = params.diff.options(args.get_flag("ignore-comments"))?; + if let Some(limit) = args.get_one::("byte-limit") { + options.byte_limit = *limit; + } + if let Some(limit) = args.get_one::("graph-limit") { + options.graph_limit = *limit; + } + if let Some(limit) = args.get_one::("parse-error-limit") { + options.parse_error_limit = *limit; + } + Ok(options) +} + +/// Every layer: defaults, the global file (or `--config`), the repository's +/// `diffr.toml`, `DIFFR_*` variables, then `--set` overrides. +fn load_config(args: &ArgMatches, workspace: &Path) -> Result { + let overrides: Vec = args + .get_many::("set") + .into_iter() + .flatten() + .cloned() + .collect(); + Ok(Config::load(Sources { + workspace, + explicit: args.get_one::("config_file").map(Path::new), + overrides: &overrides, + })?) +} + +/// The repository root when `--repo` is inside one, else the directory itself. +fn workspace_for(args: &ArgMatches) -> Result { + let location = std::fs::canonicalize(args.get_one::("repo").unwrap())?; + Ok(match Repository::discover(&location) { + Ok(repo) => repo.workdir().unwrap_or(repo.path()).to_path_buf(), + Err(_) => location, + }) +} + +/// `diffr config`: the settings screen, or one of `schema`, `show`, `set`. +fn run_config(args: &ArgMatches, sub: &ArgMatches) -> Result { + let mut stdout = io::stdout().lock(); + match sub.subcommand() { + Some(("schema", _)) => { + serde_json::to_writer_pretty(&mut stdout, &Config::schema())?; + stdout.write_all(b"\n")?; + } + Some(("show", show)) => { + let config = load_config(args, &workspace_for(args)?)?; + let reveal = show.get_flag("reveal"); + if show.get_flag("json") { + serde_json::to_writer_pretty(&mut stdout, &config::store::show(&config, reveal))?; + stdout.write_all(b"\n")?; + } else { + stdout.write_all(toml::to_string_pretty(&config)?.as_bytes())?; + } + } + Some(("set", set)) => { + let path = match args.get_one::("config_file") { + Some(path) => PathBuf::from(path), + None => config::global_path()?, + }; + config::store::set( + &path, + set.get_one::("key").unwrap(), + set.get_one::("value").unwrap(), + )?; + } + Some((other, _)) => return Err(format!("unknown config command {other}").into()), + None => { + let mut frontend = vec![OsString::from("--settings")]; + if let Some(query) = sub.get_one::("query") { + frontend.push(query.into()); + } + return launch_tui(&frontend, false); + } + } + Ok(0) +} + /// Explicit machine/text modes and redirected output must never enter the alternate screen. fn opens_tui(explicit_format: bool, metadata_or_quiet: bool, terminal: bool) -> bool { terminal && !explicit_format && !metadata_or_quiet } -fn launch_tui(args: &[OsString]) -> Result { +/// `comparison` passes the arguments after `--` as the comparison to open; +/// otherwise they are frontend flags such as `--settings`. +fn launch_tui(args: &[OsString], comparison: bool) -> Result { let entry = std::env::var_os("DIFFR_TUI_ENTRY") .map(PathBuf::from) .unwrap_or_else(|| { @@ -490,9 +574,23 @@ fn launch_tui(args: &[OsString]) -> Result { ); } let bun = std::env::var_os("DIFFR_BUN").unwrap_or_else(|| "bun".into()); - let status = std::process::Command::new(bun) - .arg("run").arg(entry).arg("--diffr").arg(std::env::current_exe()?) - .arg("--").args(args).status() + let mut command = std::process::Command::new(bun); + command.arg("run").arg(entry); + if comparison { + command + .arg("--diffr") + .arg(std::env::current_exe()?) + .arg("--") + .args(args); + } else { + // `bun run main.tsx --settings --diffr [query]` + command + .arg(&args[0]) + .arg("--diffr") + .arg(std::env::current_exe()?) + .args(&args[1..]); + } + let status = command.status() .map_err(|error| format!("Could not launch terminal frontend: {error}. Install Bun and run bun install in tui/, or use --format text."))?; Ok(status.code().unwrap_or(2)) } diff --git a/src/config.rs b/src/config.rs index 0b8a77893..5bfa0bf48 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,32 +1,131 @@ -//! Deserialize user settings, resolve defaults, and compile once before diffing. +//! One configuration, layered: bundled defaults, the user's global file, +//! the repository's `diffr.toml`, `DIFFR_*` environment variables, then +//! `--set` overrides. Every field carries a doc comment, which becomes its +//! description in `diffr config schema`, and every setting a `title` and an +//! `x-group` that settings screens show in place of the dotted key. pub(crate) mod query; +pub(crate) mod store; use crate::hash::DftHashMap; +use crate::options::DiffOptions; use crate::parse::{guess_language::Language, tree_sitter_parser}; +use figment::providers::{Env, Format, Serialized, Toml}; +use figment::Figment; use query::AnnotationQuery; -use serde::Deserialize; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use strum::IntoEnumIterator; -#[derive(Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct Config { + /// Tree-sitter fold and context queries per language, keyed by the + /// lowercase language name. Omitted queries keep the bundled ones; an + /// empty string disables that feature. + #[schemars(skip)] pub(crate) languages: BTreeMap, + /// What gets folded and what starts collapsed. pub(crate) folds: FoldsConfig, + /// Colors for the terminal frontend. + pub(crate) theme: ThemeConfig, + /// Limits on the structural comparison itself. + pub(crate) diff: DiffConfig, } -#[derive(Default, Deserialize)] +/// When a file exceeds one of these, diffr falls back to a line diff for +/// it: the alignment is line-based and `stats.fallback` carries the +/// reason; folds still come from the parse where it succeeded. The `DFT_BYTE_LIMIT`, `DFT_GRAPH_LIMIT` and +/// `DFT_PARSE_ERROR_LIMIT` variables override the file, and the matching +/// command-line flags override both. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct DiffConfig { + /// Files larger than this many bytes on either side get a line diff. + #[schemars(title = "Largest file to diff structurally (bytes)", extend("x-group" = "Diff limits"))] + pub(crate) byte_limit: usize, + /// The largest AST matching graph diffr will explore for one file. + /// A large change to a large file can exceed it; raising it costs time + /// and memory on those files only. + #[schemars(title = "Largest matching graph", extend("x-group" = "Diff limits"))] + pub(crate) graph_limit: usize, + /// Files with more tree-sitter parse errors than this get a line diff. + #[schemars(title = "Parse errors allowed", extend("x-group" = "Diff limits"))] + pub(crate) parse_error_limit: usize, +} + +impl Default for DiffConfig { + fn default() -> Self { + Self { + byte_limit: crate::options::DEFAULT_BYTE_LIMIT, + graph_limit: crate::options::DEFAULT_GRAPH_LIMIT, + parse_error_limit: crate::options::DEFAULT_PARSE_ERROR_LIMIT, + } + } +} + +impl DiffConfig { + /// The engine options for these limits, with the `DFT_*` variables + /// applied on top. A variable that is set but not a number is an error. + pub(crate) fn options(&self, ignore_comments: bool) -> Result { + let limit = |name: &str, configured: usize| -> Result { + match std::env::var(name) { + Ok(text) => text.trim().parse().map_err(|_| { + ConfigError(format!( + "{name} must be a non-negative integer, got {text:?}" + )) + }), + Err(std::env::VarError::NotPresent) => Ok(configured), + Err(std::env::VarError::NotUnicode(_)) => { + Err(ConfigError(format!("{name} is not valid UTF-8"))) + } + } + }; + Ok(DiffOptions { + byte_limit: limit("DFT_BYTE_LIMIT", self.byte_limit)?, + graph_limit: limit("DFT_GRAPH_LIMIT", self.graph_limit)?, + parse_error_limit: limit("DFT_PARSE_ERROR_LIMIT", self.parse_error_limit)?, + ignore_comments, + ..DiffOptions::default() + }) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct FoldsConfig { + /// An external JSON-RPC summarizer. + #[schemars(skip)] pub(crate) hook: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct ThemeConfig { + /// A bundled theme name. + #[schemars(title = "Theme", extend("x-group" = "Appearance"))] + pub(crate) name: String, + /// A Helix-style theme file that replaces the bundled theme. + #[schemars(title = "Theme file", extend("x-group" = "Appearance"))] + pub(crate) path: Option, +} + +impl Default for ThemeConfig { + fn default() -> Self { + Self { + name: "default-dark".to_owned(), + path: None, + } + } +} + /// A trusted subprocess that supplies summaries for large novel folds. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct HookConfig { - /// Relative command paths resolve against the config file, wherever it lives. + /// Relative command paths resolve against the file that configured the + /// hook, or the workspace when it came from the environment. #[serde(skip)] pub(crate) dir: PathBuf, pub(crate) command: Vec, @@ -51,7 +150,7 @@ fn default_startup_timeout_ms() -> u64 { 30_000 } -#[derive(Default, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct LanguageConfig { /// None keeps the bundled query; an empty string disables this feature. @@ -73,6 +172,7 @@ pub(crate) struct Params { // Unread until the hook returns as a fold mutation. #[allow(dead_code)] pub(crate) hook: Option, + pub(crate) diff: DiffConfig, } pub(crate) struct LanguageParams { @@ -98,33 +198,125 @@ impl LanguageParams { } } +/// Where the layers come from. +pub(crate) struct Sources<'a> { + /// The repository root; `diffr.toml` inside it is the repository layer. + pub(crate) workspace: &'a Path, + /// Replaces the global file. Must exist. + pub(crate) explicit: Option<&'a Path>, + /// `key=value` overrides, applied last. + pub(crate) overrides: &'a [String], +} + +/// The user's global file: `$XDG_CONFIG_HOME/diffr/config.toml`, falling +/// back to `~/.config/diffr/config.toml`. +pub(crate) fn global_path() -> Result { + let dir = match std::env::var_os("XDG_CONFIG_HOME") { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => dirs::home_dir() + .ok_or_else(|| ConfigError("no home directory for this user".into()))? + .join(".config"), + }; + Ok(dir.join("diffr").join("config.toml")) +} + +/// A `key=value` override. Values parse as TOML; anything that is not +/// valid TOML is a string. +pub(crate) fn parse_override(text: &str) -> Result<(String, toml::Value), ConfigError> { + let (key, value) = text + .split_once('=') + .ok_or_else(|| ConfigError(format!("--set expects key=value, got {text:?}")))?; + Ok((key.to_owned(), parse_value(value))) +} + +/// TOML scalars and arrays; anything else is the literal string. +pub(crate) fn parse_value(text: &str) -> toml::Value { + match toml::from_str::(&format!("v = {text}")) { + Ok(mut table) => match table.remove("v") { + Some(toml::Value::Table(_)) | Some(toml::Value::Datetime(_)) | None => { + toml::Value::String(text.to_owned()) + } + Some(value) => value, + }, + Err(_) => toml::Value::String(text.to_owned()), + } +} + impl Config { - pub(crate) fn load(workspace: &Path, explicit: Option<&Path>) -> Result { - let path = explicit - .map(Path::to_path_buf) - .unwrap_or_else(|| workspace.join("diffr.toml")); - match std::fs::read_to_string(&path) { - Ok(source) => { - let mut config = Self::from_toml(&source)?; - if let Some(hook) = &mut config.folds.hook { - hook.dir = path - .parent() - .expect("config file has a parent") - .to_path_buf(); + /// Layer every source and resolve it. Missing global and repository + /// files are fine; an explicit `--config` file must exist. + pub(crate) fn load(sources: Sources<'_>) -> Result { + let global = match sources.explicit { + Some(path) => { + if !path.is_file() { + return Err(ConfigError(format!("{}: not found", path.display()))); } - Ok(config) + path.to_path_buf() } - Err(error) if explicit.is_none() && error.kind() == std::io::ErrorKind::NotFound => { - Ok(Self::default()) + None => global_path()?, + }; + let overrides = sources + .overrides + .iter() + .map(|text| parse_override(text)) + .collect::, _>>()?; + let figment = |overrides: &[(String, toml::Value)]| { + let mut figment = Figment::from(Serialized::defaults(Config::default())) + .merge(Toml::file(&global)) + .merge(Toml::file(sources.workspace.join("diffr.toml"))) + .merge( + Env::prefixed("DIFFR_") + .filter(|key| key.as_str().contains("__")) + .split("__"), + ); + for (key, value) in overrides { + figment = figment.merge(Serialized::default(key, value)); } - Err(error) => Err(ConfigError(format!("{}: {error}", path.display()))), + figment + }; + let typed = figment(&overrides); + let (figment, mut config) = match typed.extract::() { + Ok(config) => (typed, config), + Err(error) => { + // A value like `1234` for a string key parsed as a number; the + // literal text is the intended value. + let as_strings: Vec<_> = sources + .overrides + .iter() + .map(|text| { + let (key, value) = text.split_once('=').expect("validated above"); + (key.to_owned(), toml::Value::String(value.to_owned())) + }) + .collect(); + let retry = figment(&as_strings); + match retry.extract::() { + Ok(config) if as_strings != overrides => (retry, config), + _ => return Err(ConfigError(error.to_string())), + } + } + }; + if let Some(hook) = &mut config.folds.hook { + hook.dir = figment + .find_metadata("folds.hook.command") + .and_then(|metadata| metadata.source.as_ref()) + .and_then(|source| source.file_path()) + .and_then(|path| path.parent()) + .map(Path::to_path_buf) + .unwrap_or_else(|| sources.workspace.to_path_buf()); } + Ok(config) } pub(crate) fn from_toml(source: &str) -> Result { toml::from_str(source).map_err(|error| ConfigError(error.to_string())) } + /// The JSON Schema of the configuration, with a description and default + /// on every setting. + pub(crate) fn schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(Config)).expect("schema serializes") + } + pub(crate) fn compile(self) -> Result { if let Some(hook) = &self.folds.hook { if hook.command.is_empty() { @@ -169,6 +361,7 @@ impl Config { } Ok(Params { languages, + diff: self.diff, hook: self.folds.hook, }) } @@ -213,6 +406,27 @@ mod tests { use super::*; use crate::summary::DiffResult; + #[test] + fn diff_limits_default_and_layer_from_the_file() { + let defaults = Config::default().diff; + assert_eq!(defaults.graph_limit, crate::options::DEFAULT_GRAPH_LIMIT); + assert_eq!(defaults.byte_limit, crate::options::DEFAULT_BYTE_LIMIT); + let custom = Config::from_toml("[diff]\ngraph_limit = 5").unwrap(); + assert_eq!(custom.diff.graph_limit, 5); + assert_eq!(custom.diff.byte_limit, defaults.byte_limit); + let options = custom.diff.options(true).unwrap(); + assert_eq!(options.graph_limit, 5); + assert!(options.ignore_comments); + let compiled = custom.compile().unwrap(); + assert_eq!(compiled.diff.graph_limit, 5); + let schema = Config::schema(); + assert!( + schema["$defs"]["DiffConfig"]["properties"]["graph_limit"]["description"] + .as_str() + .is_some_and(|text| !text.is_empty()) + ); + } + #[test] fn configuration_is_independent_and_omission_keeps_other_defaults() { let custom = Config::from_toml("[languages.rust]\nfolds = ''") @@ -499,3 +713,103 @@ mod tag_tests { assert!(result.lhs_folds[0].counterpart(&result.rhs_folds).is_some()); } } + +#[cfg(test)] +mod layer_tests { + use super::*; + + fn load(dir: &Path, overrides: &[&str]) -> Result { + let overrides: Vec = overrides.iter().map(|s| (*s).to_owned()).collect(); + Config::load(Sources { + workspace: dir, + explicit: Some(&dir.join("global.toml")), + overrides: &overrides, + }) + } + + #[test] + fn layers_resolve_in_order() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("global.toml"), + "[diff]\ngraph_limit = 5\nbyte_limit = 6\n[theme]\nname = 'global'\n", + ) + .unwrap(); + std::fs::write(dir.path().join("diffr.toml"), "[theme]\nname = 'repo'\n").unwrap(); + let config = load(dir.path(), &["diff.graph_limit=7"]).unwrap(); + assert_eq!(config.diff.graph_limit, 7); + assert_eq!(config.diff.byte_limit, 6); + assert_eq!( + config.diff.parse_error_limit, + crate::options::DEFAULT_PARSE_ERROR_LIMIT + ); + assert_eq!(config.theme.name, "repo"); + } + + #[test] + fn overrides_that_look_numeric_still_fill_string_keys() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("global.toml"), "").unwrap(); + let config = load(dir.path(), &["theme.name=1234"]).unwrap(); + assert_eq!(config.theme.name, "1234"); + let config = load(dir.path(), &["diff.graph_limit=9"]).unwrap(); + assert_eq!(config.diff.graph_limit, 9); + } + + #[test] + fn unknown_keys_and_missing_explicit_files_are_errors() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("global.toml"), "").unwrap(); + assert!(load(dir.path(), &["diff.typo=1"]).is_err()); + assert!(load(dir.path(), &["diff.graph_limit=abc"]).is_err()); + assert!(load(dir.path(), &["diff.graph_limit"]).is_err()); + assert!(Config::load(Sources { + workspace: dir.path(), + explicit: Some(&dir.path().join("absent.toml")), + overrides: &[], + }) + .is_err()); + } + + #[test] + fn hook_paths_resolve_against_the_file_that_configured_it() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("elsewhere"); + std::fs::create_dir(&nested).unwrap(); + std::fs::write( + nested.join("global.toml"), + "[folds.hook]\ncommand = ['hook.py']\n", + ) + .unwrap(); + let config = Config::load(Sources { + workspace: dir.path(), + explicit: Some(&nested.join("global.toml")), + overrides: &[], + }) + .unwrap(); + assert_eq!(config.folds.hook.unwrap().dir, nested); + } + + #[test] + fn schema_describes_every_setting_with_its_default() { + let schema = Config::schema(); + let diff = &schema["properties"]["diff"]; + let diff = match diff.get("$ref") { + Some(reference) => { + let name = reference.as_str().unwrap().rsplit('/').next().unwrap(); + &schema["$defs"][name] + } + None => diff, + }; + let graph_limit = &diff["properties"]["graph_limit"]; + assert_eq!(graph_limit["default"], crate::options::DEFAULT_GRAPH_LIMIT); + assert!(graph_limit["description"] + .as_str() + .unwrap() + .contains("matching graph")); + assert!(schema["properties"].get("languages").is_none()); + assert!(schema["properties"]["folds"] + .get("properties") + .is_none_or(|properties| properties.get("hook").is_none())); + } +} diff --git a/src/config/store.rs b/src/config/store.rs new file mode 100644 index 000000000..6c42352f6 --- /dev/null +++ b/src/config/store.rs @@ -0,0 +1,149 @@ +//! `diffr config`: read the schema and resolved values, and write one key to +//! the global file. +use super::{parse_value, Config, ConfigError}; +use figment::providers::{Format, Serialized, Toml}; +use figment::Figment; +use std::path::Path; + +/// The resolved configuration as JSON, with the same nesting as the TOML. +/// `reveal` is accepted for secrets; no setting holds one yet. +pub(crate) fn show(config: &Config, _reveal: bool) -> serde_json::Value { + serde_json::to_value(config).expect("config serializes") +} + +/// Write `key = value` into the global file, keeping everything else in it +/// as written. The value is validated against the configuration before +/// anything touches the disk: unknown keys and mistyped values are errors. +pub(crate) fn set(path: &Path, key: &str, value: &str) -> Result<(), ConfigError> { + if key.is_empty() || key.split('.').any(str::is_empty) { + return Err(ConfigError(format!("invalid key {key:?}"))); + } + let existing = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(ConfigError(format!("{}: {error}", path.display()))), + }; + let mut document: toml_edit::DocumentMut = existing + .parse() + .map_err(|error| ConfigError(format!("{}: {error}", path.display())))?; + let typed = parse_value(value); + let candidates = [ + Some(typed.clone()), + (!matches!(typed, toml::Value::String(_))).then(|| toml::Value::String(value.to_owned())), + ]; + let mut last_error = None; + for candidate in candidates.into_iter().flatten() { + let mut attempt = document.clone(); + assign(&mut attempt, key, &candidate)?; + match validate(&attempt.to_string()) { + Ok(()) => { + document = attempt; + last_error = None; + break; + } + Err(error) => last_error = Some(error), + } + } + if let Some(error) = last_error { + return Err(error); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| ConfigError(format!("{}: {error}", parent.display())))?; + } + std::fs::write(path, document.to_string()) + .map_err(|error| ConfigError(format!("{}: {error}", path.display()))) +} + +fn validate(text: &str) -> Result<(), ConfigError> { + Figment::from(Serialized::defaults(Config::default())) + .merge(Toml::string(text)) + .extract::() + .map(|_| ()) + .map_err(|error| ConfigError(error.to_string())) +} + +fn assign( + document: &mut toml_edit::DocumentMut, + key: &str, + value: &toml::Value, +) -> Result<(), ConfigError> { + let mut segments = key.split('.').peekable(); + let mut item = document.as_item_mut(); + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + match item.as_table_like_mut() { + Some(table) => table.insert(segment, toml_edit::Item::Value(edit_value(value)?)), + None => return Err(ConfigError(format!("{key}: parent is not a table"))), + }; + return Ok(()); + } + let table = item + .as_table_like_mut() + .ok_or_else(|| ConfigError(format!("{key}: parent is not a table")))?; + if !table.contains_key(segment) { + let mut nested = toml_edit::Table::new(); + nested.set_implicit(true); + table.insert(segment, toml_edit::Item::Table(nested)); + } + item = table.get_mut(segment).expect("just inserted"); + } + Err(ConfigError(format!("invalid key {key:?}"))) +} + +fn edit_value(value: &toml::Value) -> Result { + Ok(match value { + toml::Value::String(text) => toml_edit::Value::from(text.as_str()), + toml::Value::Integer(number) => toml_edit::Value::from(*number), + toml::Value::Float(number) => toml_edit::Value::from(*number), + toml::Value::Boolean(flag) => toml_edit::Value::from(*flag), + toml::Value::Array(items) => { + let mut array = toml_edit::Array::new(); + for item in items { + array.push(edit_value(item)?); + } + toml_edit::Value::Array(array) + } + toml::Value::Table(_) | toml::Value::Datetime(_) => { + return Err(ConfigError( + "config set takes a scalar or array value".into(), + )); + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_writes_typed_values_and_keeps_the_rest() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("config.toml"); + set(&path, "diff.graph_limit", "20").unwrap(); + set(&path, "theme.name", "1234").unwrap(); + set(&path, "theme.path", "themes/mine.toml").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("graph_limit = 20"), "{text}"); + assert!(text.contains("name = \"1234\""), "{text}"); + assert!(text.contains("path = \"themes/mine.toml\""), "{text}"); + let config = Config::from_toml(&text).unwrap(); + assert_eq!(config.diff.graph_limit, 20); + assert_eq!(config.theme.name, "1234"); + } + + #[test] + fn set_rejects_unknown_keys_and_wrong_types_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "# keep me\n[diff]\ngraph_limit = 4\n").unwrap(); + assert!(set(&path, "diff.typo", "1").is_err()); + assert!(set(&path, "diff.graph_limit", "abc").is_err()); + assert!(set(&path, "diff", "1").is_err()); + assert!(set(&path, "", "1").is_err()); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "# keep me\n[diff]\ngraph_limit = 4\n" + ); + } +} diff --git a/src/engine.rs b/src/engine.rs index 100e9a71d..7b56289a7 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -232,7 +232,10 @@ pub(crate) fn diff_file_content( line_parser::change_positions(lhs_src, rhs_src); ( FileFormat::TextFallback { - reason: "exceeded DFT_GRAPH_LIMIT".into(), + reason: format!( + "structural diff exceeded diff.graph_limit ({}); raise it in diffr config", + diff_options.graph_limit + ), }, lhs_positions, rhs_positions, @@ -340,8 +343,9 @@ pub(crate) fn diff_file_content( let format_options = FormatSizeOptions::from(BINARY).decimal_places(1); let file_format = FileFormat::TextFallback { reason: format!( - "{} exceeded DFT_BYTE_LIMIT", - format_size(num_bytes, format_options) + "{} exceeded diff.byte_limit ({}); raise it in diffr config", + format_size(num_bytes, format_options), + diff_options.byte_limit ), }; diff --git a/src/protocol/project.rs b/src/protocol/project.rs index a5bb5ed14..95d342d82 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -115,9 +115,9 @@ fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { /// difftastic reports its fallbacks as prose; the wire wants a code. fn fallback_code(reason: &str) -> &'static str { - if reason.contains("DFT_BYTE_LIMIT") { + if reason.contains("byte_limit") { "too_large" - } else if reason.contains("DFT_GRAPH_LIMIT") { + } else if reason.contains("graph_limit") { "too_complex" } else if reason.contains("parse error") { "parse_error" From 72e1f798c257929a88c1267b7a867502a7661da9 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sun, 13 Sep 2026 02:54:09 -0700 Subject: [PATCH 5/6] Rewrite regions with file and fold mutations The v3 stream now runs a registry of mutations between projection and output. File mutations set a manifest entry's visibility from its category (git's `diffr-classify`, `linguist-generated`, then built-in path rules) and language. Fold mutations collapse deleted bodies, long removed stretches and test bodies, bundle docstrings with their functions through `fold_state_id`, group related regions, and fill pseudocode labels from the built-in Gemini summarizer and the JSON-RPC hook, which is now a fold mutation. After mutations `stats.visible` is recounted, and a run-level failure stops the stream with `complete.aborted`. The `folds.*` and `summarize.*` config tables, `-U` defaulting to `folds.context_lines`, and function and module fold tags in the bundled queries come with it. Written with AI assistance (Claude Code). Agent-Session: 0de57442-ad9b-4c99-960e-4974e629b71d Agent-Session: e046556c-20f0-4bba-832b-b6784a7783c5 Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 8837929e-0118-489c-9648-e47c1831ee8c Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 4a8f6ee6-1693-4de3-9993-b9f5914cdd09 Agent-Session: 8761954a-0023-463e-996c-7c9fbabdef69 Agent-Session: 30956777-ac82-4a2b-9b52-dbfb517b52ee Agent-Session: 01a09f1b-2525-74e3-99d4-f1bdcbec53e6 --- Cargo.toml | 2 +- examples/hooks/summarize.py | 2 +- src/category.rs | 121 ++++++ src/cli.rs | 53 ++- src/config.rs | 225 +++++++++-- src/config/defaults.toml | 97 +++++ src/config/store.rs | 54 ++- src/git.rs | 42 ++- src/hook.rs | 277 +++++++------- src/main.rs | 4 +- src/mutate/collapse.rs | 698 ++++++++++++++++++++++++++++++++++ src/mutate/docstrings.rs | 552 +++++++++++++++++++++++++++ src/mutate/group.rs | 613 ++++++++++++++++++++++++++++++ src/mutate/mod.rs | 234 ++++++++++++ src/mutate/summarize.rs | 730 ++++++++++++++++++++++++++++++++++++ src/parse/folds.rs | 1 + src/protocol/mod.rs | 9 +- src/protocol/project.rs | 35 ++ src/protocol/stream.rs | 305 +++++++++++++-- src/review/tests.rs | 22 +- tests/hooks/rpc_server.py | 12 +- 21 files changed, 3841 insertions(+), 247 deletions(-) create mode 100644 src/category.rs create mode 100644 src/mutate/collapse.rs create mode 100644 src/mutate/docstrings.rs create mode 100644 src/mutate/group.rs create mode 100644 src/mutate/mod.rs create mode 100644 src/mutate/summarize.rs diff --git a/Cargo.toml b/Cargo.toml index 1834b4996..abc596bd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ ignore = ">= 0.4, < 0.4.30" owo-colors = "4.2.3" imara-diff = { version = "0.2.0", default-features = false } rayon = "1.7.0" -tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time"] } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time", "sync"] } tree_magic_mini = "3.2.0" bumpalo = "3.18.0" diff --git a/examples/hooks/summarize.py b/examples/hooks/summarize.py index 6821423e7..af8d27202 100755 --- a/examples/hooks/summarize.py +++ b/examples/hooks/summarize.py @@ -34,7 +34,7 @@ SYSTEM = ( "You rewrite regions of a source file as terse Python-style pseudocode for a diff " "viewer that shows the pseudocode in place of the collapsed region. The user supplies " - "one numbered source file and a list of folds, each with an id and 1-based line range. " + "one numbered source file and a list of folds, each with an id (the region's alignment_id) and 1-based line range. " "For each fold, write pseudocode covering only that fold's lines: keep the control flow " "and the names that matter, drop types, error plumbing and boilerplate. Aim for about one " "pseudocode line per five source lines, between one and eight lines per fold. Reply with " diff --git a/src/category.rs b/src/category.rs new file mode 100644 index 000000000..c217edd71 --- /dev/null +++ b/src/category.rs @@ -0,0 +1,121 @@ +//! File categories: `source`, `test`, `generated`, or `docs`. +//! +//! A `diffr-classify` git attribute wins outright. Otherwise a set +//! `linguist-generated` attribute means generated, and failing both, the +//! path is matched against built-in rules. Anything else is unclassified. + +pub(crate) const GENERATED: &str = "generated"; +pub(crate) const TEST: &str = "test"; +pub(crate) const DOCS: &str = "docs"; + +/// Directory names, anywhere in the path, that mark everything under them. +const GENERATED_DIRS: &[&str] = &["dist", "build", "vendor", "node_modules", "__generated__"]; +const TEST_DIRS: &[&str] = &["tests", "test", "__tests__", "spec"]; +const DOCS_DIRS: &[&str] = &["docs"]; + +/// Exact file names. +const GENERATED_FILES: &[&str] = &[ + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "Cargo.lock", + "go.sum", + "composer.lock", + "Gemfile.lock", + "poetry.lock", + "uv.lock", + "bun.lock", + "bun.lockb", +]; +const TEST_FILES: &[&str] = &["conftest.py"]; + +/// The built-in rule for a repository-relative path, if one matches. +pub(crate) fn from_path(path: &str) -> Option<&'static str> { + let mut parts = path.split('/').filter(|part| !part.is_empty()); + let name = parts.next_back().unwrap_or_default(); + let dirs: Vec<&str> = parts.collect(); + if dirs.iter().any(|dir| GENERATED_DIRS.contains(dir)) + || GENERATED_FILES.contains(&name) + || name.ends_with(".lock") + || name.ends_with(".min.js") + || name.ends_with(".min.css") + || name.ends_with(".pb.go") + || name.contains(".generated.") + || name.contains("_generated.") + { + return Some(GENERATED); + } + if dirs.iter().any(|dir| TEST_DIRS.contains(dir)) + || TEST_FILES.contains(&name) + || name.ends_with("_test.go") + || name.ends_with("_test.py") + || name == "tests.rs" + || name == "test.rs" + || name.ends_with("_test.rs") + || name.ends_with("_tests.rs") + || name.starts_with("test_") && name.ends_with(".py") + || name.contains(".test.") + || name.contains(".spec.") + { + return Some(TEST); + } + if dirs.iter().any(|dir| DOCS_DIRS.contains(dir)) || name.ends_with(".md") { + return Some(DOCS); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_in_rules_cover_the_common_layouts() { + for path in [ + "Cargo.lock", + "web/pnpm-lock.yaml", + "dist/app.js", + "src/vendor/lib.c", + "assets/app.min.js", + "api/v1/service.pb.go", + "schema.generated.ts", + "src/__generated__/types.ts", + "src/model_generated.rs", + ] { + assert_eq!(from_path(path), Some(GENERATED), "{path}"); + } + for path in [ + "tests/streaming/check.py", + "src/foo_test.go", + "src/App.test.tsx", + "src/App.spec.ts", + "pkg/test_widgets.py", + "pkg/conftest.py", + "src/__tests__/a.js", + "spec/models/user_spec.rb", + "src/review/tests.rs", + "src/parser/test.rs", + "src/git_test.rs", + "src/protocol_tests.rs", + ] { + assert_eq!(from_path(path), Some(TEST), "{path}"); + } + for path in ["README.md", "docs/cli.md", "docs/assets/a.png"] { + assert_eq!(from_path(path), Some(DOCS), "{path}"); + } + for path in [ + "src/main.rs", + "testing/helpers.rs", + "src/testament.py", + "latest.txt", + ] { + assert_eq!(from_path(path), None, "{path}"); + } + } + + #[test] + fn generated_wins_over_test_and_docs() { + assert_eq!(from_path("tests/fixtures/big.lock"), Some(GENERATED)); + assert_eq!(from_path("docs/build/index.html"), Some(GENERATED)); + } +} diff --git a/src/cli.rs b/src/cli.rs index a522f5d03..aaad8e5bc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,7 @@ //! Git-style CLI input; rendering and NDJSON remain adapters over the same engine. use crate::config::{self, Config, Sources}; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; +use crate::mutate::Mutations; use crate::options::{DiffOptions, DisplayMode, DisplayOptions}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use git2::{DiffStatsFormat, Repository}; @@ -59,7 +60,7 @@ pub(crate) fn run() -> Result { .arg(flag("null").short('z')) .arg(flag("no-renames")) .arg(flag("find-renames").short('M').conflicts_with("no-renames")) - .arg(Arg::new("unified").short('U').long("unified").default_value("3").value_parser(clap::value_parser!(u32))) + .arg(Arg::new("unified").short('U').long("unified").value_parser(clap::value_parser!(u32)).help("Unchanged lines kept around each change; defaults to folds.context_lines")) .arg(Arg::new("format").long("format").value_parser(["text", "ndjson", "snapshot"]).default_value("text")) .arg(Arg::new("display").long("display").value_parser(["inline", "side-by-side", "side-by-side-show-both"]).default_value("side-by-side")) .arg(Arg::new("color").long("color").num_args(0..=1).require_equals(true).default_missing_value("always").default_value("auto").value_parser(["auto", "always", "never"])) @@ -111,7 +112,7 @@ pub(crate) fn run() -> Result { .cloned() .collect(); let display = DisplayOptions { - num_context_lines: *args.get_one::("unified").unwrap(), + num_context_lines: args.get_one::("unified").copied().unwrap_or(3), terminal_width: args .get_one::("width") .copied() @@ -172,8 +173,17 @@ pub(crate) fn run() -> Result { } else { let params = Arc::new(load_config(&args, workspace)?.compile()?); let diff_options = diff_options(&args, ¶ms)?; + let context_lines = args + .get_one::("unified") + .copied() + .unwrap_or(params.folds.context_lines); + let mutations = if streaming { + Arc::new(Mutations::from_params(¶ms, workspace)?) + } else { + Arc::new(Mutations::default()) + }; let mut session = DiffSession::open(workspace, comparison, params, &files)?; - session.context_lines = display.num_context_lines; + session.context_lines = context_lines; session.diff_options = diff_options; let changed = session.remaining() > 0; if streaming { @@ -181,13 +191,19 @@ pub(crate) fn run() -> Result { if jobs == 0 { return Err("--jobs must be at least 1".into()); } - let failed = crate::protocol::stream::write(session, jobs, &mut io::stdout().lock())?; + let ended = + crate::protocol::stream::write(session, jobs, mutations, &mut io::stdout().lock())?; + let failed = ended.failed || ended.aborted; return Ok(if failed { 2 } else { i32::from(changed && args.get_flag("exit-code")) }); } + let display = DisplayOptions { + num_context_lines: context_lines, + ..display + }; for (file, result) in session { let result = result.map_err(|error| format!("{}: {error}", file.path()))?; render(&result, &args, &display)?; @@ -439,6 +455,13 @@ fn no_index(args: &ArgMatches, paths: Vec, display: &DisplayOptions) - let workspace = Path::new(args.get_one::("repo").unwrap()); let config = load_config(args, workspace)?.compile()?; let options = &diff_options(args, &config)?; + let display = &DisplayOptions { + num_context_lines: args + .get_one::("unified") + .copied() + .unwrap_or(config.folds.context_lines), + ..display.clone() + }; let lhs = crate::options::FileArgument::from_path_argument(&paths[0]); let rhs = crate::options::FileArgument::from_path_argument(&paths[1]); let compute = || { @@ -458,14 +481,20 @@ fn no_index(args: &ArgMatches, paths: Vec, display: &DisplayOptions) - ) }; if args.get_one::("format").map(String::as_str) == Some("ndjson") { - crate::protocol::stream::write_file( + let mutations = Mutations::from_params(&config, workspace)?; + let ended = crate::protocol::stream::write_file( &paths[0].to_string_lossy(), &paths[1].to_string_lossy(), (before.len() as u64, after.len() as u64), compute, + &mutations, &mut io::stdout().lock(), )?; - Ok(i32::from(changed && args.get_flag("exit-code"))) + Ok(if ended.failed || ended.aborted { + 2 + } else { + i32::from(changed && args.get_flag("exit-code")) + }) } else { render(&compute(), args, display)?; Ok(i32::from(changed)) @@ -528,7 +557,17 @@ fn run_config(args: &ArgMatches, sub: &ArgMatches) -> Result { serde_json::to_writer_pretty(&mut stdout, &config::store::show(&config, reveal))?; stdout.write_all(b"\n")?; } else { - stdout.write_all(toml::to_string_pretty(&config)?.as_bytes())?; + let mut redacted = config.clone(); + if !reveal + && redacted + .summarize + .api_key + .as_deref() + .is_some_and(|key| !key.is_empty()) + { + redacted.summarize.api_key = Some("".to_owned()); + } + stdout.write_all(toml::to_string_pretty(&redacted)?.as_bytes())?; } } Some(("set", set)) => { diff --git a/src/config.rs b/src/config.rs index 5bfa0bf48..bdd04f3a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,8 @@ pub(crate) struct Config { /// empty string disables that feature. #[schemars(skip)] pub(crate) languages: BTreeMap, + /// Pseudocode summaries for large new function bodies. + pub(crate) summarize: SummarizeConfig, /// What gets folded and what starts collapsed. pub(crate) folds: FoldsConfig, /// Colors for the terminal frontend. @@ -92,14 +94,121 @@ impl DiffConfig { } } -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct FoldsConfig { - /// An external JSON-RPC summarizer. + /// Hide files that were deleted outright. + #[schemars(title = "Hide deleted files", extend("x-group" = "Hidden files"))] + pub(crate) collapse_deleted_files: bool, + /// Hide files classified as generated, such as lockfiles and build output. + #[schemars(title = "Hide generated files", extend("x-group" = "Hidden files"))] + pub(crate) collapse_generated: bool, + /// Hide files classified as tests. + #[schemars(title = "Hide test files", extend("x-group" = "Hidden files"))] + pub(crate) collapse_tests: bool, + /// Bodies shorter than this are never summarized or collapsed by a rule. + #[schemars(title = "Shortest body to collapse (lines)", extend("x-group" = "Collapsed code"))] + pub(crate) min_lines: usize, + /// Collapse deleted function bodies, keeping their header line visible. + #[schemars(title = "Collapse deleted functions", extend("x-group" = "Collapsed code"))] + pub(crate) collapse_deleted: bool, + /// Removed stretches with no counterpart and at least this many lines + /// collapse in the middle, keeping their first and last line visible. + /// `0` disables it. + #[schemars(title = "Collapse removed stretches from (lines)", extend("x-group" = "Collapsed code"))] + pub(crate) collapse_removed_lines: usize, + /// Collapse the bodies of test functions on both sides, header visible. + #[schemars(title = "Collapse test function bodies", extend("x-group" = "Collapsed code"))] + pub(crate) collapse_test_bodies: bool, + /// Bundle each function with the comment above it (in Python, the + /// string that opens its body): they open and close together, and the + /// summarizer quotes the docstring before its pseudocode. + #[schemars(title = "Fold docstrings with their function", extend("x-group" = "Collapsed code"))] + pub(crate) bundle_docstrings: bool, + /// Unchanged lines kept visible on either side of a change. `-U` overrides it. + #[schemars(title = "Context lines", extend("x-group" = "Collapsed code"))] + pub(crate) context_lines: u32, + /// An external JSON-RPC summarizer, run after the built-in one. #[schemars(skip)] pub(crate) hook: Option, } +impl Default for FoldsConfig { + fn default() -> Self { + Self { + min_lines: 12, + collapse_deleted: true, + collapse_removed_lines: 5, + collapse_deleted_files: true, + collapse_generated: true, + collapse_tests: true, + collapse_test_bodies: true, + bundle_docstrings: true, + context_lines: 3, + hook: None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct SummarizeConfig { + /// Summarize large new function bodies as pseudocode. Silently off + /// without an API key. + #[schemars(title = "Summarize new functions", extend("x-group" = "Summaries"))] + pub(crate) enabled: bool, + /// Which model API to call. + #[schemars(title = "Provider", extend("x-group" = "Summaries"))] + pub(crate) provider: Provider, + /// The model name sent to the provider. + #[schemars(title = "Model", extend("x-group" = "Summaries"))] + pub(crate) model: String, + /// New function bodies shorter than this are shown as code, not + /// summarized: pseudocode only pays off once the body is long. + #[schemars(title = "Shortest body to summarize (lines)", extend("x-group" = "Summaries"))] + pub(crate) min_lines: usize, + /// The provider's API key. `GEMINI_API_KEY` or `GOOGLE_API_KEY` in the + /// environment is used when this is unset. + #[schemars(title = "API key", extend("x-group" = "Summaries"))] + pub(crate) api_key: Option, + /// Override the provider's base URL, for proxies and tests. + #[schemars(title = "Endpoint URL", extend("x-group" = "Summaries"))] + pub(crate) endpoint: Option, + /// Per-request limit in milliseconds. + #[schemars(title = "Request timeout (ms)", extend("x-group" = "Summaries"))] + pub(crate) timeout_ms: u64, + /// Requests in flight at once across files. + #[schemars(title = "Parallel requests", extend("x-group" = "Summaries"))] + pub(crate) max_concurrency: usize, + /// Retries after a timeout, rate limit, or server error before the run + /// is aborted. + #[schemars(title = "Retries", extend("x-group" = "Summaries"))] + pub(crate) retries: u32, +} + +impl Default for SummarizeConfig { + fn default() -> Self { + Self { + enabled: true, + provider: Provider::Gemini, + model: "gemini-3.8-flash".to_owned(), + min_lines: 20, + api_key: None, + endpoint: None, + timeout_ms: 60_000, + max_concurrency: 16, + retries: 3, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +#[schemars(inline)] +pub(crate) enum Provider { + Gemini, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct ThemeConfig { @@ -132,8 +241,9 @@ pub(crate) struct HookConfig { /// None sends every tagged fold; otherwise a fold needs one of these tags. #[serde(default)] pub(crate) tags: Option>, + /// Overrides `folds.min_lines` for the hook alone. #[serde(default)] - pub(crate) min_lines: usize, + pub(crate) min_lines: Option, /// Per-call limit once the hook is listening. #[serde(default = "default_timeout_ms")] pub(crate) timeout_ms: u64, @@ -169,8 +279,8 @@ impl std::error::Error for ConfigError {} pub(crate) struct Params { languages: DftHashMap>>, - // Unread until the hook returns as a fold mutation. - #[allow(dead_code)] + pub(crate) folds: FoldsConfig, + pub(crate) summarize: SummarizeConfig, pub(crate) hook: Option, pub(crate) diff: DiffConfig, } @@ -326,6 +436,11 @@ impl Config { return Err(ConfigError("folds.hook timeouts must be positive".into())); } } + if self.summarize.timeout_ms == 0 || self.summarize.max_concurrency == 0 { + return Err(ConfigError( + "summarize.timeout_ms and summarize.max_concurrency must be positive".into(), + )); + } let defaults = Self::from_toml(include_str!("config/defaults.toml"))?; let mut resolved = defaults.languages; for (name, overrides) in self.languages { @@ -361,6 +476,8 @@ impl Config { } Ok(Params { languages, + folds: self.folds.clone(), + summarize: self.summarize, diff: self.diff, hook: self.folds.hook, }) @@ -517,7 +634,7 @@ mod tests { assert_eq!(hook.tags.as_deref(), Some(&["body".to_owned()][..])); assert_eq!( (hook.min_lines, hook.timeout_ms, hook.startup_timeout_ms), - (30, 5000, 30_000) + (Some(30), 5000, 30_000) ); assert!(Config::from_toml("") .unwrap() @@ -697,6 +814,42 @@ mod tag_tests { } } + #[test] + fn javascript_test_callbacks_are_tagged() { + let params = Params::default(); + for (path, source) in [ + ( + "a.test.ts", + "it('adds', () => {\n expect(1).toBe(1);\n});\n", + ), + ("a.test.js", "describe('x', function () {\n run();\n});\n"), + ( + "a.test.tsx", + "test('y', async () => {\n await run();\n});\n", + ), + ] { + let result = DiffResult::from_sources_with_params(path, "", source, ¶ms); + assert!( + result + .rhs_folds + .iter() + .any(|fold| fold.tags == ["body", "test"]), + "{path}: {:?}", + result.rhs_folds.iter().map(|f| &f.tags).collect::>() + ); + } + let plain = DiffResult::from_sources_with_params( + "a.ts", + "", + "run('z', () => {\n go();\n});\n", + ¶ms, + ); + assert!(plain + .rhs_folds + .iter() + .all(|fold| !fold.tags.iter().any(|tag| tag == "test"))); + } + #[test] fn test_bodies_keep_both_tags_and_remain_paired() { let params = Params::default(); @@ -708,8 +861,8 @@ mod tag_tests { ); assert_eq!(result.lhs_folds.len(), 1); assert_eq!(result.rhs_folds.len(), 1); - assert_eq!(result.lhs_folds[0].tags, ["body", "test"]); - assert_eq!(result.rhs_folds[0].tags, ["body", "test"]); + assert_eq!(result.lhs_folds[0].tags, ["body", "function", "test"]); + assert_eq!(result.rhs_folds[0].tags, ["body", "function", "test"]); assert!(result.lhs_folds[0].counterpart(&result.rhs_folds).is_some()); } } @@ -732,37 +885,41 @@ mod layer_tests { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("global.toml"), - "[diff]\ngraph_limit = 5\nbyte_limit = 6\n[theme]\nname = 'global'\n", + "[folds]\nmin_lines = 5\ncollapse_tests = false\n[summarize]\nmodel = 'global'\n", ) .unwrap(); - std::fs::write(dir.path().join("diffr.toml"), "[theme]\nname = 'repo'\n").unwrap(); - let config = load(dir.path(), &["diff.graph_limit=7"]).unwrap(); - assert_eq!(config.diff.graph_limit, 7); - assert_eq!(config.diff.byte_limit, 6); - assert_eq!( - config.diff.parse_error_limit, - crate::options::DEFAULT_PARSE_ERROR_LIMIT - ); - assert_eq!(config.theme.name, "repo"); + std::fs::write( + dir.path().join("diffr.toml"), + "[summarize]\nmodel = 'repo'\n", + ) + .unwrap(); + let config = load(dir.path(), &["folds.min_lines=7"]).unwrap(); + assert_eq!(config.folds.min_lines, 7); + assert!(!config.folds.collapse_tests); + assert!(config.folds.collapse_deleted); + assert_eq!(config.summarize.model, "repo"); + assert_eq!(config.summarize.retries, 3); } #[test] fn overrides_that_look_numeric_still_fill_string_keys() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("global.toml"), "").unwrap(); - let config = load(dir.path(), &["theme.name=1234"]).unwrap(); - assert_eq!(config.theme.name, "1234"); - let config = load(dir.path(), &["diff.graph_limit=9"]).unwrap(); - assert_eq!(config.diff.graph_limit, 9); + let config = load(dir.path(), &["summarize.api_key=1234"]).unwrap(); + assert_eq!(config.summarize.api_key.as_deref(), Some("1234")); + let config = load(dir.path(), &["summarize.api_key=abc-def"]).unwrap(); + assert_eq!(config.summarize.api_key.as_deref(), Some("abc-def")); + let config = load(dir.path(), &["folds.collapse_deleted=false"]).unwrap(); + assert!(!config.folds.collapse_deleted); } #[test] fn unknown_keys_and_missing_explicit_files_are_errors() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("global.toml"), "").unwrap(); - assert!(load(dir.path(), &["diff.typo=1"]).is_err()); - assert!(load(dir.path(), &["diff.graph_limit=abc"]).is_err()); - assert!(load(dir.path(), &["diff.graph_limit"]).is_err()); + assert!(load(dir.path(), &["folds.typo=1"]).is_err()); + assert!(load(dir.path(), &["folds.min_lines=abc"]).is_err()); + assert!(load(dir.path(), &["folds.min_lines"]).is_err()); assert!(Config::load(Sources { workspace: dir.path(), explicit: Some(&dir.path().join("absent.toml")), @@ -793,23 +950,21 @@ mod layer_tests { #[test] fn schema_describes_every_setting_with_its_default() { let schema = Config::schema(); - let diff = &schema["properties"]["diff"]; - let diff = match diff.get("$ref") { + let folds = &schema["properties"]["folds"]; + let folds = match folds.get("$ref") { Some(reference) => { let name = reference.as_str().unwrap().rsplit('/').next().unwrap(); &schema["$defs"][name] } - None => diff, + None => folds, }; - let graph_limit = &diff["properties"]["graph_limit"]; - assert_eq!(graph_limit["default"], crate::options::DEFAULT_GRAPH_LIMIT); - assert!(graph_limit["description"] + let min_lines = &folds["properties"]["min_lines"]; + assert_eq!(min_lines["default"], 12); + assert!(min_lines["description"] .as_str() .unwrap() - .contains("matching graph")); + .contains("never summarized")); assert!(schema["properties"].get("languages").is_none()); - assert!(schema["properties"]["folds"] - .get("properties") - .is_none_or(|properties| properties.get("hook").is_none())); + assert!(folds["properties"].get("hook").is_none()); } } diff --git a/src/config/defaults.toml b/src/config/defaults.toml index 2bc207200..a8a07a481 100644 --- a/src/config/defaults.toml +++ b/src/config/defaults.toml @@ -7,6 +7,8 @@ folds = ''' (match_block "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_item body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) ([ (array_expression "[" @fold.open "]" @fold.close) (field_initializer_list "{" @fold.open "}" @fold.close) @@ -27,6 +29,11 @@ folds = ''' ((function_item attributes: (attributes (attribute_item) @_attribute) body: (block "{" @fold.open "}" @fold.close) @fold) (#match? @_attribute "test") (#set! tag "test")) +((mod_item body: (declaration_list "{" @fold.open "}" @fold.close) @fold) + (#set! tag "module")) +((mod_item attributes: (attributes (attribute_item) @_attribute) body: (declaration_list "{" @fold.open "}" @fold.close) @fold) + (#match? @_attribute "cfg\\(test\\)") + (#set! tag "test")) ''' context = ''' [ @@ -43,6 +50,8 @@ context = ''' folds = ''' ((block) @fold (#set! tag "body")) +((function_definition body: (block) @fold) + (#set! tag "function")) ([ (list "[" @fold.open "]" @fold.close) (dictionary "{" @fold.open "}" @fold.close) @@ -75,6 +84,10 @@ folds = ''' (field_declaration_list "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) ((literal_value "{" @fold.open "}" @fold.close) @fold (#set! tag "collection")) ((import_declaration) @fold @@ -105,6 +118,19 @@ folds = ''' (switch_body "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((variable_declarator + name: (identifier) + value: [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ]) + (#set! tag "function")) ([ (object "{" @fold.open "}" @fold.close) (array "[" @fold.open "]" @fold.close) @@ -119,6 +145,14 @@ folds = ''' (template_string) ] @fold (#set! tag "string")) +((call_expression + function: (identifier) @_name + arguments: (arguments [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ])) + (#match? @_name "^(it|test|describe)$") + (#set! tag "test")) ''' context = ''' [ @@ -140,6 +174,19 @@ folds = ''' (switch_body "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((variable_declarator + name: (identifier) + value: [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ]) + (#set! tag "function")) ([ (object "{" @fold.open "}" @fold.close) (array "[" @fold.open "]" @fold.close) @@ -154,6 +201,14 @@ folds = ''' (template_string) ] @fold (#set! tag "string")) +((call_expression + function: (identifier) @_name + arguments: (arguments [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ])) + (#match? @_name "^(it|test|describe)$") + (#set! tag "test")) ''' context = ''' [ @@ -175,6 +230,19 @@ folds = ''' (switch_body "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((variable_declarator + name: (identifier) + value: [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ]) + (#set! tag "function")) ([ (object "{" @fold.open "}" @fold.close) (array "[" @fold.open "]" @fold.close) @@ -189,6 +257,14 @@ folds = ''' (template_string) ] @fold (#set! tag "string")) +((call_expression + function: (identifier) @_name + arguments: (arguments [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ])) + (#match? @_name "^(it|test|describe)$") + (#set! tag "test")) ''' context = ''' [ @@ -210,6 +286,19 @@ folds = ''' (switch_body "{" @fold.open "}" @fold.close) ] @fold (#set! tag "body")) +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "function")) +((variable_declarator + name: (identifier) + value: [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ]) + (#set! tag "function")) ([ (object "{" @fold.open "}" @fold.close) (array "[" @fold.open "]" @fold.close) @@ -224,6 +313,14 @@ folds = ''' (template_string) ] @fold (#set! tag "string")) +((call_expression + function: (identifier) @_name + arguments: (arguments [ + (arrow_function body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (function_expression body: (statement_block "{" @fold.open "}" @fold.close) @fold) + ])) + (#match? @_name "^(it|test|describe)$") + (#set! tag "test")) ''' context = ''' [ diff --git a/src/config/store.rs b/src/config/store.rs index 6c42352f6..fb5ecaeea 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -6,9 +6,19 @@ use figment::Figment; use std::path::Path; /// The resolved configuration as JSON, with the same nesting as the TOML. -/// `reveal` is accepted for secrets; no setting holds one yet. -pub(crate) fn show(config: &Config, _reveal: bool) -> serde_json::Value { - serde_json::to_value(config).expect("config serializes") +/// The API key is redacted unless `reveal` is set. +pub(crate) fn show(config: &Config, reveal: bool) -> serde_json::Value { + let mut value = serde_json::to_value(config).expect("config serializes"); + if !reveal + && config + .summarize + .api_key + .as_deref() + .is_some_and(|key| !key.is_empty()) + { + value["summarize"]["api_key"] = serde_json::Value::String("".to_owned()); + } + value } /// Write `key = value` into the global file, keeping everything else in it @@ -120,30 +130,42 @@ mod tests { fn set_writes_typed_values_and_keeps_the_rest() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("nested").join("config.toml"); - set(&path, "diff.graph_limit", "20").unwrap(); - set(&path, "theme.name", "1234").unwrap(); - set(&path, "theme.path", "themes/mine.toml").unwrap(); + set(&path, "folds.min_lines", "20").unwrap(); + set(&path, "summarize.api_key", "1234").unwrap(); + set(&path, "folds.collapse_tests", "false").unwrap(); + set(&path, "summarize.model", "gemini-x").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains("graph_limit = 20"), "{text}"); - assert!(text.contains("name = \"1234\""), "{text}"); - assert!(text.contains("path = \"themes/mine.toml\""), "{text}"); + assert!(text.contains("min_lines = 20"), "{text}"); + assert!(text.contains("api_key = \"1234\""), "{text}"); + assert!(text.contains("collapse_tests = false"), "{text}"); + assert!(text.contains("model = \"gemini-x\""), "{text}"); let config = Config::from_toml(&text).unwrap(); - assert_eq!(config.diff.graph_limit, 20); - assert_eq!(config.theme.name, "1234"); + assert_eq!(config.folds.min_lines, 20); + assert_eq!(config.summarize.api_key.as_deref(), Some("1234")); } #[test] fn set_rejects_unknown_keys_and_wrong_types_without_writing() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - std::fs::write(&path, "# keep me\n[diff]\ngraph_limit = 4\n").unwrap(); - assert!(set(&path, "diff.typo", "1").is_err()); - assert!(set(&path, "diff.graph_limit", "abc").is_err()); - assert!(set(&path, "diff", "1").is_err()); + std::fs::write(&path, "# keep me\n[folds]\nmin_lines = 4\n").unwrap(); + assert!(set(&path, "folds.typo", "1").is_err()); + assert!(set(&path, "folds.min_lines", "abc").is_err()); + assert!(set(&path, "folds", "1").is_err()); assert!(set(&path, "", "1").is_err()); assert_eq!( std::fs::read_to_string(&path).unwrap(), - "# keep me\n[diff]\ngraph_limit = 4\n" + "# keep me\n[folds]\nmin_lines = 4\n" ); } + + #[test] + fn show_redacts_the_key_unless_revealed() { + let mut config = Config::default(); + config.summarize.api_key = Some("secret".to_owned()); + assert_eq!(show(&config, false)["summarize"]["api_key"], ""); + assert_eq!(show(&config, true)["summarize"]["api_key"], "secret"); + assert!(show(&Config::default(), false)["summarize"]["api_key"].is_null()); + assert_eq!(show(&config, false)["folds"]["min_lines"], 12); + } } diff --git a/src/git.rs b/src/git.rs index aa3277abf..d509a22b2 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,6 +1,7 @@ //! Git comparison selection and lazy source loading used by the CLI and its stdout stream. use crate::config::Params; use crate::pairing::Pairing; +use crate::parse::guess_language::{guess, language_name}; use crate::protocol; use crate::summary::DiffResult; use anyhow::Context as _; @@ -135,6 +136,7 @@ pub(crate) struct FileChange { pub(crate) class: Option, /// Git's delta sides. pub(crate) sides: Pairing, + pub(crate) language: Option, } impl FileChange { @@ -169,12 +171,16 @@ impl FileChange { (None, Some(new)) => (FileStatus::Added, Pairing::RightOnly { rhs: file_ref(new) }), (None, None) => panic!("a standalone comparison needs at least one path"), }; + let path = new_path.as_deref().or(old_path.as_deref()).expect("a path"); + let language = language_of(path); + let class = crate::category::from_path(path).map(str::to_owned); Self { old_path, new_path, status, - class: None, + class, sides, + language, } } @@ -190,11 +196,33 @@ impl FileChange { // Both sides exist; the file record carries the unmerged error. FileStatus::Conflicted => protocol::FileStatus::Modified, }, + category: self.class.clone(), + language: self.language.clone(), visibility: protocol::Visibility::default(), } } } +/// `diffr-classify` wins; then `linguist-generated`; then the built-in +/// path rules. +fn category(repo: &Repository, path: &str) -> Result> { + let attr = |name: &str| { + repo.get_attr(Path::new(path), name, AttrCheckFlags::FILE_THEN_INDEX) + .map(AttrValue::from_string) + }; + if let AttrValue::String(value) = attr("diffr-classify")? { + return Ok(Some(value.to_owned())); + } + if let AttrValue::True = attr("linguist-generated")? { + return Ok(Some(crate::category::GENERATED.to_owned())); + } + Ok(crate::category::from_path(path).map(str::to_owned)) +} + +fn language_of(path: &str) -> Option { + guess(Path::new(path), "", &[]).map(|language| language_name(language).to_owned()) +} + /// Why one file could not be diffed. Loading attaches it to the error, and /// the stream turns it into the record's `code`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -405,21 +433,17 @@ impl DiffSession { }, (None, None) => unreachable!("a delta has a path"), }; + let language = + language_of(new_path.as_deref().or(old_path.as_deref()).expect("a path")); let mut file = FileChange { old_path, new_path, status, class: None, sides, + language, }; - file.class = match AttrValue::from_string(repo.get_attr( - Path::new(file.path()), - "diffr-classify", - AttrCheckFlags::FILE_THEN_INDEX, - )?) { - AttrValue::String(value) => Some(value.to_owned()), - _ => None, - }; + file.class = category(&repo, file.path())?; pending.push(PendingFile { before: Source::from_delta( delta.old_file(), diff --git a/src/hook.rs b/src/hook.rs index b6c22911e..158d341c0 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -1,16 +1,21 @@ //! Trusted fold-summary hook: a JSON-RPC 2.0 server that diffr starts once per -//! session and calls over loopback HTTP. +//! session and calls over loopback HTTP. It is a fold mutation like the +//! built-in summarizer, and runs after it. //! //! The hook receives its port in `DIFFR_HOOK_PORT` and the diffed repository in -//! `DIFFR_WORKSPACE`. Each call carries one file and its large novel folds on -//! the after side. Workers block on their own call while the client multiplexes -//! every in-flight request on a small tokio runtime, so files still stream out -//! as each worker finishes. +//! `DIFFR_WORKSPACE`. Each call carries one file and its large new fold +//! regions on the after side. Workers block on their own call while the +//! client multiplexes every in-flight request on a small tokio runtime, so +//! files still stream out as each worker finishes. use crate::config::HookConfig; -use crate::parse::folds::Fold; -use crate::summary::{DiffResult, FileContent, FileFormat}; +use crate::hash::DftHashSet; +use crate::mutate::{ + collapse, ids, is_fold, line_count, summary_label, walk, walk_mut, Failure, FoldMutation, +}; +use crate::pairing::Pairing; +use crate::protocol::{FileChange, Region, Source, SourceRange}; +use anyhow::{anyhow, Context as _}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; use std::collections::BTreeMap; use std::net::{TcpListener, TcpStream}; use std::path::Path; @@ -21,15 +26,14 @@ use std::time::{Duration, Instant}; #[derive(Serialize)] pub(crate) struct RequestFold { - /// Index into the file's rhs folds. - id: usize, - range: Value, + /// The region id on the after side. + id: u32, + range: SourceRange, tags: Vec, + /// The label the region has before the hook answers, e.g. `Body`. placeholder: String, } -/// The one method every hook implements, `summarize`. Params are sent by -/// name; the result maps fold ids, as strings, to replacement text. #[derive(Serialize)] struct Params<'a> { path: &'a str, @@ -61,6 +65,7 @@ struct RpcError { pub(crate) struct Hook { config: HookConfig, + min_lines: usize, child: Mutex, runtime: tokio::runtime::Runtime, client: reqwest::Client, @@ -69,7 +74,11 @@ pub(crate) struct Hook { } impl Hook { - pub(crate) fn spawn(config: &HookConfig, workspace: &Path) -> crate::git::Result { + pub(crate) fn spawn( + config: &HookConfig, + default_min_lines: usize, + workspace: &Path, + ) -> anyhow::Result { let port = free_port()?; let mut child = Command::new(&config.command[0]) .args(&config.command[1..]) @@ -81,11 +90,11 @@ impl Hook { .stdout(Stdio::null()) .stderr(Stdio::inherit()) .spawn() - .map_err(|error| format!("could not start fold hook {:?}: {error}", config.command))?; + .map_err(|error| anyhow!("could not start fold hook {:?}: {error}", config.command))?; if let Err(error) = await_listening(&mut child, port, config.startup_timeout_ms) { let _ = child.kill(); let _ = child.wait(); - return Err(error.into()); + return Err(error); } let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) @@ -97,6 +106,7 @@ impl Hook { .build()?; Ok(Self { config: config.clone(), + min_lines: config.min_lines.unwrap_or(default_min_lines), child: Mutex::new(child), runtime, client, @@ -105,8 +115,20 @@ impl Hook { }) } - /// One JSON-RPC 2.0 call over loopback HTTP, blocking the worker on its reply. - fn call(&self, params: Params<'_>) -> Result, String> { + fn qualifies(&self, region: &Region, lhs_ids: &DftHashSet) -> bool { + if !is_fold(region) || lhs_ids.contains(®ion.alignment_id) { + return false; + } + if line_count(region) < self.min_lines { + return false; + } + match &self.config.tags { + Some(tags) => region.tags.iter().any(|tag| tags.contains(tag)), + None => !region.tags.is_empty(), + } + } + + fn call(&self, params: Params<'_>) -> anyhow::Result> { let request = Request { jsonrpc: "2.0", id: self.next_id.fetch_add(1, Ordering::Relaxed), @@ -122,94 +144,77 @@ impl Hook { .await .map_err(|error| { if error.is_timeout() { - format!("fold hook timed out after {}ms", self.config.timeout_ms) + anyhow!("timed out after {}ms", self.config.timeout_ms) } else { - format!("fold hook: {error}") + anyhow!(error) } })?; let status = response.status(); - let body = response - .text() - .await - .map_err(|error| format!("fold hook: {error}"))?; + let body = response.text().await?; let response: Response = serde_json::from_str(&body) - .map_err(|error| format!("fold hook: invalid response ({status}): {error}"))?; + .with_context(|| format!("invalid response ({status})"))?; match (response.result, response.error) { - (_, Some(error)) => Err(format!("fold hook reported: {}", error.message)), + (_, Some(error)) => Err(anyhow!("the hook reported: {}", error.message)), (Some(result), None) => Ok(result), - (None, None) => Err("fold hook: response has neither result nor error".to_owned()), + (None, None) => Err(anyhow!("response has neither result nor error")), } }) } +} - /// Fill in summaries for this file's qualifying folds, blocking on the hook. +impl FoldMutation for Hook { /// Files without qualifying folds never reach the hook. - pub(crate) fn summarize(&self, diff: &mut DiffResult) -> Result<(), String> { - let selected: Vec = diff - .rhs_folds - .iter() - .enumerate() - .filter(|(_, fold)| self.qualifies(fold, &diff.lhs_folds)) - .map(|(index, _)| index) - .collect(); - if selected.is_empty() { - return Ok(()); - } - let FileContent::Text(src) = &diff.rhs_src else { - return Ok(()); + fn apply(&self, file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()> { + let (lhs_ids, rhs) = match sides { + Pairing::Both { lhs, rhs } => (ids(&lhs.regions), rhs), + Pairing::RightOnly { rhs } => (DftHashSet::default(), rhs), + Pairing::LeftOnly { .. } => return Ok(()), }; - let language = match &diff.file_format { - FileFormat::SupportedLanguage(language) => { - Some(crate::parse::guess_language::language_name(*language).to_owned()) + let mut folds = Vec::new(); + walk(&rhs.regions, &mut |region| { + if self.qualifies(region, &lhs_ids) { + folds.push(RequestFold { + id: region.alignment_id, + range: region.range, + tags: region.tags.clone(), + placeholder: region.visibility.label.clone(), + }); } - _ => None, + }); + if folds.is_empty() { + return Ok(()); + } + let selected: Vec = folds.iter().map(|fold| fold.id).collect(); + let (Pairing::Both { rhs: after, .. } | Pairing::RightOnly { rhs: after }) = &file.file + else { + unreachable!("a file with after-side regions has an after path"); }; - let folds = selected - .iter() - .map(|&index| { - let fold = &diff.rhs_folds[index]; - RequestFold { - id: index, - range: json!({ - "start": {"line": fold.range.start.line.0, "byte_column": fold.range.start.byte_column}, - "end": {"line": fold.range.end.line.0, "byte_column": fold.range.end.byte_column}, - }), - tags: fold.tags.clone(), - placeholder: fold.placeholder.clone(), - } + let language = file.language.as_deref(); + let texts = self + .call(Params { + path: &after.path, + language, + src: &rhs.text, + folds, }) - .collect(); - let texts = self.call(Params { - path: &diff.display_path, - language: language.as_deref(), - src, - folds, - })?; + .context(Failure::Hook)?; + let mut by_id = BTreeMap::new(); for (key, text) in texts { - let index: usize = key + let id: u32 = key .parse() .ok() - .filter(|index| selected.contains(index)) - .ok_or_else(|| format!("fold hook answered for unknown fold {key:?}"))?; - diff.rhs_folds[index].summary = Some(text); + .filter(|id| selected.contains(id)) + .ok_or_else(|| anyhow!("answered for unknown fold {key:?}")) + .context(Failure::Hook)?; + by_id.insert(id, text); } + walk_mut(&mut rhs.regions, &mut |region| { + if let Some(text) = by_id.get(®ion.alignment_id) { + collapse(region, summary_label(language, text)); + } + }); Ok(()) } - - /// Only folds new on the rhs qualify: those without a lhs counterpart. - fn qualifies(&self, fold: &Fold, lhs_folds: &[Fold]) -> bool { - if fold.counterpart(lhs_folds).is_some() { - return false; - } - let lines = (fold.range.end.line.0 - fold.range.start.line.0 + 1) as usize; - if lines < self.config.min_lines { - return false; - } - match &self.config.tags { - Some(tags) => fold.tags.iter().any(|tag| tags.contains(tag)), - None => true, - } - } } /// Reserve a loopback port for the hook. The listener is released before the @@ -219,18 +224,18 @@ fn free_port() -> std::io::Result { } /// Poll until the hook accepts connections, or fail early if it exits. -fn await_listening(child: &mut Child, port: u16, startup_timeout_ms: u64) -> Result<(), String> { +fn await_listening(child: &mut Child, port: u16, startup_timeout_ms: u64) -> anyhow::Result<()> { let deadline = Instant::now() + Duration::from_millis(startup_timeout_ms); let address = std::net::SocketAddr::from(([127, 0, 0, 1], port)); loop { if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_ok() { return Ok(()); } - if let Some(status) = child.try_wait().map_err(|error| error.to_string())? { - return Err(format!("fold hook exited during startup with {status}")); + if let Some(status) = child.try_wait()? { + return Err(anyhow!("fold hook exited during startup with {status}")); } if Instant::now() >= deadline { - return Err(format!( + return Err(anyhow!( "fold hook did not listen on port {port} within {startup_timeout_ms}ms" )); } @@ -249,10 +254,10 @@ impl Drop for Hook { #[cfg(test)] mod tests { use super::*; - use crate::config::Config; + use crate::mutate::summarize::tests::project; use std::sync::Arc; - fn hook(mode: &str, timeout_ms: u64) -> crate::git::Result { + fn hook(mode: &str, timeout_ms: u64) -> anyhow::Result { let config = HookConfig { dir: env!("CARGO_MANIFEST_DIR").into(), command: vec![ @@ -261,36 +266,44 @@ mod tests { mode.into(), ], tags: Some(vec!["body".into()]), - min_lines: 2, + min_lines: Some(2), timeout_ms, startup_timeout_ms: 10_000, }; - Hook::spawn(&config, Path::new(".")) - } - - fn diff(rhs: &str) -> DiffResult { - let params = Config::from_toml("").unwrap().compile().unwrap(); - DiffResult::from_sources_with_options( - "file.py", - "", - rhs, - ¶ms, - &crate::options::DisplayOptions::default(), - &crate::options::DiffOptions::default(), - ) + Hook::spawn(&config, 12, Path::new(".")) } const LARGE: &str = "def f():\n a()\n b()\n c()\n\ndef g():\n d()\n"; + fn fold_labels(sides: &Pairing) -> Vec<(bool, String)> { + let mut labels = Vec::new(); + let (Pairing::Both { rhs: rhs_side, .. } | Pairing::RightOnly { rhs: rhs_side }) = &sides + else { + panic!("an after side"); + }; + walk(&rhs_side.regions, &mut |region| { + if is_fold(region) { + labels.push((region.visibility.collapsed, region.visibility.label.clone())); + } + }); + labels + } + #[test] fn summaries_land_on_selected_folds_only() { - let hook = hook("first", 5000).unwrap(); - let mut result = diff(LARGE); - assert_eq!(result.rhs_folds.len(), 2); - hook.summarize(&mut result).unwrap(); - assert_eq!(result.rhs_folds[0].summary.as_deref(), Some("summary of f")); - assert_eq!(result.rhs_folds[1].summary, None); - assert!(result.lhs_folds.iter().all(|fold| fold.summary.is_none())); + let hook = hook("echo", 5000).unwrap(); + let (file, mut sides) = project("file.py", "", LARGE); + hook.apply(&file, &mut sides).unwrap(); + // `g` has a one-line body, which is not a region. + assert_eq!( + fold_labels(&sides), + vec![( + true, + "# pseudocode +pseudo Body" + .to_owned() + )] + ); } #[test] @@ -300,24 +313,26 @@ mod tests { .map(|_| { let hook = Arc::clone(&hook); std::thread::spawn(move || { - let mut result = diff(LARGE); - hook.summarize(&mut result).unwrap(); - result.rhs_folds[0].summary.clone() + let (file, mut sides) = project("file.py", "", LARGE); + hook.apply(&file, &mut sides).unwrap(); + fold_labels(&sides)[0].1.clone() }) }) .collect(); for worker in workers { - assert_eq!(worker.join().unwrap().as_deref(), Some("pseudo Body")); + assert_eq!(worker.join().unwrap(), "# pseudocode\npseudo Body"); } } #[test] - fn small_or_untagged_folds_never_reach_the_hook() { + fn small_paired_or_untagged_folds_never_reach_the_hook() { let hook = hook("error", 5000).unwrap(); - let mut result = diff("import os\nimport sys\n"); - hook.summarize(&mut result).unwrap(); - let mut result = diff("def f():\n a()\n"); - hook.summarize(&mut result).unwrap(); + let (file, mut sides) = project("file.py", "", "import os\nimport sys\n"); + hook.apply(&file, &mut sides).unwrap(); + let (file, mut sides) = project("file.py", "", "def f():\n a()\n"); + hook.apply(&file, &mut sides).unwrap(); + let (file, mut sides) = project("file.py", LARGE, LARGE); + hook.apply(&file, &mut sides).unwrap(); } #[test] @@ -330,23 +345,27 @@ mod tests { } #[test] - fn call_failures_are_reported_without_losing_the_diff() { - let mut result = diff(LARGE); + fn call_failures_are_reported_without_changing_the_regions() { + let (file, mut sides) = project("file.py", "", LARGE); let error = hook("slow", 200) .unwrap() - .summarize(&mut result) + .apply(&file, &mut sides) .unwrap_err(); - assert!(error.contains("timed out"), "{error}"); + assert!(format!("{error:#}").contains("timed out"), "{error:#}"); let error = hook("error", 5000) .unwrap() - .summarize(&mut result) + .apply(&file, &mut sides) .unwrap_err(); - assert!(error.contains("declined"), "{error}"); + assert!(format!("{error:#}").contains("declined"), "{error:#}"); let error = hook("bad", 5000) .unwrap() - .summarize(&mut result) + .apply(&file, &mut sides) .unwrap_err(); - assert!(error.starts_with("fold hook:"), "{error}"); - assert!(result.rhs_folds.iter().all(|fold| fold.summary.is_none())); + assert!( + format!("{error:#}").contains("invalid response"), + "{error:#}" + ); + assert_eq!(error.downcast_ref::(), Some(&Failure::Hook)); + assert!(fold_labels(&sides).iter().all(|(collapsed, _)| !collapsed)); } } diff --git a/src/main.rs b/src/main.rs index cec0f186a..9ee453bb4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ #![warn(clippy::todo)] #![warn(clippy::dbg_macro)] +mod category; mod cli; mod config; mod conflicts; @@ -54,12 +55,11 @@ mod files; mod git; mod gitattributes; mod hash; -// The fold hook has no caller until it returns as a fold mutation. -#[allow(dead_code)] mod hook; mod line_folds; mod line_parser; mod lines; +mod mutate; mod options; mod pairing; mod parse; diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs new file mode 100644 index 000000000..3ddcac113 --- /dev/null +++ b/src/mutate/collapse.rs @@ -0,0 +1,698 @@ +//! Rules that start things collapsed: whole files by category, deleted +//! function bodies, test bodies, and the middle of large removed stretches. +use super::group::next_id; +use super::{collapse, ids, is_fold, line_count, one_sided, walk_mut, FileMutation, FoldMutation}; +use crate::category; +use crate::hash::DftHashSet; +use crate::pairing::Pairing; +use crate::protocol::{ + FileChange, FileStatus, Node, Region, Source, SourcePos, SourceRange, Visibility, +}; + +/// Deleted, generated and test files start hidden behind a placeholder. +pub(crate) struct HiddenCategories { + pub(crate) deleted: bool, + pub(crate) generated: bool, + pub(crate) tests: bool, +} + +impl FileMutation for HiddenCategories { + fn apply(&self, file: &mut FileChange) -> anyhow::Result<()> { + let label = match (file.status, file.category.as_deref()) { + (FileStatus::Deleted, _) if self.deleted => "Deleted file · hidden by default", + (_, Some(category::GENERATED)) if self.generated => { + "Generated file · hidden by default" + } + (_, Some(category::TEST)) if self.tests => "Test file · hidden by default", + _ => return Ok(()), + }; + file.visibility.collapsed = true; + file.visibility.label = label.to_owned(); + Ok(()) + } +} + +/// Deleted function bodies of at least `min_lines` start collapsed with a +/// line count. The header line stays visible by fold semantics. A body +/// counts as deleted only when nothing under it is paired: a function +/// whose header moved but whose lines still align is a rewrite and stays +/// open. +pub(crate) struct DeletedBodies { + pub(crate) min_lines: usize, +} + +impl FoldMutation for DeletedBodies { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()> { + let (lhs, rhs_ids) = match sides { + Pairing::Both { lhs, rhs } => (lhs, ids(&rhs.regions)), + Pairing::LeftOnly { lhs } => (lhs, DftHashSet::default()), + Pairing::RightOnly { .. } => return Ok(()), + }; + walk_mut(&mut lhs.regions, &mut |region| { + if is_fold(region) + && region.tags.iter().any(|tag| tag == "function") + && one_sided(region, &rhs_ids) + && line_count(region) >= self.min_lines + { + let count = line_count(region); + collapse(region, format!("{count} lines removed")); + } + }); + Ok(()) + } +} + +/// Test bodies start collapsed on both sides, paired or not, so a diff +/// reads as the code under test first. A whole test module, such as a +/// Rust `#[cfg(test)] mod tests`, collapses as one fold labelled +/// "test module". The header stays visible and the fold expands like any +/// other. +pub(crate) struct TestBodies; + +/// A test body shorter than this stays open: a one-line assertion is +/// cheaper to read than a fold row. +const MIN_TEST_BODY_LINES: usize = 3; + +impl FoldMutation for TestBodies { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()> { + let sources: Vec<&mut Source> = match sides { + Pairing::Both { lhs, rhs } => vec![lhs, rhs], + Pairing::LeftOnly { lhs } => vec![lhs], + Pairing::RightOnly { rhs } => vec![rhs], + }; + for source in sources { + walk_mut(&mut source.regions, &mut |region| { + if is_fold(region) + && region.tags.iter().any(|tag| tag == "test") + && line_count(region) >= MIN_TEST_BODY_LINES + { + let label = if region.tags.iter().any(|tag| tag == "module") { + "test module" + } else { + "test body" + }; + collapse(region, label.to_owned()); + } + }); + } + Ok(()) + } +} + +/// Removed stretches with no counterpart and at least `min_lines` lines are +/// split into three leaves: the first line open, the middle collapsed with +/// a line count, the last line open, so the reader still sees red at both +/// ends. Only stretches in unpaired code qualify: the nearest enclosing +/// `function` fold (or, outside any function, the nearest enclosing fold) +/// must itself be one-sided, so a rewritten function shows its red and +/// green lines in place. Stretches at the top level keep the plain rule. +/// Leaves under a fold that already starts collapsed are left alone. +pub(crate) struct RemovedRuns { + pub(crate) min_lines: usize, +} + +impl FoldMutation for RemovedRuns { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()> { + let mut next_id = next_id(sides); + let (lhs, rhs_ids) = match sides { + Pairing::Both { lhs, rhs } => (lhs, ids(&rhs.regions)), + Pairing::LeftOnly { lhs } => (lhs, DftHashSet::default()), + Pairing::RightOnly { .. } => return Ok(()), + }; + // A leaf needs a first, a middle, and a last line to split. + let threshold = self.min_lines.max(3); + split_removed_runs( + &mut lhs.regions, + &rhs_ids, + threshold, + false, + Gates::default(), + &mut next_id, + ); + Ok(()) + } +} + +/// Whether the enclosing folds are one-sided: the nearest `function` fold, +/// and the nearest fold of any tag. `None` when there is no such fold. +#[derive(Clone, Copy, Default)] +struct Gates { + function: Option, + any: Option, +} + +impl Gates { + fn enter(self, fold: &Region, rhs_ids: &DftHashSet) -> Self { + let unpaired = one_sided(fold, rhs_ids); + Self { + function: if fold.tags.iter().any(|tag| tag == "function") { + Some(unpaired) + } else { + self.function + }, + any: Some(unpaired), + } + } + + /// Inside a paired function nothing collapses; outside any function + /// the nearest fold decides; at the top level everything qualifies. + fn open(self) -> bool { + self.function.or(self.any).unwrap_or(true) + } +} + +fn split_removed_runs( + regions: &mut Vec, + rhs_ids: &DftHashSet, + threshold: usize, + under_collapsed: bool, + gates: Gates, + next_id: &mut u32, +) { + let mut out = Vec::with_capacity(regions.len()); + for mut region in regions.drain(..) { + let collapsed = under_collapsed || region.visibility.collapsed; + let splits = !under_collapsed + && gates.open() + && !rhs_ids.contains(®ion.alignment_id) + && line_count(®ion) >= threshold; + let inner = if is_fold(®ion) { + gates.enter(®ion, rhs_ids) + } else { + gates + }; + match &mut region.node { + Node::Fold { children } => { + split_removed_runs(children, rhs_ids, threshold, collapsed, inner, next_id); + out.push(region); + } + Node::Leaf { .. } if splits => out.extend(split_leaf(region, next_id)), + Node::Leaf { .. } => out.push(region), + } + } + *regions = out; +} + +/// First line, collapsed middle, last line. The first piece keeps the +/// leaf's id; the others take fresh ones. Every piece is one-sided, so +/// nothing needs pairing. +fn split_leaf(region: Region, next_id: &mut u32) -> Vec { + let Node::Leaf { changed } = region.node else { + unreachable!("only leaves are split"); + }; + let lines = region.range.lines(); + let (first, last) = (lines.start, lines.end - 1); + let at = |line: u32| SourcePos { line, column: 0 }; + let piece = |id: u32, range: SourceRange, tags: Vec, visibility: Visibility| { + let changed = changed + .iter() + .copied() + .filter(|span| range.lines().contains(&span.line)) + .collect(); + Region { + alignment_id: id, + fold_state_id: id, + range, + tags, + visibility, + node: Node::Leaf { changed }, + } + }; + let middle_id = *next_id; + let last_id = *next_id + 1; + *next_id += 2; + let hidden = last - first - 1; + vec![ + piece( + region.alignment_id, + SourceRange { + start: region.range.start, + end: at(first + 1), + }, + region.tags.clone(), + region.visibility.clone(), + ), + piece( + middle_id, + SourceRange { + start: at(first + 1), + end: at(last), + }, + vec!["removed".to_owned()], + Visibility { + collapsed: true, + label: format!("{hidden} lines removed"), + }, + ), + piece( + last_id, + SourceRange { + start: at(last), + end: region.range.end, + }, + region.tags, + Visibility::default(), + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bodies_collapse_on_both_sides_and_stay_expandable() { + let before = "#[test]\nfn t() {\n a();\n b();\n c();\n}\n\nfn f() {\n a();\n b();\n c();\n}\n"; + let after = "#[test]\nfn t() {\n a();\n b();\n changed();\n}\n\nfn f() {\n a();\n b();\n c();\n}\n"; + let (file, mut sides) = crate::mutate::summarize::tests::project("a.rs", before, after); + TestBodies.apply(&file, &mut sides).unwrap(); + let Pairing::Both { + lhs: lhs_side, + rhs: rhs_side, + } = &sides + else { + panic!("both sides"); + }; + for source in [lhs_side, rhs_side] { + let mut folds = Vec::new(); + walk(&source.regions, &mut |region| { + if is_fold(region) { + folds.push(( + region.tags.contains(&"test".to_owned()), + region.visibility.collapsed, + region.visibility.label.clone(), + )); + } + }); + assert_eq!( + folds, + vec![ + (true, true, "test body".to_owned()), + (false, false, "Body".to_owned()) + ] + ); + } + // A `#[cfg(test)]` module collapses as one labelled fold; the test + // bodies inside keep their own label. + let (file, mut sides) = crate::mutate::summarize::tests::project( + "a.rs", + "", + "#[cfg(test)]\nmod tests {\n #[test]\n fn t() {\n a();\n b();\n c();\n }\n}\n", + ); + TestBodies.apply(&file, &mut sides).unwrap(); + let mut labels = Vec::new(); + let (Pairing::Both { rhs: rhs_side, .. } | Pairing::RightOnly { rhs: rhs_side }) = &sides + else { + panic!("an after side"); + }; + walk(&rhs_side.regions, &mut |region| { + if is_fold(region) && region.visibility.collapsed { + labels.push(region.visibility.label.clone()); + } + }); + assert_eq!(labels, ["test module", "test body"]); + // A tiny test body stays open. + let (file, mut sides) = crate::mutate::summarize::tests::project( + "a.rs", + "", + "#[test]\nfn t() {\n a();\n}\n", + ); + TestBodies.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { rhs: rhs_side, .. } | Pairing::RightOnly { rhs: rhs_side }) = &sides + else { + panic!("an after side"); + }; + walk(&rhs_side.regions, &mut |region| { + assert!(!region.visibility.collapsed); + }); + } + use crate::mutate::walk; + use crate::protocol::{FileRef, FileStatus, Visibility}; + + fn manifest(category: Option<&str>) -> FileChange { + FileChange { + file: Pairing::RightOnly { + rhs: FileRef { + path: "x".to_owned(), + oid: String::new(), + mode: String::new(), + }, + }, + status: FileStatus::Added, + category: category.map(str::to_owned), + language: None, + visibility: Visibility::default(), + } + } + + #[test] + fn hidden_categories_follow_the_switches() { + let rule = HiddenCategories { + deleted: false, + generated: true, + tests: false, + }; + let mut file = manifest(Some("generated")); + rule.apply(&mut file).unwrap(); + assert!(file.visibility.collapsed); + assert_eq!(file.visibility.label, "Generated file · hidden by default"); + let mut file = manifest(Some("test")); + rule.apply(&mut file).unwrap(); + assert!(!file.visibility.collapsed); + let mut file = manifest(None); + rule.apply(&mut file).unwrap(); + assert!(!file.visibility.collapsed); + } + + #[test] + fn deleted_files_are_hidden_whatever_their_category() { + let rule = HiddenCategories { + deleted: true, + generated: true, + tests: true, + }; + let mut file = manifest(Some("test")); + file.status = FileStatus::Deleted; + rule.apply(&mut file).unwrap(); + assert!(file.visibility.collapsed); + assert_eq!(file.visibility.label, "Deleted file · hidden by default"); + + let off = HiddenCategories { + deleted: false, + generated: false, + tests: false, + }; + let mut file = manifest(None); + file.status = FileStatus::Deleted; + off.apply(&mut file).unwrap(); + assert!(!file.visibility.collapsed); + } + + use crate::protocol::Span; + + fn removed_leaf(id: u32, start: u32, end: u32, changed: &[u32]) -> Region { + Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: SourcePos { + line: start, + column: 0, + }, + end: SourcePos { + line: end, + column: 0, + }, + }, + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { + changed: changed + .iter() + .map(|&line| Span { + line, + start_column: 0, + end_column: 4, + }) + .collect(), + }, + } + } + + fn left_only(regions: Vec) -> Pairing { + Pairing::LeftOnly { + lhs: Source { + text: String::new(), + syntax: vec![], + regions, + }, + } + } + + fn shape(regions: &[Region]) -> Vec<(u32, u32, u32, bool, String, Vec)> { + regions + .iter() + .map(|region| { + let Node::Leaf { changed } = ®ion.node else { + panic!("leaf expected"); + }; + let lines = region.range.lines(); + ( + region.alignment_id, + lines.start, + lines.end, + region.visibility.collapsed, + region.visibility.label.clone(), + changed.iter().map(|span| span.line).collect(), + ) + }) + .collect() + } + + #[test] + fn removed_runs_keep_the_first_and_last_line_open() { + let mut sides = left_only(vec![removed_leaf(0, 10, 17, &[10, 11, 12, 13, 14, 15, 16])]); + RemovedRuns { min_lines: 5 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + assert_eq!( + shape(&lhs_side.regions), + vec![ + (0, 10, 11, false, String::new(), vec![10]), + ( + 1, + 11, + 16, + true, + "5 lines removed".to_owned(), + vec![11, 12, 13, 14, 15] + ), + (2, 16, 17, false, String::new(), vec![16]), + ] + ); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + let middle = &lhs_side.regions[1]; + assert_eq!(middle.tags, vec!["removed".to_owned()]); + } + + #[test] + fn removed_runs_respect_the_threshold_and_the_switch() { + let mut sides = left_only(vec![removed_leaf(0, 0, 4, &[0, 1, 2, 3])]); + RemovedRuns { min_lines: 5 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + assert_eq!(lhs_side.regions.len(), 1); + // The registry never builds the rule at 0; a tiny threshold still + // needs three lines to split. + let mut sides = left_only(vec![removed_leaf(0, 0, 2, &[0, 1])]); + RemovedRuns { min_lines: 1 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + assert_eq!(lhs_side.regions.len(), 1); + } + + #[test] + fn removed_runs_skip_paired_leaves_and_collapsed_ancestors() { + let paired = removed_leaf(7, 0, 8, &[]); + let mut sides = Pairing::Both { + lhs: Source { + text: String::new(), + syntax: vec![], + regions: vec![ + paired.clone(), + Region { + alignment_id: 1, + fold_state_id: 1, + range: SourceRange { + start: SourcePos { line: 8, column: 0 }, + end: SourcePos { + line: 20, + column: 0, + }, + }, + tags: vec!["body".to_owned()], + visibility: Visibility { + collapsed: true, + label: "12 lines removed".to_owned(), + }, + node: Node::Fold { + children: vec![removed_leaf(2, 8, 20, &[])], + }, + }, + removed_leaf(3, 20, 30, &[]), + ], + }, + rhs: Source { + text: String::new(), + syntax: vec![], + regions: vec![paired], + }, + }; + RemovedRuns { min_lines: 5 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + let lhs = &lhs_side.regions; + assert_eq!(lhs.len(), 5, "paired leaf, collapsed fold, three pieces"); + assert_eq!(lhs[0].alignment_id, 7); + let Node::Fold { children } = &lhs[1].node else { + panic!("fold expected"); + }; + assert_eq!( + children.len(), + 1, + "leaf under a collapsed fold is untouched" + ); + assert_eq!( + shape(&lhs[2..]), + vec![ + (3, 20, 21, false, String::new(), vec![]), + (8, 21, 29, true, "8 lines removed".to_owned(), vec![]), + (9, 29, 30, false, String::new(), vec![]), + ] + ); + } + + fn function_fold(id: u32, start: u32, end: u32, children: Vec) -> Region { + Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: SourcePos { + line: start, + column: 0, + }, + end: SourcePos { + line: end, + column: 0, + }, + }, + tags: vec!["body".to_owned(), "function".to_owned()], + visibility: Visibility::default(), + node: Node::Fold { children }, + } + } + + fn both(lhs: Vec, rhs: Vec) -> Pairing { + let source = |regions| Source { + text: String::new(), + syntax: vec![], + regions, + }; + Pairing::Both { + lhs: source(lhs), + rhs: source(rhs), + } + } + + #[test] + fn removed_runs_stay_open_under_a_paired_function() { + // The lhs function fold has no counterpart, but its second leaf + // does: a rewrite, so its removed stretch stays in place. + let rewritten = function_fold( + 1, + 0, + 20, + vec![ + removed_leaf(2, 0, 10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + removed_leaf(3, 10, 20, &[]), + ], + ); + // Wholly removed: nothing under it is on the rhs. + let removed = function_fold( + 4, + 20, + 40, + vec![removed_leaf(5, 20, 40, &[20, 21, 22, 23, 24, 25, 26, 27])], + ); + let mut sides = both(vec![rewritten, removed], vec![removed_leaf(3, 0, 10, &[])]); + RemovedRuns { min_lines: 5 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + let lhs = &lhs_side.regions; + let Node::Fold { children } = &lhs[0].node else { + panic!("fold expected"); + }; + assert_eq!(children.len(), 2, "nothing split under the paired function"); + assert!(children.iter().all(|child| !child.visibility.collapsed)); + let Node::Fold { children } = &lhs[1].node else { + panic!("fold expected"); + }; + assert_eq!( + shape(children), + vec![ + (5, 20, 21, false, String::new(), vec![20]), + ( + 6, + 21, + 39, + true, + "18 lines removed".to_owned(), + (21..28).collect() + ), + (7, 39, 40, false, String::new(), vec![]), + ] + ); + } + + #[test] + fn deleted_bodies_skip_folds_with_paired_content() { + // Header moved (fold id absent on the rhs) but the body lines align. + let rewritten = function_fold(1, 0, 20, vec![removed_leaf(2, 0, 20, &[])]); + let removed = function_fold(3, 20, 40, vec![removed_leaf(4, 20, 40, &[])]); + let mut sides = both(vec![rewritten, removed], vec![removed_leaf(2, 0, 20, &[])]); + DeletedBodies { min_lines: 3 } + .apply(&manifest(None), &mut sides) + .unwrap(); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + let lhs = &lhs_side.regions; + assert!(!lhs[0].visibility.collapsed, "a rewrite stays open"); + assert!(lhs[1].visibility.collapsed); + assert_eq!(lhs[1].visibility.label, "20 lines removed"); + } + + #[test] + fn deleted_bodies_collapse_when_large_and_one_sided() { + let before = "def gone():\n a()\n b()\n c()\n\ndef kept():\n a()\n b()\n c()\n\ndef tiny():\n a()\n"; + let after = "def kept():\n a()\n b()\n c()\n"; + let (file, mut sides) = crate::mutate::summarize::tests::project("m.py", before, after); + DeletedBodies { min_lines: 3 } + .apply(&file, &mut sides) + .unwrap(); + let (Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs }) = &sides else { + panic!("a before side"); + }; + let mut collapsed = Vec::new(); + walk(&lhs.regions, &mut |region| { + if is_fold(region) && region.visibility.collapsed { + collapsed.push((region.range.start.line, region.visibility.label.clone())); + } + }); + assert_eq!(collapsed, vec![(1, "3 lines removed".to_owned())]); + } +} diff --git a/src/mutate/docstrings.rs b/src/mutate/docstrings.rs new file mode 100644 index 000000000..c8acebd48 --- /dev/null +++ b/src/mutate/docstrings.rs @@ -0,0 +1,552 @@ +//! Bundle each function with its docstring, so they open and close together. +//! +//! A docstring is the run of comment lines just above a function, with only +//! blank lines and the function's own signature between them; in Python it +//! is instead a string that is the body's first statement. Its lines become +//! leaves tagged `docstring` whose `fold_state_id` is the function's. The +//! function's pairing across sides is untouched: a docstring leaf that is +//! paired keeps its bundle only when the other side bundles it with the +//! same function too. [`DocstringVisibility`] then collapses a docstring, +//! with an empty label, wherever its function starts collapsed. +use super::group::next_id; +use super::{collapse, comment_marker, ids, walk, walk_mut, FoldMutation}; +use crate::hash::{DftHashMap, DftHashSet}; +use crate::pairing::Pairing; +use crate::protocol::{FileChange, Node, Region, Source, SourcePos, SourceRange}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) const TAG: &str = "docstring"; + +/// How many signature lines may sit between a docstring and the line a +/// function's body opens on. +const MAX_SIGNATURE_LINES: u32 = 12; + +pub(crate) struct Docstrings; + +impl FoldMutation for Docstrings { + fn apply(&self, file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()> { + let language = file.language.as_deref(); + let mut next = next_id(sides); + let (mut lhs, mut rhs) = sides_mut(sides); + let lhs_docs = lhs + .as_deref() + .map(|source| usable(source, find(source, language))) + .unwrap_or_default(); + let rhs_docs = rhs + .as_deref() + .map(|source| usable(source, find(source, language))) + .unwrap_or_default(); + if lhs_docs.is_empty() && rhs_docs.is_empty() { + return Ok(()); + } + + // Split every leaf at docstring edges, mirroring splits across + // paired leaves so both sides keep equal-length pieces. + let mut offsets: BTreeMap> = BTreeMap::new(); + for (source, docs) in [(lhs.as_deref(), &lhs_docs), (rhs.as_deref(), &rhs_docs)] { + let Some(source) = source else { continue }; + let edges: BTreeSet = docs.iter().flat_map(|doc| [doc.start, doc.end]).collect(); + walk(&source.regions, &mut |region| { + if let Node::Leaf { .. } = region.node { + let lines = region.range.lines(); + for &edge in edges.range(lines.start + 1..lines.end) { + offsets + .entry(region.alignment_id) + .or_default() + .insert(edge - lines.start); + } + } + }); + } + let mut fresh: BTreeMap<(u32, usize), u32> = BTreeMap::new(); + if let Some(lhs) = lhs.as_deref_mut() { + split(&mut lhs.regions, &offsets, &mut fresh, &mut next); + } + if let Some(rhs) = rhs.as_deref_mut() { + split(&mut rhs.regions, &offsets, &mut fresh, &mut next); + } + + // Mark, then undo any paired docstring leaf whose partner disagrees. + let lhs_marked = lhs + .as_deref_mut() + .map(|source| mark(source, &lhs_docs)) + .unwrap_or_default(); + let rhs_marked = rhs + .as_deref_mut() + .map(|source| mark(source, &rhs_docs)) + .unwrap_or_default(); + let lhs_ids = lhs + .as_deref() + .map(|source| ids(&source.regions)) + .unwrap_or_default(); + let rhs_ids = rhs + .as_deref() + .map(|source| ids(&source.regions)) + .unwrap_or_default(); + let mut revert: DftHashSet = DftHashSet::default(); + for (marked, other_marked, other_ids) in [ + (&lhs_marked, &rhs_marked, &rhs_ids), + (&rhs_marked, &lhs_marked, &lhs_ids), + ] { + for (id, fold_state) in marked { + if other_ids.contains(id) && other_marked.get(id) != Some(fold_state) { + revert.insert(*id); + } + } + } + if !revert.is_empty() { + for source in [lhs, rhs].into_iter().flatten() { + unmark(&mut source.regions, &revert); + } + } + Ok(()) + } +} + +/// Collapse each docstring, with an empty label, whose function starts +/// collapsed. Runs after every mutation that collapses functions. +pub(crate) struct DocstringVisibility; + +impl FoldMutation for DocstringVisibility { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()> { + let (lhs, rhs) = sides_mut(sides); + for source in [lhs, rhs].into_iter().flatten() { + let mut collapsed: DftHashSet = DftHashSet::default(); + walk(&source.regions, &mut |region| { + if is_function(region) && region.visibility.collapsed { + collapsed.insert(region.fold_state_id); + } + }); + walk_mut(&mut source.regions, &mut |region| { + if is_docstring(region) && collapsed.contains(®ion.fold_state_id) { + collapse(region, String::new()); + } + }); + } + Ok(()) + } +} + +/// The docstring text for a function: its docstring lines on this side +/// with comment markers and quotes stripped, or `None` when there is none +/// or it says nothing. +pub(crate) fn text_for(source: &Source, fold_state_id: u32) -> Option { + let lines: Vec<&str> = source.text.split_terminator('\n').collect(); + let mut words = Vec::new(); + walk(&source.regions, &mut |region| { + if is_docstring(region) && region.fold_state_id == fold_state_id { + for line in region.range.lines() { + if let Some(text) = lines.get(line as usize) { + let stripped = strip_markers(text); + if !stripped.is_empty() { + words.push(stripped.to_owned()); + } + } + } + } + }); + let text = words.join(" "); + (!text.is_empty()).then_some(text) +} + +pub(crate) fn is_docstring(region: &Region) -> bool { + region.tags.iter().any(|tag| tag == TAG) +} + +fn is_function(region: &Region) -> bool { + matches!(region.node, Node::Fold { .. }) && region.tags.iter().any(|tag| tag == "function") +} + +/// A docstring's half-open line range and the function it belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Doc { + fold_state_id: u32, + start: u32, + end: u32, +} + +fn find(source: &Source, language: Option<&str>) -> Vec { + let lines: Vec<&str> = source.text.split_terminator('\n').collect(); + let mut docs = Vec::new(); + walk(&source.regions, &mut |region| { + if !is_function(region) { + return; + } + let opens = region.range.start.line; + let found = if language == Some("Python") { + python_docstring(&lines, opens) + } else { + comment_above(&lines, opens, comment_marker(language)) + }; + if let Some((start, end)) = found { + docs.push(Doc { + fold_state_id: region.fold_state_id, + start, + end, + }); + } + }); + docs +} + +/// Drop docstrings that touch a leaf which already starts collapsed: those +/// lines are hidden already, and splitting a gap would break it up. +fn usable(source: &Source, docs: Vec) -> Vec { + docs.into_iter() + .filter(|doc| { + let mut clear = true; + walk(&source.regions, &mut |region| { + if let Node::Leaf { .. } = region.node { + let lines = region.range.lines(); + if lines.start < doc.end && doc.start < lines.end && region.visibility.collapsed + { + clear = false; + } + } + }); + clear + }) + .collect() +} + +/// Comment lines above the line a function's body opens on, skipping its +/// signature and then blank lines. A line that ends a statement or a block +/// stops the search, so a comment separated from the function by code does +/// not count. +fn comment_above(lines: &[&str], opens: u32, marker: &str) -> Option<(u32, u32)> { + let is_comment = |line: &str| { + let line = line.trim_start(); + line.starts_with(marker) + || (marker == "//" + && (line.starts_with("/*") || line.starts_with('*') || line.starts_with("*/"))) + }; + let is_blank = |line: &str| line.trim().is_empty(); + let ends_code = |line: &str| { + let line = line.trim_end(); + line.ends_with(';') || line.ends_with('}') || line.ends_with('{') + }; + let mut at = opens; + // The signature: lines above the opening line that are not blank, not + // comments, and do not end a statement or a block. + let mut signature = 0; + while at > 0 { + let line = lines.get(at as usize - 1)?; + if is_blank(line) || is_comment(line) || ends_code(line) || signature >= MAX_SIGNATURE_LINES + { + break; + } + at -= 1; + signature += 1; + } + while at > 0 + && lines + .get(at as usize - 1) + .is_some_and(|line| is_blank(line)) + { + at -= 1; + } + let end = at; + while at > 0 + && lines + .get(at as usize - 1) + .is_some_and(|line| is_comment(line)) + { + at -= 1; + } + (at < end).then_some((at, end)) +} + +/// A string that is the first statement of a Python body. The body fold +/// opens on its first statement line. +fn python_docstring(lines: &[&str], opens: u32) -> Option<(u32, u32)> { + let first = lines.get(opens as usize)?.trim_start(); + let body = first.trim_start_matches(['r', 'R', 'u', 'U', 'b', 'B', 'f', 'F']); + let quote = ["\"\"\"", "'''", "\"", "'"] + .into_iter() + .find(|quote| body.starts_with(quote))?; + if quote.len() == 1 { + return Some((opens, opens + 1)); + } + if body[quote.len()..].contains(quote) { + return Some((opens, opens + 1)); + } + (opens as usize + 1..lines.len()) + .find(|&index| lines[index].contains(quote)) + .map(|index| (opens, index as u32 + 1)) +} + +fn strip_markers(line: &str) -> &str { + let mut text = line.trim(); + for prefix in [ + "///", "//!", "//", "/**", "/*", "*/", "*", "#", "--", ";;", ";", "%", + ] { + if let Some(rest) = text.strip_prefix(prefix) { + text = rest.trim(); + break; + } + } + for quote in ["\"\"\"", "'''"] { + text = text + .trim_start_matches(quote) + .trim_end_matches(quote) + .trim(); + } + text.trim_end_matches("*/").trim() +} + +fn sides_mut(sides: &mut Pairing) -> (Option<&mut Source>, Option<&mut Source>) { + match sides { + Pairing::Both { lhs, rhs } => (Some(lhs), Some(rhs)), + Pairing::LeftOnly { lhs } => (Some(lhs), None), + Pairing::RightOnly { rhs } => (None, Some(rhs)), + } +} + +/// Cut leaves at the given offsets. The first piece keeps the leaf's ids; +/// later pieces take fresh ids shared by the two sides of a paired leaf. +fn split( + regions: &mut Vec, + offsets: &BTreeMap>, + fresh: &mut BTreeMap<(u32, usize), u32>, + next: &mut u32, +) { + let mut out = Vec::with_capacity(regions.len()); + for mut region in regions.drain(..) { + match &mut region.node { + Node::Fold { children } => { + split(children, offsets, fresh, next); + out.push(region); + } + Node::Leaf { .. } => match offsets.get(®ion.alignment_id) { + Some(cuts) => out.extend(pieces(region, cuts, fresh, next)), + None => out.push(region), + }, + } + } + *regions = out; +} + +fn pieces( + region: Region, + cuts: &BTreeSet, + fresh: &mut BTreeMap<(u32, usize), u32>, + next: &mut u32, +) -> Vec { + let Node::Leaf { changed } = region.node else { + unreachable!("only leaves are split"); + }; + let lines = region.range.lines(); + let at = |line: u32| SourcePos { line, column: 0 }; + let mut bounds: Vec = vec![lines.start]; + bounds.extend(cuts.iter().map(|cut| lines.start + cut)); + bounds.push(lines.end); + bounds + .windows(2) + .enumerate() + .map(|(index, window)| { + let range = SourceRange { + start: if index == 0 { + region.range.start + } else { + at(window[0]) + }, + end: at(window[1]), + }; + let id = if index == 0 { + region.alignment_id + } else { + *fresh + .entry((region.alignment_id, index)) + .or_insert_with(|| { + let id = *next; + *next += 1; + id + }) + }; + let span_lines = range.lines(); + Region { + alignment_id: id, + fold_state_id: if index == 0 { region.fold_state_id } else { id }, + range, + tags: region.tags.clone(), + visibility: region.visibility.clone(), + node: Node::Leaf { + changed: changed + .iter() + .copied() + .filter(|span| span_lines.contains(&span.line)) + .collect(), + }, + } + }) + .collect() +} + +/// Tag the leaves inside each docstring and give them the function's fold +/// state. Returns alignment id to fold state id for every marked leaf. +fn mark(source: &mut Source, docs: &[Doc]) -> DftHashMap { + let mut marked = DftHashMap::default(); + walk_mut(&mut source.regions, &mut |region| { + if let Node::Leaf { .. } = region.node { + let lines = region.range.lines(); + if let Some(doc) = docs + .iter() + .find(|doc| doc.start <= lines.start && lines.end <= doc.end) + { + if !is_docstring(region) { + region.tags.push(TAG.to_owned()); + } + region.fold_state_id = doc.fold_state_id; + marked.insert(region.alignment_id, doc.fold_state_id); + } + } + }); + marked +} + +fn unmark(regions: &mut [Region], ids: &DftHashSet) { + walk_mut(regions, &mut |region| { + if ids.contains(®ion.alignment_id) && is_docstring(region) { + region.tags.retain(|tag| tag != TAG); + region.fold_state_id = region.alignment_id; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mutate::summarize::tests::project; + + fn docstrings(source: &Source) -> Vec<(u32, u32, u32)> { + let mut out = Vec::new(); + walk(&source.regions, &mut |region| { + if is_docstring(region) { + let lines = region.range.lines(); + out.push((lines.start, lines.end, region.fold_state_id)); + } + }); + out + } + + fn function_state(source: &Source, header: &str) -> u32 { + let lines: Vec<&str> = source.text.split_terminator('\n').collect(); + let mut found = None; + walk(&source.regions, &mut |region| { + if is_function(region) { + let start = region.range.start.line as usize; + if (start.saturating_sub(3)..=start).any(|line| lines[line].contains(header)) { + found.get_or_insert(region.fold_state_id); + } + } + }); + found.expect("function fold") + } + + #[test] + fn rust_doc_and_line_comments_above_a_function_are_its_docstring() { + let before = "fn keep() -> u32 {\n 1\n}\n"; + let after = "fn keep() -> u32 {\n 1\n}\n\n/// Adds one.\n/// Twice, really.\nfn add(\n a: u32,\n) -> u32 {\n a + 2\n}\n\n// Plain comment.\nfn sub(a: u32) -> u32 {\n a - 1\n}\n"; + let (file, mut sides) = project("a.rs", before, after); + Docstrings.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { + panic!("an after side"); + }; + let docs = docstrings(rhs); + let add = function_state(rhs, "fn add("); + let sub = function_state(rhs, "fn sub("); + assert!( + docs.iter().any(|&(s, e, f)| (s, e, f) == (4, 6, add)), + "{docs:?}" + ); + assert!( + docs.iter().any(|&(s, e, f)| (s, e, f) == (12, 13, sub)), + "{docs:?}" + ); + assert_eq!( + text_for(rhs, add).as_deref(), + Some("Adds one. Twice, really.") + ); + assert_eq!(crate::protocol::project::tree_violation(&rhs.regions), None); + } + + #[test] + fn a_comment_separated_from_the_function_by_code_does_not_count() { + let after = "// About the constant.\nconst X: u32 = 1;\nfn f() -> u32 {\n X\n}\n"; + let (file, mut sides) = project("a.rs", "", after); + Docstrings.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { rhs: rhs_side, .. } | Pairing::RightOnly { rhs: rhs_side }) = &sides + else { + panic!("an after side"); + }; + assert!(docstrings(rhs_side).is_empty()); + } + + #[test] + fn a_python_string_first_in_the_body_is_its_docstring() { + let after = + "def f(a):\n \"\"\"Double a.\n\n Returns an int.\n \"\"\"\n return a * 2\n"; + let (file, mut sides) = project("a.py", "", after); + Docstrings.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { + panic!("an after side"); + }; + let f = function_state(rhs, "def f("); + let docs = docstrings(rhs); + assert!(!docs.is_empty(), "{docs:?}"); + assert!( + docs.iter() + .all(|&(s, e, state)| s >= 1 && e <= 5 && state == f), + "{docs:?}" + ); + assert_eq!( + text_for(rhs, f).as_deref(), + Some("Double a. Returns an int.") + ); + } + + #[test] + fn a_docstring_collapses_with_its_function_and_shares_its_fold_state() { + let after = "/// Documented.\nfn gone() -> u32 {\n let a = 1;\n let b = 2;\n let c = 3;\n a + b + c\n}\n"; + let (file, mut sides) = project("a.rs", "", after); + Docstrings.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &mut sides else { + panic!("the after side exists"); + }; + walk_mut(&mut rhs.regions, &mut |region| { + if is_function(region) { + collapse(region, "summary".to_owned()); + } + }); + DocstringVisibility.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { + panic!("an after side"); + }; + let state = function_state(rhs, "fn gone("); + let mut seen = false; + walk(&rhs.regions, &mut |region| { + if is_docstring(region) { + seen = true; + assert_eq!(region.fold_state_id, state); + assert!(region.visibility.collapsed); + assert!(region.visibility.label.is_empty()); + } + }); + assert!(seen); + } + + #[test] + fn a_paired_docstring_over_a_paired_function_bundles_on_both_sides() { + let before = "/// Stays.\nfn f() -> u32 {\n 1\n}\n"; + let after = "/// Stays.\nfn f() -> u32 {\n 2\n}\n"; + let (file, mut sides) = project("a.rs", before, after); + Docstrings.apply(&file, &mut sides).unwrap(); + let Pairing::Both { lhs, rhs } = &sides else { + panic!("both sides"); + }; + let (lhs_docs, rhs_docs) = (docstrings(lhs), docstrings(rhs)); + assert_eq!(lhs_docs.len(), 1, "{lhs_docs:?}"); + assert_eq!(lhs_docs, rhs_docs); + assert_eq!(lhs_docs[0].2, function_state(lhs, "fn f(")); + } +} diff --git a/src/mutate/group.rs b/src/mutate/group.rs new file mode 100644 index 000000000..85b63ecb9 --- /dev/null +++ b/src/mutate/group.rs @@ -0,0 +1,613 @@ +//! The last fold mutation: tidy what the earlier ones collapsed. +//! +//! Adjacent context gaps become one gap, and a run of adjacent sibling +//! folds that all start collapsed is wrapped in one new `group` fold so the +//! reader sees one row instead of a stack of them. Expanding the group +//! reveals each child's own collapsed row. Both steps keep ids paired: a +//! merge or a group happens on one side only when the other side either +//! does the same to the same ids or holds none of them. +use super::docstrings::is_docstring; +use super::{ids, is_fold, one_sided, walk, FoldMutation}; +use crate::hash::{DftHashMap, DftHashSet}; +use crate::pairing::Pairing; +use crate::protocol::{FileChange, Node, Region, Source, SourceRange, Visibility}; + +pub(crate) struct GroupCollapsed; + +impl FoldMutation for GroupCollapsed { + fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()> { + let mut next_id = next_id(sides); + merge_gaps(sides); + group_folds(sides, &mut next_id); + Ok(()) + } +} + +/// An id above every region id in the file: the projection's dense ids +/// and any ids earlier mutations minted. +pub(super) fn next_id(sides: &Pairing) -> u32 { + let sources = match sides { + Pairing::Both { lhs, rhs } => vec![lhs, rhs], + Pairing::LeftOnly { lhs } => vec![lhs], + Pairing::RightOnly { rhs } => vec![rhs], + }; + let mut max = None; + for source in sources { + walk(&source.regions, &mut |region| { + max = max.max(Some(region.alignment_id)); + }); + } + max.map_or(0, |max| max + 1) +} + +fn is_gap(region: &Region) -> bool { + matches!(region.node, Node::Leaf { .. }) + && region.visibility.collapsed + && region.tags.iter().any(|tag| tag == "unchanged") +} + +// ── gaps ────────────────────────────────────────────────────────────────── + +/// Pairs of adjacent sibling gaps, keyed by the first one's id. +fn adjacent_gaps(regions: &[Region], out: &mut DftHashMap) { + for pair in regions.windows(2) { + if is_gap(&pair[0]) && is_gap(&pair[1]) && pair[0].range.end == pair[1].range.start { + out.insert(pair[0].alignment_id, pair[1].alignment_id); + } + } + for region in regions { + if let Node::Fold { children } = ®ion.node { + adjacent_gaps(children, out); + } + } +} + +fn merge_gaps(sides: &mut Pairing) { + let (lhs_adjacent, rhs_adjacent, lhs_ids, rhs_ids) = { + let mut lhs_adjacent = DftHashMap::default(); + let mut rhs_adjacent = DftHashMap::default(); + let (lhs_ids, rhs_ids) = match &*sides { + Pairing::Both { lhs, rhs } => { + adjacent_gaps(&lhs.regions, &mut lhs_adjacent); + adjacent_gaps(&rhs.regions, &mut rhs_adjacent); + (ids(&lhs.regions), ids(&rhs.regions)) + } + Pairing::LeftOnly { lhs } => { + adjacent_gaps(&lhs.regions, &mut lhs_adjacent); + (ids(&lhs.regions), DftHashSet::default()) + } + Pairing::RightOnly { rhs } => { + adjacent_gaps(&rhs.regions, &mut rhs_adjacent); + (DftHashSet::default(), ids(&rhs.regions)) + } + }; + (lhs_adjacent, rhs_adjacent, lhs_ids, rhs_ids) + }; + // A pair merges when the other side merges the same pair or has neither id. + let agrees = + |first: u32, second: u32, other: &DftHashMap, other_ids: &DftHashSet| { + other.get(&first) == Some(&second) + || (!other_ids.contains(&first) && !other_ids.contains(&second)) + }; + let lhs_merges: DftHashSet = lhs_adjacent + .iter() + .filter(|(&first, &second)| agrees(first, second, &rhs_adjacent, &rhs_ids)) + .map(|(&first, _)| first) + .collect(); + let rhs_merges: DftHashSet = rhs_adjacent + .iter() + .filter(|(&first, &second)| agrees(first, second, &lhs_adjacent, &lhs_ids)) + .map(|(&first, _)| first) + .collect(); + if let Some(lhs) = lhs_mut(sides) { + merge_gap_runs(&mut lhs.regions, &lhs_merges); + } + if let Some(rhs) = rhs_mut(sides) { + merge_gap_runs(&mut rhs.regions, &rhs_merges); + } +} + +/// Fold each run of mergeable gaps into its first member, which keeps its +/// id so the pairing with the other side's merged gap holds. +fn merge_gap_runs(regions: &mut Vec, merges: &DftHashSet) { + let mut merged: Vec = Vec::with_capacity(regions.len()); + for mut region in regions.drain(..) { + if let Node::Fold { children } = &mut region.node { + merge_gap_runs(children, merges); + } + match merged.last_mut() { + Some(last) if merges.contains(&last.alignment_id) && is_gap(®ion) => { + last.range.end = region.range.end; + let count = last.range.lines().len(); + last.visibility.label = format!("{count} unchanged lines"); + // The merged gap must itself continue a run only if it was + // marked to; the mark lives on the first id, which it keeps. + } + _ => merged.push(region), + } + } + *regions = merged; +} + +// ── groups ──────────────────────────────────────────────────────────────── + +/// A collapsed fold labelled as removed lines only counts when nothing +/// under it is paired; a rewrite must not be swept into a "functions +/// removed" group. +fn removed_label(region: &Region) -> bool { + region.visibility.label.ends_with("lines removed") +} + +/// A unit is a collapsed fold, with the single-line leaf just before it +/// when there is one: the `def` or `fn` line whose body the fold hides. +/// A docstring leaf that shares the fold's fold state belongs to the unit +/// too and starts it. Returns the index of the unit's fold. +fn unit_at(regions: &[Region], at: usize, other_ids: &DftHashSet) -> Option { + let collapsed_fold = |region: &Region| { + is_fold(region) + && region.visibility.collapsed + && (!removed_label(region) || one_sided(region, other_ids)) + }; + let mut index = at; + let docstring = regions + .get(index) + .filter(|region| !is_fold(region) && is_docstring(region)) + .map(|region| region.fold_state_id); + if docstring.is_some() { + index += 1; + } + let bundled = |region: &Region| docstring.is_none_or(|id| region.fold_state_id == id); + let region = regions.get(index)?; + if collapsed_fold(region) && bundled(region) { + return Some(index); + } + let header = !is_fold(region) && !is_docstring(region) && region.range.lines().len() == 1; + let fold = regions.get(index + 1)?; + (header && collapsed_fold(fold) && bundled(fold)).then_some(index + 1) +} + +/// Lines a separator between units may span: blank lines, at most. +const MAX_SEPARATOR_LINES: usize = 2; + +/// Maximal runs of two or more units among siblings, separated by at most +/// a couple of open leaf lines, as their child ids in document order. +fn collapsed_fold_runs(regions: &[Region], other_ids: &DftHashSet, out: &mut Vec>) { + for region in regions { + // Nothing under a collapsed fold is visible, so nothing there + // needs grouping. + if let (Node::Fold { children }, false) = (®ion.node, region.visibility.collapsed) { + collapsed_fold_runs(children, other_ids, out); + } + } + let mut at = 0; + while at < regions.len() { + let Some(mut last) = unit_at(regions, at, other_ids) else { + at += 1; + continue; + }; + let first = at; + let mut units = 1; + loop { + let mut next = last + 1; + let mut separator_lines = 0; + while next < regions.len() + && !is_fold(®ions[next]) + && !regions[next].visibility.collapsed + && unit_at(regions, next, other_ids).is_none() + { + separator_lines += regions[next].range.lines().len(); + next += 1; + } + if separator_lines > MAX_SEPARATOR_LINES { + break; + } + match unit_at(regions, next, other_ids) { + Some(end) => { + last = end; + units += 1; + } + None => break, + } + } + if units >= 2 { + out.push( + regions[first..=last] + .iter() + .map(|r| r.alignment_id) + .collect(), + ); + } + at = last + 1; + } +} + +fn group_folds(sides: &mut Pairing, next_id: &mut u32) { + let mut lhs_runs = Vec::new(); + let mut rhs_runs = Vec::new(); + match &*sides { + Pairing::Both { lhs, rhs } => { + collapsed_fold_runs(&lhs.regions, &ids(&rhs.regions), &mut lhs_runs); + collapsed_fold_runs(&rhs.regions, &ids(&lhs.regions), &mut rhs_runs); + } + Pairing::LeftOnly { lhs } => { + collapsed_fold_runs(&lhs.regions, &DftHashSet::default(), &mut lhs_runs); + } + Pairing::RightOnly { rhs } => { + collapsed_fold_runs(&rhs.regions, &DftHashSet::default(), &mut rhs_runs); + } + } + // A run whose ids form the same run on the other side shares the group id. + let mut group_ids: DftHashMap, u32> = DftHashMap::default(); + for run in lhs_runs.iter().chain(&rhs_runs) { + if !group_ids.contains_key(run) { + group_ids.insert(run.clone(), *next_id); + *next_id += 1; + } + } + let by_first = |runs: &[Vec]| -> DftHashMap { + runs.iter() + .map(|run| (run[0], (run.len(), group_ids[run]))) + .collect() + }; + let lhs_by_first = by_first(&lhs_runs); + let rhs_by_first = by_first(&rhs_runs); + if let Some(lhs) = lhs_mut(sides) { + wrap_runs(&mut lhs.regions, &lhs_by_first); + } + if let Some(rhs) = rhs_mut(sides) { + wrap_runs(&mut rhs.regions, &rhs_by_first); + } +} + +fn wrap_runs(regions: &mut Vec, by_first: &DftHashMap) { + for region in regions.iter_mut() { + if let Node::Fold { children } = &mut region.node { + wrap_runs(children, by_first); + } + } + let mut wrapped: Vec = Vec::with_capacity(regions.len()); + let mut pending = std::mem::take(regions).into_iter(); + while let Some(region) = pending.next() { + let Some(&(len, id)) = by_first.get(®ion.alignment_id) else { + wrapped.push(region); + continue; + }; + let mut children = vec![region]; + while children.len() < len { + children.push(pending.next().expect("a run is contiguous")); + } + wrapped.push(group(id, children)); + } + *regions = wrapped; +} + +fn group(id: u32, children: Vec) -> Region { + let folds: Vec<&Region> = children.iter().filter(|child| is_fold(child)).collect(); + let count = folds.len(); + let label = if folds + .iter() + .all(|fold| fold.visibility.label.ends_with("lines removed")) + { + format!("{count} functions removed") + } else if folds + .iter() + .all(|fold| fold.visibility.label == "test body") + { + format!("{count} test bodies") + } else if folds.iter().all(|fold| { + fold.visibility + .label + .lines() + .next() + .is_some_and(|first| first.ends_with(" pseudocode")) + }) { + format!("{count} functions summarized") + } else { + format!("{count} folded regions") + }; + Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: children[0].range.start, + end: children[children.len() - 1].range.end, + }, + tags: vec!["group".to_owned()], + visibility: Visibility { + collapsed: true, + label, + }, + node: Node::Fold { children }, + } +} + +fn lhs_mut(sides: &mut Pairing) -> Option<&mut Source> { + match sides { + Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => Some(lhs), + Pairing::RightOnly { .. } => None, + } +} + +fn rhs_mut(sides: &mut Pairing) -> Option<&mut Source> { + match sides { + Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs } => Some(rhs), + Pairing::LeftOnly { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mutate::collapse::{DeletedBodies, TestBodies}; + use crate::mutate::docstrings::{DocstringVisibility, Docstrings}; + use crate::mutate::summarize::tests::project; + use crate::protocol::SourcePos; + + #[test] + fn removed_groups_only_wrap_one_sided_folds() { + let leaf = |id: u32, start: u32, end: u32| Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: SourcePos { + line: start, + column: 0, + }, + end: SourcePos { + line: end, + column: 0, + }, + }, + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { changed: vec![] }, + }; + let removed_fold = |id: u32, start: u32, end: u32, child: Region| Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: SourcePos { + line: start, + column: 0, + }, + end: SourcePos { + line: end, + column: 0, + }, + }, + tags: vec!["body".to_owned(), "function".to_owned()], + visibility: Visibility { + collapsed: true, + label: format!("{} lines removed", end - start), + }, + node: Node::Fold { + children: vec![child], + }, + }; + // Two adjacent "removed" folds, but the first one's leaf is paired. + let lhs = vec![ + leaf(1, 0, 1), + removed_fold(2, 1, 6, leaf(3, 1, 6)), + leaf(4, 6, 7), + removed_fold(5, 7, 12, leaf(6, 7, 12)), + ]; + let rhs = vec![leaf(3, 0, 5)]; + let source = |regions| Source { + text: String::new(), + syntax: vec![], + regions, + }; + let mut sides = Pairing::Both { + lhs: source(lhs), + rhs: source(rhs), + }; + let file = crate::mutate::summarize::tests::project("m.py", "", "").0; + GroupCollapsed.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + let lhs = &lhs_side.regions; + assert!( + lhs.iter().all(|region| region.tags != ["group"]), + "a rewrite next to a removal is not a run of removals" + ); + } + + #[test] + fn adjacent_deleted_bodies_are_grouped_under_one_collapsed_fold() { + let before = "def a():\n x()\n y()\n z()\n\ndef b():\n x()\n y()\n z()\n\ndef c():\n x()\n y()\n z()\n\nkeep = 1\n"; + let after = "keep = 1\n"; + let (file, mut sides) = project("m.py", before, after); + DeletedBodies { min_lines: 3 } + .apply(&file, &mut sides) + .unwrap(); + GroupCollapsed.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs }) = &sides else { + panic!("a before side"); + }; + let group = lhs + .regions + .iter() + .find(|region| region.tags == ["group"]) + .expect("a group fold"); + assert_eq!(group.visibility.label, "3 functions removed"); + assert!(group.visibility.collapsed); + let Node::Fold { children } = &group.node else { + panic!("a group is a fold"); + }; + for child in children { + eprintln!( + "child {} {:?} {:?} fold={}", + child.alignment_id, + child.range.lines(), + child.visibility.label, + is_fold(child) + ); + } + let folds: Vec<_> = children.iter().filter(|child| is_fold(child)).collect(); + assert_eq!(folds.len(), 3); + assert!(folds.iter().all(|child| child.visibility.collapsed)); + // The group starts on the first `def` line and ends with the last body. + assert_eq!(group.range.start.line, 0); + assert_eq!(group.range.end, folds[2].range.end); + let (Pairing::Both { rhs: rhs_side, .. } | Pairing::RightOnly { rhs: rhs_side }) = &sides + else { + panic!("an after side"); + }; + let rhs_ids = super::super::ids(&rhs_side.regions); + assert!( + !rhs_ids.contains(&group.alignment_id), + "a one-sided group has a fresh id" + ); + let mut seen = DftHashSet::default(); + walk(&lhs.regions, &mut |region| { + assert!( + seen.insert(region.alignment_id), + "duplicate id {}", + region.alignment_id + ); + }); + } + + #[test] + fn documented_test_bodies_group_with_their_docstrings() { + let before = "fn keep() {}\n"; + let after = "fn keep() {}\n\n/// First.\n#[test]\nfn a() {\n x();\n y();\n z();\n}\n\n/// Second.\n#[test]\nfn b() {\n x();\n y();\n z();\n}\n\n/// Third.\n#[test]\nfn c() {\n x();\n y();\n z();\n}\n"; + let (file, mut sides) = project("m.rs", before, after); + TestBodies.apply(&file, &mut sides).unwrap(); + Docstrings.apply(&file, &mut sides).unwrap(); + DocstringVisibility.apply(&file, &mut sides).unwrap(); + GroupCollapsed.apply(&file, &mut sides).unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { + panic!("an after side"); + }; + let mut groups = Vec::new(); + walk(&rhs.regions, &mut |region| { + if region.tags == ["group"] { + groups.push(region.clone()); + } + }); + assert_eq!(groups.len(), 1, "one group for the three documented tests"); + let group = &groups[0]; + assert_eq!(group.visibility.label, "3 test bodies"); + let Node::Fold { children } = &group.node else { + panic!("a group is a fold"); + }; + let docstrings = children.iter().filter(|child| is_docstring(child)).count(); + assert_eq!(docstrings, 3, "every docstring is inside the group"); + let first = children.first().unwrap(); + assert!( + is_docstring(first), + "the group starts at the first docstring" + ); + assert_eq!(group.range.start, first.range.start); + } + + #[test] + fn a_single_collapsed_fold_is_left_alone() { + let before = "def a():\n x()\n y()\n z()\n\nkeep = 1\n"; + let after = "keep = 1\n"; + let (file, mut sides) = project("m.py", before, after); + DeletedBodies { min_lines: 3 } + .apply(&file, &mut sides) + .unwrap(); + GroupCollapsed.apply(&file, &mut sides).unwrap(); + let mut groups = 0; + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + walk(&lhs_side.regions, &mut |region| { + groups += usize::from(region.tags == ["group"]); + }); + assert_eq!(groups, 0); + } + + #[test] + fn adjacent_gaps_merge_on_both_sides_and_keep_their_pairing() { + let mut sides = Pairing::Both { + lhs: source(vec![gap(0, 0, 3), gap(1, 3, 7), leaf(2, 7, 8)]), + rhs: source(vec![gap(0, 0, 3), gap(1, 3, 7), leaf(2, 7, 8)]), + }; + merge_gaps(&mut sides); + let Pairing::Both { + lhs: lhs_side, + rhs: rhs_side, + } = &sides + else { + panic!("both sides"); + }; + for source in [lhs_side, rhs_side] { + assert_eq!(source.regions.len(), 2); + assert_eq!(source.regions[0].alignment_id, 0); + assert_eq!(source.regions[0].range.lines(), 0..7); + assert_eq!(source.regions[0].visibility.label, "7 unchanged lines"); + } + } + + #[test] + fn gaps_do_not_merge_when_the_other_side_separates_them() { + let mut sides = Pairing::Both { + lhs: source(vec![gap(0, 0, 3), gap(1, 3, 7)]), + rhs: source(vec![gap(0, 0, 3), leaf(5, 3, 4), gap(1, 4, 8)]), + }; + merge_gaps(&mut sides); + let (Pairing::Both { lhs: lhs_side, .. } | Pairing::LeftOnly { lhs: lhs_side }) = &sides + else { + panic!("a before side"); + }; + assert_eq!(lhs_side.regions.len(), 2); + let (Pairing::Both { rhs: rhs_side, .. } | Pairing::RightOnly { rhs: rhs_side }) = &sides + else { + panic!("an after side"); + }; + assert_eq!(rhs_side.regions.len(), 3); + } + + fn source(regions: Vec) -> Source { + Source { + text: String::new(), + syntax: Vec::new(), + regions, + } + } + + fn range(start: u32, end: u32) -> SourceRange { + SourceRange { + start: crate::protocol::SourcePos { + line: start, + column: 0, + }, + end: crate::protocol::SourcePos { + line: end, + column: 0, + }, + } + } + + fn gap(id: u32, start: u32, end: u32) -> Region { + Region { + alignment_id: id, + fold_state_id: id, + range: range(start, end), + tags: vec!["unchanged".to_owned()], + visibility: Visibility { + collapsed: true, + label: format!("{} unchanged lines", end - start), + }, + node: Node::Leaf { changed: vec![] }, + } + } + + fn leaf(id: u32, start: u32, end: u32) -> Region { + Region { + alignment_id: id, + fold_state_id: id, + range: range(start, end), + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { changed: vec![] }, + } + } +} diff --git a/src/mutate/mod.rs b/src/mutate/mod.rs new file mode 100644 index 000000000..ed40edb60 --- /dev/null +++ b/src/mutate/mod.rs @@ -0,0 +1,234 @@ +//! Mutations run on the wire types after projection and before a record is +//! written: file mutations on the manifest, fold mutations on each file's +//! region trees. diffr's diff internals never see them. +//! +//! Order: file mutations (hidden categories), then fold mutations in this +//! sequence: deleted function bodies, test bodies, removed runs, docstring +//! bundling, the built-in summarizer, the JSON-RPC hook, docstring +//! visibility, and last the grouping pass that merges adjacent gaps and +//! wraps runs of collapsed folds. A mutation +//! failure aborts the run; retries belong inside a mutation. +pub(crate) mod collapse; +pub(crate) mod docstrings; +pub(crate) mod group; +pub(crate) mod summarize; +use crate::config::Params; +use crate::hash::DftHashSet; +use crate::hook::Hook; +use crate::pairing::Pairing; +use crate::protocol::{FileChange, Node, Region, Source, Visibility}; +use std::fmt; +use std::path::Path; + +pub(crate) trait FileMutation: Send + Sync { + fn apply(&self, file: &mut FileChange) -> anyhow::Result<()>; +} + +pub(crate) trait FoldMutation: Send + Sync { + fn apply(&self, file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()>; +} + +/// Which mutation stopped the run. The failing mutation attaches it to its +/// error, and the stream turns it into `complete.aborted.code`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Failure { + Summarizer, + Hook, +} + +impl Failure { + pub(crate) fn code(self) -> &'static str { + match self { + Self::Summarizer => "summarizer_failed", + Self::Hook => "hook_failed", + } + } +} + +impl fmt::Display for Failure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Summarizer => "summarizer", + Self::Hook => "fold hook", + }) + } +} + +impl std::error::Error for Failure {} + +/// The enabled mutations, in the order they run. +#[derive(Default)] +pub(crate) struct Mutations { + file: Vec>, + fold: Vec>, +} + +impl Mutations { + /// Build the registry from configuration. Starting the hook or the + /// summarizer client can fail, which is a setup error before `start`. + pub(crate) fn from_params(params: &Params, workspace: &Path) -> anyhow::Result { + let mut mutations = Self::default(); + let folds = ¶ms.folds; + if folds.collapse_deleted_files || folds.collapse_generated || folds.collapse_tests { + mutations.file.push(Box::new(collapse::HiddenCategories { + deleted: folds.collapse_deleted_files, + generated: folds.collapse_generated, + tests: folds.collapse_tests, + })); + } + if folds.collapse_deleted { + mutations.fold.push(Box::new(collapse::DeletedBodies { + min_lines: folds.min_lines, + })); + } + if folds.collapse_test_bodies { + mutations.fold.push(Box::new(collapse::TestBodies)); + } + if folds.collapse_removed_lines > 0 { + mutations.fold.push(Box::new(collapse::RemovedRuns { + min_lines: folds.collapse_removed_lines, + })); + } + if folds.bundle_docstrings { + mutations.fold.push(Box::new(docstrings::Docstrings)); + } + if params.summarize.enabled { + if let Some(summarizer) = summarize::Summarizer::new(¶ms.summarize)? { + mutations.fold.push(Box::new(summarizer)); + } + } + if let Some(hook) = ¶ms.hook { + mutations + .fold + .push(Box::new(Hook::spawn(hook, folds.min_lines, workspace)?)); + } + if folds.bundle_docstrings { + mutations + .fold + .push(Box::new(docstrings::DocstringVisibility)); + } + mutations.fold.push(Box::new(group::GroupCollapsed)); + Ok(mutations) + } + + pub(crate) fn apply_file(&self, file: &mut FileChange) -> anyhow::Result<()> { + for mutation in &self.file { + mutation.apply(file)?; + } + Ok(()) + } + + pub(crate) fn apply_fold( + &self, + file: &FileChange, + sides: &mut Pairing, + ) -> anyhow::Result<()> { + for mutation in &self.fold { + mutation.apply(file, sides)?; + } + Ok(()) + } +} + +// ── region helpers ──────────────────────────────────────────────────────── + +/// Visit every region, parents before children. +pub(crate) fn walk_mut(regions: &mut [Region], visit: &mut impl FnMut(&mut Region)) { + for region in regions { + visit(region); + if let Node::Fold { children } = &mut region.node { + walk_mut(children, visit); + } + } +} + +pub(crate) fn walk(regions: &[Region], visit: &mut impl FnMut(&Region)) { + for region in regions { + visit(region); + if let Node::Fold { children } = ®ion.node { + walk(children, visit); + } + } +} + +/// Every alignment id on a side, to tell one-sided regions from paired ones. +pub(crate) fn ids(regions: &[Region]) -> DftHashSet { + let mut ids = DftHashSet::default(); + walk(regions, &mut |region| { + ids.insert(region.alignment_id); + }); + ids +} + +/// True when nothing under `region`, itself included, has a counterpart on +/// the other side. A fold whose header line did not align can still hold +/// paired leaves, and such a fold is a rewrite, not a removal. +pub(crate) fn one_sided(region: &Region, other_ids: &DftHashSet) -> bool { + let mut paired = false; + walk(std::slice::from_ref(region), &mut |inner| { + paired |= other_ids.contains(&inner.alignment_id); + }); + !paired +} + +pub(crate) fn line_count(region: &Region) -> usize { + region.range.lines().len() +} + +pub(crate) fn is_fold(region: &Region) -> bool { + matches!(region.node, Node::Fold { .. }) +} + +/// The line-comment marker for a language's display name, `#` when unknown. +pub(crate) fn comment_marker(language: Option<&str>) -> &'static str { + match language.unwrap_or_default() { + "C" | "C++" | "C#" | "Objective-C" | "Rust" | "Go" | "Java" | "Kotlin" | "Swift" + | "Scala" | "Dart" | "JavaScript" | "JavaScript JSX" | "TypeScript" | "TypeScript TSX" + | "QML" | "PHP" | "Zig" | "Solidity" | "Proto" | "Verilog" | "Smali" | "Gleam" | "F#" + | "Apex" => "//", + "Lua" | "SQL" | "Haskell" | "Elm" | "Ada" | "VHDL" | "OCaml" | "OCaml Interface" => "--", + "Common Lisp" | "Emacs Lisp" | "Clojure" | "Scheme" | "Racket" | "Janet" | "Assembly" => { + ";" + } + "Erlang" | "LaTeX" => "%", + "Pascal" => "//", + _ => "#", + } +} + +/// The collapsed label for a summary: a comment line saying it is +/// pseudocode, then the text. +pub(crate) fn summary_label(language: Option<&str>, text: &str) -> String { + format!( + "{} pseudocode\n{}", + comment_marker(language), + text.trim_end() + ) +} + +pub(crate) fn collapse(region: &mut Region, label: String) { + region.visibility = Visibility { + collapsed: true, + label, + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comment_markers_follow_the_language() { + assert_eq!(comment_marker(Some("Python")), "#"); + assert_eq!(comment_marker(Some("Rust")), "//"); + assert_eq!(comment_marker(Some("TypeScript TSX")), "//"); + assert_eq!(comment_marker(Some("Lua")), "--"); + assert_eq!(comment_marker(Some("Clojure")), ";"); + assert_eq!(comment_marker(Some("Erlang")), "%"); + assert_eq!(comment_marker(None), "#"); + assert_eq!( + summary_label(Some("Go"), "x = 1\nreturn x\n"), + "// pseudocode\nx = 1\nreturn x" + ); + } +} diff --git a/src/mutate/summarize.rs b/src/mutate/summarize.rs new file mode 100644 index 000000000..3f58cf9ce --- /dev/null +++ b/src/mutate/summarize.rs @@ -0,0 +1,730 @@ +//! The built-in summarizer: large new function bodies become Python-style +//! pseudocode, shown in place of the collapsed body. +use super::docstrings; +#[cfg(test)] +use super::walk; +use super::{collapse, ids, is_fold, line_count, summary_label, walk_mut, Failure, FoldMutation}; +use crate::config::{Provider, SummarizeConfig}; +use crate::hash::DftHashSet; +use crate::pairing::Pairing; +use crate::protocol::{FileChange, Source}; +use crate::protocol::{Node, Region}; +use anyhow::{anyhow, Context as _}; +use serde::Deserialize; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::Duration; +use tokio::sync::Semaphore; + +const SYSTEM: &str = "For each listed fold, rewrite that function body as short python-flavored \ +pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as \ +possible: about one pseudocode line per five source lines, and never more than a third \ +of the body's lines. When a fold lists a doc, also set \"summary\" to one sentence copied \ +verbatim from that doc; otherwise leave it empty. Answer with a JSON array of \ +{\"id\", \"summary\", \"pseudocode\"} objects, one per fold."; + +const DEFAULT_ENDPOINT: &str = "https://generativelanguage.googleapis.com"; + +pub(crate) struct Summarizer { + config: SummarizeConfig, + min_lines: usize, + api_key: String, + endpoint: String, + runtime: tokio::runtime::Runtime, + client: reqwest::Client, + limit: Semaphore, +} + +/// One fold to summarize: its region id, 1-based inclusive line range, and +/// the text of its docstring when it has one. +struct Request { + id: u32, + first_line: u32, + last_line: u32, + doc: Option, +} + +#[derive(Deserialize)] +struct Answer { + id: u32, + #[serde(default)] + summary: String, + pseudocode: String, +} + +/// What the model returned for one fold: an optional sentence quoted from +/// its docstring, and the pseudocode. +struct Summary { + quote: Option, + pseudocode: String, +} + +impl Summarizer { + /// `None` when no API key resolves: the summarizer is then simply off. + pub(crate) fn new(config: &SummarizeConfig) -> anyhow::Result> { + let Some(api_key) = resolve_key(config) else { + return Ok(None); + }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("diffr-summarizer") + .enable_all() + .build()?; + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(config.timeout_ms)) + .build()?; + Ok(Some(Self { + config: config.clone(), + min_lines: config.min_lines, + api_key, + endpoint: config + .endpoint + .clone() + .unwrap_or_else(|| DEFAULT_ENDPOINT.to_owned()), + runtime, + client, + limit: Semaphore::new(config.max_concurrency), + })) + } + + fn url(&self) -> String { + match self.config.provider { + Provider::Gemini => format!( + "{}/v1beta/models/{}:generateContent", + self.endpoint.trim_end_matches('/'), + self.config.model + ), + } + } + + fn prompt(&self, path: &str, language: Option<&str>, src: &str, folds: &[Request]) -> String { + let numbered: Vec = src + .split_terminator('\n') + .enumerate() + .map(|(index, line)| format!("{:5} | {line}", index + 1)) + .collect(); + let ranges: Vec = folds + .iter() + .map(|fold| { + let doc = fold + .doc + .as_ref() + .map(|doc| format!("\n doc: {doc}")) + .unwrap_or_default(); + format!( + "- fold {}: lines {}-{}{doc}", + fold.id, fold.first_line, fold.last_line + ) + }) + .collect(); + format!( + "File {path} ({}):\n\n{}\n\nFolds:\n{}", + language.unwrap_or("unknown language"), + numbered.join("\n"), + ranges.join("\n") + ) + } + + /// One request per file, retried on transient failures. Any other + /// failure is a run-level failure. + fn complete( + &self, + path: &str, + language: Option<&str>, + src: &str, + folds: &[Request], + ) -> anyhow::Result> { + let body = json!({ + "systemInstruction": {"parts": [{"text": SYSTEM}]}, + "contents": [{"role": "user", "parts": [{"text": self.prompt(path, language, src, folds)}]}], + "generationConfig": { + "temperature": 0, + "maxOutputTokens": 600 * folds.len() + 200, + "thinkingConfig": {"thinkingBudget": 0}, + "responseMimeType": "application/json", + "responseSchema": { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "properties": { + "id": {"type": "INTEGER"}, + "summary": {"type": "STRING"}, + "pseudocode": {"type": "STRING"}, + }, + "required": ["id", "pseudocode"], + }, + }, + }, + }); + let url = self.url(); + let failed = |message: String| anyhow!("{}: {message}", self.config.model); + let text = self.runtime.block_on(async { + let _permit = self + .limit + .acquire() + .await + .expect("semaphore is never closed"); + let mut attempt = 0; + loop { + let response = self + .client + .post(&url) + .header("x-goog-api-key", &self.api_key) + .json(&body) + .send() + .await; + let retry = match response { + Ok(response) if response.status().is_success() => { + return response + .json::() + .await + .map_err(|error| failed(error.to_string())); + } + Ok(response) + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS + || response.status().is_server_error() => + { + format!("HTTP {}", response.status()) + } + Ok(response) => { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(failed(format!( + "HTTP {status} {}", + detail.chars().take(200).collect::() + ))); + } + Err(error) + if error.is_timeout() || error.is_connect() || error.is_request() => + { + error.to_string() + } + Err(error) => return Err(failed(error.to_string())), + }; + if attempt >= self.config.retries { + return Err(failed(format!("{retry} after {} attempts", attempt + 1))); + } + attempt += 1; + tokio::time::sleep(Duration::from_millis(250 * (1 << attempt.min(6)))).await; + } + })?; + let content = text["candidates"][0]["content"]["parts"] + .as_array() + .and_then(|parts| parts.last()) + .and_then(|part| part["text"].as_str()) + .ok_or_else(|| failed("no text in the response".to_owned()))?; + let answers: Vec = + serde_json::from_str(content).map_err(|error| failed(format!("{error}: {content}")))?; + let mut texts = BTreeMap::new(); + for answer in answers { + if !folds.iter().any(|fold| fold.id == answer.id) { + return Err(failed(format!("answered for unknown fold {}", answer.id))); + } + if !answer.pseudocode.trim().is_empty() { + let doc = folds + .iter() + .find(|fold| fold.id == answer.id) + .and_then(|fold| fold.doc.as_deref()); + texts.insert( + answer.id, + Summary { + quote: quoted(doc, &answer.summary), + pseudocode: answer.pseudocode.trim().to_owned(), + }, + ); + } + } + Ok(texts) + } +} + +/// New function bodies on the after side of at least `min_lines` lines: +/// folds tagged `function` with no counterpart. Only the outermost +/// qualifying body is taken, never one nested inside it; test bodies and +/// folds that already start collapsed are skipped. +pub(crate) fn select(sides: &Pairing, min_lines: usize) -> Vec<(u32, u32, u32)> { + let (lhs_ids, rhs) = match sides { + Pairing::Both { lhs, rhs } => (ids(&lhs.regions), rhs), + Pairing::RightOnly { rhs } => (DftHashSet::default(), rhs), + Pairing::LeftOnly { .. } => return Vec::new(), + }; + let mut selected = Vec::new(); + fn visit( + regions: &[Region], + lhs_ids: &DftHashSet, + min_lines: usize, + selected: &mut Vec<(u32, u32, u32)>, + ) { + for region in regions { + let tag = |name: &str| region.tags.iter().any(|tag| tag == name); + if is_fold(region) + && tag("function") + && !tag("test") + && !region.visibility.collapsed + && !lhs_ids.contains(®ion.alignment_id) + && line_count(region) >= min_lines + { + let lines = region.range.lines(); + selected.push((region.alignment_id, lines.start + 1, lines.end)); + continue; + } + if let Node::Fold { children } = ®ion.node { + visit(children, lhs_ids, min_lines, selected); + } + } + } + visit(&rhs.regions, &lhs_ids, min_lines, &mut selected); + selected +} + +/// The model's sentence, kept only when it really is a verbatim quote from +/// the docstring: compared with runs of whitespace collapsed. +fn quoted(doc: Option<&str>, summary: &str) -> Option { + let squash = |text: &str| text.split_whitespace().collect::>().join(" "); + let (doc, sentence) = (squash(doc?), squash(summary)); + (!sentence.is_empty() && doc.contains(&sentence)).then_some(sentence) +} + +/// Pseudocode earns its place only when it is clearly shorter than the +/// code: a summary with more than half the body's non-blank lines is +/// dropped and the body stays open. +fn compresses(summary: &str, body: &[&str]) -> bool { + let summary_lines = summary + .lines() + .filter(|line| !line.trim().is_empty()) + .count(); + let body_lines = body.iter().filter(|line| !line.trim().is_empty()).count(); + summary_lines * 2 <= body_lines +} + +/// The fold state of the region with this alignment id on one side. +fn fold_state_of(source: &Source, alignment_id: u32) -> u32 { + let mut state = alignment_id; + super::walk(&source.regions, &mut |region| { + if region.alignment_id == alignment_id { + state = region.fold_state_id; + } + }); + state +} + +fn resolve_key(config: &SummarizeConfig) -> Option { + config + .api_key + .clone() + .filter(|key| !key.is_empty()) + .or_else(|| { + std::env::var("GEMINI_API_KEY") + .ok() + .filter(|key| !key.is_empty()) + }) + .or_else(|| { + std::env::var("GOOGLE_API_KEY") + .ok() + .filter(|key| !key.is_empty()) + }) +} + +impl FoldMutation for Summarizer { + fn apply(&self, file: &FileChange, sides: &mut Pairing) -> anyhow::Result<()> { + let selected = select(sides, self.min_lines); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = sides else { + return Ok(()); + }; + let folds: Vec = selected + .into_iter() + .map(|(id, first_line, last_line)| Request { + id, + first_line, + last_line, + doc: docstrings::text_for(rhs, fold_state_of(rhs, id)), + }) + .collect(); + if folds.is_empty() { + return Ok(()); + } + let (Pairing::Both { rhs: after, .. } | Pairing::RightOnly { rhs: after }) = &file.file + else { + unreachable!("a file with after-side regions has an after path"); + }; + let language = file.language.as_deref(); + let lines: Vec<&str> = rhs.text.split_terminator('\n').collect(); + let mut texts = self + .complete(&after.path, language, &rhs.text, &folds) + .context(Failure::Summarizer)?; + texts.retain(|id, summary| { + let fold = folds + .iter() + .find(|fold| fold.id == *id) + .expect("answered fold"); + let body = &lines[fold.first_line as usize - 1..fold.last_line as usize]; + compresses(&summary.pseudocode, body) + }); + walk_mut(&mut rhs.regions, &mut |region| { + if let Some(summary) = texts.get(®ion.alignment_id) { + let text = match &summary.quote { + Some(quote) => format!("{quote}\n{}", summary.pseudocode), + None => summary.pseudocode.clone(), + }; + collapse(region, summary_label(language, &text)); + } + }); + Ok(()) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::config::Config; + use crate::protocol::{project, Diff, FileRef, FileStatus, Visibility}; + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpListener; + + /// Project a two-source comparison the way the stream does. + pub(crate) fn project(path: &str, before: &str, after: &str) -> (FileChange, Pairing) { + project_with(path, before, after, crate::options::DiffOptions::default()) + } + + pub(crate) fn project_with( + path: &str, + before: &str, + after: &str, + options: crate::options::DiffOptions, + ) -> (FileChange, Pairing) { + let params = Config::from_toml("").unwrap().compile().unwrap(); + let result = crate::summary::DiffResult::from_sources_with_options( + path, + before, + after, + ¶ms, + &crate::options::DisplayOptions::default(), + &options, + ); + let file_ref = FileRef { + path: path.to_owned(), + oid: String::new(), + mode: String::new(), + }; + let file = FileChange { + file: Pairing::Both { + lhs: file_ref.clone(), + rhs: file_ref, + }, + status: FileStatus::Modified, + category: None, + language: crate::parse::guess_language::guess(std::path::Path::new(path), "", &[]) + .map(|language| crate::parse::guess_language::language_name(language).to_owned()), + visibility: Visibility::default(), + }; + let diff = project::diff( + &result, + project::Inputs { + file: &file.file, + sizes: (before.len() as u64, after.len() as u64), + syntax: (Vec::new(), Vec::new()), + }, + ); + let Diff::Text { sides, .. } = diff else { + panic!("text diff expected"); + }; + (file, sides) + } + + const LARGE: &str = "def f():\n a()\n b()\n c()\n\ndef g():\n d()\n"; + + /// Answer each request with the next canned response. + fn serve(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let handle = std::thread::spawn(move || { + let mut bodies = Vec::new(); + for (status, body) in responses { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + } + let mut request = vec![0; length]; + reader.read_exact(&mut request).unwrap(); + bodies.push(String::from_utf8(request).unwrap()); + let reason = if status == 200 { "OK" } else { "Error" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + reader.get_mut().write_all(response.as_bytes()).unwrap(); + } + bodies + }); + (endpoint, handle) + } + + /// The label of the only fold region on the after side. + fn fold_label(sides: &Pairing) -> String { + let mut labels = Vec::new(); + let (Pairing::Both { rhs: rhs_side, .. } | Pairing::RightOnly { rhs: rhs_side }) = &sides + else { + panic!("an after side"); + }; + walk(&rhs_side.regions, &mut |region| { + if is_fold(region) { + labels.push(region.visibility.label.clone()); + } + }); + assert_eq!(labels.len(), 1, "{labels:?}"); + labels.remove(0) + } + + fn gemini_answer(items: &[(u32, &str)]) -> String { + let answers: Vec<_> = items + .iter() + .map(|(id, text)| json!({"id": id, "pseudocode": text})) + .collect(); + json!({"candidates": [{"content": {"parts": [{"text": serde_json::to_string(&answers).unwrap()}]}}]}) + .to_string() + } + + fn summarizer(endpoint: &str, retries: u32) -> Summarizer { + let config = SummarizeConfig { + api_key: Some("test-key".to_owned()), + endpoint: Some(endpoint.to_owned()), + retries, + timeout_ms: 5000, + min_lines: 3, + ..SummarizeConfig::default() + }; + Summarizer::new(&config).unwrap().unwrap() + } + + #[test] + fn selection_takes_new_bodies_of_at_least_min_lines() { + let (_, sides) = project("a.py", "", LARGE); + let selected = select(&sides, 3); + assert_eq!(selected.len(), 1); + assert_eq!((selected[0].1, selected[0].2), (2, 4)); + let (_, sides) = project("a.py", LARGE, LARGE); + assert!(select(&sides, 3).is_empty()); + } + + #[test] + fn selection_still_finds_new_bodies_when_the_match_fell_back() { + let before = "def keep():\n a = 1\n b = 2\n return a + b\n"; + let after = "def keep():\n a = 1\n b = 2\n return a + b\n\ndef fresh():\n x = 1\n y = 2\n return x + y\n"; + let (_, sides) = project_with( + "a.py", + before, + after, + crate::options::DiffOptions { + graph_limit: 1, + ..crate::options::DiffOptions::default() + }, + ); + let selected = select(&sides, 3); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert_eq!((selected[0].1, selected[0].2), (7, 9)); + } + + #[test] + fn selection_takes_outermost_function_bodies_only() { + // A method inside an impl: the impl's declaration_list is a body but + // not a function, so the method is the outermost selection. + let after = "impl A {\n fn m(&self) {\n a();\n b();\n c();\n let f = || {\n d();\n e();\n g();\n };\n f();\n }\n}\n"; + let (_, sides) = project("a.rs", "", after); + let selected = select(&sides, 3); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert_eq!((selected[0].1, selected[0].2), (2, 12)); + // Below the threshold, nothing. + assert!(select(&sides, 30).is_empty()); + } + + #[test] + fn selection_skips_test_bodies_and_collapsed_folds() { + let after = "#[test]\nfn t() {\n a();\n b();\n c();\n}\n\nfn f() {\n a();\n b();\n c();\n}\n"; + let (file, mut sides) = project("a.rs", "", after); + let selected = select(&sides, 3); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert_eq!((selected[0].1, selected[0].2), (8, 11)); + crate::mutate::collapse::TestBodies + .apply(&file, &mut sides) + .unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &mut sides else { + panic!("rhs expected"); + }; + walk_mut(&mut rhs.regions, &mut |region| { + if region.range.start.line == 7 { + region.visibility.collapsed = true; + } + }); + assert!(select(&sides, 3).is_empty()); + } + + #[test] + fn long_summaries_are_discarded_and_the_body_stays_open() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&sides, 3)[0].0; + let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "a()\nb()\nc()")]))]); + summarizer(&endpoint, 0).apply(&file, &mut sides).unwrap(); + server.join().unwrap(); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { + panic!("an after side"); + }; + let mut folds = Vec::new(); + walk(&rhs.regions, &mut |region| { + if is_fold(region) { + folds.push((region.visibility.collapsed, region.visibility.label.clone())); + } + }); + assert_eq!(folds, vec![(false, "Body".to_owned())]); + } + + #[test] + fn summaries_collapse_selected_folds_with_a_pseudocode_comment() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&sides, 3)[0].0; + let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "call a, b, c")]))]); + summarizer(&endpoint, 0).apply(&file, &mut sides).unwrap(); + let bodies = server.join().unwrap(); + assert!(bodies[0].contains("thinkingBudget")); + assert!(bodies[0].contains(&format!("fold {id}: lines 2-4"))); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { + panic!("an after side"); + }; + let mut labels = Vec::new(); + walk(&rhs.regions, &mut |region| { + if is_fold(region) { + labels.push((region.visibility.collapsed, region.visibility.label.clone())); + } + }); + // `g` has a one-line body, which is not a region. + assert_eq!( + labels, + vec![( + true, + "# pseudocode +call a, b, c" + .to_owned() + )] + ); + } + + #[test] + fn a_docstring_is_sent_and_only_a_verbatim_sentence_from_it_is_kept() { + let after = "/// Sums three numbers. Used by tests.\nfn total(a: u32, b: u32, c: u32) -> u32 {\n let x = a;\n let y = b;\n let z = c;\n x + y + z\n}\n"; + let (file, mut sides) = project("a.rs", "", after); + super::docstrings::Docstrings + .apply(&file, &mut sides) + .unwrap(); + let id = select(&sides, 3)[0].0; + let answer = |summary: &str| { + let answers = + vec![json!({"id": id, "summary": summary, "pseudocode": "return a + b + c"})]; + json!({"candidates": [{"content": {"parts": [{"text": serde_json::to_string(&answers).unwrap()}]}}]}) + .to_string() + }; + let (endpoint, server) = serve(vec![(200, answer("Sums three numbers."))]); + let summarizer = summarizer(&endpoint, 0); + summarizer.apply(&file, &mut sides).unwrap(); + let bodies = server.join().unwrap(); + assert!( + bodies[0].contains("doc: Sums three numbers. Used by tests."), + "{}", + bodies[0] + ); + assert_eq!( + fold_label(&sides), + "// pseudocode\nSums three numbers.\nreturn a + b + c" + ); + + // A sentence the docstring does not contain is dropped. + let (file, mut sides) = project("a.rs", "", after); + super::docstrings::Docstrings + .apply(&file, &mut sides) + .unwrap(); + let (endpoint, server) = serve(vec![(200, answer("Adds things up."))]); + super::tests::summarizer(&endpoint, 0) + .apply(&file, &mut sides) + .unwrap(); + server.join().unwrap(); + assert_eq!(fold_label(&sides), "// pseudocode\nreturn a + b + c"); + } + + #[test] + fn transient_failures_are_retried_then_succeed() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&sides, 3)[0].0; + let (endpoint, server) = serve(vec![ + (503, "{}".to_owned()), + (429, "{}".to_owned()), + (200, gemini_answer(&[(id, "retry ok")])), + ]); + summarizer(&endpoint, 3).apply(&file, &mut sides).unwrap(); + assert_eq!(server.join().unwrap().len(), 3); + let label = fold_label(&sides); + assert!(label.ends_with("retry ok"), "{label}"); + } + + #[test] + fn hard_failures_and_exhausted_retries_are_run_failures() { + let (file, mut sides) = project("a.py", "", LARGE); + let (endpoint, server) = serve(vec![(400, "{\"error\": \"bad key\"}".to_owned())]); + let error = summarizer(&endpoint, 3) + .apply(&file, &mut sides) + .unwrap_err(); + server.join().unwrap(); + assert_eq!(error.downcast_ref::(), Some(&Failure::Summarizer)); + assert!(format!("{error:#}").contains("HTTP 400"), "{error:#}"); + let (endpoint, server) = serve(vec![(500, "{}".to_owned()), (500, "{}".to_owned())]); + let error = summarizer(&endpoint, 1) + .apply(&file, &mut sides) + .unwrap_err(); + server.join().unwrap(); + assert!( + format!("{error:#}").contains("after 2 attempts"), + "{error:#}" + ); + let (Pairing::Both { rhs: rhs_side, .. } | Pairing::RightOnly { rhs: rhs_side }) = &sides + else { + panic!("an after side"); + }; + assert!(!rhs_side.regions[0].visibility.collapsed); + } + + #[test] + fn small_files_never_call_the_model() { + let (file, mut sides) = project("a.py", "", "def h():\n e()\n"); + let config = SummarizeConfig { + api_key: Some("k".to_owned()), + endpoint: Some("http://127.0.0.1:1".to_owned()), + min_lines: 3, + ..SummarizeConfig::default() + }; + Summarizer::new(&config) + .unwrap() + .unwrap() + .apply(&file, &mut sides) + .unwrap(); + } + + #[test] + fn no_key_means_no_summarizer() { + let config = SummarizeConfig { + api_key: None, + ..SummarizeConfig::default() + }; + // Only meaningful when the environment carries no key. + if std::env::var_os("GEMINI_API_KEY").is_none() + && std::env::var_os("GOOGLE_API_KEY").is_none() + { + assert!(Summarizer::new(&config).unwrap().is_none()); + } + } +} diff --git a/src/parse/folds.rs b/src/parse/folds.rs index e4c0da62a..7cbd93d85 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -30,6 +30,7 @@ pub(crate) struct Fold { impl Fold { /// The fold on the other side that pairs with this one, if any. + #[cfg(test)] pub(crate) fn counterpart<'a>(&self, other_side: &'a [Fold]) -> Option<&'a Fold> { let partner = self.partner?; other_side.iter().find(|other| other.syntax_id == partner) diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index a78f1fdef..4b317fcce 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -97,6 +97,12 @@ pub struct FileChange { /// `LeftOnly` is a deletion, `RightOnly` an addition. pub file: Pairing, pub status: FileStatus, + /// `source`, `test`, `generated`, `docs`, or a repository's own class, + /// from git attributes and built-in path rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, #[serde(default, skip_serializing_if = "Visibility::is_unset")] pub visibility: Visibility, } @@ -244,7 +250,6 @@ pub struct SourceRange { impl SourceRange { /// The lines this range touches, half-open. A range ending at column /// zero does not touch its end line. - #[cfg(test)] pub fn lines(&self) -> std::ops::Range { let end = if self.end.column == 0 { self.end.line @@ -423,6 +428,8 @@ mod tests { }, }, status: FileStatus::Added, + category: Some("generated".to_owned()), + language: None, visibility: Visibility { collapsed: true, label: "Generated file · hidden by default".to_owned(), diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 95d342d82..bfeb64200 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -676,6 +676,41 @@ mod tests { ); } + #[test] + fn line_diff_fallbacks_pair_a_changed_signature_with_the_old_one() { + let before = "fn first() {\n one();\n}\n\n/// Old doc.\nfn helper() {\n x();\n y();\n z();\n}\n\nfn check(fold: Fold) -> bool {\n a();\n b();\n c();\n}\n"; + let after = "fn first() {\n one();\n}\n\nfn check(region: Region) -> bool {\n a();\n b();\n c();\n}\n"; + let options = DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }; + let (_, sides) = + crate::mutate::summarize::tests::project_with("m.rs", before, after, options); + let Pairing::Both { lhs, rhs } = &sides else { + panic!("both sides"); + }; + let signature = |source: &Source, needle: &str| { + let mut found = None; + crate::mutate::walk(&source.regions, &mut |region| { + if matches!(region.node, Node::Leaf { .. }) + && source + .text + .lines() + .nth(region.range.start.line as usize) + .is_some_and(|line| line.starts_with(needle)) + { + found = Some(region.alignment_id); + } + }); + found.expect("a leaf starts on the signature line") + }; + assert_eq!( + signature(lhs, "fn check("), + signature(rhs, "fn check("), + "the new signature pairs with the old one, not with the removed doc comment" + ); + } + fn refs(lhs: bool, rhs: bool) -> Pairing { let file_ref = FileRef { path: "a.py".to_owned(), diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index b61adad38..e61c3cb71 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -1,25 +1,40 @@ //! The stdout stream: manifest, one record per file as it finishes, footer. use super::project::{self, Inputs}; -use super::{Event, FileChange, Outcome, Problem, Snapshot, VERSION}; +use super::{ + Diff, Event, FileChange, LineCounts, Node, Outcome, Pairing, Problem, Region, Snapshot, Source, + VERSION, +}; use crate::git::{DiffSession, FileError, LoadedFile}; +use crate::hash::DftHashSet; +use crate::mutate::{Failure, Mutations}; use crate::summary::DiffResult; use anyhow::anyhow; use rayon::iter::{ParallelBridge, ParallelIterator}; use std::io::{BufWriter, Write}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc::{sync_channel, SyncSender}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::thread; -/// Returns whether any file failed. Files are diffed on `jobs` workers and -/// emitted as they finish. The queue holds at most one ready record, so -/// computation overlaps output without retaining the whole comparison. +/// What the stream ended with: whether any file failed, and whether the run +/// was cut short by a run-level failure. +pub(crate) struct Ended { + pub(crate) failed: bool, + pub(crate) aborted: bool, +} + +/// Files are diffed on `jobs` workers and emitted as they finish. The queue +/// holds at most one ready record, so computation overlaps output without +/// retaining the whole comparison. pub(crate) fn write( session: DiffSession, jobs: usize, + mutations: Arc, output: &mut impl Write, -) -> anyhow::Result { - let manifest = manifest(&session); +) -> anyhow::Result { + // File mutations run before the manifest is written; a failure there is + // a setup error, not a stream event. + let manifest = manifest(&session, &mutations)?; let (sender, receiver) = sync_channel(1); let pool = rayon::ThreadPoolBuilder::new() .num_threads(jobs) @@ -27,37 +42,48 @@ pub(crate) fn write( .build()?; let worker = thread::spawn(move || { // A disconnected consumer cancels production after the files in flight. - let _ = produce(session, manifest, &pool, sender); + let _ = produce(session, manifest, &pool, &mutations, sender); }); let mut output = BufWriter::new(output); - let result: anyhow::Result = (|| { - let mut failed = false; + let result: anyhow::Result = (|| { + let mut ended = Ended { + failed: false, + aborted: false, + }; for event in &receiver { - if let Event::Complete { failed: count, .. } = &event { - failed = *count > 0; + if let Event::Complete { + failed, aborted, .. + } = &event + { + ended.failed = *failed > 0; + ended.aborted = aborted.is_some(); } serde_json::to_writer(&mut output, &event)?; output.write_all(b"\n")?; output.flush()?; } - Ok(failed) + Ok(ended) })(); // Wake a producer blocked on a full queue if writing failed. drop(receiver); let joined = worker.join(); - let failed = result?; + let ended = result?; joined.map_err(|_| anyhow!("diff computation thread panicked"))?; - Ok(failed) + Ok(ended) } /// The consumer went away; production stops after the files in flight. struct Disconnected; -fn manifest(session: &DiffSession) -> Vec { +fn manifest(session: &DiffSession, mutations: &Mutations) -> anyhow::Result> { session .file_manifest() .iter() - .map(crate::git::FileChange::manifest_entry) + .map(|file| { + let mut entry = file.manifest_entry(); + mutations.apply_file(&mut entry)?; + Ok(entry) + }) .collect() } @@ -65,6 +91,7 @@ fn produce( session: DiffSession, manifest: Vec, pool: &rayon::ThreadPool, + mutations: &Mutations, sender: SyncSender, ) -> Result<(), Disconnected> { let send = |event: Event| sender.send(event).map_err(|_| Disconnected); @@ -77,6 +104,7 @@ fn produce( let succeeded = AtomicU32::new(0); let failed = AtomicU32::new(0); let cancelled = Arc::new(AtomicBool::new(false)); + let aborted: Mutex> = Mutex::new(None); let loader = Loader { session, cancelled: Arc::clone(&cancelled), @@ -84,8 +112,16 @@ fn produce( pool.install(|| { loader.par_bridge().for_each(|(file, loaded)| { let outcome = match loaded { - Ok(loaded) => Outcome::Diff { - diff: file_diff(&loaded), + Ok(loaded) => match file_diff(&loaded, mutations) { + Ok(diff) => Outcome::Diff { diff }, + Err(error) => { + // A run-level failure: stop pulling files, let the ones in + // flight finish, and report why in the footer. + failed.fetch_add(1, Ordering::Relaxed); + cancelled.store(true, Ordering::Relaxed); + aborted.lock().expect("abort reason").get_or_insert(error); + return; + } }, Err(error) => Outcome::Error { error: wire_error(&error), @@ -107,7 +143,10 @@ fn produce( send(Event::Complete { succeeded: succeeded.into_inner(), failed: failed.into_inner(), - aborted: None, + aborted: aborted + .into_inner() + .expect("abort reason") + .map(|error| wire_error(&error)), }) } @@ -115,9 +154,12 @@ fn produce( /// the typed cause attached where the error arose; an error nothing /// classified is `internal`. fn wire_error(error: &anyhow::Error) -> Problem { - let code = match error.downcast_ref::() { - Some(kind) => kind.code(), - None => "internal", + let code = if let Some(kind) = error.downcast_ref::() { + kind.code() + } else if let Some(failure) = error.downcast_ref::() { + failure.code() + } else { + "internal" }; Problem { code: code.to_owned(), @@ -125,13 +167,33 @@ fn wire_error(error: &anyhow::Error) -> Problem { } } -fn file_diff(loaded: &LoadedFile) -> super::Diff { +/// `Err` here is a run-level failure, not a file-level one. +fn file_diff(loaded: &LoadedFile, mutations: &Mutations) -> anyhow::Result { + let diff = loaded.diff(); let inputs = Inputs { file: &loaded.file.sides, sizes: loaded.sizes(), syntax: (Vec::new(), Vec::new()), }; - project::diff(&loaded.diff(), inputs) + let mut entry = loaded.file.manifest_entry(); + mutations.apply_file(&mut entry)?; + mutate(mutations, &entry, project::diff(&diff, inputs)) +} + +/// Fold mutations rewrite a text diff's regions, and its visible counts are +/// recounted after them. +fn mutate(mutations: &Mutations, entry: &FileChange, diff: Diff) -> anyhow::Result { + match diff { + Diff::Text { + mut sides, + mut stats, + } => { + mutations.apply_fold(entry, &mut sides)?; + stats.visible = visible_counts(&sides); + Ok(Diff::Text { sides, stats }) + } + binary @ Diff::Binary { .. } => Ok(binary), + } } /// Reads sources serially on whichever worker pulls next; diffing then @@ -157,10 +219,13 @@ pub(crate) fn write_file( after: &str, sizes: (u64, u64), compute: impl FnOnce() -> DiffResult, + mutations: &Mutations, output: &mut impl Write, -) -> anyhow::Result<()> { +) -> anyhow::Result { let file = crate::git::FileChange::standalone(before, after); let mut output = BufWriter::new(output); + let mut entry: FileChange = file.manifest_entry(); + mutations.apply_file(&mut entry)?; serde_json::to_writer( &mut output, &Event::Start { @@ -171,12 +236,12 @@ pub(crate) fn write_file( rhs: Snapshot::Path { path: after.to_owned(), }, - files: vec![file.manifest_entry()], + files: vec![entry.clone()], }, )?; output.write_all(b"\n")?; output.flush()?; - let diff = project::diff( + let projected = project::diff( &compute(), Inputs { file: &file.sides, @@ -184,23 +249,183 @@ pub(crate) fn write_file( syntax: (Vec::new(), Vec::new()), }, ); - serde_json::to_writer( - &mut output, - &Event::File { - file: file.sides, - outcome: Outcome::Diff { diff }, - }, - )?; - output.write_all(b"\n")?; + let aborted = match mutate(mutations, &entry, projected) { + Ok(diff) => { + serde_json::to_writer( + &mut output, + &Event::File { + file: file.sides, + outcome: Outcome::Diff { diff }, + }, + )?; + output.write_all(b"\n")?; + None + } + Err(error) => Some(wire_error(&error)), + }; + let ended = Ended { + failed: false, + aborted: aborted.is_some(), + }; serde_json::to_writer( &mut output, &Event::Complete { - succeeded: 1, - failed: 0, - aborted: None, + succeeded: u32::from(!ended.aborted), + failed: u32::from(ended.aborted), + aborted, }, )?; output.write_all(b"\n")?; output.flush()?; - Ok(()) + Ok(ended) +} + +/// Changed lines that start on screen. A line counts when it carries a +/// `changed` span, or when its leaf exists on one side only (every line of +/// a one-sided leaf is new or removed, blank ones included). Lines inside a +/// collapsed region, or under one, are not counted. +pub(crate) fn visible_counts(sides: &Pairing) -> LineCounts { + fn ids(regions: &[Region], out: &mut DftHashSet) { + for region in regions { + out.insert(region.alignment_id); + if let Node::Fold { children } = ®ion.node { + ids(children, out); + } + } + } + fn count(regions: &[Region], other: &DftHashSet, hidden: bool) -> u32 { + let mut total = 0; + for region in regions { + let hidden = hidden || region.visibility.collapsed; + match ®ion.node { + Node::Leaf { changed } => { + if hidden { + continue; + } + if other.contains(®ion.alignment_id) { + let lines: DftHashSet = changed.iter().map(|span| span.line).collect(); + total += lines.len() as u32; + } else { + total += region.range.lines().len() as u32; + } + } + Node::Fold { children } => total += count(children, other, hidden), + } + } + total + } + let side_ids = |source: &Source| { + let mut out = DftHashSet::default(); + ids(&source.regions, &mut out); + out + }; + match sides { + Pairing::Both { lhs, rhs } => LineCounts { + added: count(&rhs.regions, &side_ids(lhs), false), + removed: count(&lhs.regions, &side_ids(rhs), false), + }, + Pairing::LeftOnly { lhs } => LineCounts { + added: 0, + removed: count(&lhs.regions, &DftHashSet::default(), false), + }, + Pairing::RightOnly { rhs } => LineCounts { + added: count(&rhs.regions, &DftHashSet::default(), false), + removed: 0, + }, + } +} + +#[cfg(test)] +mod visible_tests { + use super::*; + use crate::protocol::{SourcePos, SourceRange, Span, Visibility}; + + fn pos(line: u32) -> SourcePos { + SourcePos { line, column: 0 } + } + + fn leaf(id: u32, lines: (u32, u32), changed: &[u32], collapsed: bool) -> Region { + Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: pos(lines.0), + end: pos(lines.1), + }, + tags: vec![], + visibility: Visibility { + collapsed, + label: String::new(), + }, + node: Node::Leaf { + changed: changed + .iter() + .map(|&line| Span { + line, + start_column: 0, + end_column: 1, + }) + .collect(), + }, + } + } + + fn fold(id: u32, lines: (u32, u32), collapsed: bool, children: Vec) -> Region { + Region { + alignment_id: id, + fold_state_id: id, + range: SourceRange { + start: pos(lines.0), + end: pos(lines.1), + }, + tags: vec!["body".to_owned()], + visibility: Visibility { + collapsed, + label: String::new(), + }, + node: Node::Fold { children }, + } + } + + fn source(regions: Vec) -> Source { + Source { + text: String::new(), + syntax: vec![], + regions, + } + } + + #[test] + fn counts_span_lines_and_every_line_of_a_one_sided_leaf() { + let rhs = source(vec![ + // paired leaf: only the lines with spans count (two, one twice) + leaf(1, (0, 3), &[0, 1, 1], false), + // paired leaf without spans: unchanged context, not counted + leaf(9, (3, 4), &[], false), + // one-sided leaf with no spans (blank lines): every line counts + leaf(2, (4, 6), &[], false), + // collapsed leaf: hidden + leaf(3, (6, 9), &[6, 7], true), + // open fold with an open one-sided leaf: every line counts + fold(4, (9, 12), false, vec![leaf(5, (9, 12), &[10], false)]), + // collapsed fold: its open child is hidden by the ancestor + fold(6, (12, 15), true, vec![leaf(7, (12, 15), &[13, 14], false)]), + ]); + let lhs = source(vec![ + leaf(1, (0, 3), &[0], false), + leaf(9, (3, 4), &[], false), + leaf(8, (4, 7), &[4, 5], true), + ]); + let counts = visible_counts(&Pairing::Both { lhs, rhs }); + assert_eq!(counts.added, 2 + 2 + 3); + assert_eq!(counts.removed, 1); + } + + #[test] + fn a_missing_side_counts_nothing() { + let rhs = source(vec![leaf(1, (0, 1), &[0], false)]); + let counts = visible_counts(&Pairing::RightOnly { rhs }); + assert_eq!(counts.added, 1); + assert_eq!(counts.removed, 0); + } } diff --git a/src/review/tests.rs b/src/review/tests.rs index 04f84622e..24a967a1e 100644 --- a/src/review/tests.rs +++ b/src/review/tests.rs @@ -77,6 +77,26 @@ mod folds { .is_some_and(|r| text(src, r) == "\"\"\"first\nsecond\"\"\""))); } + #[test] + fn cfg_test_modules_are_test_modules_and_plain_modules_are_not() { + let src = "mod plain {\n fn a() {}\n}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn t() {\n a();\n }\n}\n"; + let result = DiffResult::from_sources("a.rs", "", src); + let tags: Vec> = result + .rhs_folds + .iter() + .filter(|fold| fold.tags.iter().any(|tag| tag == "module")) + .map(|fold| fold.tags.clone()) + .collect(); + assert_eq!( + tags, + [vec!["body", "module"], vec!["body", "module", "test"]] + ); + assert!(result + .rhs_folds + .iter() + .any(|fold| fold.tags == ["body", "function", "test"])); + } + #[test] fn flattened_test_body_keeps_the_existing_string_match_on_both_sides() { let lhs = "def test_doc():\n \"\"\"some shared words before\"\"\"\n"; @@ -85,7 +105,7 @@ mod folds { assert_eq!(result.lhs_folds.len(), 1); assert_eq!(result.rhs_folds.len(), 1); let fold = &result.lhs_folds[0]; - assert_eq!(fold.tags, ["body", "test"]); + assert_eq!(fold.tags, ["body", "function", "test"]); let (left, right) = paired(fold, &result.rhs_folds).expect("reuse the replaced-string correspondence"); assert_eq!(text(lhs, left), "\"\"\"some shared words before\"\"\""); diff --git a/tests/hooks/rpc_server.py b/tests/hooks/rpc_server.py index 668b33032..0a244ab18 100755 --- a/tests/hooks/rpc_server.py +++ b/tests/hooks/rpc_server.py @@ -3,10 +3,10 @@ Listens on 127.0.0.1:$DIFFR_HOOK_PORT. The first argument selects a behavior: echo answer every fold with "pseudo " - first answer only fold 0 with "summary of f" + first answer only the first fold with "summary of f" error return a JSON-RPC error for every request slow never answer (sleeps inside the handler) - cwd answer fold 0 with the working directory and assert DIFFR_WORKSPACE + cwd answer the first fold with the working directory and assert DIFFR_WORKSPACE bad return a non-JSON body """ @@ -40,15 +40,17 @@ def do_POST(self): else: if MODE == "echo": texts = { - str(f["id"]): "pseudo " + f["placeholder"] for f in params["folds"] + # `id` is the region's alignment_id on the after side. + str(f["id"]): "pseudo " + f["placeholder"] + for f in params["folds"] } elif MODE == "first": - texts = {"0": "summary of f"} + texts = {str(params["folds"][0]["id"]): "summary of f"} elif MODE == "cwd": assert os.environ["DIFFR_WORKSPACE"] == sys.argv[2], os.environ[ "DIFFR_WORKSPACE" ] - texts = {"0": os.getcwd()} + texts = {str(params["folds"][0]["id"]): os.getcwd()} else: raise SystemExit(f"unknown mode {MODE}") body = json.dumps( From 2e37db6370b5ccb6437590a5703213fe98ce6341 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sun, 13 Sep 2026 02:56:03 -0700 Subject: [PATCH 6/6] Read wire v3 in the terminal frontend The TUI now reads per-side region trees: rows zip by `alignment_id`, folds open and close by `fold_state_id`, collapsed regions show their header then a pseudocode band, and hidden files show a "Load diff" placeholder. Counts come from `stats.visible`. Colours come from tree-sitter capture names, which `--syntax` adds to the stream (including line-diff fallbacks, whose language is guessed from the path), through bundled Helix-style themes. `diffr config` opens a searchable settings screen. The PTY launch integration test and its fixtures are removed. Written with AI assistance (Claude Code). Agent-Session: 0de57442-ad9b-4c99-960e-4974e629b71d Agent-Session: e046556c-20f0-4bba-832b-b6784a7783c5 Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 8837929e-0118-489c-9648-e47c1831ee8c Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 4a8f6ee6-1693-4de3-9993-b9f5914cdd09 Agent-Session: 8761954a-0023-463e-996c-7c9fbabdef69 Agent-Session: 30956777-ac82-4a2b-9b52-dbfb517b52ee Agent-Session: 01a09f1b-2525-74e3-99d4-f1bdcbec53e6 --- src/cli.rs | 23 +- src/parse/tree_sitter_parser.rs | 27 + src/protocol/project.rs | 63 +++ src/protocol/stream.rs | 70 ++- tui/README.md | 143 ++++-- tui/package.json | 4 +- tui/packages/hunk/src/diffr/config.test.ts | 78 +++ tui/packages/hunk/src/diffr/config.ts | 175 +++++++ tui/packages/hunk/src/diffr/counts.test.ts | 17 + tui/packages/hunk/src/diffr/counts.ts | 45 ++ tui/packages/hunk/src/diffr/fileTree.test.ts | 7 +- tui/packages/hunk/src/diffr/fileTree.ts | 18 +- tui/packages/hunk/src/diffr/fixture.ts | 100 ++-- tui/packages/hunk/src/diffr/folds.test.ts | 88 ---- tui/packages/hunk/src/diffr/folds.ts | 112 ----- tui/packages/hunk/src/diffr/regions.test.ts | 231 +++++++++ tui/packages/hunk/src/diffr/regions.ts | 172 +++++++ tui/packages/hunk/src/diffr/rows.test.ts | 221 +++++---- tui/packages/hunk/src/diffr/rows.ts | 468 +++++++++--------- tui/packages/hunk/src/diffr/selection.ts | 20 +- tui/packages/hunk/src/diffr/store.ts | 43 +- tui/packages/hunk/src/diffr/stream.test.ts | 94 ++-- tui/packages/hunk/src/diffr/stream.ts | 22 +- tui/packages/hunk/src/diffr/theme.test.ts | 67 +++ tui/packages/hunk/src/diffr/theme.ts | 182 +++++++ tui/packages/hunk/src/diffr/wire.ts | 214 ++++---- tui/packages/hunk/src/main.tsx | 44 +- tui/packages/hunk/src/ui/App.test.tsx | 245 ++++++--- tui/packages/hunk/src/ui/App.tsx | 239 +++++---- tui/packages/hunk/src/ui/Settings.test.tsx | 134 +++++ tui/packages/hunk/src/ui/Settings.tsx | 249 ++++++++++ tui/packages/hunk/src/ui/diff/CodeRowView.tsx | 37 +- tui/packages/hunk/src/ui/diff/diffRowModel.ts | 20 +- tui/test/fixtures/after.ts | 4 - tui/test/fixtures/before.ts | 3 - tui/test/fixtures/comparison.ndjson | 4 +- tui/test/pty/launch-integration.test.ts | 83 ---- tui/test/pty/launch.py | 66 --- tui/themes/LICENSE | 373 ++++++++++++++ tui/themes/README.md | 14 + tui/themes/gruvbox.toml | 153 ++++++ tui/themes/onedark.toml | 118 +++++ tui/themes/onelight.toml | 201 ++++++++ tui/themes/solarized_light.toml | 155 ++++++ 44 files changed, 3683 insertions(+), 1163 deletions(-) create mode 100644 tui/packages/hunk/src/diffr/config.test.ts create mode 100644 tui/packages/hunk/src/diffr/config.ts create mode 100644 tui/packages/hunk/src/diffr/counts.test.ts create mode 100644 tui/packages/hunk/src/diffr/counts.ts delete mode 100644 tui/packages/hunk/src/diffr/folds.test.ts delete mode 100644 tui/packages/hunk/src/diffr/folds.ts create mode 100644 tui/packages/hunk/src/diffr/regions.test.ts create mode 100644 tui/packages/hunk/src/diffr/regions.ts create mode 100644 tui/packages/hunk/src/diffr/theme.test.ts create mode 100644 tui/packages/hunk/src/diffr/theme.ts create mode 100644 tui/packages/hunk/src/ui/Settings.test.tsx create mode 100644 tui/packages/hunk/src/ui/Settings.tsx delete mode 100644 tui/test/fixtures/after.ts delete mode 100644 tui/test/fixtures/before.ts delete mode 100644 tui/test/pty/launch-integration.test.ts delete mode 100644 tui/test/pty/launch.py create mode 100644 tui/themes/LICENSE create mode 100644 tui/themes/README.md create mode 100644 tui/themes/gruvbox.toml create mode 100644 tui/themes/onedark.toml create mode 100644 tui/themes/onelight.toml create mode 100644 tui/themes/solarized_light.toml diff --git a/src/cli.rs b/src/cli.rs index aaad8e5bc..a38a6b02a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -62,6 +62,7 @@ pub(crate) fn run() -> Result { .arg(flag("find-renames").short('M').conflicts_with("no-renames")) .arg(Arg::new("unified").short('U').long("unified").value_parser(clap::value_parser!(u32)).help("Unchanged lines kept around each change; defaults to folds.context_lines")) .arg(Arg::new("format").long("format").value_parser(["text", "ndjson", "snapshot"]).default_value("text")) + .arg(flag("syntax").help("Include every token's tree-sitter capture name in --format ndjson output")) .arg(Arg::new("display").long("display").value_parser(["inline", "side-by-side", "side-by-side-show-both"]).default_value("side-by-side")) .arg(Arg::new("color").long("color").num_args(0..=1).require_equals(true).default_missing_value("always").default_value("auto").value_parser(["auto", "always", "never"])) .arg(flag("no-color")) @@ -105,6 +106,9 @@ pub(crate) fn run() -> Result { if streaming && (args.get_flag("quiet") || args.contains_id("metadata")) { return Err("--format ndjson cannot be combined with --quiet or metadata output".into()); } + let stream_options = crate::protocol::stream::Options { + syntax: args.get_flag("syntax"), + }; let items: Vec = args .get_many::("items") .into_iter() @@ -137,6 +141,7 @@ pub(crate) fn run() -> Result { &args, items.into_iter().chain(explicit_paths).collect(), &display, + stream_options, ); } if args.get_flag("null") && !args.get_flag("name-only") && !args.get_flag("name-status") { @@ -191,8 +196,13 @@ pub(crate) fn run() -> Result { if jobs == 0 { return Err("--jobs must be at least 1".into()); } - let ended = - crate::protocol::stream::write(session, jobs, mutations, &mut io::stdout().lock())?; + let ended = crate::protocol::stream::write( + session, + jobs, + mutations, + stream_options, + &mut io::stdout().lock(), + )?; let failed = ended.failed || ended.aborted; return Ok(if failed { 2 @@ -425,7 +435,12 @@ fn print_metadata(diff: &git2::Diff<'_>, args: &ArgMatches, width: usize) -> Res Ok(()) } -fn no_index(args: &ArgMatches, paths: Vec, display: &DisplayOptions) -> Result { +fn no_index( + args: &ArgMatches, + paths: Vec, + display: &DisplayOptions, + stream_options: crate::protocol::stream::Options, +) -> Result { if paths.len() != 2 { return Err("--no-index requires two file paths".into()); } @@ -487,7 +502,9 @@ fn no_index(args: &ArgMatches, paths: Vec, display: &DisplayOptions) - &paths[1].to_string_lossy(), (before.len() as u64, after.len() as u64), compute, + &config, &mutations, + stream_options, &mut io::stdout().lock(), )?; Ok(if ended.failed || ended.aborted { diff --git a/src/parse/tree_sitter_parser.rs b/src/parse/tree_sitter_parser.rs index a42b0c095..1156df31d 100644 --- a/src/parse/tree_sitter_parser.rs +++ b/src/parse/tree_sitter_parser.rs @@ -1468,6 +1468,33 @@ fn tree_highlights( } } +/// Every highlight capture in `src` as byte intervals with the capture +/// name from the language's highlights query. Intervals may nest; the +/// caller decides precedence. +pub(crate) fn highlight_captures( + src: &str, + config: &'static TreeSitterConfig, +) -> Vec<(usize, usize, &'static str)> { + let tree = to_tree(src, config); + let names = config.highlight_query.capture_names(); + let mut cursor = ts::QueryCursor::new(); + let mut matches = cursor.matches(&config.highlight_query, tree.root_node(), src.as_bytes()); + let mut captures = Vec::new(); + while let Some(matched) = matches.next() { + for capture in matched.captures { + let node = capture.node; + if node.start_byte() < node.end_byte() { + captures.push(( + node.start_byte(), + node.end_byte(), + names[capture.index as usize], + )); + } + } + } + captures +} + pub(crate) fn print_tree(src: &str, tree: &tree_sitter::Tree) { let mut cursor = tree.walk(); print_cursor(src, &mut cursor, 0); diff --git a/src/protocol/project.rs b/src/protocol/project.rs index bfeb64200..438b36a3f 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -27,6 +27,7 @@ use crate::line_parser; use crate::pairing::Pairing; use crate::parse::folds::{self, Fold}; use crate::parse::syntax::{MatchKind, MatchedPos, SyntaxId}; +use crate::parse::tree_sitter_parser::{highlight_captures, TreeSitterConfig}; use crate::summary::{DiffResult, FileContent, FileFormat}; use std::collections::{BTreeMap, BTreeSet}; @@ -126,6 +127,47 @@ fn fallback_code(reason: &str) -> &'static str { } } +/// Highlight spans for one side, per line, sorted, non-overlapping. Where +/// captures nest the innermost wins. +pub(crate) fn syntax_spans(src: &str, parser: &'static TreeSitterConfig) -> Vec { + let mut captures = highlight_captures(src, parser); + // Paint larger captures first so smaller (inner) ones overwrite them. + captures.sort_by_key(|(start, end, _)| std::cmp::Reverse(end - start)); + let mut owner: Vec> = vec![None; src.len()]; + for (start, end, name) in captures { + for slot in &mut owner[start..end] { + *slot = Some(name); + } + } + let mut spans = Vec::new(); + let mut line_start = 0; + for (line, text) in src.split_inclusive('\n').enumerate() { + let content_len = text.trim_end_matches('\n').len(); + let mut run: Option<(usize, &'static str)> = None; + for column in 0..=content_len { + let current = (column < content_len) + .then(|| owner[line_start + column]) + .flatten(); + match (run, current) { + (Some((_, name)), Some(now)) if now == name => {} + (Some((start, name)), _) => { + spans.push(SyntaxSpan { + line: line as u32, + start_column: start as u32, + end_column: column as u32, + capture: name.to_owned(), + }); + run = current.map(|name| (column, name)); + } + (None, Some(name)) => run = Some((column, name)), + (None, None) => {} + } + } + line_start += text.len(); + } + spans +} + // ── regions ─────────────────────────────────────────────────────────────── /// A leaf after splitting. `key` identifies its counterpart on the other @@ -1477,4 +1519,25 @@ mod tests { }; assert_eq!((lhs.size, rhs.size), (3, 5)); } + + #[test] + fn syntax_spans_are_per_line_sorted_and_innermost() { + let parser = crate::parse::tree_sitter_parser::from_language( + crate::parse::guess_language::Language::Python, + ); + let spans = syntax_spans("def f(x):\n return \"a\"\n", parser); + for pair in spans.windows(2) { + assert!( + pair[0].line < pair[1].line + || (pair[0].line == pair[1].line && pair[0].end_column <= pair[1].start_column), + "{pair:?}" + ); + } + assert!(spans + .iter() + .any(|span| span.capture == "keyword" && span.line == 0)); + assert!(spans + .iter() + .any(|span| span.capture.starts_with("string") && span.line == 1)); + } } diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index e61c3cb71..beee80c8d 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -2,12 +2,12 @@ use super::project::{self, Inputs}; use super::{ Diff, Event, FileChange, LineCounts, Node, Outcome, Pairing, Problem, Region, Snapshot, Source, - VERSION, + SyntaxSpan, VERSION, }; use crate::git::{DiffSession, FileError, LoadedFile}; use crate::hash::DftHashSet; use crate::mutate::{Failure, Mutations}; -use crate::summary::DiffResult; +use crate::summary::{DiffResult, FileContent, FileFormat}; use anyhow::anyhow; use rayon::iter::{ParallelBridge, ParallelIterator}; use std::io::{BufWriter, Write}; @@ -16,6 +16,13 @@ use std::sync::mpsc::{sync_channel, SyncSender}; use std::sync::{Arc, Mutex}; use std::thread; +/// Runtime choices that shape every file record. +#[derive(Clone, Copy)] +pub(crate) struct Options { + /// Emit every token's capture name. + pub(crate) syntax: bool, +} + /// What the stream ended with: whether any file failed, and whether the run /// was cut short by a run-level failure. pub(crate) struct Ended { @@ -30,6 +37,7 @@ pub(crate) fn write( session: DiffSession, jobs: usize, mutations: Arc, + options: Options, output: &mut impl Write, ) -> anyhow::Result { // File mutations run before the manifest is written; a failure there is @@ -42,7 +50,7 @@ pub(crate) fn write( .build()?; let worker = thread::spawn(move || { // A disconnected consumer cancels production after the files in flight. - let _ = produce(session, manifest, &pool, &mutations, sender); + let _ = produce(session, manifest, &pool, &mutations, options, sender); }); let mut output = BufWriter::new(output); let result: anyhow::Result = (|| { @@ -92,6 +100,7 @@ fn produce( manifest: Vec, pool: &rayon::ThreadPool, mutations: &Mutations, + options: Options, sender: SyncSender, ) -> Result<(), Disconnected> { let send = |event: Event| sender.send(event).map_err(|_| Disconnected); @@ -112,7 +121,7 @@ fn produce( pool.install(|| { loader.par_bridge().for_each(|(file, loaded)| { let outcome = match loaded { - Ok(loaded) => match file_diff(&loaded, mutations) { + Ok(loaded) => match file_diff(&loaded, mutations, options) { Ok(diff) => Outcome::Diff { diff }, Err(error) => { // A run-level failure: stop pulling files, let the ones in @@ -168,12 +177,20 @@ fn wire_error(error: &anyhow::Error) -> Problem { } /// `Err` here is a run-level failure, not a file-level one. -fn file_diff(loaded: &LoadedFile, mutations: &Mutations) -> anyhow::Result { +fn file_diff(loaded: &LoadedFile, mutations: &Mutations, options: Options) -> anyhow::Result { let diff = loaded.diff(); + let syntax = if options.syntax { + let (Pairing::Both { rhs: side, .. } + | Pairing::LeftOnly { lhs: side } + | Pairing::RightOnly { rhs: side }) = &loaded.file.sides; + syntax_spans(&diff, &loaded.params, std::path::Path::new(&side.path)) + } else { + (Vec::new(), Vec::new()) + }; let inputs = Inputs { file: &loaded.file.sides, sizes: loaded.sizes(), - syntax: (Vec::new(), Vec::new()), + syntax, }; let mut entry = loaded.file.manifest_entry(); mutations.apply_file(&mut entry)?; @@ -196,6 +213,35 @@ fn mutate(mutations: &Mutations, entry: &FileChange, diff: Diff) -> anyhow::Resu } } +/// Highlight spans for both sides. A line-diff fallback still has a +/// language, guessed from the path, so its sides get colours too. +pub(crate) fn syntax_spans( + diff: &DiffResult, + params: &crate::config::Params, + path: &std::path::Path, +) -> (Vec, Vec) { + let language = match &diff.file_format { + FileFormat::SupportedLanguage(language) => Some(*language), + FileFormat::TextFallback { .. } => { + let sample = match (&diff.lhs_src, &diff.rhs_src) { + (FileContent::Text(src), _) | (_, FileContent::Text(src)) => src.as_str(), + _ => "", + }; + crate::parse::guess_language::guess(path, sample, &[]) + } + FileFormat::PlainText | FileFormat::Binary => None, + }; + let Some(language) = language else { + return (Vec::new(), Vec::new()); + }; + let parser = params.language(language).parser; + let spans = |content: &FileContent| match content { + FileContent::Text(src) => project::syntax_spans(src, parser), + FileContent::Binary => Vec::new(), + }; + (spans(&diff.lhs_src), spans(&diff.rhs_src)) +} + /// Reads sources serially on whichever worker pulls next; diffing then /// proceeds on that worker while others pull further files. struct Loader { @@ -219,7 +265,9 @@ pub(crate) fn write_file( after: &str, sizes: (u64, u64), compute: impl FnOnce() -> DiffResult, + params: &crate::config::Params, mutations: &Mutations, + options: Options, output: &mut impl Write, ) -> anyhow::Result { let file = crate::git::FileChange::standalone(before, after); @@ -241,12 +289,18 @@ pub(crate) fn write_file( )?; output.write_all(b"\n")?; output.flush()?; + let diff = compute(); + let syntax = if options.syntax { + syntax_spans(&diff, params, std::path::Path::new(after)) + } else { + (Vec::new(), Vec::new()) + }; let projected = project::diff( - &compute(), + &diff, Inputs { file: &file.sides, sizes, - syntax: (Vec::new(), Vec::new()), + syntax, }, ); let aborted = match mutate(mutations, &entry, projected) { diff --git a/tui/README.md b/tui/README.md index f102419d6..aed1659b0 100644 --- a/tui/README.md +++ b/tui/README.md @@ -29,6 +29,53 @@ Saved streams use the same reader: `--input -` reads a pipe and opens the controlling terminal for keyboard input. +The frontend launches Rust with `--format ndjson --syntax`: the wire is diffr's v2 +protocol (`src/protocol.rs`) and `--syntax` adds a tree-sitter capture name per token, +which is the only source of syntax colour here. + +## Themes + +Colours come from Helix theme files: TOML keyed by tree-sitter capture names such as +`keyword`, `function.method`, `string`, `comment`, `type`, `variable.parameter`, with +a `[palette]` section and `ui.*` keys for chrome. A capture falls back to its parent +scope (`keyword.return` → `keyword`). Four themes are bundled under `themes/` (Helix's +own, MPL-2.0): `default-dark` (onedark), `default-light` (onelight), `gruvbox`, and +`solarized_light`. + +The frontend reads `diffr config show --json` at startup and uses `theme.path` when set, +else `theme.name` from the bundled index. An unknown name is an error, not a fallback. +To use any other Helix theme, download it from +https://github.com/helix-editor/helix/tree/master/runtime/themes and point at it: + +```sh +diffr config set theme.path ~/.config/helix/themes/dracula.toml +``` + +`t` toggles between `default-dark` and `default-light`. Change tints (line and word +backgrounds) are mixed from the theme's background and its `diff.plus`/`diff.minus` +colours. Recordings played with `--input` take `--theme ` and default +to `default-dark`. + +## Settings screen + +`diffr config` opens a searchable settings screen in this frontend. The CLI contract it +relies on: + +```sh +bun run packages/hunk/src/main.tsx --settings --diffr /path/to/diffr [initial query] +diffr config schema # JSON Schema; each property carries `description` and `default` +diffr config show --json # resolved values, nested like the schema +diffr config set # dotted key, value as typed: true, 12, gemini +``` + +The list view: type to filter (fuzzy over keys, substring over descriptions), `↑`/`↓` to +move, Enter to edit, Esc to quit. `●` marks a value that differs from its default and an +empty value shows as ``. Enter opens an edit view for the selected setting with +its key, description and default: booleans and enums pick from a list (`↑`/`↓`, or `y`/`n` +for booleans), numbers and strings use a text field, masked for credential-looking keys +such as `api_key`. Enter saves through `diffr config set` and returns to the refreshed +list; Esc returns without saving. Both views carry a footer hint line. + ## Controls - Wheel, arrows, j/k, Page Up/Down, Home/End: scroll. @@ -36,22 +83,42 @@ Saved streams use the same reader: - File header click or Enter: collapse/expand the file. - `\` / Cmd-B (when forwarded by the terminal): toggle the file tree. - Click folders to expand/collapse them; click files to navigate. The active file is highlighted and revealed as the diff scrolls. -- File headers stay pinned while scrolling and show unique novel-line counts (`+added −removed`) from Rust hunks. +- A summary strip under the menubar shows the comparison (`main…HEAD`, `index…working + tree`), the file count, the totals `+N −M` in the theme's diff colours, and GitHub's + five-block bar. Totals and file-header counts are diffr's `stats.visible`, shown verbatim + and summed over loaded files: folding changes what is on screen, never the numbers. + Files still pending add nothing, and the total ends in `…` until the stream completes. +- `i` (or clicking the totals) opens a breakdown for the whole comparison and the current + file, straight from the wire: `visible` and `textual`, plus a `line diff: ` line + when tree-sitter fell back to a line diff for that file. Esc closes it. +- Moved code: a region whose `alignment_id` counterpart comes earlier in reading order is a + move. Both copies render in the theme's moved tint (`diff.delta.moved`, else `diff.delta`, + else a muted blue from `ui.selection`) with no `+`/`−`, under a label row: `moved from + line N` on the right, `moved to line M` on the left. Edits inside a moved copy keep the + word tint. Click the label, or press `gm` with the copy on screen, to jump to the other copy. +- A file diffr marks hidden by default (generated, test) opens collapsed with a GitHub-style + placeholder: `Load diff` and the reason line. Click it or press Enter to reveal. - File, View, Navigate, Theme and Help menus expose the supported controls. - `[` / `]`: previous/next hunk. - Folds follow VS Code with controls always shown: a foldable row shows `▾` in the - gutter, and a collapsed fold shows `▸` plus a `⋯ Placeholder` after the header line. - Click either to toggle. Alt-click also folds or unfolds every nested region. Vim chords - act on the fold whose header is the top row: `za` toggle, `zo` open, `zc` close, with - `zA` / `zO` / `zC` recursive; `zM` / `zR` (and View > Fold all / Unfold all) fold or - unfold every visible fold; `zj` / `zk` scroll to the next or previous fold header. - The header and closing delimiter stay visible; paired unchanged folds - collapse on both sides. A fold on one side only blanks that side's cells, keeping the - other side's lines in Rust's alignment. -- `c`: toggle compact/all context. Compact uses Rust-selected nearby and enclosing syntax context, with an ellipsis for each omitted stretch. + gutter, and a collapsed fold shows `▸` plus `⋯ Label` after the header line. A label + with several lines (pseudocode from a summarizer) shows only `⋯` on the header and hangs + the label under it, indented one level, inside the fold tint. Click either to toggle. + Alt-click also folds or unfolds every nested region. Vim chords act on the fold whose + header is the top row: `za` toggle, `zo` open, `zc` close, with `zA` / `zO` / `zC` + recursive; `zM` / `zR` (and View > Fold all / Unfold all) fold or unfold every fold; + `zj` / `zk` scroll to the next or previous fold header. The header and closing + delimiter stay visible; regions that share a `fold_state_id` collapse together, on both sides + and within one side (a docstring bundled with its function folds and unfolds with it; an + empty label shows as a bare `⋯`). A fold on one + side only blanks that side's cells, keeping the other side's lines in Rust's alignment. +- Context gaps are folds too: an unchanged run diffr trimmed to N lines around changes + arrives as a collapsed leaf tagged `unchanged`, and renders as one fold row with its + label (`142 unchanged lines`) that toggles like any other. `c` opens or closes every + such gap at once. Folds and gaps start in the state diffr's `visibility` asks for. - `s`: split/unified; initial mode is responsive to width. - `w`: wrap; Left/Right: horizontal scrolling when unwrapped. -- `t`: dark/light theme. +- `t`: toggle between the bundled dark and light defaults (see Themes). - Drag code rows: select original source lines on the starting side; `y` or Copy copies via OSC 52. Escape clears selection. Character-level selection and drag autoscroll are not implemented in this first pass. @@ -61,33 +128,38 @@ Saved streams use the same reader: ```text Rust CLI -- implicit interactive output --> Bun frontend -Bun frontend -- same comparison arguments + --format ndjson --> Rust subprocess +Bun frontend -- same comparison arguments + --format ndjson --syntax --> Rust subprocess Rust stdout --> validated events --> file store -file source + token spans + full-file alignment --> split/unified rows +per-side text + syntax spans + region trees --> leaves zipped by alignment_id --> split/unified rows rows + width + wrapping --> measured row bounds row bounds + viewport --> mounted OpenTUI rows -mouse/keyboard --> viewer state --> updated projection +mouse/keyboard --> viewer state (collapsed ids, closed files) --> updated projection ``` - `src/cli.rs` owns argument interpretation and selecting interactive versus explicit output. The frontend does not resolve revisions or invoke Git. -- `src/stream.rs` owns serialization. Repository comparisons retain their existing - wire format. Standalone comparisons add start-event operands `{kind: "file", path}`. -- `packages/hunk/src/diffr/wire.ts` validates the Rust field shapes. Source positions - remain zero-based UTF-8 byte offsets. Fold metadata, tags, pairings and placeholders - are retained intact. -- `diffr/folds.ts` turns fold ranges into VS Code-style regions: one region per header - line (the outermost wins), unchanged pairs share one id, and a trailing line hides only - when nothing follows the range on it. Collapsed ids live in viewer state; folded lines - are masked per side before row building, so no realignment happens. +- `src/protocol.rs` is the wire contract: a `start` manifest, one `file` record per file + with `diff` or `error`, and a `complete` footer. Sides are `lhs`/`rhs` by presence. + Each text side carries its full text, optional `syntax` spans, and a `regions` tree + whose leaves tile the file; the same `alignment_id` on both sides means correspondence; `fold_state_id` groups what toggles together. +- `packages/hunk/src/diffr/wire.ts` validates those shapes with Zod and fills omitted + defaults. Columns remain zero-based UTF-8 byte offsets. +- `diffr/regions.ts` flattens each side's tree into leaves and folds, computes which lines + a collapsed fold hides (a trailing line hides only when nothing follows the range on + it), and seeds the default collapsed set from `visibility`. Collapsed ids live in + viewer state, keyed by `fold_state_id`, so regions sharing it (paired ones included) toggle together. - `diffr/stream.ts` validates event ordering, versions and completion counts, handles arbitrary chunk boundaries, and rejects truncated streams. - `diffr/store.ts` holds completed files and errors. The UI can display files while subsequent results are arriving. Quit terminates the comparison subprocess. -- `diffr/rows.ts` consumes the exact full-file line pairs. It adds empty split cells where - Rust supplies null, and groups unified removals before additions between shared - context lines. Syntax colors and novelty emphasis come from Rust token spans. - There is no patch parser, second diff algorithm or frontend syntax highlighter. +- `diffr/rows.ts` zips the two leaf lists on their ids: paired leaves pair rows line for + line, unpaired leaves get blank cells opposite, and a leaf whose partner already went + by is a move, shown one-sided on each side. Folding masks lines per side; nothing is + realigned. Line tint comes from a leaf's `changed` spans, word emphasis from the spans + themselves, and foreground colour from `syntax` capture names through a small theme + table. There is no patch parser, second diff algorithm or frontend tokenizer. +- `diffr/config.ts` and `ui/Settings.tsx` implement the settings screen over the + `diffr config` commands. - `diffr/geometry.ts` measures wrapping and equal-height split rows. Retained Hunk `styledSpanLayout.ts`, `ui/lib/text.ts` and `rowWindowing.ts` handle styled text slicing, terminal column measurement and binary-search viewport selection. @@ -115,20 +187,17 @@ retains Hunk's MIT notice. The parent Rust project keeps its existing license. ```sh cargo build --bin diffr cargo test --bin diffr -python3 tests/streaming/check.py cd tui bun run typecheck bun test packages -bun run test:integration ``` -The integration tests include real Rust wire output, redirected CLI behavior, and a -Unix PTY test for interactive launch, layout switching, mouse file toggles and clean -shutdown. They require Python 3 and the debug Rust binary. Set `DIFFR_TEST_BIN` to test -another binary. OpenTUI tests verify drag-copy, split/unified rendering, and bounded +OpenTUI tests verify drag-copy, split/unified rendering, and bounded mounted widgets while scrolling a 5,000-line file. -Full-file alignment is supplied by Rust as `aligned_rows` (zero-based line pairs, null for padding). Hunks supply the default context selection and navigation; omitted stretches become ellipsis rows without changing alignment. Press `c` to reveal all source. Older saved streams must be regenerated. +Alignment is supplied by Rust as per-side region trees whose leaves share ids across +sides. Context selection arrives as collapsed `unchanged` leaves; `[` and `]` jump +between runs of changed rows. Saved streams from the v1 wire must be regenerated. @@ -137,10 +206,12 @@ width survives hiding and reopening the tree and is clamped on terminal resize. Navigation uses Hunk's key matcher and defaults: j/k or arrows scroll lines; d/u or Ctrl-D/Ctrl-U scroll half pages; f/b, PageDown/PageUp, or Ctrl-F/Ctrl-B -scroll full pages; Space/Shift-Space also page; g (or gg)/G go to start/end. +scroll full pages; Space/Shift-Space also page; g (or gg)/G go to start/end; `gm` jumps +between the two copies of moved code. h/l or arrows pan horizontally. Cmd-B toggles the tree; backslash is its fallback. -The first NDJSON event includes `files: FileChange[]` in comparison order. +The first NDJSON event includes `files` in comparison order, each with its status, +category, language and default visibility. The tree renders this manifest immediately; pending files are marked ◌ and failures !. Diffs appear in tree order as they arrive, retaining the visible source row when an earlier file loads. Result arrival order need not match manifest order; each diff --git a/tui/package.json b/tui/package.json index ba8e4490d..fc6ef39bf 100644 --- a/tui/package.json +++ b/tui/package.json @@ -5,9 +5,7 @@ "scripts": { "start": "bun run packages/hunk/src/main.tsx", "typecheck": "tsc --noEmit", - "test": "bun test packages", - "test:integration": "bun test test/pty", - "test:tty-smoke": "bun test test/pty" + "test": "bun test packages" }, "dependencies": { "@opentui/core": "0.5.6", diff --git a/tui/packages/hunk/src/diffr/config.test.ts b/tui/packages/hunk/src/diffr/config.test.ts new file mode 100644 index 000000000..2f7b5eff6 --- /dev/null +++ b/tui/packages/hunk/src/diffr/config.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { filterSettings, flattenSchema, fuzzyScore, isDefault, parseValue } from "./config"; +export const schemaFixture = { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "Params", + type: "object", + properties: { + folds: { + type: "object", + properties: { + min_lines: { type: "integer", title: "Shortest body to collapse", "x-group": "Collapsed code", description: "Bodies shorter than this are never summarized or collapsed.", default: 12 }, + collapse_deleted: { type: "boolean", title: "Collapse deleted functions", "x-group": "Collapsed code", description: "Collapse deleted function bodies.", default: true }, + collapse_tests: { type: "boolean", title: "Hide test files", "x-group": "Hidden files", description: "Hide files classed as test.", default: true }, + }, + }, + summarize: { + type: "object", + properties: { + provider: { type: "string", title: "Provider", "x-group": "Summaries", enum: ["gemini", "none"], description: "Model provider.", default: "gemini" }, + api_key: { title: "API key", "x-group": "Summaries", anyOf: [{ type: "string" }, { type: "null" }], description: "API key for the provider.", default: null }, + model: { $ref: "#/$defs/Model", title: "Model", "x-group": "Summaries" }, + }, + }, + }, + $defs: { Model: { type: "string", description: "Model name.", default: "gemini-2.5-flash" } }, +}; +export const valuesFixture = { + folds: { min_lines: 12, collapse_deleted: true, collapse_tests: false }, + summarize: { provider: "gemini", api_key: null, model: "gemini-2.5-flash" }, +}; +test("schema flattens to dotted keys with descriptions, defaults, and current values", () => { + const settings = flattenSchema(schemaFixture, valuesFixture); + expect(settings.map((s) => [s.key, s.type, s.default, s.value])).toEqual([ + ["folds.min_lines", "integer", 12, 12], + ["folds.collapse_deleted", "boolean", true, true], + ["folds.collapse_tests", "boolean", true, false], + ["summarize.provider", "enum", "gemini", "gemini"], + ["summarize.api_key", "string", null, null], + ["summarize.model", "string", "gemini-2.5-flash", "gemini-2.5-flash"], + ]); + expect(settings.map((s) => [s.title, s.group])).toEqual([ + ["Shortest body to collapse", "Collapsed code"], + ["Collapse deleted functions", "Collapsed code"], + ["Hide test files", "Hidden files"], + ["Provider", "Summaries"], + ["API key", "Summaries"], + ["Model", "Summaries"], + ]); + expect(settings[3].options).toEqual(["gemini", "none"]); + expect(settings.map(isDefault)).toEqual([true, true, false, true, true, true]); +}); +test("a setting without a title or group is a schema error", () => { + const schema = structuredClone(schemaFixture) as typeof schemaFixture & Record; + delete (schema.properties.folds.properties.min_lines as Record).title; + expect(() => flattenSchema(schema, valuesFixture)).toThrow("folds.min_lines"); +}); +test("fuzzy filtering narrows over title, key, group and description, keeping groups together", () => { + const settings = flattenSchema(schemaFixture, valuesFixture); + expect(fuzzyScore("cltest", "folds.collapse_tests")).not.toBeNull(); + expect(fuzzyScore("xyz", "folds.collapse_tests")).toBeNull(); + // An empty query keeps schema order, which is already grouped. + expect(filterSettings(settings, "").map((s) => s.key)).toEqual(settings.map((s) => s.key)); + expect(filterSettings(settings, "hide test").map((s) => s.key)).toEqual(["folds.collapse_tests"]); + expect(filterSettings(settings, "api")[0].key).toBe("summarize.api_key"); + // A description mention ("... or collapsed.") still finds the setting. + expect(filterSettings(settings, "shorter").map((s) => s.key)).toEqual(["folds.min_lines"]); + // Matching a group name lists the group in schema order. + expect(filterSettings(settings, "summaries").map((s) => s.key)).toEqual(["summarize.provider", "summarize.api_key", "summarize.model"]); +}); +test("edited values are parsed in the setting's type and bad input is rejected", () => { + const [minLines, collapse, , provider] = flattenSchema(schemaFixture, valuesFixture); + expect(parseValue(minLines, "20")).toBe(20); + expect(() => parseValue(minLines, "2.5")).toThrow("integer"); + expect(parseValue(collapse, "false")).toBe(false); + expect(() => parseValue(collapse, "yes")).toThrow(); + expect(parseValue(provider, "none")).toBe("none"); + expect(() => parseValue(provider, "openai")).toThrow("gemini, none"); +}); diff --git a/tui/packages/hunk/src/diffr/config.ts b/tui/packages/hunk/src/diffr/config.ts new file mode 100644 index 000000000..2d1932054 --- /dev/null +++ b/tui/packages/hunk/src/diffr/config.ts @@ -0,0 +1,175 @@ +/** Read diffr's config schema and values through its CLI, and flatten them into settings rows. */ +import { spawnSync } from "node:child_process"; +export type SettingType = "boolean" | "integer" | "number" | "string" | "enum"; +export interface Setting { + /** Dotted path, e.g. `folds.min_lines`. */ + key: string; + /** The human name shown in place of the key. */ + title: string; + /** The section a settings screen lists this setting under. */ + group: string; + description: string; + type: SettingType; + options?: string[]; + default: unknown; + value: unknown; +} +export interface ConfigClient { + schema(): unknown; + show(): unknown; + set(key: string, value: string): void; +} +interface JsonSchema { + type?: string | string[]; + title?: string; + "x-group"?: string; + description?: string; + default?: unknown; + enum?: unknown[]; + properties?: Record; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + $ref?: string; + $defs?: Record; + definitions?: Record; +} +function resolve(schema: JsonSchema, root: JsonSchema): JsonSchema { + if (!schema.$ref) return schema; + const name = schema.$ref.replace(/^#\/(\$defs|definitions)\//, ""); + const target = root.$defs?.[name] ?? root.definitions?.[name]; + if (!target) throw new Error(`Unresolved schema reference ${schema.$ref}`); + // Keywords beside a `$ref` describe this use of the target, so they win over the target's own. + const { $ref: _, ...siblings } = schema; + return { ...resolve(target, root), ...siblings }; +} +/** `Option` derives to `anyOf: [T, null]`; settings edit the `T`. */ +function unwrap(schema: JsonSchema, root: JsonSchema): JsonSchema { + const resolved = resolve(schema, root); + const variants = resolved.anyOf ?? resolved.oneOf; + if (!variants) return resolved; + const present = variants.map((v) => resolve(v, root)).filter((v) => v.type !== "null"); + if (present.length !== 1) throw new Error("Settings only support optional single-typed values"); + return { + ...present[0], + title: resolved.title ?? present[0].title, + "x-group": resolved["x-group"] ?? present[0]["x-group"], + description: resolved.description ?? present[0].description, + default: "default" in resolved ? resolved.default : present[0].default, + }; +} +function settingType(schema: JsonSchema): SettingType { + if (schema.enum) return "enum"; + const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type; + if (type === "boolean" || type === "integer" || type === "number" || type === "string") return type; + throw new Error(`Unsupported setting type ${String(type)}`); +} +export function flattenSchema(rawSchema: unknown, rawValues: unknown): Setting[] { + const root = rawSchema as JsonSchema; + const settings: Setting[] = []; + const walk = (schema: JsonSchema, values: unknown, prefix: string) => { + const node = unwrap(schema, root); + if (node.properties) { + for (const [name, child] of Object.entries(node.properties)) { + const value = values && typeof values === "object" ? (values as Record)[name] : undefined; + walk(child, value, prefix ? `${prefix}.${name}` : name); + } + return; + } + const type = settingType(node); + if (!node.title || !node["x-group"]) throw new Error(`Setting ${prefix} has no title or x-group in the schema`); + settings.push({ + key: prefix, + title: node.title, + group: node["x-group"], + description: node.description ?? "", + type, + options: type === "enum" ? node.enum!.map(String) : undefined, + default: node.default, + value: values, + }); + }; + walk(root, rawValues, ""); + return settings; +} +/** Subsequence match: every query character in order, scored by tightness and word starts. */ +export function fuzzyScore(query: string, text: string): number | null { + const q = query.toLowerCase(), t = text.toLowerCase(); + if (!q) return 0; + let score = 0, position = 0; + for (const char of q) { + const index = t.indexOf(char, position); + if (index < 0) return null; + score += index === position ? 3 : index === 0 || /[^a-z0-9]/.test(t[index - 1]) ? 2 : 1; + position = index + 1; + } + return score; +} +/** Titles and keys match fuzzily; group names and descriptions only as whole substrings, so prose does not swamp the list. */ +function settingScore(setting: Setting, query: string): number | null { + const substring = (text: string) => (text.toLowerCase().includes(query.toLowerCase()) ? query.length : null); + const scores = [ + fuzzyScore(query, setting.title), + fuzzyScore(query, setting.key), + substring(setting.group), + substring(setting.description), + ].filter((score): score is number => score !== null); + return scores.length ? Math.max(...scores) : null; +} +/** Settings matching `query`, in schema order within each group; groups follow their best match. */ +export function filterSettings(settings: Setting[], query: string): Setting[] { + const matches = settings + .map((setting, index) => ({ setting, index, score: settingScore(setting, query) })) + .filter((entry): entry is { setting: Setting; index: number; score: number } => entry.score !== null); + const best = new Map(); + for (const { setting, score } of matches) best.set(setting.group, Math.max(best.get(setting.group) ?? -1, score)); + const firstIndex = new Map(); + for (const { setting, index } of matches) if (!firstIndex.has(setting.group)) firstIndex.set(setting.group, index); + return matches + .sort( + (a, b) => + best.get(b.setting.group)! - best.get(a.setting.group)! || + firstIndex.get(a.setting.group)! - firstIndex.get(b.setting.group)! || + b.score - a.score || + a.index - b.index, + ) + .map((entry) => entry.setting); +} +export const formatValue = (value: unknown) => + value === undefined || value === null ? "" : typeof value === "string" ? value : JSON.stringify(value); +export const isDefault = (setting: Setting) => formatValue(setting.value) === formatValue(setting.default); +/** Parse an edited value in the setting's type; throws on malformed input. */ +export function parseValue(setting: Setting, text: string): unknown { + switch (setting.type) { + case "boolean": + if (text !== "true" && text !== "false") throw new Error("Expected true or false"); + return text === "true"; + case "integer": + case "number": { + const number = Number(text); + if (text.trim() === "" || Number.isNaN(number) || (setting.type === "integer" && !Number.isInteger(number))) + throw new Error(`Expected ${setting.type === "integer" ? "an integer" : "a number"}`); + return number; + } + case "enum": + if (!setting.options!.includes(text)) throw new Error(`Expected one of ${setting.options!.join(", ")}`); + return text; + case "string": + return text; + } +} +function run(binary: string, args: string[]): string { + const result = spawnSync(binary, args, { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr.trim() || `diffr ${args.join(" ")} exited with ${result.status}`); + return result.stdout; +} +/** The CLI contract: `config schema`, `config show --json`, `config set `. */ +export function cliClient(binary: string): ConfigClient { + return { + schema: () => JSON.parse(run(binary, ["config", "schema"])), + show: () => JSON.parse(run(binary, ["config", "show", "--json"])), + set: (key, value) => { + run(binary, ["config", "set", key, value]); + }, + }; +} diff --git a/tui/packages/hunk/src/diffr/counts.test.ts b/tui/packages/hunk/src/diffr/counts.test.ts new file mode 100644 index 000000000..0bad94f1c --- /dev/null +++ b/tui/packages/hunk/src/diffr/counts.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { blockBar, comparisonLabel } from "./counts"; +test("the block bar splits five blocks by share and keeps a block for any non-zero side", () => { + expect(blockBar({ added: 0, removed: 0 })).toEqual(["neutral", "neutral", "neutral", "neutral", "neutral"]); + expect(blockBar({ added: 10, removed: 0 })).toEqual(["added", "added", "added", "added", "added"]); + expect(blockBar({ added: 3, removed: 1 })).toEqual(["added", "added", "added", "removed", "neutral"]); + expect(blockBar({ added: 1, removed: 1 })).toEqual(["added", "added", "removed", "removed", "neutral"]); + expect(blockBar({ added: 99, removed: 1 })).toEqual(["added", "added", "added", "added", "removed"]); + expect(blockBar({ added: 13, removed: 6 })).toEqual(["added", "added", "added", "removed", "neutral"]); + expect(blockBar({ added: 1, removed: 99 })).toEqual(["added", "removed", "removed", "removed", "removed"]); +}); +test("comparison labels shorten shas and name the special snapshots", () => { + expect(comparisonLabel({ type: "revision", rev: "main" }, { type: "revision", rev: "0123456789abcdef0123456789abcdef01234567" })) + .toBe("main…0123456"); + expect(comparisonLabel({ type: "index" }, { type: "working_tree" })).toBe("index…working tree"); + expect(comparisonLabel({ type: "empty_tree" }, { type: "path", path: "after.ts" })).toBe("empty tree…after.ts"); +}); diff --git a/tui/packages/hunk/src/diffr/counts.ts b/tui/packages/hunk/src/diffr/counts.ts new file mode 100644 index 000000000..8244648fe --- /dev/null +++ b/tui/packages/hunk/src/diffr/counts.ts @@ -0,0 +1,45 @@ +/** Change counts for the headers. The numbers are diffr's `stats`, shown verbatim: folding + * changes what is on screen, never the count. */ +import type { LineCounts } from "./wire"; +export type { LineCounts }; +export const zero: LineCounts = { added: 0, removed: 0 }; +export const add = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added + b.added, removed: a.removed + b.removed }); +/** GitHub's five-block bar: whole blocks by share, any non-zero side keeps one, the rest grey. */ +export function blockBar(counts: LineCounts, blocks = 5): ("added" | "removed" | "neutral")[] { + const total = counts.added + counts.removed; + if (total === 0) return Array.from({ length: blocks }, () => "neutral"); + let green = Math.floor((blocks * counts.added) / total); + let red = Math.floor((blocks * counts.removed) / total); + if (counts.added > 0 && green === 0) green = 1; + if (counts.removed > 0 && red === 0) red = 1; + while (green + red > blocks) { + if (green >= red) green--; + else red--; + } + return [ + ...Array.from({ length: green }, () => "added" as const), + ...Array.from({ length: red }, () => "removed" as const), + ...Array.from({ length: blocks - green - red }, () => "neutral" as const), + ]; +} +export type Snapshot = + | { type: "revision"; rev: string } + | { type: "index" } + | { type: "working_tree" } + | { type: "empty_tree" } + | { type: "path"; path: string }; +export function snapshotLabel(snapshot: Snapshot): string { + switch (snapshot.type) { + case "revision": + return /^[0-9a-f]{40}$/.test(snapshot.rev) ? snapshot.rev.slice(0, 7) : snapshot.rev; + case "index": + return "index"; + case "working_tree": + return "working tree"; + case "empty_tree": + return "empty tree"; + case "path": + return snapshot.path; + } +} +export const comparisonLabel = (lhs: Snapshot, rhs: Snapshot) => `${snapshotLabel(lhs)}…${snapshotLabel(rhs)}`; diff --git a/tui/packages/hunk/src/diffr/fileTree.test.ts b/tui/packages/hunk/src/diffr/fileTree.test.ts index 59c7b9d3e..fc92c6458 100644 --- a/tui/packages/hunk/src/diffr/fileTree.test.ts +++ b/tui/packages/hunk/src/diffr/fileTree.test.ts @@ -4,7 +4,7 @@ import { createTestDiffFile } from "./fixture"; test("tree shares directories, retains file identities, and folds subtrees", () => { const files = ["src/ui/App.tsx", "README.md", "src/core.rs", "test/ui/App.tsx"].map(path => { const file = createTestDiffFile(); - file.file.new_path = path; + file.file = { rhs: { path, oid: "0", mode: "100644" } }; return file; }); const tree = buildFileTree(files); @@ -14,6 +14,7 @@ test("tree shares directories, retains file identities, and folds subtrees", () ]); expect(flattenFileTree(tree, new Set(["/src"])).some(r => r.node.fileIndex === 0)).toBe(false); expect(parentDirectories(files[0])).toEqual(["/src", "/src/ui"]); - files[0].diff.hunks.push(files[0].diff.hunks[0]); - expect(lineCounts(files[0])).toEqual({added: 2, removed: 1}); + expect(lineCounts(files[0])).toEqual({ textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 }, fallback: undefined }); + files[0].diff = { type: "binary", lhs: { size: 1 } }; + expect(lineCounts(files[0])).toEqual({ textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }); }); diff --git a/tui/packages/hunk/src/diffr/fileTree.ts b/tui/packages/hunk/src/diffr/fileTree.ts index eae2bfe0d..d16d1fc92 100644 --- a/tui/packages/hunk/src/diffr/fileTree.ts +++ b/tui/packages/hunk/src/diffr/fileTree.ts @@ -1,4 +1,4 @@ -import type { DiffFile } from "./wire"; +import { filePath, type DiffFile, type FileChange } from "./wire"; export interface TreeNode { key: string; @@ -11,11 +11,11 @@ export interface TreeRow { depth: number; } /** Paths identify directory nodes; streamed file indexes preserve diff navigation. */ -export function buildFileTree(files: Pick[]): TreeNode[] { +export function buildFileTree(files: Pick[]): TreeNode[] { const roots: TreeNode[] = []; const directories = new Map(); files.forEach((file, fileIndex) => { - const path = file.file.new_path ?? file.file.old_path ?? ""; + const path = filePath(file.file); const parts = path.split("/").filter(Boolean); let children = roots, prefix = ""; parts.forEach((name, i) => { @@ -48,14 +48,12 @@ export function flattenFileTree(nodes: TreeNode[], closed: Set, depth = ? flattenFileTree(node.children, closed, depth + 1) : []), ]); } -export function parentDirectories(file: Pick): string[] { - const parts = (file.file.new_path ?? file.file.old_path ?? "").split("/").filter(Boolean); +export function parentDirectories(file: Pick): string[] { + const parts = filePath(file.file).split("/").filter(Boolean); return parts.slice(0, -1).map((_, i) => "/" + parts.slice(0, i + 1).join("/")); } -/** Count novel source lines once even when context hunks overlap. */ +/** The wire's counts, verbatim: textual and visible, and why the AST match fell back if it did. */ export function lineCounts(file: DiffFile) { - return { - added: new Set(file.diff.hunks.flatMap(h => h.novel_rhs)).size, - removed: new Set(file.diff.hunks.flatMap(h => h.novel_lhs)).size, - }; + if (file.diff.type === "binary") return { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }; + return { textual: file.diff.stats.textual, visible: file.diff.stats.visible, fallback: file.diff.stats.fallback }; } diff --git a/tui/packages/hunk/src/diffr/fixture.ts b/tui/packages/hunk/src/diffr/fixture.ts index 19302ba50..4198a58f5 100644 --- a/tui/packages/hunk/src/diffr/fixture.ts +++ b/tui/packages/hunk/src/diffr/fixture.ts @@ -1,53 +1,65 @@ -/** Provide a hand-authored source correspondence for renderer and protocol tests. */ -import type { DiffFile } from "./wire"; +/** Provide a hand-authored wire v3 file record for renderer and protocol tests. */ +import type { DiffFile, Region, Span } from "./wire"; +const pos = (line: number, column = 0) => ({ line, column }); +export function leaf(id: number, start: number, end: number, changed: Span[] = []): Region { + return { alignment_id: id, fold_state_id: id, start: pos(start), end: pos(end), tags: [], + visibility: { collapsed: false, label: "" }, kind: "leaf", changed, children: [] }; +} +export function fold( + id: number, + start: [number, number], + end: [number, number], + children: Region[], + label = "Body", + tags = ["body"], + collapsed = false, +): Region { + return { alignment_id: id, fold_state_id: id, start: pos(...start), end: pos(...end), tags, + visibility: { collapsed, label }, kind: "fold", changed: [], children }; +} +export const line = (line: number, start_column: number, end_column: number): Span => + ({ line, start_column, end_column }); export function createTestDiffFile(): DiffFile { return { type: "file", file: { - old_path: "demo.ts", - new_path: "demo.ts", - class: null, - status: "modified", + lhs: { path: "demo.ts", oid: "1111111", mode: "100644" }, + rhs: { path: "demo.ts", oid: "2222222", mode: "100644" }, }, diff: { - display_path: "demo.ts", - extra_info: null, - file_format: { SupportedLanguage: "TypeScript" }, - lhs_src: { Text: 'start();\nsend("old");\nfinish();\n' }, - rhs_src: { Text: 'start();\nsend("new");\nextra();\nfinish();\n' }, - lhs_positions: [ - { - pos: { line: 1, start_col: 6, end_col: 9 }, - kind: { - NovelWord: { highlight: { Atom: { String: "StringLiteral" } } }, - }, - }, - ], - rhs_positions: [ - { - pos: { line: 1, start_col: 6, end_col: 9 }, - kind: { - NovelWord: { highlight: { Atom: { String: "StringLiteral" } } }, - }, - }, - ], - aligned_rows: [[0, 0], [1, 1], [null, 2], [2, 3]], - hunks: [ - { - novel_lhs: [1], - novel_rhs: [1, 2], - lines: [ - [0, 0], - [1, 1], - [null, 2], - [2, 3], - ], - }, - ], - lhs_folds: [], - rhs_folds: [], - has_byte_changes: [3, 4], - has_syntactic_changes: true, + type: "text", + lhs: { + text: 'start();\nsend("old");\nfinish();\n', + syntax: [ + { line: 1, start_column: 0, end_column: 4, capture: "function.call" }, + { line: 1, start_column: 5, end_column: 10, capture: "string" }, + ], + regions: [leaf(1, 0, 1), leaf(2, 1, 2, [line(1, 6, 9)]), leaf(4, 2, 3)], + }, + rhs: { + text: 'start();\nsend("new");\nextra();\nfinish();\n', + syntax: [ + { line: 1, start_column: 0, end_column: 4, capture: "function.call" }, + { line: 1, start_column: 5, end_column: 10, capture: "string" }, + ], + regions: [ + leaf(1, 0, 1), + leaf(2, 1, 2, [line(1, 6, 9)]), + leaf(3, 2, 3, [line(2, 0, 8)]), + leaf(4, 3, 4), + ], + }, + stats: { textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 } }, }, }; } +/** Replace both sides with identical numbered lines paired in one leaf. */ +export function withIdenticalLines(file: DiffFile, count: number): DiffFile { + const lines = Array.from({ length: count }, (_, i) => `line ${i}`); + const text = lines.join("\n"); + const source = () => ({ text, syntax: [], regions: [leaf(1, 0, count)] }); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = source(); + file.diff.rhs = source(); + return file; +} diff --git a/tui/packages/hunk/src/diffr/folds.test.ts b/tui/packages/hunk/src/diffr/folds.test.ts deleted file mode 100644 index 3b7af3e50..000000000 --- a/tui/packages/hunk/src/diffr/folds.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, test } from "bun:test"; -import { createTestDiffFile } from "./fixture"; -import { foldRegions, hiddenLines, nestedRegions } from "./folds"; -import { dark, rowsForFile } from "./rows"; -import type { DiffFile } from "./wire"; -/** Rust-style body folds: header and closing brace stay visible, like VS Code. */ -export function createFoldedDiffFile(): DiffFile { - const file = createTestDiffFile(); - const lines = [ - "fn outer() {", // 0 - " inner(|| {", // 1 - " a();", // 2 - " b();", // 3 - " });", // 4 - " // trailing", // 5 - " // comment", // 6 - "}", // 7 - ]; - const text = lines.join("\n") + "\n"; - file.diff.lhs_src = file.diff.rhs_src = { Text: text }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{ novel_lhs: [], novel_rhs: [3], lines: file.diff.aligned_rows }]; - const range = (a: [number, number], b: [number, number]) => ({ - start: { line: a[0], byte_column: a[1] }, - end: { line: b[0], byte_column: b[1] }, - }); - const paired = (r: ReturnType, placeholder: string, tags: string[]) => ({ - tags, range: r, match_kind: { Unchanged: { opposite: r } }, placeholder, - }); - file.diff.rhs_folds = [ - paired(range([0, 12], [7, 0]), "Body", ["body"]), - paired(range([1, 14], [4, 4]), "Body", ["body"]), - // A whole-node fold ending at end of line hides its last line too. - paired(range([5, 4], [6, 14]), "Comment", ["comment"]), - // Single-line folds are never foldable. - paired(range([2, 8], [2, 11]), "Call", ["call"]), - ]; - file.diff.lhs_folds = file.diff.rhs_folds; - return file; -} -test("regions keep headers visible, hide trailing lines only when nothing follows", () => { - const regions = foldRegions(createFoldedDiffFile().diff); - const right = regions.filter((r) => r.side === 1); - expect(right.map((r) => [r.id, r.startLine, r.endLine, r.hideEnd])).toEqual([ - ["R0:7", 0, 7, false], - ["R1:4", 1, 4, false], - ["R5:6", 5, 6, true], - ]); - // Unchanged folds share ids across sides so both collapse together. - expect(regions.filter((r) => r.side === 0).map((r) => r.id)).toEqual(["R0:7", "R1:4", "R5:6"]); - expect(nestedRegions(right, right[0]).map((r) => r.id)).toEqual(["R1:4", "R5:6"]); -}); -test("collapsed regions hide each side's lines between header and close", () => { - const diff = createFoldedDiffFile().diff; - const regions = foldRegions(diff); - const hidden = hiddenLines(diff.aligned_rows, regions, new Set(["R1:4", "R5:6"]), () => true); - expect(hidden.map((side) => [...side].sort())).toEqual([[2, 3, 6], [2, 3, 6]]); - // A fold whose header the viewer does not show cannot hide anything. - expect(hiddenLines(diff.aligned_rows, regions, new Set(["R1:4"]), () => false)[1].size).toBe(0); -}); -test("a fold on one side leaves the other side's lines beside blank cells", () => { - const file = createFoldedDiffFile(); - // The right-hand closure body is novel; the left keeps two lines aligned inside it. - file.diff.rhs_folds = [{ - tags: ["body"], placeholder: "Body", match_kind: "Novel", - range: { start: { line: 1, byte_column: 14 }, end: { line: 4, byte_column: 4 } }, - }]; - file.diff.lhs_folds = []; - const rows = rowsForFile(file, 0, "split", dark, true, new Set(["R1:4"])).filter((r) => r.left); - expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ - [1, 1], [2, 2], [3, undefined], [4, undefined], [5, 5], [6, 6], [7, 7], [8, 8], - ]); - expect(rows[2].right!.kind).toBe("empty"); - expect(rows[1].right!.fold?.collapsed).toBe(true); - expect(rows[1].left!.fold).toBeUndefined(); -}); -test("rows carry fold headers on both layouts and drop hidden lines", () => { - const file = createFoldedDiffFile(); - const split = rowsForFile(file, 0, "split", dark, true, new Set(["R1:4"])).filter((r) => r.right); - expect(split.map((r) => r.right!.lineNumber)).toEqual([1, 2, 5, 6, 7, 8]); - expect(split[1].right!.fold).toEqual({ id: "R1:4", placeholder: "Body", collapsed: true }); - expect(split[1].left!.fold).toEqual({ id: "R1:4", placeholder: "Body", collapsed: true }); - expect(split[0].right!.fold?.collapsed).toBe(false); - const unified = rowsForFile(file, 0, "unified", dark, true, new Set(["R0:7"])).filter((r) => r.cell); - expect(unified.map((r) => r.cell!.newLineNumber)).toEqual([1, 8]); - expect(unified[0].cell!.fold?.id).toBe("R0:7"); -}); diff --git a/tui/packages/hunk/src/diffr/folds.ts b/tui/packages/hunk/src/diffr/folds.ts deleted file mode 100644 index d920381cf..000000000 --- a/tui/packages/hunk/src/diffr/folds.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** Derive VS Code-style fold regions from diffr folds; collapsed state belongs to the viewer. */ -import type { DiffResult } from "./wire"; -export type Side = 0 | 1; -export interface FoldRegion { - /** Shared by an unchanged fold and its opposite, so both sides collapse together. */ - id: string; - side: Side; - /** The header line, always visible, carrying the chevron and the placeholder. */ - startLine: number; - /** The last line of the fold range; hidden only when nothing follows the range on it. */ - endLine: number; - hideEnd: boolean; - placeholder: string; -} -export interface RowFold { - id: string; - placeholder: string; - collapsed: boolean; -} -export const sourceLines = (text: string) => - text === "" ? [] : text.replace(/\n$/, "").split("\n"); -const encoder = new TextEncoder(); -function regionsForSide(diff: DiffResult, side: Side): FoldRegion[] { - const source = side ? diff.rhs_src : diff.lhs_src; - const folds = side ? diff.rhs_folds : diff.lhs_folds; - if (source === "Binary") return []; - const lines = sourceLines(source.Text); - const byStart = new Map(); - for (const fold of folds) { - const { start, end } = fold.range; - const endLine = lines[end.line]; - if (endLine === undefined) - throw new Error(`diffr fold references missing line ${end.line}`); - const hideEnd = encoder.encode(endLine.trimEnd()).length <= end.byte_column; - const hiddenLines = end.line - start.line - (hideEnd ? 0 : 1); - if (hiddenLines < 1) continue; - const paired = - fold.match_kind === "Novel" - ? null - : side - ? fold.range - : fold.match_kind.Unchanged.opposite; - const id = paired - ? `R${paired.start.line}:${paired.end.line}` - : `${side ? "R" : "L"}${start.line}:${end.line}`; - const region = { id, side, startLine: start.line, endLine: end.line, hideEnd, - placeholder: fold.placeholder }; - // Like VS Code, one region per header line: keep the outermost. - const existing = byStart.get(start.line); - if (!existing || existing.endLine < end.line) byStart.set(start.line, region); - } - return [...byStart.values()].sort((a, b) => a.startLine - b.startLine); -} -export function foldRegions(diff: DiffResult): FoldRegion[] { - return [...regionsForSide(diff, 0), ...regionsForSide(diff, 1)]; -} -/** Regions strictly inside another on the same side, for Alt-click recursive folding. */ -export function nestedRegions(regions: FoldRegion[], outer: FoldRegion) { - return regions.filter( - (r) => r.side === outer.side && r.startLine > outer.startLine && r.endLine <= outer.endLine, - ); -} -/** First aligned row showing each source line, per side. */ -function rowsOfLines(alignedRows: DiffResult["aligned_rows"]) { - const rowOfLine = [new Map(), new Map()]; - for (const [index, pair] of alignedRows.entries()) - for (const side of [0, 1] as const) { - const line = pair[side]; - if (line !== null && !rowOfLine[side].has(line)) rowOfLine[side].set(line, index); - } - return rowOfLine; -} -/** Source lines hidden per side. Each side folds independently; alignment is never recomputed. */ -export function hiddenLines( - alignedRows: DiffResult["aligned_rows"], - regions: FoldRegion[], - collapsed: ReadonlySet, - headerShown: (rowIndex: number) => boolean, -): [Set, Set] { - const rowOfLine = rowsOfLines(alignedRows); - const hidden: [Set, Set] = [new Set(), new Set()]; - for (const region of regions) { - if (!collapsed.has(region.id)) continue; - const startRow = rowOfLine[region.side].get(region.startLine); - if (startRow === undefined) - throw new Error(`diffr fold at line ${region.startLine} is not aligned`); - if (!headerShown(startRow)) continue; - const last = region.endLine - (region.hideEnd ? 0 : 1); - for (let line = region.startLine + 1; line <= last; line++) hidden[region.side].add(line); - } - return hidden; -} -/** Header rows keyed by aligned-row index, one per side. */ -export function foldHeaders( - alignedRows: DiffResult["aligned_rows"], - regions: FoldRegion[], - collapsed: ReadonlySet, -): [Map, Map] { - const rowOfLine = rowsOfLines(alignedRows); - const headers: [Map, Map] = [new Map(), new Map()]; - for (const region of regions) { - const row = rowOfLine[region.side].get(region.startLine); - if (row === undefined) - throw new Error(`diffr fold at line ${region.startLine} is not aligned`); - headers[region.side].set(row, { - id: region.id, - placeholder: region.placeholder, - collapsed: collapsed.has(region.id), - }); - } - return headers; -} diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts new file mode 100644 index 000000000..bdfaabecd --- /dev/null +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -0,0 +1,231 @@ +import { expect, test } from "bun:test"; +import { createTestDiffFile, fold, leaf, line } from "./fixture"; +import { alignmentIds, defaultCollapsed, flatten, foldHeaders, foldIds, foldTint, gapIds, hiddenLines } from "./regions"; +import { dark, rowsForFile } from "./rows"; +import type { DiffFile, Region } from "./wire"; +import { foldBackground } from "../ui/diff/CodeRowView"; +/** Rust-style body folds: header and closing brace stay visible, like VS Code. */ +export function createFoldedDiffFile(): DiffFile { + const file = createTestDiffFile(); + const lines = [ + "fn outer() {", // 0 + " inner(|| {", // 1 + " a();", // 2 + " b();", // 3 + " });", // 4 + " // trailing", // 5 + " // comment", // 6 + "}", // 7 + ]; + const text = lines.join("\n") + "\n"; + // ids: 10 outer body, 11 closure body, 12 comment; leaves 1..5 tile the file. + const regions = (changed: boolean): Region[] => [ + fold(10, [0, 12], [7, 0], [ + leaf(1, 0, 1), + fold(11, [1, 14], [4, 4], [leaf(2, 1, 3), leaf(3, 3, 4, changed ? [line(3, 8, 12)] : []), leaf(4, 4, 5)]), + // A whole-node fold ending at end of line hides its last line too. + fold(12, [5, 4], [6, 14], [leaf(5, 5, 7)], "Comment", ["comment"]), + leaf(6, 7, 8), + ]), + ]; + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = { text, syntax: [], regions: regions(false) }; + file.diff.rhs = { text, syntax: [], regions: regions(true) }; + file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } }; + return file; +} +test("folds keep headers visible, hide trailing lines only when nothing follows", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + const { folds, leaves } = flatten(file.diff); + expect(folds[1].map((f) => [f.foldStateId, f.headerLine, f.lastHidden])).toEqual([[10, 0, 6], [11, 1, 3], [12, 5, 6]]); + expect(folds[1][0].nested).toEqual([11, 12]); + expect(leaves[1].map((l) => [l.foldStateId, l.startLine, l.endLine])).toEqual([[1, 0, 1], [2, 1, 3], [3, 3, 4], [4, 4, 5], [5, 5, 7], [6, 7, 8]]); + expect([...hiddenLines(folds[1], new Set([11, 12]))].sort()).toEqual([2, 3, 6]); + // A fold hidden inside a collapsed outer fold hides nothing of its own. + expect([...hiddenLines(folds[1], new Set([10, 11]))].sort()).toEqual([1, 2, 3, 4, 5, 6]); + expect(foldHeaders(folds[1], leaves[1], new Set([11]), alignmentIds(file.diff)[0]).get(1)).toEqual({ id: 11, label: "Body", collapsed: true, tint: "neutral" }); +}); +test("visibility seeds collapsed ids and tags name the context gaps", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.rhs!.regions[0].visibility = { collapsed: true, label: "Body" }; + const gap = leaf(6, 7, 8); + gap.tags = ["unchanged"]; + gap.visibility = { collapsed: true, label: "1 unchanged line" }; + file.diff.rhs!.regions[0].children[3] = gap; + expect([...defaultCollapsed(file.diff)]).toEqual([6, 10]); + expect(gapIds(file.diff)).toEqual([6]); +}); +test("a fold on one side leaves the other side's lines beside blank cells", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // The right-hand closure body is a novel fold with its own leaves; the left keeps flat leaves. + file.diff.lhs!.regions = [leaf(1, 0, 1), leaf(2, 1, 3), leaf(3, 3, 4), leaf(4, 4, 5), leaf(5, 5, 7), leaf(6, 7, 8)]; + const rows = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.left); + expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ + [1, 1], [2, 2], [3, undefined], [4, undefined], [5, 5], [6, 6], [7, 7], [8, 8], + ]); + expect(rows[2].right!.kind).toBe("empty"); + expect(rows[1].right!.fold?.collapsed).toBe(true); + expect(rows[1].left!.fold).toBeUndefined(); +}); +test("rows carry fold headers on both layouts and drop hidden lines", () => { + const file = createFoldedDiffFile(); + const split = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.right); + expect(split.map((r) => r.right!.lineNumber)).toEqual([1, 2, 5, 6, 7, 8]); + expect(split[1].right!.fold).toEqual({ id: 11, label: "Body", collapsed: true, tint: "neutral" }); + expect(split[1].left!.fold).toEqual({ id: 11, label: "Body", collapsed: true, tint: "neutral" }); + expect(split[0].right!.fold?.collapsed).toBe(false); + const unified = rowsForFile(file, 0, "unified", dark, new Set([10])).filter((r) => r.cell); + expect(unified.map((r) => r.cell!.newLineNumber)).toEqual([1, 8]); + expect(unified[0].cell!.fold?.id).toBe(10); +}); +test("a multi-line label hangs under the collapsed header inside the fold tint", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + const pseudocode = "# pseudocode\ncall a\ncall b"; + file.diff.rhs!.regions[0].children[1].visibility = { collapsed: false, label: pseudocode }; + file.diff.lhs!.regions[0].children[1].visibility = { collapsed: false, label: pseudocode }; + const rows = rowsForFile(file, 0, "split", dark, new Set([11])); + const labels = rows.filter((r) => r.right?.foldLabel); + expect(labels.map((r) => r.right!.spans[0].text)).toEqual([" # pseudocode", " call a", " call b"]); + expect(labels.map((r) => r.left!.spans[0].text)).toEqual([" # pseudocode", " call a", " call b"]); + expect(rows.indexOf(labels[0])).toBe(rows.findIndex((r) => r.right?.fold?.id === 11) + 1); + const unified = rowsForFile(file, 0, "unified", dark, new Set([11])); + expect(unified.filter((r) => r.cell?.foldLabel)).toHaveLength(3); +}); +test("a collapsed leaf is one fold row with the chevron, its label, and no line number", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs!.regions[0].visibility = file.diff.rhs!.regions[0].visibility = { collapsed: true, label: "1 unchanged line" }; + const split = rowsForFile(file, 0, "split", dark, new Set([1])); + expect(split[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true, tint: "neutral" }); + expect(split[1].left!.lineNumber).toBeUndefined(); + expect(split[1].right!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true, tint: "neutral" }); + expect(split.filter((r) => r.left?.fold?.id === 1)).toHaveLength(1); + const unified = rowsForFile(file, 0, "unified", dark, new Set([1])); + expect(unified[1].cell).toMatchObject({ fold: { id: 1, collapsed: true } }); + // Open, the leaf's first line carries the chevron so it can be collapsed again. + expect(rowsForFile(file, 0, "split", dark, new Set())[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: false, tint: "neutral" }); + // Unlabelled gaps get a computed label; ordinary leaves are not foldable. + const tagged = createTestDiffFile(); + if (tagged.diff.type !== "text") throw new Error(); + tagged.diff.rhs!.regions[0].tags = ["unchanged"]; + const rows = rowsForFile(tagged, 0, "split", dark, new Set()); + expect(rows[1].right!.fold).toEqual({ id: 1, label: "1 unchanged lines", collapsed: false, tint: "neutral" }); + expect(rows[1].left!.fold).toBeUndefined(); + expect(rows[2].right!.fold).toBeUndefined(); +}); + +test("a fold that runs to the end of the file may end one past its last line", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = "fn a() {\n b();\n}\n"; + // Three lines; the fold's hull ends at (3, 0), just past the last line. + const regions: Region[] = [fold(10, [0, 0], [3, 0], [leaf(1, 0, 1), leaf(2, 1, 3)])]; + file.diff.lhs = { text, syntax: [], regions }; + file.diff.rhs = { text, syntax: [], regions }; + const { folds } = flatten(file.diff); + expect(folds[0][0]?.lastHidden).toBe(2); +}); + +test("regions sharing a fold_state_id collapse and expand as one bundle", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = [ + "/// Adds two numbers.", // 0 + "/// Wraps on overflow.", // 1 + "fn add(a: u8, b: u8) -> u8 {", // 2 + " a.wrapping_add(b)", // 3 + "}", // 4 + ].join("\n") + "\n"; + // The docstring (alignment 20) and the function body (alignment 21) share fold state 21. + const docstring: Region = { ...fold(20, [0, 0], [2, 0], [leaf(1, 0, 2)], "", ["comment"], true), fold_state_id: 21 }; + const body = fold(21, [2, 28], [4, 0], [leaf(2, 2, 4)], "// pseudocode\nreturn a + b", ["body", "function"], true); + const regions: Region[] = [docstring, body, leaf(3, 4, 5)]; + file.diff.lhs = { text, syntax: [], regions }; + file.diff.rhs = { text, syntax: [], regions }; + const collapsed = defaultCollapsed(file.diff); + expect([...collapsed]).toEqual([21]); + const { folds } = flatten(file.diff); + // One id hides both the docstring's continuation and the body. + expect([...hiddenLines(folds[1], collapsed)].sort()).toEqual([1, 3]); + expect([...hiddenLines(folds[1], new Set())]).toEqual([]); + const rows = rowsForFile(file, 0, "split", dark, collapsed).filter((r) => r.right && !r.right.foldLabel); + // The docstring is a one-row ⋯ fold (empty label), the signature stays visible, and both headers + // carry the same id, so either chevron toggles the pair. + const headers = rows.filter((r) => r.right!.fold).map((r) => [r.right!.lineNumber, r.right!.fold!.id, r.right!.fold!.label, r.right!.fold!.collapsed]); + expect(headers).toEqual([[1, 21, "", true], [3, 21, "// pseudocode\nreturn a + b", true]]); + expect(rows.map((r) => r.right!.lineNumber)).toEqual([1, 3, 5]); +}); + +/** + * The shape diffr emits for a new, summarized function (src/category.rs `from_path`, + * src/protocol/project.rs `syntax_spans`): a one-line docstring leaf tagged `docstring`, collapsed + * with an empty label, sharing fold state 4 with the collapsed function fold after it, right side only. + */ +export function createBundledDiffFile(): DiffFile { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = [ + "];", // 0 + "/// The built-in rule for a path.", // 1 + "pub(crate) fn from_path(path: &str) -> Option<&str> {", // 2 + " None", // 3 + "}", // 4 + ].join("\n") + "\n"; + const docstring: Region = { + ...leaf(38, 1, 2), fold_state_id: 4, tags: ["docstring"], visibility: { collapsed: true, label: "" }, + }; + const body = fold(4, [2, 52], [4, 0], [leaf(5, 2, 4)], "// pseudocode\nreturn None", ["body", "function"], true); + file.diff.rhs = { text, syntax: [], regions: [leaf(3, 0, 1), docstring, body, leaf(6, 4, 5)] }; + file.diff.lhs = undefined; + return file; +} +test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ row", () => { + const file = createBundledDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const collapsed = defaultCollapsed(file.diff); + expect([...collapsed]).toEqual([4]); + expect(foldIds(file.diff)).toContain(4); + const rows = rowsForFile(file, 0, "split", dark, collapsed).filter((r) => r.right && !r.right.foldLabel); + // Line 1 ("];"), then the docstring as one ⋯ fold row, then the signature carrying the body fold. + expect(rows.map((r) => [r.right!.lineNumber, r.right!.fold?.id, r.right!.fold?.label, r.right!.fold?.collapsed])) + .toEqual([[1, undefined, undefined, undefined], [undefined, 4, "", true], [3, 4, "// pseudocode\nreturn None", true], [5, undefined, undefined, undefined]]); + // Right side only, so both the ⋯ row and the summary take the added tint. + expect(rows.filter((r) => r.right!.fold).map((r) => r.right!.fold!.tint)).toEqual(["inserted", "inserted"]); + // Expanding the shared id reveals the docstring line too. + const open = rowsForFile(file, 0, "split", dark, new Set()).filter((r) => r.right && !r.right.foldLabel); + expect(open.map((r) => r.right!.lineNumber)).toEqual([1, 2, 3, 4, 5]); +}); + +test("a collapsed fold takes its side's change tint when one-sided and stays neutral when paired", () => { + const text = "fn a() {\n x\n}\n"; + const body = (id: number, label: string) => fold(id, [0, 8], [2, 0], [leaf(id + 100, 0, 2)], label, ["body", "function"], true); + const rowsFor = (lhs: Region[] | undefined, rhs: Region[] | undefined) => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = lhs && { text, syntax: [], regions: [...lhs, leaf(9, 2, 3)] }; + file.diff.rhs = rhs && { text, syntax: [], regions: [...rhs, leaf(9, 2, 3)] }; + return rowsForFile(file, 0, "split", dark, defaultCollapsed(file.diff)); + }; + // Inserted: a summary on the right with no counterpart. The header and every label row are green. + const inserted = rowsFor(undefined, [body(7, "// pseudocode\nreturn x")]); + const header = inserted.find((r) => r.right?.fold)!.right!; + expect(header.fold!.tint).toBe("inserted"); + expect(inserted.filter((r) => r.right?.foldLabel).map((r) => r.right!.foldTint)).toEqual(["inserted", "inserted"]); + expect(foldBackground(dark, "inserted")).toBe(dark.addition); + // Removed: the same body only on the left. + const removed = rowsFor([body(7, "2 lines removed")], undefined); + expect(removed.find((r) => r.left?.fold)!.left!.fold!.tint).toBe("removed"); + expect(foldBackground(dark, "removed")).toBe(dark.deletion); + // Paired: the same alignment id on both sides keeps the neutral fold background. + const paired = rowsFor([body(7, "Body")], [body(7, "Body")]); + const both = paired.find((r) => r.left?.fold && r.right?.fold)!; + expect([both.left!.fold!.tint, both.right!.fold!.tint]).toEqual(["neutral", "neutral"]); + expect(foldBackground(dark, "neutral")).toBe(dark.foldBackground); + expect(foldTint(7, 1, new Set([7]))).toBe("neutral"); + expect(foldTint(7, 1, new Set())).toBe("inserted"); + expect(foldTint(7, 0, new Set())).toBe("removed"); +}); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts new file mode 100644 index 000000000..922d40d11 --- /dev/null +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -0,0 +1,172 @@ +/** Flatten diffr's per-side region trees; collapsed state belongs to the viewer. */ +import type { Region, Source, Span, TextDiff } from "./wire"; +export type Side = 0 | 1; +/** A leaf tiles its side; the same alignmentId on the other side is its counterpart. */ +export interface Leaf { + /** Pairs this leaf with its counterpart across sides; the row zip keys on it. */ + alignmentId: number; + /** Regions sharing it toggle together; collapse state keys on it. */ + foldStateId: number; + side: Side; + startLine: number; + /** Exclusive. */ + endLine: number; + changed: Map; + tags: string[]; + collapsed: boolean; + label: string; +} +export interface Fold { + alignmentId: number; + foldStateId: number; + side: Side; + /** The header line, always visible, carrying the chevron and the label. */ + headerLine: number; + /** Last hidden line, inclusive; the closing delimiter stays visible when text follows the range. */ + lastHidden: number; + label: string; + tags: string[]; + collapsed: boolean; + /** Ids of folds nested inside, for recursive fold commands. */ + nested: number[]; +} +/** The change tint of a fold: a one-sided region takes its side's change colour, a paired one stays neutral. */ +export type FoldTint = "inserted" | "removed" | "neutral"; +export interface RowFold { + /** The fold-state id: what toggling this header toggles. */ + id: number; + label: string; + collapsed: boolean; + tint: FoldTint; +} +/** One-sided means the region's alignment_id is absent on the other side. */ +export function foldTint(alignmentId: number, side: Side, otherSide: ReadonlySet): FoldTint { + if (otherSide.has(alignmentId)) return "neutral"; + return side ? "inserted" : "removed"; +} +/** Every alignment id on each side, leaves and folds alike. */ +export function alignmentIds(diff: TextDiff): readonly [Set, Set] { + const { leaves, folds } = flatten(diff); + return [0, 1].map((side) => new Set([...leaves[side].map((l) => l.alignmentId), ...folds[side].map((f) => f.alignmentId)])) as unknown as readonly [Set, Set]; +} +export const sourceLines = (text: string) => + text === "" ? [] : text.replace(/\n$/, "").split("\n"); +const encoder = new TextEncoder(); +/** Lines a leaf covers, half-open: an end at column zero does not touch its end line. */ +export function leafLines(region: Region): [number, number] { + return [region.start.line, region.end.column === 0 ? region.end.line : region.end.line + 1]; +} +export function flattenSide(source: Source, side: Side): { leaves: Leaf[]; folds: Fold[] } { + const lines = sourceLines(source.text); + const leaves: Leaf[] = []; + const folds: Fold[] = []; + const visit = (region: Region, ancestors: Fold[]) => { + if (region.kind === "leaf") { + const [startLine, endLine] = leafLines(region); + const changed = new Map(); + for (const span of region.changed) changed.set(span.line, [...(changed.get(span.line) ?? []), span]); + leaves.push({ alignmentId: region.alignment_id, foldStateId: region.fold_state_id, side, startLine, endLine, + changed, tags: region.tags, + collapsed: region.visibility.collapsed, label: region.visibility.label }); + return; + } + const { start, end } = region; + // An end at column 0 does not touch its line, so it may sit one past the last line of the + // file. Otherwise the end line must exist, and it is hidden when the range covers its text. + let hideEnd = false; + if (end.column > 0) { + const endText = lines[end.line]; + if (endText === undefined) throw new Error(`diffr fold references missing line ${end.line}`); + hideEnd = encoder.encode(endText.trimEnd()).length <= end.column; + } else if (end.line > lines.length) { + throw new Error(`diffr fold references missing line ${end.line}`); + } + const fold: Fold = { alignmentId: region.alignment_id, foldStateId: region.fold_state_id, side, + headerLine: start.line, + lastHidden: end.line - (hideEnd ? 0 : 1), label: region.visibility.label, tags: region.tags, + collapsed: region.visibility.collapsed, nested: [] }; + for (const ancestor of ancestors) ancestor.nested.push(fold.foldStateId); + folds.push(fold); + for (const child of region.children) visit(child, [...ancestors, fold]); + }; + for (const region of source.regions) visit(region, []); + return { leaves, folds }; +} +export function flatten(diff: TextDiff) { + const lhs = diff.lhs ? flattenSide(diff.lhs, 0) : { leaves: [], folds: [] }; + const rhs = diff.rhs ? flattenSide(diff.rhs, 1) : { leaves: [], folds: [] }; + return { leaves: [lhs.leaves, rhs.leaves] as const, folds: [lhs.folds, rhs.folds] as const }; +} +/** A leaf is novel when it carries change spans or has no counterpart on the other side. */ +export function novelLeaves(leaves: readonly [Leaf[], Leaf[]]): Set { + const ids = [new Set(leaves[0].map((l) => l.alignmentId)), new Set(leaves[1].map((l) => l.alignmentId))]; + const novel = new Set(); + for (const side of [0, 1] as const) + for (const leaf of leaves[side]) + if (leaf.changed.size > 0 || !ids[side ? 0 : 1].has(leaf.alignmentId)) novel.add(leaf); + return novel; +} +/** Fold-state ids diffr asks to start collapsed: context gaps and folded bodies, on either side. */ +export function defaultCollapsed(diff: TextDiff): Set { + const ids = new Set(); + const { leaves, folds } = flatten(diff); + for (const item of [...leaves.flat(), ...folds.flat()]) if (item.collapsed) ids.add(item.foldStateId); + return ids; +} +/** Every collapsible id on either side, folds and foldable leaves alike, for fold-all commands. */ +export function foldIds(diff: TextDiff): number[] { + const { folds, leaves } = flatten(diff); + return [...new Set([...folds.flat().map((fold) => fold.foldStateId), ...leaves.flat().filter(foldableLeaf).map((l) => l.foldStateId)])]; +} +/** Ids nested inside a collapsible region, for recursive fold commands. Every region sharing the + * fold-state id counts, so a bundle such as a docstring and its function unfolds as one. */ +export function nestedIds(diff: TextDiff, id: number): number[] { + const { folds, leaves } = flatten(diff); + const members = folds.flat().filter((f) => f.foldStateId === id); + if (members.length) return [...new Set(members.flatMap((f) => f.nested))].filter((n) => n !== id); + if (!leaves.flat().some((leaf) => leaf.foldStateId === id)) throw new Error(`Unknown region ${id}`); + return []; +} +/** Context-gap leaves: unchanged runs diffr trimmed to N lines around changes. */ +export function gapIds(diff: TextDiff): number[] { + return [...new Set(flatten(diff).leaves.flat().filter((leaf) => leaf.tags.includes("unchanged")).map((l) => l.foldStateId))]; +} +/** Source lines hidden on one side by collapsed folds whose header is visible. */ +export function hiddenLines(folds: Fold[], collapsed: ReadonlySet): Set { + const hidden = new Set(); + // Outer folds first: a fold whose header is already hidden hides nothing of its own. + const ordered = [...folds].sort((a, b) => a.headerLine - b.headerLine || b.lastHidden - a.lastHidden); + for (const fold of ordered) { + if (!collapsed.has(fold.foldStateId) || hidden.has(fold.headerLine)) continue; + for (let line = fold.headerLine + 1; line <= fold.lastHidden; line++) hidden.add(line); + } + return hidden; +} +/** A leaf collapses like a fold when diffr starts it collapsed, labelled it, or tagged it as a + * context gap. A docstring bundled with its function arrives collapsed with an empty label. */ +export const foldableLeaf = (leaf: Leaf) => + leaf.collapsed || leaf.label !== "" || leaf.tags.includes("unchanged"); +/** An empty label renders as a bare `⋯`; only a context gap falls back to its line count. */ +export const leafLabel = (leaf: Leaf) => + leaf.label || (leaf.tags.includes("unchanged") ? `${leaf.endLine - leaf.startLine} unchanged lines` : ""); +/** Fold headers keyed by source line, one per side; a fold wins the header line of its first leaf. */ +export function foldHeaders( + folds: Fold[], + leaves: Leaf[], + collapsed: ReadonlySet, + otherSide: ReadonlySet, +): Map { + const headers = new Map(); + for (const leaf of leaves) + if (foldableLeaf(leaf)) + headers.set(leaf.startLine, { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: collapsed.has(leaf.foldStateId), + tint: foldTint(leaf.alignmentId, leaf.side, otherSide) }); + for (const fold of folds) { + const existing = headers.get(fold.headerLine); + const existingFold = existing && folds.find((f) => f.foldStateId === existing.id); + if (existingFold && existingFold.lastHidden >= fold.lastHidden) continue; + headers.set(fold.headerLine, { id: fold.foldStateId, label: fold.label, collapsed: collapsed.has(fold.foldStateId), + tint: foldTint(fold.alignmentId, fold.side, otherSide) }); + } + return headers; +} diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index af6489f4d..a4b74bab3 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; -import { createTestDiffFile } from "./fixture"; -import { dark, lineSpans, rowsForFile } from "./rows"; +import { createTestDiffFile, leaf, line, withIdenticalLines } from "./fixture"; +import { captureColor, dark, light, lineSpans, rowsForFile } from "./rows"; import { measureRows, visibleRows } from "./geometry"; import { copySelection } from "./selection"; -test("split uses supplied pairs and padding, unified groups old before new", () => { +test("split zips leaves on their ids, unified groups old before new", () => { const file = createTestDiffFile(); const split = rowsForFile(file, 0, "split", dark).filter((r) => r.left); expect(split.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ @@ -12,6 +12,7 @@ test("split uses supplied pairs and padding, unified groups old before new", () [undefined, 3], [3, 4], ]); + expect(split.map((r) => r.right!.kind)).toEqual(["context", "addition", "addition", "context"]); const unified = rowsForFile(file, 0, "unified", dark).filter((r) => r.cell); expect( unified.map( @@ -24,34 +25,85 @@ test("split uses supplied pairs and padding, unified groups old before new", () "+extra();", " finish();", ]); + expect(rowsForFile(file, 0, "split", dark).filter((r) => r.hunkStart)).toHaveLength(1); }); -test("byte spans survive multibyte characters and tabs", () => { +test("moved code renders as moved on both copies, labelled with where the other copy starts", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // `b` moved above `a`, and its line was also edited. + file.diff.lhs = { text: "a\nb\n", syntax: [], regions: [leaf(1, 0, 1), leaf(2, 1, 2, [line(1, 0, 1)])] }; + file.diff.rhs = { text: "b2\na\n", syntax: [], regions: [leaf(2, 0, 1, [line(0, 0, 2)]), leaf(1, 1, 2)] }; + const rows = rowsForFile(file, 0, "split", dark).filter((r) => r.left); + const text = (c: { spans: { text: string }[] }) => c.spans.map((s) => s.text).join(""); + expect(rows.map((r) => [r.left!.moveLabel ? text(r.left!) : r.left!.lineNumber, + r.right!.moveLabel ? text(r.right!) : r.right!.lineNumber])).toEqual([ + [undefined, "moved from line 2"], [undefined, 1], [1, 2], ["moved to line 1", undefined], [2, undefined], + ]); + const movedRight = rows[1].right!, movedLeft = rows[4].left!; + // No +/−, the moved tint instead of added or removed, and each copy points at the other. + expect([movedRight.kind, movedRight.sign, movedRight.moveKind, movedRight.jump]).toEqual( + ["context", " ", "moved", { side: "left", line: 2 }]); + expect([movedLeft.kind, movedLeft.sign, movedLeft.moveKind, movedLeft.jump]).toEqual( + ["context", " ", "moved", { side: "right", line: 1 }]); + expect(rows[0].right!.jump).toEqual({ side: "left", line: 2 }); + // The edit inside the moved copy still gets the strong word tint. + expect(movedRight.spans.some((s) => s.bg === dark.addWord)).toBe(true); + expect(dark.moved).not.toBe(dark.addition); + expect(dark.moved).not.toBe(dark.deletion); + // The unchanged line between the copies is plain context. + expect([rows[2].left!.moveKind, rows[2].left!.kind]).toEqual([undefined, "context"]); +}); +test("changed spans paint the darker word tint, distinct from the line tint", () => { + for (const theme of [dark, light]) { + const spans = lineSpans("let x = old + y;", [], [line(0, 8, 11)], "right", theme); + expect(spans.map((s) => [s.text, s.bg])).toEqual([ + ["let x = ", undefined], ["old", theme.addWord], [" + y;", undefined], + ]); + expect(theme.addWord).not.toBe(theme.addition); + expect(lineSpans("old", [], [line(0, 0, 3)], "left", theme)[0].bg).toBe(theme.deleteWord); + expect(theme.deleteWord).not.toBe(theme.deletion); + } +}); +test("every line of a novel leaf is tinted, even without a span", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: "a\n", syntax: [], regions: [leaf(1, 0, 1)] }; + // A new block: one changed word, a blank line, and an unpaired leaf with no spans at all. + file.diff.rhs = { text: "a\nb = 1\n\nc\n", syntax: [], regions: [leaf(1, 0, 1), leaf(2, 1, 3, [line(1, 4, 5)]), leaf(3, 3, 4)] }; + const rows = rowsForFile(file, 0, "split", dark).filter((r) => r.right); + expect(rows.map((r) => [r.right!.kind, r.right!.spans.some((s) => s.bg)])).toEqual([ + ["context", false], ["addition", true], ["addition", false], ["addition", false], + ]); +}); +test("byte spans survive multibyte characters and tabs; captures pick theme colours", () => { const spans = lineSpans( "é\t变量", - [ - { - pos: { line: 0, start_col: 3, end_col: 9 }, - kind: { Novel: { highlight: { Atom: "Type" } } }, - }, - ], + [{ line: 0, start_column: 3, end_column: 9, capture: "type.builtin" }], + [line(0, 3, 9)], "right", dark, ); expect(spans.map((s) => s.text).join("")).toBe("é 变量"); expect(spans.at(-1)!.bg).toBe(dark.addWord); - expect(() => - lineSpans( - "é", - [ - { - pos: { line: 0, start_col: 1, end_col: 2 }, - kind: { Novel: { highlight: "Delimiter" } }, - }, - ], - "left", - dark, - ), - ).toThrow(); + expect(spans.at(-1)!.fg).toBe(dark.syntax("type")!); + expect(() => lineSpans("é", [], [line(0, 1, 9)], "left", dark)).toThrow(); + expect(captureColor("function.method", dark)).toBe(dark.syntax("function")!); + expect(captureColor("unknown.thing", dark)).toBe(dark.fg); +}); +test("the innermost syntax capture colours a nested span", () => { + const spans = lineSpans( + 'f("x")', + [ + { line: 0, start_column: 0, end_column: 6, capture: "function.call" }, + { line: 0, start_column: 2, end_column: 5, capture: "string" }, + ], + [], + "left", + dark, + ); + expect(spans.map((s) => [s.text, s.fg])).toEqual([ + ['f(', dark.syntax("function")], ['"x"', dark.syntax("string")], [")", dark.syntax("function")], + ]); }); test("wrapping adds equal split heights and windowing mounts only intersecting rows", () => { const rows = rowsForFile(createTestDiffFile(), 0, "split", dark); @@ -74,88 +126,42 @@ test("selection copies one source side and excludes padding and added lines", () ).toBe('start();\nsend("old");\nfinish();'); }); test("large file mounts a bounded viewport", () => { - const file = createTestDiffFile(); - const lines = Array.from({ length: 20000 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.hunks[0] = { - novel_lhs: [], - novel_rhs: [], - lines: lines.map((_, i) => [i, i]), - }; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - const geometry = measureRows( - rowsForFile(file, 0, "split", dark), - 120, - false, - 0, - ); + const file = withIdenticalLines(createTestDiffFile(), 20000); + const geometry = measureRows(rowsForFile(file, 0, "split", dark), 120, false, 0); expect(visibleRows(geometry, 10000, 40)).toHaveLength(40); }); - -test("full-file rows include source outside hunks and retain navigation without headers", () => { - const file = createTestDiffFile(); - file.diff.hunks[0].lines = [[1, 1], [null, 2]]; - for (const layout of ["split", "unified"] as const) { - const rows = rowsForFile(file, 0, layout, dark, true); - expect(rows.filter(r => r.label).map(r => r.label)).toEqual(["demo.ts"]); - expect(rows.filter(r => r.hunkStart)).toHaveLength(1); - expect(copySelection([file], rows, {anchor: rows[1].key, end: rows.at(-1)!.key, side: "left"})) - .toBe(file.diff.lhs_src === "Binary" ? "" : file.diff.lhs_src.Text.trimEnd()); - } - file.diff.hunks = []; - expect(rowsForFile(file, 0, "split", dark, true)).toHaveLength(5); -}); - -test("unchanged string literals use the normal text color", () => { - const span = { line: 0, start_col: 0, end_col: 3 }; - const result = lineSpans('"x"', [{ - pos: span, - kind: { UnchangedToken: { - highlight: { Atom: { String: "StringLiteral" } }, - self_pos: [span], opposite_pos: [span], - } }, - }], "right", dark); - expect(result).toEqual([{ text: '"x"', fg: dark.fg, bg: undefined }]); -}); - -test("matched tokens keep syntax styling even when their counterpart is on another row", () => { - const pos = { line: 2, start_col: 0, end_col: 3 }; - const positions = [{ - pos, kind: { UnchangedToken: { - highlight: "Delimiter" as const, self_pos: [pos], - opposite_pos: [{ ...pos, line: 8 }], - } }, - }]; - for (const side of ["left", "right"] as const) { - expect(lineSpans("foo", positions, side, dark)[0]) - .toEqual({ text: "foo", fg: dark.fg, bg: undefined }); - } -}); - -test("compact context preserves Rust-selected signature and changes with one gap per omitted run", () => { +test("context gaps come from collapsed unchanged leaves, one row per gap", () => { const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({ length: 12 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: [[1,1], [2,2], [8,8], [9,9]]}]; + const gap = (id: number, start: number, end: number) => { + const region = leaf(id, start, end); + region.tags = ["unchanged"]; + region.visibility = { collapsed: true, label: `${end - start} unchanged lines` }; + return region; + }; + const regions = () => [gap(1, 0, 1), leaf(2, 1, 3, [line(1, 0, 6)]), gap(3, 3, 8), leaf(4, 8, 10), gap(5, 10, 12)]; + file.diff.lhs = { text: lines.join("\n"), syntax: [], regions: regions() }; + file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: regions() }; + const split = rowsForFile(file, 0, "split", dark, new Set([1, 3, 5])); + const shown = (fold: { label: string; collapsed: boolean } | undefined, line: number | undefined) => + fold?.collapsed ? fold.label : line; + expect(split.slice(1).map(r => shown(r.left?.fold, r.left?.lineNumber))) + .toEqual(["1 unchanged lines", 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); + // Unified shows the novel leaf's lines once per side, removals first. + const unified = rowsForFile(file, 0, "unified", dark, new Set([1, 3, 5])); + expect(unified.slice(1).map(r => shown(r.cell?.fold, r.cell?.newLineNumber ?? r.cell?.oldLineNumber))) + .toEqual(["1 unchanged lines", 2, 3, 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); for (const layout of ["split", "unified"] as const) { - const rows = rowsForFile(file, 0, layout, dark); - expect(rows.slice(1).map(r => r.label ?? r.left?.lineNumber ?? r.cell?.oldLineNumber)) - .toEqual(["…", 2, 3, "…", 9, 10, "…"]); - expect(rows.filter(r => r.hunkStart)).toHaveLength(1); - expect(rowsForFile(file, 0, layout, dark, true)).toHaveLength(13); + expect(rowsForFile(file, 0, layout, dark, new Set([1, 3, 5])).filter(r => r.hunkStart)).toHaveLength(1); + expect(rowsForFile(file, 0, layout, dark, new Set()).length).toBeGreaterThanOrEqual(13); } }); - -test("unified trusts diffr flags despite different source indentation", () => { +test("unified trusts diffr's changed spans despite different source indentation", () => { const file = createTestDiffFile(); - file.diff.lhs_src = {Text: " call();\n"}; - file.diff.rhs_src = {Text: " call();\n"}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = [[0, 0]]; - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: [[0, 0]]}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: " call();\n", syntax: [], regions: [leaf(1, 0, 1)] }; + file.diff.rhs = { text: " call();\n", syntax: [], regions: [leaf(1, 0, 1)] }; const rows = rowsForFile(file, 0, "unified", dark); expect(rows).toHaveLength(2); expect(rows[1].cell).toMatchObject({kind: "context", sign: " ", oldLineNumber: 1, newLineNumber: 1}); @@ -164,13 +170,20 @@ test("unified trusts diffr flags despite different source indentation", () => { expect(copySelection([file], rows, {anchor: rows[1].key, end: rows[1].key, side})) .toBe(side === "left" ? " call();" : " call();"); } - // Only the side flagged novel may receive change styling. - file.diff.hunks[0].novel_rhs = [0]; + // Only the side with a changed span may receive change styling. + file.diff.rhs.regions = [leaf(1, 0, 1, [line(0, 0, 11)])]; const changed = rowsForFile(file, 0, "unified", dark).slice(1); expect(changed.map(r => r.cell!.kind)).toEqual(["context", "addition"]); - file.diff.aligned_rows = [[0, null], [null, 0]]; - file.diff.hunks[0].novel_rhs = []; - file.diff.hunks[0].lines = file.diff.aligned_rows; - expect(rowsForFile(file, 0, "unified", dark).slice(1).map(r => r.cell!.kind)) - .toEqual(["context", "context"]); +}); +test("binary and one-sided files render without a second side", () => { + const file = createTestDiffFile(); + file.diff = { type: "binary", lhs: { size: 10 }, rhs: { size: 12 } }; + expect(rowsForFile(file, 0, "split", dark).map((r) => r.label)).toEqual(["demo.ts", "Binary file"]); + const added = createTestDiffFile(); + added.file = { rhs: added.file.rhs }; + if (added.diff.type !== "text") throw new Error(); + added.diff = { type: "text", rhs: { text: "new\n", syntax: [], regions: [leaf(1, 0, 1, [line(0, 0, 3)])] }, + stats: { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } } }; + const rows = rowsForFile(added, 0, "split", dark).filter((r) => r.left); + expect(rows.map((r) => [r.left!.kind, r.right!.lineNumber])).toEqual([["empty", 1]]); }); diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 0e12f9fc9..88929ce8c 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -1,126 +1,65 @@ -/** Project Rust full-file correspondence into Hunk terminal cells; never compute a second diff. */ -import type { DiffFile, DiffResult, Highlight, MatchedPos } from "./wire"; -import type { - RenderSpan, - SplitLineCell, - UnifiedLineCell, -} from "../ui/diff/diffRowModel"; +/** Project diffr's per-side region trees into terminal cells by zipping leaves on their ids. */ +import type { DiffFile, Span, SyntaxSpan } from "./wire"; +import { filePath } from "./wire"; +import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diffRowModel"; import { measureTextWidth } from "../ui/lib/text"; -import { foldHeaders, foldRegions, hiddenLines, sourceLines } from "./folds"; +import { alignmentIds, flatten, foldHeaders, foldTint, hiddenLines, leafLabel, novelLeaves, sourceLines, type Leaf, type RowFold } from "./regions"; +import { loadBundledTheme, type Palette } from "./theme"; export { sourceLines }; export type Layout = "split" | "unified"; export interface ViewerRow { key: string; fileIndex: number; - hunkIndex?: number; + /** First row of a run of changed rows, for `[` and `]`. */ hunkStart?: boolean; label?: string; + /** A hidden-file placeholder: "Load diff" reveals the file. */ + loadDiff?: boolean; left?: SplitLineCell; right?: SplitLineCell; cell?: UnifiedLineCell; } -export interface Palette { - bg: string; - fg: string; - muted: string; - addition: string; - deletion: string; - addWord: string; - deleteWord: string; - keyword: string; - type: string; - comment: string; - /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ - foldBackground: string; - foldPlaceholder: string; +export type { Palette } from "./theme"; +/** The bundled defaults, for tests and the settings screen. */ +export const dark: Palette = loadBundledTheme("default-dark"); +export const light: Palette = loadBundledTheme("default-light"); +/** Foreground for a tree-sitter capture: the theme's scope, its parents, else plain text. */ +export function captureColor(capture: string, theme: Palette): string { + return theme.syntax(capture) ?? theme.fg; } -export const dark: Palette = { - bg: "#0d1117", - fg: "#e6edf3", - muted: "#8b949e", - addition: "#12261e", - deletion: "#301a20", - addWord: "#24583a", - deleteWord: "#74333c", - keyword: "#ff7b72", - type: "#79c0ff", - comment: "#8b949e", - foldBackground: "#152434", - foldPlaceholder: "#808080", -}; -export const light: Palette = { - bg: "#ffffff", - fg: "#24292f", - muted: "#57606a", - addition: "#dafbe1", - deletion: "#ffebe9", - addWord: "#aceebb", - deleteWord: "#ffcecb", - keyword: "#cf222e", - type: "#0550ae", - comment: "#6e7781", - foldBackground: "#e6f3ff", - foldPlaceholder: "#808080", -}; -function color(token: Highlight, theme: Palette) { - if (token === "Delimiter") return theme.fg; - const atom = token.Atom; - return typeof atom === "object" - ? theme.fg - : atom === "Keyword" - ? theme.keyword - : atom === "Type" - ? theme.type - : atom === "Comment" - ? theme.comment - : theme.fg; -} -function byLine(positions: MatchedPos[]) { - const result = new Map(); - for (const position of positions) { - const list = result.get(position.pos.line) ?? []; - list.push(position); - result.set(position.pos.line, list); - } - return result; -} -/** Translate UTF-8 byte spans before expanding tabs into terminal cells. */ +/** Colour a line from byte-addressed syntax and change spans, then expand tabs into cells. */ export function lineSpans( text: string, - positions: MatchedPos[], + syntax: SyntaxSpan[], + changed: Span[], side: "left" | "right", theme: Palette, ): RenderSpan[] { const bytes = new TextEncoder().encode(text), decoder = new TextDecoder("utf-8", { fatal: true }); + const bounds = new Set([0, bytes.length]); + for (const span of [...syntax, ...changed]) { + if (span.start_column > span.end_column || span.end_column > bytes.length) + throw new Error("Invalid diffr span"); + bounds.add(span.start_column); + bounds.add(span.end_column); + } + const sorted = [...bounds].sort((a, b) => a - b); + const wordBg = side === "left" ? theme.deleteWord : theme.addWord; const spans: RenderSpan[] = []; - let cursor = 0; - for (const position of [...positions].sort( - (a, b) => a.pos.start_col - b.pos.start_col, - )) { - const { start_col: start, end_col: end } = position.pos; - if (start < cursor || end < start || end > bytes.length) - throw new Error("Invalid or overlapping diffr token span"); - if (start > cursor) - spans.push({ - text: decoder.decode(bytes.slice(cursor, start)), - fg: theme.fg, - }); - const kind = Object.values(position.kind)[0]; - const novel = "Novel" in position.kind || "NovelWord" in position.kind; + for (let i = 0; i + 1 < sorted.length; i++) { + const start = sorted[i], end = sorted[i + 1]; + // Innermost syntax capture wins, so pick the narrowest span covering this segment. + const capture = syntax + .filter((s) => s.start_column <= start && s.end_column >= end) + .sort((a, b) => a.end_column - a.start_column - (b.end_column - b.start_column))[0]; + const emphasized = changed.some((s) => s.start_column <= start && s.end_column >= end); spans.push({ text: decoder.decode(bytes.slice(start, end)), - fg: color(kind.highlight, theme), - bg: novel - ? side === "left" - ? theme.deleteWord - : theme.addWord - : undefined, + fg: capture ? captureColor(capture.capture, theme) : theme.fg, + bg: emphasized ? wordBg : undefined, }); - cursor = end; } - if (cursor < bytes.length) - spans.push({ text: decoder.decode(bytes.slice(cursor)), fg: theme.fg }); let column = 0; return spans.map((span) => ({ ...span, @@ -134,166 +73,241 @@ export function lineSpans( .join(""), })); } -/** Render the complete Rust alignment; hunks supply change flags and navigation only. */ +function byLine(spans: SyntaxSpan[]) { + const result = new Map(); + for (const span of spans) result.set(span.line, [...(result.get(span.line) ?? []), span]); + return result; +} +const indentOf = (text: string) => text.match(/^\s*/)![0]; +/** Rows for a hidden file: GitHub's "Load diff" placeholder under the header. */ +export function placeholderRows(fileIndex: number, label: string): ViewerRow[] { + return [ + { key: `${fileIndex}:load`, fileIndex, label: "Load diff", loadDiff: true }, + { key: `${fileIndex}:why`, fileIndex, label: label || "Hidden by default" }, + ]; +} +/** Render both sides by zipping leaves on their ids; folding never realigns. */ export function rowsForFile( file: DiffFile, fileIndex: number, layout: Layout, theme: Palette, - fullContext = false, - collapsed: ReadonlySet = new Set(), + collapsed: ReadonlySet = new Set(), ): ViewerRow[] { const d = file.diff; - const rows: ViewerRow[] = [ - { - key: `${fileIndex}:header`, - fileIndex, - label: file.file.new_path ?? file.file.old_path ?? d.display_path, - }, - ]; - if (d.lhs_src === "Binary" || d.rhs_src === "Binary") - return [ - ...rows, - { key: `${fileIndex}:binary`, fileIndex, label: "Binary file" }, - ]; - const left = sourceLines(d.lhs_src.Text), - right = sourceLines(d.rhs_src.Text); - const positions = [byLine(d.lhs_positions), byLine(d.rhs_positions)]; - const caches = [ - new Map(), - new Map(), - ]; - const cell = ( - line: number | null, - side: 0 | 1, - novel: Set, - ): SplitLineCell => { - if (line === null) return { kind: "empty", sign: " ", spans: [] }; - const text = (side ? right : left)[line]; - if (text === undefined) - throw new Error(`diffr alignment references missing line ${line}`); + const rows: ViewerRow[] = [{ key: `${fileIndex}:header`, fileIndex, label: filePath(file.file) }]; + if (d.type === "binary") + return [...rows, { key: `${fileIndex}:binary`, fileIndex, label: "Binary file" }]; + const texts = [sourceLines(d.lhs?.text ?? ""), sourceLines(d.rhs?.text ?? "")]; + const syntax = [byLine(d.lhs?.syntax ?? []), byLine(d.rhs?.syntax ?? [])]; + const { leaves, folds } = flatten(d); + const hidden = [hiddenLines(folds[0], collapsed), hiddenLines(folds[1], collapsed)]; + const ids = alignmentIds(d); + const headers = [foldHeaders(folds[0], leaves[0], collapsed, ids[1]), foldHeaders(folds[1], leaves[1], collapsed, ids[0])]; + const caches = [new Map(), new Map()]; + // Every line of a novel leaf is tinted; the spans inside get the darker word tint on top. + const novelSet = novelLeaves(leaves); + const novel = (leaf: Leaf) => novelSet.has(leaf); + const moves = movedLeaves(leaves); + const cell = (leaf: Leaf | null, line: number | null, side: 0 | 1): SplitLineCell => { + if (line === null || leaf === null) return { kind: "empty", sign: " ", spans: [] }; + const text = texts[side][line]; + if (text === undefined) throw new Error(`diffr region references missing line ${line}`); let spans = caches[side].get(line); if (!spans) { - spans = lineSpans( - text, - positions[side].get(line) ?? [], - side ? "right" : "left", - theme, - ); + spans = lineSpans(text, syntax[side].get(line) ?? [], leaf.changed.get(line) ?? [], + side ? "right" : "left", theme); caches[side].set(line, spans); } - const changed = novel.has(line); + const partner = moves.partner[side].get(leaf); + if (partner) { + // Moved code is neither added nor removed; edits inside keep their word tint. + return { + kind: "context", + sign: " ", + moveKind: "moved", + jump: { side: side ? "left" : "right", line: partner.startLine + (line - leaf.startLine) + 1 }, + lineNumber: line + 1, + spans, + fold: headers[side].get(line), + }; + } + const changed = novel(leaf); return { kind: changed ? (side ? "addition" : "deletion") : "context", sign: changed ? (side ? "+" : "-") : " ", lineNumber: line + 1, spans, + fold: headers[side].get(line), }; }; - const novelLeft = new Set(d.hunks.flatMap(h => h.novel_lhs)); - const novelRight = new Set(d.hunks.flatMap(h => h.novel_rhs)); - const hunkLeft = new Map(), hunkRight = new Map(); - for (const [index, hunk] of d.hunks.entries()) { - for (const [l, r] of hunk.lines) { - if (l !== null && !hunkLeft.has(l)) hunkLeft.set(l, index); - if (r !== null && !hunkRight.has(r)) hunkRight.set(r, index); + /** "moved to line M" on the left, "moved from line N" on the right, at the top of a moved copy. */ + const moveLabel = (leaf: Leaf) => { + const target = moves.runStart[leaf.side].get(leaf); + if (target === undefined || hidden[leaf.side].has(leaf.startLine)) return; + const text = leaf.side ? `moved from line ${target}` : `moved to line ${target}`; + const labelled: SplitLineCell = { kind: "context", sign: " ", moveKind: "moved", moveLabel: true, + jump: { side: leaf.side ? "left" : "right", line: target }, spans: [{ text, fg: theme.movedText }] }; + const empty: SplitLineCell = { kind: "empty", sign: " ", spans: [] }; + const key = `${fileIndex}:moved:${leaf.side}:${leaf.startLine}`; + if (layout === "split") { + rows.push({ key, fileIndex, left: leaf.side ? empty : labelled, right: leaf.side ? labelled : empty }); + return; } - } - const regions = foldRegions(d); - const shown = (index: number) => { - const [l, r] = d.aligned_rows[index]; - return fullContext || (l !== null && hunkLeft.has(l)) || (r !== null && hunkRight.has(r)); + const cell = { ...labelled, kind: "context" as const }; + (leaf.side ? pendingNew : pendingOld).push({ key, fileIndex, cell }); }; - const hidden = hiddenLines(d.aligned_rows, regions, collapsed, shown); - const headers = foldHeaders(d.aligned_rows, regions, collapsed); - let pendingOld: ViewerRow[] = [], - pendingNew: ViewerRow[] = []; + let pendingOld: ViewerRow[] = [], pendingNew: ViewerRow[] = []; const flush = () => { rows.push(...pendingOld, ...pendingNew); pendingOld = []; pendingNew = []; }; - for (const [index, pair] of d.aligned_rows.entries()) { - const hunkIndex = (pair[0] === null ? undefined : hunkLeft.get(pair[0])) - ?? (pair[1] === null ? undefined : hunkRight.get(pair[1])); - // Folded lines lose their cell; a row folded on one side keeps the other side's - // line beside a blank cell, and a row folded on both sides disappears. - const l = pair[0] !== null && hidden[0].has(pair[0]) ? null : pair[0], - r = pair[1] !== null && hidden[1].has(pair[1]) ? null : pair[1]; - if (l === null && r === null) continue; - // Rust's hunk selection includes nearby lines and enclosing syntax context. - // Keep both cells of a selected alignment row; never realign after hiding. - if (!fullContext && hunkIndex === undefined) { + // A collapsed leaf is one fold row: chevron and label, no line number, same toggle as a body fold. + const collapsedLeaf = (left: Leaf | null, right: Leaf | null) => { + flush(); + const anchor = (left ?? right)!; + const folded = (leaf: Leaf | null): SplitLineCell => leaf + ? { kind: "context", sign: " ", spans: [], fold: { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: true, + tint: foldTint(leaf.alignmentId, leaf.side, ids[leaf.side ? 0 : 1]) } } + : { kind: "empty", sign: " ", spans: [] }; + const key = `${fileIndex}:gap:${anchor.foldStateId}`; + if (layout === "split") rows.push({ key, fileIndex, left: folded(left), right: folded(right) }); + else rows.push({ key, fileIndex, cell: { kind: "context", sign: " ", spans: [], fold: folded(anchor).fold } }); + }; + const emit = (l: number | null, r: number | null, left: Leaf | null, right: Leaf | null) => { + if (l !== null && hidden[0].has(l)) l = null; + if (r !== null && hidden[1].has(r)) r = null; + if (l === null && r === null) return; + const a = cell(left, l, 0), b = cell(right, r, 1); + const key = `${fileIndex}:${l ?? "_"}:${r ?? "_"}`; + if (layout === "split") { + rows.push({ key, fileIndex, left: a, right: b }); + return; + } + // Correspondence and novelty come from diffr, including formatting-only changes. + if (l !== null && r !== null && a.kind === "context" && b.kind === "context") { flush(); - if (rows.at(-1)?.label !== "…") { - rows.push({ key: `${fileIndex}:gap:${l ?? "_"}:${r ?? "_"}`, fileIndex, label: "…" }); - } - continue; + rows.push({ key, fileIndex, cell: { kind: "context", sign: " ", oldLineNumber: l + 1, + newLineNumber: r + 1, fold: b.fold ?? a.fold, spans: b.spans } }); + return; } - const a = { ...cell(l, 0, novelLeft), fold: headers[0].get(index) }, - b = { ...cell(r, 1, novelRight), fold: headers[1].get(index) }; - const key = `${fileIndex}:${l ?? "_"}:${r ?? "_"}`; - if (layout === "split") - rows.push({ key, fileIndex, hunkIndex, left: a, right: b }); - else { - // Correspondence and novelty come from diffr, including formatting-only changes. - const shared = - l !== null && - r !== null && - !novelLeft.has(l) && - !novelRight.has(r); - if (shared) { - flush(); - rows.push({ - key, - fileIndex, - hunkIndex, - cell: { - kind: "context", - sign: " ", - oldLineNumber: l + 1, - newLineNumber: r + 1, - fold: b.fold ?? a.fold, - spans: b.spans, - }, - }); - } else { - if (l !== null) - pendingOld.push({ - key: `${key}:old`, - fileIndex, - hunkIndex, - cell: { - kind: a.kind === "deletion" ? "deletion" : "context", - sign: a.sign, - oldLineNumber: l + 1, - fold: a.fold, - spans: a.spans, - }, - }); - if (r !== null) - pendingNew.push({ - key: `${key}:new`, - fileIndex, - hunkIndex, - cell: { - kind: b.kind === "addition" ? "addition" : "context", - sign: b.sign, - newLineNumber: r + 1, - fold: b.fold, - spans: b.spans, - }, - }); - } + if (l !== null) + pendingOld.push({ key: `${key}:old`, fileIndex, cell: { kind: a.kind === "deletion" ? "deletion" : "context", + sign: a.sign, oldLineNumber: l + 1, fold: a.fold, spans: a.spans, moveKind: a.moveKind, jump: a.jump } }); + if (r !== null) + pendingNew.push({ key: `${key}:new`, fileIndex, cell: { kind: b.kind === "addition" ? "addition" : "context", + sign: b.sign, newLineNumber: r + 1, fold: b.fold, spans: b.spans, moveKind: b.moveKind, jump: b.jump } }); + }; + const leafRows = (left: Leaf | null, right: Leaf | null) => { + const anchor = left ?? right; + if (!anchor) return; + if (collapsed.has(anchor.foldStateId)) { + if (!hidden[anchor.side].has(anchor.startLine)) collapsedLeaf(left, right); + return; } + if (!left || !right) moveLabel(anchor); + const length = Math.max(left ? left.endLine - left.startLine : 0, right ? right.endLine - right.startLine : 0); + for (let i = 0; i < length; i++) + emit(left && i < left.endLine - left.startLine ? left.startLine + i : null, + right && i < right.endLine - right.startLine ? right.startLine + i : null, left, right); + }; + // Zip: walk the left leaves; a partner ahead on the right flushes what precedes it as right-only. + const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); + let cursor = 0; + const flushRight = (until: number) => { + for (; cursor < until; cursor++) leafRows(null, leaves[1][cursor]); + }; + for (const left of leaves[0]) { + const partner = rightIndex.get(left.alignmentId); + if (partner === undefined || partner < cursor) { + // Unpaired, or a move whose partner was already shown: one-sided rows. + leafRows(left, null); + continue; + } + flushRight(partner); + leafRows(left, leaves[1][partner]); + cursor = partner + 1; } + flushRight(leaves[1].length); flush(); - const visited = new Set(); - for (const row of rows) { - if (row.hunkIndex !== undefined && !visited.has(row.hunkIndex)) { - row.hunkStart = true; - visited.add(row.hunkIndex); + return withFoldLabels(markHunks(rows), texts, theme); +} +/** + * Moves: a leaf whose counterpart the zip has already passed. Both copies are moved, and each run + * of consecutive moved leaves on a side is one copy whose label names where the other copy starts. + */ +export function movedLeaves(leaves: readonly [Leaf[], Leaf[]]) { + const partner = [new Map(), new Map()] as const; + const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); + let cursor = 0; + for (const left of leaves[0]) { + const index = rightIndex.get(left.alignmentId); + if (index === undefined) continue; + if (index < cursor) { + partner[0].set(left, leaves[1][index]); + partner[1].set(leaves[1][index], left); + continue; + } + cursor = index + 1; + } + // First leaf of each copy -> the other copy's first line, 1-based. + const runStart = [new Map(), new Map()] as const; + for (const side of [0, 1] as const) { + let run: Leaf[] = []; + const close = () => { + if (run.length) runStart[side].set(run[0], Math.min(...run.map((leaf) => partner[side].get(leaf)!.startLine)) + 1); + run = []; + }; + for (const leaf of leaves[side]) { + if (partner[side].has(leaf)) run.push(leaf); + else close(); } + close(); + } + return { partner, runStart }; +} +function markHunks(rows: ViewerRow[]): ViewerRow[] { + let inHunk = false; + for (const row of rows) { + const changed = row.cell + ? row.cell.kind !== "context" || row.cell.moveKind === "moved" + : row.left !== undefined && (row.left.kind !== "context" || row.right!.kind !== "context" + || row.left.moveKind === "moved" || row.right!.moveKind === "moved"); + if (changed && !inHunk) row.hunkStart = true; + inHunk = changed; } return rows; } +/** A collapsed fold with a multi-line label shows the label under its header, inside the fold tint. */ +function withFoldLabels(rows: ViewerRow[], texts: string[][], theme: Palette): ViewerRow[] { + const result: ViewerRow[] = []; + const labelLines = (fold: RowFold | undefined) => + fold?.collapsed && fold.label.includes("\n") ? fold.label.split("\n") : []; + const labelCell = (text: string | undefined, kind: SplitLineCell["kind"], indent: string, fold?: RowFold): SplitLineCell => + text === undefined + ? { kind: "empty", sign: " ", spans: [] } + : { kind, sign: " ", foldLabel: true, foldTint: fold?.tint, spans: [{ text: indent + text, fg: theme.foldPlaceholder }] }; + for (const row of rows) { + result.push(row); + if (row.cell) { + const lines = labelLines(row.cell.fold); + const line = row.cell.newLineNumber ?? row.cell.oldLineNumber; + const indent = indentOf(texts[row.cell.newLineNumber === undefined ? 0 : 1][(line ?? 1) - 1] ?? "") + " "; + lines.forEach((text, i) => result.push({ key: `${row.key}:label:${i}`, fileIndex: row.fileIndex, + cell: { ...labelCell(text, "context", indent, row.cell!.fold), oldLineNumber: undefined, newLineNumber: undefined } as UnifiedLineCell })); + continue; + } + if (!row.left || !row.right) continue; + const left = labelLines(row.left.fold), right = labelLines(row.right.fold); + const indents = [ + indentOf(texts[0][(row.left.lineNumber ?? 1) - 1] ?? "") + " ", + indentOf(texts[1][(row.right.lineNumber ?? 1) - 1] ?? "") + " ", + ]; + for (let i = 0; i < Math.max(left.length, right.length); i++) + result.push({ key: `${row.key}:label:${i}`, fileIndex: row.fileIndex, + left: labelCell(left[i], row.left.kind === "empty" ? "context" : row.left.kind, indents[0], row.left.fold), + right: labelCell(right[i], row.right.kind === "empty" ? "context" : row.right.kind, indents[1], row.right.fold) }); + } + return result; +} diff --git a/tui/packages/hunk/src/diffr/selection.ts b/tui/packages/hunk/src/diffr/selection.ts index c6abe16e0..af503611f 100644 --- a/tui/packages/hunk/src/diffr/selection.ts +++ b/tui/packages/hunk/src/diffr/selection.ts @@ -33,18 +33,16 @@ export function copySelection( const key = `${row.fileIndex}:${n}`; if (seen.has(key)) continue; seen.add(key); - const source = - selection.side === "left" - ? files[row.fileIndex].diff.lhs_src - : files[row.fileIndex].diff.rhs_src; - if (source !== "Binary") { - let lines = sources.get(row.fileIndex); - if (!lines) { - lines = source.Text.split("\n"); - sources.set(row.fileIndex, lines); - } - result.push(lines[n - 1]); + const diff = files[row.fileIndex].diff; + if (diff.type !== "text") continue; + const source = selection.side === "left" ? diff.lhs : diff.rhs; + if (!source) continue; + let lines = sources.get(row.fileIndex); + if (!lines) { + lines = source.text.split("\n"); + sources.set(row.fileIndex, lines); } + result.push(lines[n - 1]); } return result.join("\n"); } diff --git a/tui/packages/hunk/src/diffr/store.ts b/tui/packages/hunk/src/diffr/store.ts index a34b9c25f..6ff5a4331 100644 --- a/tui/packages/hunk/src/diffr/store.ts +++ b/tui/packages/hunk/src/diffr/store.ts @@ -1,6 +1,9 @@ /** Hold streamed files separately from presentation state and notify React in batches. */ -import { fileIdentity, type FileChange, type DiffEvent, type DiffFile } from "./wire"; +import { fileIdentity, filePath, type FileChange, type DiffEvent, type DiffFile } from "./wire"; +type StartEvent = Extract; export interface Snapshot { + /** The two ends of the comparison, from the start event. */ + comparison: { lhs: StartEvent["lhs"]; rhs: StartEvent["rhs"] } | null; files: DiffFile[]; inventory: FileChange[]; failedFiles: Map; @@ -10,6 +13,7 @@ export interface Snapshot { } export class DiffStore { private value: Snapshot = { + comparison: null, files: [], inventory: [], failedFiles: new Map(), @@ -27,22 +31,33 @@ export class DiffStore { getSnapshot = () => this.value; accept(event: DiffEvent) { if (event.type === "start") - this.value = { ...this.value, total: event.total, inventory: event.files }; - if (event.type === "file") - this.value = { ...this.value, files: [...this.value.files, event], - inventory: this.value.inventory.some(f => fileIdentity(f) === fileIdentity(event.file)) - ? this.value.inventory : [...this.value.inventory, event.file] }; - if (event.type === "file_error") + this.value = { ...this.value, comparison: { lhs: event.lhs, rhs: event.rhs }, total: event.files.length, inventory: event.files }; + if (event.type === "file") { + const identity = fileIdentity(event.file); + const inventory = this.value.inventory.some((f) => fileIdentity(f.file) === identity) + ? this.value.inventory + : [ + ...this.value.inventory, + { file: event.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }, + ]; + if (event.diff) + this.value = { ...this.value, inventory, files: [...this.value.files, { ...event, diff: event.diff }] }; + else if (event.error) + this.value = { + ...this.value, + inventory, + failedFiles: new Map(this.value.failedFiles).set(identity, event.error.message), + errors: [...this.value.errors, `${filePath(event.file)}: ${event.error.message}`], + }; + } + if (event.type === "complete") this.value = { ...this.value, - failedFiles: new Map(this.value.failedFiles).set(fileIdentity(event.file), event.message), - errors: [ - ...this.value.errors, - `${event.file.new_path ?? event.file.old_path}: ${event.message}`, - ], + complete: true, + errors: event.aborted + ? [...this.value.errors, `diffr stopped early: ${event.aborted.message}`] + : this.value.errors, }; - if (event.type === "complete") - this.value = { ...this.value, complete: true }; this.listeners.forEach((listener) => listener()); } fail(error: unknown) { diff --git a/tui/packages/hunk/src/diffr/stream.test.ts b/tui/packages/hunk/src/diffr/stream.test.ts index 20b26cdd9..bfa526f8a 100644 --- a/tui/packages/hunk/src/diffr/stream.test.ts +++ b/tui/packages/hunk/src/diffr/stream.test.ts @@ -1,13 +1,15 @@ import { expect, test } from "bun:test"; import { readDiffStream } from "./stream"; -import { createTestDiffFile } from "./fixture"; +import { createTestDiffFile, fold, leaf } from "./fixture"; +import type { FileChange } from "./wire"; +const manifest = (file: ReturnType): FileChange => + ({ file: file.file, status: "modified", visibility: { collapsed: false, label: "" } }); const start = { type: "start", - version: 1, - before: { kind: "index" }, - after: { kind: "working_tree" }, - total: 1, - files: [createTestDiffFile().file], + version: 3, + lhs: { type: "index" }, + rhs: { type: "working_tree" }, + files: [manifest(createTestDiffFile())], }; async function decode(events: unknown[]) { const text = events.map((e) => JSON.stringify(e)).join("\n"), @@ -17,59 +19,69 @@ async function decode(events: unknown[]) { } return Array.fromAsync(readDiffStream(chunks())); } -test("decode byte-fragmented Unicode stream and preserve fold metadata", async () => { +test("decode byte-fragmented Unicode stream and preserve region trees", async () => { const file = createTestDiffFile(); - file.file.new_path = "变量.ts"; - file.diff.lhs_folds = [ - { - tags: ["function"], - range: { - start: { line: 0, byte_column: 0 }, - end: { line: 2, byte_column: 8 }, - }, - placeholder: "…", - summary: null, - match_kind: "Novel", - }, - ]; - const events = [{...start, files: [file.file]}, file, { type: "complete", succeeded: 1, failed: 0 }]; + file.file = { lhs: { ...file.file.lhs!, path: "变量.ts" }, rhs: { ...file.file.rhs!, path: "变量.ts" } }; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs!.regions = [fold(9, [0, 0], [2, 8], [leaf(1, 0, 3)], "Body", ["function"], true)]; + const events = [{...start, files: [manifest(file)]}, file, { type: "complete", succeeded: 1, failed: 0 }]; expect((await decode(events)) as unknown).toEqual(events); }); +test("omitted defaults are filled in", async () => { + const file = createTestDiffFile(); + const bare = { type: "file", file: file.file, diff: { type: "text", + lhs: { text: "a\n", regions: [{ alignment_id: 1, fold_state_id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, + rhs: { text: "a\n", regions: [{ alignment_id: 1, fold_state_id: 1, kind: "leaf", start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, + stats: { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } } } }; + const [, decoded] = await decode([{ ...start, files: [{ file: file.file, status: "modified" }] }, bare, + { type: "complete", succeeded: 1, failed: 0 }]); + expect(decoded).toMatchObject({ diff: { lhs: { syntax: [], regions: [{ tags: [], changed: [], children: [], + visibility: { collapsed: false, label: "" } }] } } }); +}); test("reject missing completion, counts, unknown version and invalid ordering", async () => { for (const events of [ [start], - [{ ...start, version: 2 }], + [{ ...start, version: 1 }], [createTestDiffFile()], [start, { type: "complete", succeeded: 1, failed: 0 }], [start, start], + [start, { type: "file", file: createTestDiffFile().file }], + [start, { ...createTestDiffFile(), error: { code: "x", message: "y" } }], ]) await expect(decode(events)).rejects.toThrow(); }); -test("file errors complete without discarding earlier successful results", async () => { +test("file errors and aborts complete without discarding earlier successful results", async () => { const file = createTestDiffFile(); - const failedFile = {...file.file, old_path: "failed.ts", new_path: "failed.ts"}; - expect( - ( - await decode([ - { ...start, total: 2, files: [file.file, failedFile] }, - file, - { type: "file_error", file: failedFile, message: "unreadable" }, - { type: "complete", succeeded: 1, failed: 1 }, - ]) - ).length, - ).toBe(4); + const failed = { lhs: { path: "failed.ts", oid: "3", mode: "100644" }, rhs: { path: "failed.ts", oid: "4", mode: "100644" } }; + const decoded = await decode([ + { ...start, files: [manifest(file), { file: failed, status: "modified" }] }, + file, + { type: "file", file: failed, error: { code: "not_utf8", message: "unreadable" } }, + { type: "complete", succeeded: 1, failed: 1 }, + ]); + expect(decoded.length).toBe(4); + const aborted = await decode([ + { ...start, files: [manifest(file), { file: failed, status: "modified" }] }, + file, + { type: "complete", succeeded: 1, failed: 0, aborted: { code: "hook_failed", message: "503" } }, + ]); + expect(aborted.length).toBe(3); + await expect(decode([ + { ...start, files: [manifest(file), { file: failed, status: "modified" }] }, + file, + { type: "complete", succeeded: 1, failed: 0 }, + ])).rejects.toThrow(); }); - -test("manifest validates count and identities while allowing results in arrival order", async () => { +test("manifest validates identities while allowing results in arrival order", async () => { const a = createTestDiffFile(), b = createTestDiffFile(); - b.file = {...b.file, old_path: "b.ts", new_path: "b.ts"}; + b.file = { lhs: { path: "b.ts", oid: "5", mode: "100644" }, rhs: { path: "b.ts", oid: "6", mode: "100644" } }; for (const events of [ - [{...start, files: []}], - [{...start, total: 2, files: [a.file, a.file]}], - [{...start, total: 2, files: [a.file, b.file]}, b, b], + [{...start, files: []}, { type: "complete", succeeded: 0, failed: 0 }, a], + [{...start, files: [manifest(a), manifest(a)]}], + [{...start, files: [manifest(a), manifest(b)]}, b, b], [start, b], ]) await expect(decode(events)).rejects.toThrow(); - expect((await decode([{...start, total: 2, files: [a.file, b.file]}, b, a, + expect((await decode([{...start, files: [manifest(a), manifest(b)]}, b, a, {type: "complete", succeeded: 2, failed: 0}])).map(e => e.type)) .toEqual(["start", "file", "file", "complete"]); }); diff --git a/tui/packages/hunk/src/diffr/stream.ts b/tui/packages/hunk/src/diffr/stream.ts index fabddc313..0fad3d0f6 100644 --- a/tui/packages/hunk/src/diffr/stream.ts +++ b/tui/packages/hunk/src/diffr/stream.ts @@ -20,27 +20,27 @@ export async function* readDiffStream( if (event.type === "start") { if (started) throw new Error("Duplicate diffr start"); started = true; - total = event.total; - inventory = new Set(event.files.map(fileIdentity)); - if (event.files.length !== total || inventory.size !== total) + total = event.files.length; + inventory = new Set(event.files.map((file) => fileIdentity(file.file))); + if (inventory.size !== total) throw new Error("Inconsistent diffr file manifest"); } else { if (!started) throw new Error("Missing diffr start"); - if (event.type === "file" || event.type === "file_error") { + if (event.type === "file") { const identity = fileIdentity(event.file); if (!inventory.has(identity) || received.has(identity)) throw new Error("Unknown or duplicate diffr file result"); received.add(identity); + if (event.diff) succeeded++; + else failed++; } - if (event.type === "file") succeeded++; - if (event.type === "file_error") failed++; if (event.type === "complete") { - if ( - event.succeeded !== succeeded || - event.failed !== failed || - succeeded + failed !== total - ) + const finished = succeeded + failed; + if (event.succeeded !== succeeded || event.failed !== failed) throw new Error("Inconsistent diffr completion counts"); + // An abort ends the run early; every file already answered stays valid. + if ((finished < total) !== (event.aborted !== undefined)) + throw new Error("Inconsistent diffr completion"); complete = true; } } diff --git a/tui/packages/hunk/src/diffr/theme.test.ts b/tui/packages/hunk/src/diffr/theme.test.ts new file mode 100644 index 000000000..aec8213ed --- /dev/null +++ b/tui/packages/hunk/src/diffr/theme.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { bundledThemes, colorOf, loadBundledTheme, paletteFromHelix, parseHelixTheme, scopeStyle, themeConfig, themesFromConfig } from "./theme"; +const sample = ` +"keyword" = { fg = "red", modifiers = ["bold"] } +"keyword.control" = { fg = "purple" } +"string" = "green" +"comment" = { fg = "#808080", modifiers = ["italic"] } +"ui.background" = { bg = "black" } +"ui.text" = { fg = "white" } +"ui.selection" = { bg = "#264f78" } +"diff.plus" = "green" +"diff.minus" = "red" + +[palette] +red = "#e06c75" +purple = "#c678dd" +green = "#98c379" +black = "#282c34" +white = "#abb2bf" +`; +test("scopes fall back to their parents and colours resolve through the palette", () => { + const theme = parseHelixTheme(sample, "sample"); + expect(scopeStyle(theme, "keyword.return")!.fg).toBe("red"); + expect(scopeStyle(theme, "keyword.control.import")!.fg).toBe("purple"); + expect(scopeStyle(theme, "string.special")!.fg).toBe("green"); + expect(scopeStyle(theme, "variable.parameter")).toBeUndefined(); + expect(colorOf(theme, "red")).toBe("#e06c75"); + expect(colorOf(theme, "#123456")).toBe("#123456"); + expect(colorOf(theme, "light-blue")).toBe("#3b8eea"); + expect(() => colorOf(theme, "chartreuse")).toThrow("unknown colour"); + const palette = paletteFromHelix(theme); + expect(palette.bg).toBe("#282c34"); + expect(palette.isLight).toBe(false); + expect(palette.syntax("keyword.return")).toBe("#e06c75"); + expect(palette.syntax("comment")).toBe("#808080"); + expect(palette.syntax("variable")).toBeUndefined(); + expect(palette.highlight).toBe("#264f78"); + expect(palette.addWord).not.toBe(palette.addition); +}); +test("a theme without ui colours or with inheritance is rejected", () => { + expect(() => paletteFromHelix(parseHelixTheme('"keyword" = "red"', "bare"))).toThrow("ui.background"); + expect(() => parseHelixTheme('inherits = "onedark"\n"ui.background" = { bg = "black" }', "child")).toThrow("inherits"); +}); +test("bundled themes load, the defaults are aliases, and captures are visibly distinct", () => { + for (const name of Object.keys(bundledThemes)) { + const palette = loadBundledTheme(name); + const colours = ["keyword", "string", "comment", "type", "function"].map((c) => palette.syntax(c)); + expect(colours.every((c) => c !== undefined)).toBe(true); + // gruvbox shares one green between strings and functions; everything else stays apart. + expect(new Set(colours).size).toBeGreaterThanOrEqual(4); + expect(palette.syntax("keyword.storage.modifier.ref")).toBeDefined(); + } + expect(loadBundledTheme("default-dark").isLight).toBe(false); + expect(loadBundledTheme("default-light").isLight).toBe(true); + expect(loadBundledTheme("default-dark").bg).toBe(loadBundledTheme("onedark").bg); + expect(() => loadBundledTheme("dracula")).toThrow("Unknown theme dracula"); +}); +test("the theme set follows diffr's config and errors on a missing section", () => { + expect(themeConfig({ theme: { name: "gruvbox", path: null } })).toEqual({ name: "gruvbox", path: null }); + expect(() => themeConfig({ folds: {} })).toThrow("theme section"); + const set = themesFromConfig({ name: "gruvbox", path: null }); + expect(set.initial.name).toBe("gruvbox"); + expect(set.dark.name).toBe("default-dark"); + expect(set.light.name).toBe("default-light"); + expect(() => themesFromConfig({ name: "nope", path: null })).toThrow("Unknown theme"); + expect(() => themesFromConfig({ name: "default-dark", path: "/nonexistent/theme.toml" })).toThrow(); +}); diff --git a/tui/packages/hunk/src/diffr/theme.ts b/tui/packages/hunk/src/diffr/theme.ts new file mode 100644 index 000000000..99453ea85 --- /dev/null +++ b/tui/packages/hunk/src/diffr/theme.ts @@ -0,0 +1,182 @@ +/** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +export interface Style { + fg?: string; + bg?: string; + modifiers: string[]; +} +export interface HelixTheme { + name: string; + styles: Map; + palette: Map; +} +/** Colours the row painter reads; syntax colours come from the theme by capture name. */ +export interface Palette { + name: string; + isLight: boolean; + bg: string; + fg: string; + muted: string; + /** Header, menu, and sidebar chrome. */ + chrome: string; + /** Sidebar highlight for the active file. */ + highlight: string; + addition: string; + deletion: string; + addWord: string; + deleteWord: string; + addedText: string; + removedText: string; + /** Background and label colour for code that moved. */ + moved: string; + movedText: string; + /** An accent for interactive text such as links and the layout badge. */ + accent: string; + /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ + foldBackground: string; + foldPlaceholder: string; + /** Foreground for a tree-sitter capture such as `keyword.return`; undefined when the theme has no scope for it. */ + syntax: (capture: string) => string | undefined; +} +/** Helix's named terminal colours, used when a theme writes `fg = "red"` outside its palette. */ +const ansi: Record = { + black: "#000000", red: "#cd3131", green: "#0dbc79", yellow: "#e5e510", blue: "#2472c8", + magenta: "#bc3fbc", cyan: "#11a8cd", white: "#e5e5e5", gray: "#666666", grey: "#666666", + "light-red": "#f14c4c", "light-green": "#23d18b", "light-yellow": "#f5f543", "light-blue": "#3b8eea", + "light-magenta": "#d670d6", "light-cyan": "#29b8db", "light-white": "#ffffff", "light-gray": "#a0a0a0", + "light-grey": "#a0a0a0", "light-black": "#333333", +}; +interface RawStyle { + fg?: string; + bg?: string; + modifiers?: string[]; +} +export function parseHelixTheme(text: string, name: string): HelixTheme { + const raw = Bun.TOML.parse(text) as Record; + const palette = new Map(); + const rawPalette = raw.palette; + if (rawPalette && typeof rawPalette === "object") + for (const [key, value] of Object.entries(rawPalette as Record)) { + if (typeof value !== "string") throw new Error(`Theme ${name}: palette entry ${key} is not a colour`); + palette.set(key, value); + } + const styles = new Map(); + for (const [scope, value] of Object.entries(raw)) { + if (scope === "palette" || scope === "inherits") continue; + if (typeof value === "string") styles.set(scope, { fg: value, modifiers: [] }); + else if (value && typeof value === "object") { + const style = value as RawStyle; + styles.set(scope, { fg: style.fg, bg: style.bg, modifiers: style.modifiers ?? [] }); + } else throw new Error(`Theme ${name}: scope ${scope} has an unsupported value`); + } + if (typeof raw.inherits === "string") + throw new Error(`Theme ${name} inherits from ${raw.inherits}; inherited themes are not supported`); + return { name, styles, palette }; +} +/** A palette name, a hex colour, or one of Helix's terminal colour names. */ +export function colorOf(theme: HelixTheme, value: string): string { + const fromPalette = theme.palette.get(value); + if (fromPalette !== undefined) return colorOf(theme, fromPalette); + if (/^#[0-9a-fA-F]{6}$/.test(value)) return value; + const named = ansi[value]; + if (named) return named; + throw new Error(`Theme ${theme.name}: unknown colour ${value}`); +} +/** The style for a scope, falling back to its parent scopes: `keyword.return` → `keyword`. */ +export function scopeStyle(theme: HelixTheme, scope: string): Style | undefined { + const parts = scope.split("."); + while (parts.length) { + const style = theme.styles.get(parts.join(".")); + if (style) return style; + parts.pop(); + } + return undefined; +} +const scopeFg = (theme: HelixTheme, scope: string) => { + const style = scopeStyle(theme, scope); + return style?.fg === undefined ? undefined : colorOf(theme, style.fg); +}; +const scopeBg = (theme: HelixTheme, scope: string) => { + const style = scopeStyle(theme, scope); + return style?.bg === undefined ? undefined : colorOf(theme, style.bg); +}; +function luminance(hex: string) { + const channel = (i: number) => parseInt(hex.slice(i, i + 2), 16) / 255; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} +function mix(hex: string, other: string, amount: number) { + const channel = (i: number) => + Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - amount) + parseInt(other.slice(i, i + 2), 16) * amount); + return `#${[1, 3, 5].map((i) => channel(i).toString(16).padStart(2, "0")).join("")}`; +} +/** Build the painter's palette: chrome from `ui.*`, syntax by capture, change tints mixed into the background. */ +export function paletteFromHelix(theme: HelixTheme): Palette { + const bg = scopeBg(theme, "ui.background"); + const fg = scopeFg(theme, "ui.text"); + if (!bg || !fg) throw new Error(`Theme ${theme.name} lacks ui.background or ui.text`); + const isLight = luminance(bg) > 0.5; + const muted = scopeFg(theme, "ui.linenr") ?? scopeFg(theme, "comment") ?? mix(fg, bg, 0.4); + const plus = scopeFg(theme, "diff.plus") ?? (isLight ? "#1a7f37" : "#7ee787"); + const minus = scopeFg(theme, "diff.minus") ?? (isLight ? "#cf222e" : "#ffa198"); + const selection = scopeBg(theme, "ui.selection") ?? mix(bg, fg, 0.15); + // Moved code: the theme's own delta colour, else a muted blue drawn from the selection. + const delta = scopeFg(theme, "diff.delta.moved") ?? scopeFg(theme, "diff.delta") + ?? mix(selection, isLight ? "#2f6fbf" : "#6fa8f0", 0.6); + return { + name: theme.name, + isLight, + bg, + fg, + muted, + chrome: scopeBg(theme, "ui.statusline") ?? mix(bg, fg, 0.06), + highlight: selection, + addition: mix(bg, plus, 0.18), + deletion: mix(bg, minus, 0.18), + addWord: mix(bg, plus, 0.42), + deleteWord: mix(bg, minus, 0.42), + addedText: plus, + removedText: minus, + moved: mix(bg, delta, 0.16), + movedText: delta, + accent: scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg, + foldBackground: scopeBg(theme, "ui.cursorline.primary") ?? mix(bg, fg, 0.1), + foldPlaceholder: muted, + syntax: (capture) => scopeFg(theme, capture), + }; +} +/** Bundled Helix themes under tui/themes; the two defaults are aliases. */ +export const bundledThemes: Record = { + "default-dark": "onedark", + "default-light": "onelight", + onedark: "onedark", + onelight: "onelight", + gruvbox: "gruvbox", + solarized_light: "solarized_light", +}; +const themesDir = resolve(import.meta.dir, "../../../../themes"); +export function loadBundledTheme(name: string): Palette { + const file = bundledThemes[name]; + if (!file) throw new Error(`Unknown theme ${name}; bundled themes: ${Object.keys(bundledThemes).join(", ")}`); + return paletteFromHelix(parseHelixTheme(readFileSync(resolve(themesDir, `${file}.toml`), "utf8"), name)); +} +export function loadThemeFile(path: string): Palette { + return paletteFromHelix(parseHelixTheme(readFileSync(path, "utf8"), path)); +} +export interface ThemeSet { + initial: Palette; + dark: Palette; + light: Palette; +} +/** Resolve the theme diffr's config names, plus the two defaults the `t` key toggles between. */ +export function themesFromConfig(config: { name: string; path: string | null }): ThemeSet { + const dark = loadBundledTheme("default-dark"), light = loadBundledTheme("default-light"); + const initial = config.path ? loadThemeFile(config.path) : loadBundledTheme(config.name); + return { initial, dark, light }; +} +export function themeConfig(show: unknown): { name: string; path: string | null } { + const theme = (show as { theme?: { name?: unknown; path?: unknown } }).theme; + if (!theme || typeof theme.name !== "string" || (theme.path !== null && theme.path !== undefined && typeof theme.path !== "string")) + throw new Error("diffr config show did not include a theme section"); + return { name: theme.name, path: theme.path ?? null }; +} diff --git a/tui/packages/hunk/src/diffr/wire.ts b/tui/packages/hunk/src/diffr/wire.ts index f83d32a16..dca692c30 100644 --- a/tui/packages/hunk/src/diffr/wire.ts +++ b/tui/packages/hunk/src/diffr/wire.ts @@ -1,120 +1,122 @@ -/** Validate the lossless Rust wire format without translating it through patch metadata. */ +/** Validate diffr's wire v2 (src/protocol.rs): tagged enums, pairings by presence, defaults omitted. */ import { z } from "zod"; const uint = z.number().int().nonnegative(); -const point = z.object({ line: uint, byte_column: uint }); -const range = z.object({ start: point, end: point }); -const span = z.object({ line: uint, start_col: uint, end_col: uint }); -const highlight = z.union([ - z.literal("Delimiter"), - z.object({ - Atom: z.union([ - z.enum([ - "Normal", - "Type", - "Keyword", - "Comment", - "TreeSitterError", - "CanIgnore", - ]), - z.object({ String: z.enum(["StringLiteral", "Text"]) }), - ]), - }), +/** `{lhs, rhs}`, `{lhs}` or `{rhs}`; never neither. */ +function pairing(item: T) { + return z + .object({ lhs: item.optional(), rhs: item.optional() }) + .refine((value) => value.lhs !== undefined || value.rhs !== undefined, { + message: "a pairing needs at least one side", + }); +} +export type Pairing = { lhs?: T; rhs?: T }; +const fileRef = z.object({ path: z.string(), oid: z.string(), mode: z.string() }); +const visibility = z.object({ + collapsed: z.boolean().default(false), + label: z.string().default(""), +}); +const problem = z.object({ code: z.string(), message: z.string() }); +const fileChange = z.object({ + file: pairing(fileRef), + status: z.enum(["added", "deleted", "modified", "renamed", "copied", "type_changed"]), + category: z.string().optional(), + language: z.string().optional(), + visibility: visibility.default({ collapsed: false, label: "" }), +}); +const snapshot = z.discriminatedUnion("type", [ + z.object({ type: z.literal("revision"), rev: z.string() }), + z.object({ type: z.literal("index") }), + z.object({ type: z.literal("working_tree") }), + z.object({ type: z.literal("empty_tree") }), + z.object({ type: z.literal("path"), path: z.string() }), ]); -const position = z.object({ - pos: span, - kind: z.union([ - z.object({ - UnchangedToken: z.object({ - highlight, - self_pos: z.array(span), - opposite_pos: z.array(span), - }), - }), - z.object({ - UnchangedPartOfNovelItem: z.object({ - highlight, - self_pos: span, - opposite_pos: z.array(span), - }), - }), - z.object({ Novel: z.object({ highlight }) }), - z.object({ NovelWord: z.object({ highlight }) }), - z.object({ Ignored: z.object({ highlight }) }), - ]), +const sourcePos = z.object({ line: uint, column: uint }); +const span = z.object({ line: uint, start_column: uint, end_column: uint }); +const syntaxSpan = span.extend({ capture: z.string() }); +export interface Region { + /** Same value on the other side: the visual counterpart, one-to-one. Keys the row zip. */ + alignment_id: number; + /** Regions sharing it open and close together, on either side. Keys collapse state. */ + fold_state_id: number; + start: { line: number; column: number }; + end: { line: number; column: number }; + tags: string[]; + visibility: { collapsed: boolean; label: string }; + kind: "leaf" | "fold"; + changed: { line: number; start_column: number; end_column: number }[]; + children: Region[]; +} +const regionBase = z.object({ + alignment_id: uint, + fold_state_id: uint, + start: sourcePos, + end: sourcePos, + tags: z.array(z.string()).default([]), + visibility: visibility.default({ collapsed: false, label: "" }), }); -const fold = z.object({ - tags: z.array(z.string()), - range, - match_kind: z.union([ - z.literal("Novel"), - z.object({ Unchanged: z.object({ opposite: range }) }), +const region: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + regionBase.extend({ kind: z.literal("leaf"), changed: z.array(span).default([]) }) + .transform((leaf) => ({ ...leaf, children: [] as Region[] })), + regionBase.extend({ kind: z.literal("fold"), children: z.array(region) }) + .transform((fold) => ({ ...fold, changed: [] as Region["changed"] })), ]), - placeholder: z.string(), - summary: z.string().nullable(), +); +const source = z.object({ + text: z.string(), + syntax: z.array(syntaxSpan).default([]), + regions: z.array(region).default([]), }); -const source = z.union([z.literal("Binary"), z.object({ Text: z.string() })]); -export const diffResultSchema = z.object({ - display_path: z.string(), - extra_info: z.string().nullable(), - file_format: z.union([ - z.enum(["PlainText", "Binary"]), - z.object({ SupportedLanguage: z.string() }), - z.object({ TextFallback: z.object({ reason: z.string() }) }), - ]), - lhs_src: source, - rhs_src: source, - lhs_positions: z.array(position), - rhs_positions: z.array(position), - lhs_folds: z.array(fold), - rhs_folds: z.array(fold), - aligned_rows: z.array(z.tuple([uint.nullable(), uint.nullable()])), - hunks: z.array( - z.object({ - novel_lhs: z.array(uint), - novel_rhs: z.array(uint), - lines: z.array(z.tuple([uint.nullable(), uint.nullable()])), - }), - ), - has_byte_changes: z.tuple([uint, uint]).nullable(), - has_syntactic_changes: z.boolean(), +const lineCounts = z.object({ added: uint, removed: uint }); +const stats = z.object({ + textual: lineCounts, + /** Changed lines shown under diffr's default fold state; the frontend adjusts it as folds toggle. */ + visible: lineCounts, + /** Present when tree-sitter did not run and this is a line diff. */ + fallback: problem.optional(), }); -const operand = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("file"), path: z.string() }), - z.object({ kind: z.literal("revision"), ref: z.string() }), - z.object({ kind: z.literal("index") }), - z.object({ kind: z.literal("working_tree") }), - z.object({ kind: z.literal("empty_tree") }), +const diff = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text"), lhs: source.optional(), rhs: source.optional(), stats }) + .refine((value) => value.lhs !== undefined || value.rhs !== undefined, { + message: "a text diff needs at least one side", + }), + z.object({ + type: z.literal("binary"), + lhs: z.object({ size: uint }).optional(), + rhs: z.object({ size: uint }).optional(), + }), ]); -const file = z.object({ - old_path: z.string().nullable(), - new_path: z.string().nullable(), - status: z.enum([ - "added", - "deleted", - "modified", - "renamed", - "type_changed", - "conflicted", - ]), - class: z.string().nullable(), -}); +const fileEvent = z + .object({ type: z.literal("file"), file: pairing(fileRef), diff: diff.optional(), error: problem.optional() }) + .refine((value) => (value.diff === undefined) !== (value.error === undefined), { + message: "a file record needs exactly one of diff or error", + }); export const eventSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("start"), - version: z.literal(1), - before: operand, - after: operand, - total: uint, - files: z.array(file), + version: z.literal(3), + lhs: snapshot, + rhs: snapshot, + files: z.array(fileChange), }), - z.object({ type: z.literal("file"), file, diff: diffResultSchema }), - z.object({ type: z.literal("file_error"), file, message: z.string() }), - z.object({ type: z.literal("complete"), succeeded: uint, failed: uint }), + fileEvent, + z.object({ type: z.literal("complete"), succeeded: uint, failed: uint, aborted: problem.optional() }), ]); -export type FileChange = z.infer; -export const fileIdentity = (file: FileChange) => JSON.stringify([file.old_path, file.new_path]); -export type DiffResult = z.infer; +export type FileRef = z.infer; +export type FileChange = z.infer; +export type Visibility = z.infer; +export type Problem = z.infer; +export type Source = z.infer; +export type TextDiff = Extract, { type: "text" }>; +export type Diff = z.infer; +export type Span = z.infer; +export type SyntaxSpan = z.infer; +export type Stats = z.infer; +export type LineCounts = z.infer; export type DiffEvent = z.infer; -export type DiffFile = Extract; -export type MatchedPos = DiffResult["lhs_positions"][number]; -export type Highlight = z.infer; +export type FileEvent = Extract; +/** A file record that carries a diff; failures are kept separately by the store. */ +export type DiffFile = FileEvent & { diff: Diff }; +export const fileIdentity = (file: Pairing) => + JSON.stringify([file.lhs?.path ?? null, file.rhs?.path ?? null]); +export const filePath = (file: Pairing) => file.rhs?.path ?? file.lhs?.path ?? ""; diff --git a/tui/packages/hunk/src/main.tsx b/tui/packages/hunk/src/main.tsx index ac7d3f00f..25b9a36e3 100644 --- a/tui/packages/hunk/src/main.tsx +++ b/tui/packages/hunk/src/main.tsx @@ -6,16 +6,51 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { readDiffStream } from "./diffr/stream"; import { DiffStore } from "./diffr/store"; +import { cliClient } from "./diffr/config"; +import { loadBundledTheme, loadThemeFile, themeConfig, themesFromConfig, type ThemeSet } from "./diffr/theme"; import { App } from "./ui/App"; +import { Settings } from "./ui/Settings"; const args = process.argv.slice(2), store = new DiffStore(); +// `diffr config` opens the settings screen: bun run main.tsx --settings --diffr /path/to/diffr [query] +if (args[0] === "--settings") { + if (args[1] !== "--diffr" || !args[2]) { + console.error("Usage: bun run start --settings --diffr /path/to/diffr [initial query]"); + process.exit(2); + } + const settingsRenderer = await createCliRenderer({ + useMouse: false, + exitOnCtrlC: false, + screenMode: "alternate-screen", + }); + const quitSettings = () => { + settingsRenderer.destroy(); + process.exit(0); + }; + process.once("SIGTERM", quitSettings); + process.once("SIGINT", quitSettings); + createRoot(settingsRenderer).render( + , + ); + await new Promise(() => {}); +} let child: ReturnType | undefined; let chunks: AsyncIterable; let input: NodeJS.ReadStream = process.stdin; let ttyFd: number | undefined; let expectsDifferenceExit = false; let comparisonExitCode = 0; +let themes: ThemeSet; if (args[0] === "--input" && args[1]) { + // A recording has no diffr to ask; `--theme ` picks one, else the dark default. + const themeArg = args.indexOf("--theme"); + const chosen = themeArg >= 0 ? args[themeArg + 1] : "default-dark"; + if (!chosen) throw new Error("--theme needs a bundled name or a Helix theme path"); + themes = { + initial: chosen.endsWith(".toml") ? loadThemeFile(chosen) : loadBundledTheme(chosen), + dark: loadBundledTheme("default-dark"), + light: loadBundledTheme("default-light"), + }; chunks = args[1] === "-" ? process.stdin : createReadStream(args[1]); if (args[1] === "-") { ttyFd = openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"); @@ -23,17 +58,20 @@ if (args[0] === "--input" && args[1]) { } } else if (args[0] === "--diffr" && args[1]) { const comparison = args.slice(args[2] === "--" ? 3 : 2); + // The theme comes from diffr's own config, so every frontend reads the same choice. + themes = themesFromConfig(themeConfig(cliClient(args[1]).show())); const separator = comparison.indexOf("--"); expectsDifferenceExit = comparison .slice(0, separator < 0 ? comparison.length : separator) .includes("--exit-code"); - child = spawn(args[1], ["--format", "ndjson", ...comparison], { + // The frontend has no tokenizer, so it asks Rust for syntax spans. + child = spawn(args[1], ["--format", "ndjson", "--syntax", ...comparison], { stdio: ["ignore", "pipe", "pipe"], }); chunks = child.stdout!; } else { console.error( - "Usage: bun run start --diffr /path/to/diffr -- [comparison arguments]\n bun run start --input recording.ndjson (or -)", + "Usage: bun run start --diffr /path/to/diffr -- [comparison arguments]\n bun run start --input recording.ndjson (or -) [--theme name|file.toml]\n bun run start --settings --diffr /path/to/diffr [query]", ); process.exit(2); } @@ -61,7 +99,7 @@ function quit() { process.once("SIGTERM", quit); process.once("SIGINT", quit); const root = createRoot(renderer); -root.render(); +root.render(); let stderr = ""; child?.stderr?.on("data", (data) => { stderr = (stderr + data.toString()).slice(-16384); diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index 9788b3256..70abc4691 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -5,13 +5,20 @@ import { TextRenderable, type BaseRenderable } from "@opentui/core"; import { App } from "./App"; import { DiffStore } from "../diffr/store"; -import { createTestDiffFile } from "../diffr/fixture"; -import { createFoldedDiffFile } from "../diffr/folds.test"; +import { createTestDiffFile, leaf, line, withIdenticalLines } from "../diffr/fixture"; +import type { DiffFile } from "../diffr/wire"; +import { createBundledDiffFile, createFoldedDiffFile } from "../diffr/regions.test"; +import { loadBundledTheme } from "../diffr/theme"; +const themes = { initial: loadBundledTheme("default-dark"), dark: loadBundledTheme("default-dark"), light: loadBundledTheme("default-light") }; +const at = (file: DiffFile, path: string) => { + file.file = { lhs: { path, oid: "1", mode: "100644" }, rhs: { path, oid: "2", mode: "100644" } }; + return file; +}; test("render real OpenTUI rows, switch layout, collapse and reopen file with mouse", async () => { const store = new DiffStore(); store.accept(createTestDiffFile()); const testRenderer = await testRender( - {}} />, + {}} themes={themes} />, { width: 150, height: 20 }, ); try { @@ -26,7 +33,7 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou "copyToClipboardOSC52", ).mockReturnValue(true); await act(async () => { - await testRenderer.mockMouse.drag(40, 2, 40, 5); + await testRenderer.mockMouse.drag(40, 3, 40, 6); }); await act(async () => { testRenderer.mockInput.pressKey("y"); @@ -42,13 +49,13 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou frame.indexOf('send("new")'), ); await act(async () => { - await testRenderer.mockMouse.click(32, 1); + await testRenderer.mockMouse.click(32, 2); }); await testRenderer.waitForFrame( (frame) => frame.includes("▸") && !frame.includes('send("old")'), ); await act(async () => { - await testRenderer.mockMouse.click(32, 1); + await testRenderer.mockMouse.click(32, 2); }); await testRenderer.waitForFrame((frame) => frame.includes('send("old")')); } finally { @@ -60,16 +67,10 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou test("scrolling a large stream keeps terminal renderables bounded", async () => { const store = new DiffStore(), file = createTestDiffFile(); - const lines = Array.from({ length: 5000 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.hunks = [ - { novel_lhs: [], novel_rhs: [], lines: lines.map((_, i) => [i, i]) }, - ]; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); + withIdenticalLines(file, 5000); store.accept(file); const testRenderer = await testRender( - {}} />, + {}} themes={themes} />, { width: 150, height: 20 }, ); try { @@ -86,7 +87,7 @@ test("scrolling a large stream keeps terminal renderables bounded", async () => } expect(count(testRenderer.renderer.root)).toBeLessThan(250); await act(async () => { - await testRenderer.mockMouse.scroll(70, 8, "up"); + await testRenderer.mockMouse.scroll(70, 9, "up"); }); await testRenderer.waitForFrame((frame) => !frame.includes("line 4999")); } finally { @@ -98,36 +99,34 @@ test("scrolling a large stream keeps terminal renderables bounded", async () => test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", async () => { const store = new DiffStore(); for (const path of ["src/alpha.ts", "src/nested/beta.ts"]) { - const file = createTestDiffFile(); - file.file.new_path = path; + const file = at(createTestDiffFile(), path); const lines = Array.from({length: 60}, (_, i) => `code ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text: lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [20], novel_rhs: [20,21], lines: file.diff.aligned_rows}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 20), leaf(2, 20, 21, [line(20, 0, 7)]), leaf(3, 21, 60)] }; + file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 20), leaf(2, 20, 21, [line(20, 0, 7)]), leaf(4, 21, 22, [line(21, 0, 7)]), leaf(3, 22, 60)] }; store.accept(file); } - const t = await testRender( {}} />, {width:150, height:20}); + const t = await testRender( {}} themes={themes} />, {width:150, height:20}); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("▾ src")); expect(t.captureCharFrame()).toContain("▾ nested"); // Sorted tree: src / nested / beta.ts / alpha.ts. - await act(async () => { await t.mockMouse.click(8, 3); }); - await t.waitForFrame(f => f.split("\n")[1].includes("src/nested/beta.ts")); + await act(async () => { await t.mockMouse.click(8, 4); }); + await t.waitForFrame(f => f.split("\n")[2].includes("src/nested/beta.ts")); await act(async () => { t.mockInput.pressKey("\x1b[6~"); }); await t.waitForFrame(f => f.includes("code 20")); - expect(t.captureCharFrame().split("\n")[1]).toContain("src/nested/beta.ts"); - expect(t.captureCharFrame().split("\n")[1]).toContain("+2 -1"); + expect(t.captureCharFrame().split("\n")[2]).toContain("src/nested/beta.ts"); + expect(t.captureCharFrame().split("\n")[2]).toContain("+2 −1"); await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => !f.includes("▾ src ")); - expect(t.captureCharFrame().split("\n")[1].trimStart()).toStartWith("▾ src/nested/beta.ts"); + expect(t.captureCharFrame().split("\n")[2].trimStart()).toStartWith("▾ src/nested/beta.ts"); await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => f.includes("▾ nested")); await act(async () => { await t.mockMouse.click(9, 0); }); - await t.waitForFrame(f => f.includes("Context: compact")); + await t.waitForFrame(f => f.includes("Toggle context gaps")); await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise(resolve => setTimeout(resolve, 50)); }); - await t.waitForFrame(f => !f.includes("Context: compact")); + await t.waitForFrame(f => !f.includes("Toggle context gaps")); } finally { await act(async () => { t.renderer.destroy(); }); } @@ -135,13 +134,11 @@ test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", as test("Hunk navigation chords and draggable sidebar preserve viewport behavior", async () => { const store = new DiffStore(), file = createTestDiffFile(); const lines = Array.from({length: 150}, (_, i) => `row ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text: lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: file.diff.aligned_rows}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 150)] }; store.accept(file); - const t = await testRender( {}} />, {width:150, height:20}); - const firstSource = () => Number(t.captureCharFrame().split("\n")[2].match(/row (\d+)/)?.[1]); + const t = await testRender( {}} themes={themes} />, {width:150, height:20}); + const firstSource = () => Number(t.captureCharFrame().split("\n")[3].match(/row (\d+)/)?.[1]); const press = async (name: string, ctrl = false) => { await act(async () => { t.mockInput.pressKey(name, {ctrl}); }); await t.renderOnce(); @@ -150,11 +147,11 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("row 0")); await press("d", true); - expect(firstSource()).toBe(9); + expect(firstSource()).toBe(8); await press("u", true); expect(firstSource()).toBe(0); await press("f", true); - expect(firstSource()).toBe(18); + expect(firstSource()).toBe(17); await press("b", true); expect(firstSource()).toBe(0); await press("f"); @@ -164,14 +161,14 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", expect(t.captureCharFrame()).toContain("row 149"); await press("g"); expect(firstSource()).toBe(0); - expect(t.captureCharFrame().split("\n")[2].indexOf("│")).toBe(27); + expect(t.captureCharFrame().split("\n")[3].indexOf("│")).toBe(27); await act(async () => { await t.mockMouse.drag(27, 8, 47, 8); }); - await t.waitForFrame(f => f.split("\n")[2].indexOf("│") === 47); + await t.waitForFrame(f => f.split("\n")[3].indexOf("│") === 47); await press("\\"); await press("\\"); - expect(t.captureCharFrame().split("\n")[2].indexOf("│")).toBe(47); + expect(t.captureCharFrame().split("\n")[3].indexOf("│")).toBe(47); await act(async () => { await t.mockMouse.drag(47, 8, 2, 8); }); - await t.waitForFrame(f => f.split("\n")[2].indexOf("│") === 15); + await t.waitForFrame(f => f.split("\n")[3].indexOf("│") === 15); expect(firstSource()).toBe(0); } finally { await act(async () => { t.renderer.destroy(); }); @@ -179,21 +176,21 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", }); test("initial manifest renders pending tree and remembers a jump until its diff arrives", async () => { const store = new DiffStore(), a = createTestDiffFile(), b = createTestDiffFile(); - a.file = {...a.file, old_path:"src/a.ts", new_path:"src/a.ts"}; - b.file = {...b.file, old_path:"src/b.ts", new_path:"src/b.ts"}; - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:2, files:[a.file,b.file]}); - const t = await testRender( {}} />, {width:150, height:20}); + at(a, "src/a.ts"); + at(b, "src/b.ts"); + const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:[entry(a), entry(b)]}); + const t = await testRender( {}} themes={themes} />, {width:150, height:20}); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("◌ a.ts") && f.includes("◌ b.ts")); expect(t.captureCharFrame()).not.toContain('send("old")'); - await act(async () => { await t.mockMouse.click(8,3); }); + await act(async () => { await t.mockMouse.click(8,4); }); await t.waitForFrame(f => f.includes("Waiting for b.ts")); await act(async () => { store.accept(a); }); await t.waitForFrame(f => f.includes("◌ b.ts")); await act(async () => { store.accept(b); }); - await t.waitForFrame(f => f.split("\n")[1].includes("src/b.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("src/b.ts")); expect(t.captureCharFrame()).not.toContain("◌ b.ts"); } finally { await act(async () => { t.renderer.destroy(); }); @@ -202,39 +199,36 @@ test("initial manifest renders pending tree and remembers a jump until its diff test("streaming diffs follow tree order without moving the visible source row", async () => { const store = new DiffStore(); const files = ["z/last.ts", "a/first.ts", "m/middle.ts"].map(path => { - const file = createTestDiffFile(); - file.file = {...file.file, old_path:path, new_path:path}; + const file = at(createTestDiffFile(), path); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({length:50}, (_, i) => `code ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text:lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i,i]); - file.diff.hunks = [{novel_lhs:[], novel_rhs:[], lines:file.diff.aligned_rows}]; + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 50)] }; return file; }); - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:3, files:files.map(f => f.file)}); - const t = await testRender( {}} />, {width:150, height:20}); - const sidebarLines = () => t.captureCharFrame().split("\n").slice(1,8).map(line => line.slice(0,27).trim()); + const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); + const t = await testRender( {}} themes={themes} />, {width:150, height:20}); + const sidebarLines = () => t.captureCharFrame().split("\n").slice(2,9).map(line => line.slice(0,27).trim()); try { await act(async () => { await t.renderOnce(); store.accept(files[0]); store.accept(files[2]); }); await t.waitForFrame(f => f.includes("m/middle.ts")); expect(sidebarLines().slice(0,6)).toEqual(["▾ a", "◌ first.ts", "▾ m", "middle.ts", "▾ z", "last.ts"]); await act(async () => { t.mockInput.pressKey("g"); }); - await t.waitForFrame(f => f.split("\n")[1].includes("m/middle.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("m/middle.ts")); await act(async () => { t.mockInput.pressKey("d", {ctrl:true}); }); await t.renderOnce(); - const before = t.captureCharFrame().split("\n")[2].slice(28); + const before = t.captureCharFrame().split("\n")[3].slice(28); await act(async () => { store.accept(files[1]); }); - await t.waitForFrame(f => !f.includes("◌ first.ts") && f.split("\n")[1].includes("m/middle.ts")); - expect(t.captureCharFrame().split("\n")[2].slice(28)).toBe(before); + await t.waitForFrame(f => !f.includes("◌ first.ts") && f.split("\n")[2].includes("m/middle.ts")); + expect(t.captureCharFrame().split("\n")[3].slice(28)).toBe(before); expect(sidebarLines().slice(0,6)).toEqual(["▾ a", "first.ts", "▾ m", "middle.ts", "▾ z", "last.ts"]); await act(async () => { store.accept({type:"complete", succeeded:3, failed:0}); }); await t.renderOnce(); - expect(t.captureCharFrame().split("\n")[2].slice(28)).toBe(before); + expect(t.captureCharFrame().split("\n")[3].slice(28)).toBe(before); await act(async () => { t.mockInput.pressKey("g"); }); - await t.waitForFrame(f => f.split("\n")[1].includes("a/first.ts")); - await act(async () => { await t.mockMouse.click(8,4); }); - await t.waitForFrame(f => f.split("\n")[1].includes("m/middle.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("a/first.ts")); + await act(async () => { await t.mockMouse.click(8,5); }); + await t.waitForFrame(f => f.split("\n")[2].includes("m/middle.ts")); } finally { await act(async () => { t.renderer.destroy(); }); } @@ -244,22 +238,19 @@ for (const wrap of [false, true]) for (const unified of [false, true]) test(`stream arrivals preserve code in every commit (wrap=${wrap}, unified=${unified})`, async () => { const store = new DiffStore(); const files = ["m/current.ts", "a/earlier.ts", "z/later.ts"].map(path => { - const file = createTestDiffFile(); - file.file = {...file.file, old_path:path, new_path:path}; + const file = at(createTestDiffFile(), path); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({length:50}, (_, i) => `source ${path} ${i} ${"word ".repeat(20)}`); - file.diff.lhs_src = file.diff.rhs_src = {Text:lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i,i]); - file.diff.hunks = [{novel_lhs:[], novel_rhs:[], lines:file.diff.aligned_rows}]; + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 50)] }; return file; }); - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:files.length, files:files.map(f => f.file)}); + const entry = (file: DiffFile) => ({ file: file.file, status: "modified" as const, visibility: { collapsed: false, label: "" } }); + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); store.accept(files[0]); let capture: (() => void) | undefined; const commits: string[][] = []; const t = await testRender( capture?.()}> - {}} /> + {}} themes={themes} /> , {width:150, height:20}); const sourceCells = (node: BaseRenderable): string[] => { if (node instanceof TextRenderable) { @@ -292,14 +283,14 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a const store = new DiffStore(); const file = createFoldedDiffFile(); // Pad the file past the viewport so the z key can act on a scrolled-to row. - const text = (file.diff.rhs_src as { Text: string }).Text; + if (file.diff.type !== "text") throw new Error(); const tail = Array.from({ length: 20 }, (_, i) => `tail ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: text + tail.join("\n") + "\n" }; - const rows = file.diff.aligned_rows.length; - file.diff.aligned_rows.push(...tail.map((_, i): [number, number] => [rows + i, rows + i])); - file.diff.hunks[0].lines = file.diff.aligned_rows; + for (const source of [file.diff.lhs!, file.diff.rhs!]) { + source.text += tail.join("\n") + "\n"; + source.regions.push(leaf(99, 8, 28)); + } store.accept(file); - const t = await testRender( {}} />, { width: 150, height: 20 }); + const t = await testRender( {}} themes={themes} />, { width: 150, height: 20 }); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame((f) => f.includes("inner(|| {")); @@ -354,3 +345,95 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a await act(async () => { t.renderer.destroy(); }); } }); + +test("the summary strip shows the wire's visible totals, unmoved by folding, and i opens the breakdown", async () => { + const store = new DiffStore(); + store.accept({type:"start", version:3, lhs:{type:"revision", rev:"main"}, rhs:{type:"working_tree"}, + files:[{ file: createFoldedDiffFile().file, status: "modified", visibility: { collapsed: false, label: "" } }]}); + store.accept(createFoldedDiffFile()); + const t = await testRender( {}} themes={themes} />, { width: 150, height: 24 }); + const render = async () => { await act(async () => { await t.renderOnce(); }); }; + const lines = () => t.captureCharFrame().split("\n"); + try { + await render(); + await render(); + expect(t.captureCharFrame()).toContain("inner(|| {"); + // One changed rhs line inside the closure body; still loading, so the total is partial. + expect(lines()[1]).toContain("main…working tree"); + expect(lines()[1]).toContain("1 files"); + expect(lines()[1]).toContain("+1 −0…"); + expect(lines()[1]).toContain("■■■■■"); + expect(lines()[2]).toContain("+1 −0"); + await act(async () => { store.accept({ type: "complete", succeeded: 1, failed: 0 }); }); + await render(); + expect(lines()[1]).not.toContain("−0…"); + // Collapse the closure body: the change is hidden, but the counts are diffr's and stay put. + const inner = lines().findIndex((l) => l.includes("inner(|| {")); + const chevronX = lines()[inner].indexOf("▾"); + await act(async () => { await t.mockMouse.click(chevronX, inner); }); + await render(); + expect(t.captureCharFrame()).not.toContain("a();"); + expect(lines()[1]).toContain("+1 −0"); + expect(lines()[1]).toContain("■■■■■"); + expect(lines()[2]).toContain("+1 −0"); + await act(async () => { t.mockInput.pressKey("i"); }); + await render(); + const frame = t.captureCharFrame(); + expect(frame).toContain("All files"); + expect(frame).toContain("visible +1 −0"); + expect(frame).toContain("textual +1 −0"); + expect(frame).not.toContain("line diff"); + await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise((resolve) => setTimeout(resolve, 100)); }); + await render(); + expect(t.captureCharFrame()).not.toContain("visible +1"); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); + +test("a docstring and its function sharing a fold_state_id open and close together from either row", async () => { + const store = new DiffStore(); + const file = createBundledDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // Pad past the viewport so z chords can act on a row scrolled to the top. + const tail = Array.from({ length: 20 }, (_, i) => `tail ${i}`); + file.diff.rhs!.text += tail.join("\n") + "\n"; + file.diff.rhs!.regions.push(leaf(99, 5, 25)); + store.accept(file); + const t = await testRender( {}} themes={themes} />, { width: 150, height: 20 }); + const lines = () => t.captureCharFrame().split("\n"); + const rowOf = (needle: string) => lines().findIndex((l) => l.includes(needle)); + const docOpen = (f: string) => f.includes("/// The built-in rule") && f.includes(" None"); + const docClosed = (f: string) => !f.includes("/// The built-in rule") && !f.includes(" None") && f.includes("fn from_path"); + const chord = async (...keys: string[]) => { for (const key of keys) await act(async () => { t.mockInput.pressKey(key); }); }; + try { + await act(async () => { await t.renderOnce(); }); + await t.waitForFrame(docClosed); + // Mouse, docstring row: its ⋯ row opens the docstring and the body together. + const bare = lines().findIndex((l) => /▸\s+⋯/.test(l)); + expect(bare).toBeGreaterThan(0); + expect(bare).toBeLessThan(rowOf("fn from_path")); + await act(async () => { await t.mockMouse.click(lines()[bare].indexOf("▸"), bare); }); + await t.waitForFrame(docOpen); + // Mouse, function row: its chevron closes both again. + const header = rowOf("fn from_path"); + await act(async () => { await t.mockMouse.click(lines()[header].indexOf("▾"), header); }); + await t.waitForFrame(docClosed); + // Keys: z chords act on the top row, under the sticky file header. Scroll past the header and + // line 1 so the docstring's ⋯ row is the top row; zo / zc / za then act on the pair. + await chord("j", "j"); + await t.waitForFrame((f) => !f.includes("1 ];") && !/▸\s+⋯/.test(f)); + // The top row sits under the sticky header, so judge by the body: open shows it, closed shows + // the pseudocode label instead. + const bodyOpen = (f: string) => f.includes(" None") && !f.includes("// pseudocode"); + const bodyClosed = (f: string) => !f.includes(" None") && f.includes("// pseudocode"); + await chord("z", "o"); + await t.waitForFrame(bodyOpen); + await chord("z", "c"); + await t.waitForFrame(bodyClosed); + await chord("z", "a"); + await t.waitForFrame(bodyOpen); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index 11e455bbc..d8f3b59a4 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -15,21 +15,23 @@ import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from ". import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; +import type { MoveJump } from "./diff/diffRowModel"; import { - dark, - light, rowsForFile, type Layout, type ViewerRow, } from "../diffr/rows"; +import type { Palette, ThemeSet } from "../diffr/theme"; import { measureRows, visibleRows } from "../diffr/geometry"; import { copySelection, selectionBounds, type SourceSelection, } from "../diffr/selection"; -import { fileIdentity, type DiffFile } from "../diffr/wire"; -import { foldRegions, nestedRegions, type FoldRegion, type RowFold } from "../diffr/folds"; +import { fileIdentity, filePath, type DiffFile } from "../diffr/wire"; +import { defaultCollapsed, foldIds, gapIds, nestedIds, type RowFold } from "../diffr/regions"; +import { placeholderRows } from "../diffr/rows"; +import { add, blockBar, comparisonLabel, zero, type LineCounts } from "../diffr/counts"; import type { DiffStore } from "../diffr/store"; import { sanitizeTerminalLine } from "../lib/terminalText"; import { sliceTextByWidth } from "./lib/text"; @@ -38,9 +40,11 @@ const fit = (text: string, width: number) => export function App({ store, onQuit, + themes, }: { store: DiffStore; onQuit: () => void; + themes: ThemeSet; }) { const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); const renderer = useRenderer(), @@ -48,39 +52,50 @@ export function App({ const [mode, setMode] = useState("auto"), [showSidebar, setShowSidebar] = useState(true), [wrap, setWrap] = useState(false), - [fullContext, setFullContext] = useState(false), - [isLight, setLight] = useState(false); + [theme, setTheme] = useState(themes.initial); const [scroll, setScroll] = useState(0), - [horizontal, setHorizontal] = useState(0), - [closed, setClosed] = useState>(new Set()); + [horizontal, setHorizontal] = useState(0); + // Files the user closed or opened; unset files follow the manifest's visibility. + const [closed, setClosed] = useState>(new Map()); const [selection, setSelection] = useState(null), [message, setMessage] = useState(""); - // Fold ids collapsed per loaded file; VS Code keeps this per editor model. - const [collapsed, setCollapsed] = useState>>(new Map()); + // Fold ids collapsed per loaded file; unset files start where diffr's visibility says. + const [collapsed, setCollapsed] = useState>>(new Map()); // Vim's z prefix: the next key names the fold command. const pendingZ = useRef(false); + // `g` goes home at once but remembers where the view was, so a following `m` can jump from there + // to the other copy of moved code. + const pendingG = useRef(null); const [closedDirectories, setClosedDirectories] = useState>(new Set()); const [treeScroll, setTreeScroll] = useState(0); const [sidebarWidth, setSidebarWidth] = useState(28); const sidebarDrag = useRef<{ x: number; width: number } | null>(null); const [pendingFile, setPendingFile] = useState(null); const [menu, setMenu] = useState(null); + const [showBreakdown, setShowBreakdown] = useState(false); const dragging = useRef(false), thumbDragging = useRef(false); - const theme = isLight ? light : dark, - sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; + // `t` swaps between the two bundled defaults; a configured theme is left by the first press. + const toggleTheme = () => setTheme((current) => (current.isLight ? themes.dark : themes.light)); + const sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; const contentWidth = Math.max(10, width - sidebar - 1), - viewportHeight = Math.max(1, height - 2); + viewportHeight = Math.max(1, height - 3); const layout = mode === "auto" ? (contentWidth >= 100 ? "split" : "unified") : mode; const loadedByIdentity = useMemo(() => new Map(snapshot.files.map((f, i) => [fileIdentity(f.file), i])), [snapshot.files]); - const inventory = useMemo(() => snapshot.inventory.map(file => ({file})), [snapshot.inventory]); + const inventory = snapshot.inventory; + const manifestOf = (file: DiffFile) => inventory.find(entry => fileIdentity(entry.file) === fileIdentity(file.file)); + const foldsOf = (index: number): ReadonlySet => { + const file = snapshot.files[index]; + return collapsed.get(index) ?? (file.diff.type === "text" ? defaultCollapsed(file.diff) : new Set()); + }; + const isClosed = (index: number) => closed.get(index) ?? manifestOf(snapshot.files[index])?.visibility.collapsed ?? false; const tree = useMemo(() => buildFileTree(inventory), [inventory]); // Keep loaded indexes stable for row keys, selections and file expansion. // Present arriving diffs in tree order throughout loading. const fileOrder = useMemo(() => flattenFileTree(tree, new Set()).flatMap(({node}) => { if (node.fileIndex === undefined) return []; - const loaded = loadedByIdentity.get(fileIdentity(snapshot.inventory[node.fileIndex])); + const loaded = loadedByIdentity.get(fileIdentity(snapshot.inventory[node.fileIndex].file)); return loaded === undefined ? [] : [loaded]; }), [snapshot.inventory, tree, loadedByIdentity]); @@ -90,19 +105,23 @@ export function App({ const rows = useMemo(() => { const all = fileOrder.flatMap(index => { const file = snapshot.files[index]; - const folds = collapsed.get(index) ?? new Set(); - const key = `${index}:${layout}:${isLight}:${fullContext}:${[...folds].sort().join(",")}`; + const folds = foldsOf(index); + const key = `${index}:${layout}:${theme.name}:${[...folds].sort((a, b) => a - b).join(",")}`; let cached = rowCache.current.get(file); if (cached?.key !== key) { - cached = { key, rows: rowsForFile(file, index, layout, theme, fullContext, folds) }; + cached = { key, rows: rowsForFile(file, index, layout, theme, folds) }; rowCache.current.set(file, cached); } - return closed.has(index) ? cached.rows.slice(0, 1) : cached.rows; + if (!isClosed(index)) return cached.rows; + const manifest = manifestOf(file); + return manifest?.visibility.collapsed + ? [cached.rows[0], ...placeholderRows(index, manifest.visibility.label)] + : cached.rows.slice(0, 1); }); for (const [i, error] of snapshot.errors.entries()) all.push({ key: `error:${i}`, fileIndex: -1, label: error }); return all; - }, [snapshot.files, snapshot.errors, layout, theme, closed, fullContext, fileOrder, collapsed]); + }, [snapshot.files, snapshot.errors, snapshot.inventory, layout, theme, closed, fileOrder, collapsed]); const geometry = useMemo( () => measureRows(rows, contentWidth, wrap, horizontal), [rows, contentWidth, wrap, horizontal], @@ -127,39 +146,29 @@ export function App({ } const move = (amount: number) => setScroll((current) => Math.max(0, Math.min(maxScroll, current + amount))); - const toggleFile = (index: number) => { - setClosed((old) => { - const next = new Set(old); - if (next.has(index)) next.delete(index); - else next.add(index); - return next; - }); - }; - const regionCache = useRef(new WeakMap()); - const regionsOf = (file: DiffFile) => { - let regions = regionCache.current.get(file); - if (!regions) { - regions = foldRegions(file.diff); - regionCache.current.set(file, regions); - } - return regions; - }; - const setFolds = (fileIndex: number, ids: string[], collapse: boolean) => + const toggleFile = (index: number) => + setClosed((old) => new Map(old).set(index, !isClosed(index))); + const setFolds = (fileIndex: number, ids: number[], collapse: boolean) => setCollapsed((old) => { - const next = new Set(old.get(fileIndex) ?? []); + const next = new Set(foldsOf(fileIndex)); for (const id of ids) if (collapse) next.add(id); else next.delete(id); return new Map(old).set(fileIndex, next); }); - // Recursive commands (Alt-click, zC, zO, zA) include every region nested inside. + // Recursive commands (Alt-click, zC, zO, zA) include every fold nested inside. const setFold = (fileIndex: number, fold: RowFold, collapse: boolean, recursive: boolean) => { - const regions = regionsOf(snapshot.files[fileIndex]); - const region = regions.find((r) => r.id === fold.id); - if (!region) throw new Error(`Unknown fold ${fold.id}`); - const ids = recursive - ? [fold.id, ...nestedRegions(regions, region).map((r) => r.id)] - : [fold.id]; + const file = snapshot.files[fileIndex]; + if (file.diff.type !== "text") throw new Error("Binary files have no folds"); + const ids = recursive ? [fold.id, ...nestedIds(file.diff, fold.id)] : [fold.id]; setFolds(fileIndex, ids, collapse); }; + // `c`: reveal every context gap, or hide them again. + const toggleContext = () => { + const opened = snapshot.files.some((file, index) => + file.diff.type === "text" && gapIds(file.diff).some((id) => !foldsOf(index).has(id))); + snapshot.files.forEach((file, index) => { + if (file.diff.type === "text") setFolds(index, gapIds(file.diff), opened); + }); + }; const toggleFold = (fileIndex: number, fold: RowFold, recursive: boolean) => setFold(fileIndex, fold, !fold.collapsed, recursive); const rowFold = (row: ViewerRow) => row.cell?.fold ?? row.right?.fold ?? row.left?.fold; @@ -186,19 +195,31 @@ export function App({ else if (letter === "o") setFold(current.fileIndex, fold, false, recursive); else if (letter === "c") setFold(current.fileIndex, fold, true, recursive); }; - const foldAll = (collapse: boolean) => { - const byFile = new Map(); - for (const row of rows) { - const fold = rowFold(row); - if (fold) byFile.set(row.fileIndex, [...(byFile.get(row.fileIndex) ?? []), fold.id]); - } - if (collapse) for (const [fileIndex, ids] of byFile) setFolds(fileIndex, ids, true); - else setCollapsed(new Map()); - }; + const foldAll = (collapse: boolean) => + snapshot.files.forEach((file, index) => { + if (file.diff.type === "text") setFolds(index, foldIds(file.diff), collapse); + }); const jump = (index: number) => { const row = geometry.rows.find((r) => r.row.fileIndex === index); if (row) setScroll(Math.min(maxScroll, row.top)); }; + // Moved code: scroll to the counterpart line on the other side, in the same file. + const jumpToMove = (fileIndex: number, target: MoveJump) => { + const lineOf = (row: ViewerRow) => target.side === "left" + ? (row.left?.lineNumber ?? row.cell?.oldLineNumber) + : (row.right?.lineNumber ?? row.cell?.newLineNumber); + const found = geometry.rows.find((r) => r.row.fileIndex === fileIndex && lineOf(r.row) === target.line); + if (!found) return setMessage(`Line ${target.line} is folded or not loaded`); + setScroll(Math.min(maxScroll, found.top)); + setMessage(`Moved code: ${target.side} line ${target.line}`); + }; + const jumpFromTop = (from = top) => { + const current = visibleRows(geometry, from, Math.max(1, viewportHeight)).map((r) => r.row) + .find((row) => (row.cell ?? row.right)?.jump ?? row.left?.jump); + const jumpOf = current && ((current.cell ?? current.right)?.jump ?? current.left?.jump); + if (!current || !jumpOf) return setMessage("No moved code on screen"); + jumpToMove(current.fileIndex, jumpOf); + }; const navigateHunk = (direction: number) => { const headers = geometry.rows.filter((r) => r.row.hunkStart); const target = @@ -218,6 +239,11 @@ export function App({ useKeyboard((key) => { // Hunk's chord matcher handles raw control bytes and Kitty events alike. const is = (...chords: string[]) => !key.super && chords.some(chord => matchesKey(chord, key)); + if (pendingG.current !== null) { + const from = pendingG.current; + pendingG.current = null; + if (is("m")) return jumpFromTop(from); + } if (pendingZ.current) { pendingZ.current = false; const command = key.shift ? key.name?.toUpperCase() : key.name; @@ -235,7 +261,8 @@ export function App({ else if (is("pageup", "b", "shift+space", "ctrl+b")) move(-viewportHeight); else if (is("down", "j")) move(1); else if (is("up", "k")) move(-1); - else if (is("g", "home")) setScroll(0); + else if (is("g")) { pendingG.current = top; setScroll(0); } + else if (is("home")) setScroll(0); else if (is("G", "end")) setScroll(maxScroll); else if (is("right", "shift+right", "l")) setHorizontal(n => n + (key.shift ? 16 : 4)); else if (is("left", "shift+left", "h")) setHorizontal(n => Math.max(0, n - (key.shift ? 16 : 4))); @@ -245,10 +272,11 @@ export function App({ setMode(layout === "split" ? "unified" : "split"); setSelection(null); } else if (key.name === "w") setWrap((v) => !v); - else if (key.name === "c") { setFullContext(v => !v); setSelection(null); } - else if (key.name === "t") setLight((v) => !v); + else if (key.name === "c") { toggleContext(); setSelection(null); } + else if (key.name === "t") toggleTheme(); else if (key.name === "y") copy(); - else if (key.name === "escape") { setSelection(null); setMenu(null); } + else if (key.name === "escape") { setSelection(null); setMenu(null); setShowBreakdown(false); } + else if (key.name === "i") setShowBreakdown((v) => !v); else if (key.name === "return") { const current = visibleRows(geometry, top, 1)[0]; if (current && current.row.fileIndex >= 0) @@ -266,7 +294,7 @@ export function App({ const viewport = visibleRows(geometry, top, viewportHeight); const currentFile = viewport[0]?.row.fileIndex ?? 0; const activeIdentity = pendingFile ?? (snapshot.files[currentFile] ? fileIdentity(snapshot.files[currentFile].file) : null); - const currentTreeFile = snapshot.inventory.findIndex(file => fileIdentity(file) === activeIdentity); + const currentTreeFile = snapshot.inventory.findIndex(entry => fileIdentity(entry.file) === activeIdentity); useEffect(() => { if (pendingFile === null) return; const index = loadedByIdentity.get(pendingFile); @@ -280,6 +308,13 @@ export function App({ }, [pendingFile, loadedByIdentity, geometry, maxScroll, snapshot.failedFiles, snapshot.complete]); const treeRows = useMemo(() => flattenFileTree(tree, closedDirectories), [tree, closedDirectories]); const counts = useMemo(() => snapshot.files.map(lineCounts), [snapshot.files]); + // Headline numbers are diffr's stats.visible, verbatim: folding never changes them. + const totals = useMemo(() => ({ + visible: counts.reduce((sum, c) => add(sum, c.visible), zero), + textual: counts.reduce((sum, c) => add(sum, c.textual), zero), + fallbacks: counts.filter((c) => c.fallback).length, + }), [counts]); + const plusMinus = (c: LineCounts) => `+${c.added} −${c.removed}`; useEffect(() => { const file = inventory[currentTreeFile]; if (!file) return; @@ -296,18 +331,18 @@ export function App({ }, [currentTreeFile, treeRows, viewportHeight]); const sidebarStart = Math.min(treeScroll, Math.max(0, treeRows.length - viewportHeight)); const fileHeader = (fileIndex: number, key: string) => { - const file = snapshot.files[fileIndex], count = counts[fileIndex]; - if (!file) return null; - const path = file.file.new_path ?? file.file.old_path ?? file.diff.display_path; + const file = snapshot.files[fileIndex], count = counts[fileIndex]?.visible; + if (!file || !count) return null; + const path = filePath(file.file); const statsWidth = String(count.added).length + String(count.removed).length + 5; return toggleFile(fileIndex)}> - {fit(sanitizeTerminalLine(`${closed.has(fileIndex) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} + {fit(sanitizeTerminalLine(`${isClosed(fileIndex) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} - {` +${count.added}`} - {` -${count.removed} `} + {` +${count.added}`} + {` −${count.removed} `} ; }; const rendered = []; @@ -328,20 +363,13 @@ export function App({ key={row.key} height={1} width={contentWidth} - fg={theme.muted} + fg={row.loadDiff ? theme.accent : theme.muted} selectable={false} onMouseUp={() => { - if (row.key.endsWith(":header")) toggleFile(row.fileIndex); + if (row.loadDiff) toggleFile(row.fileIndex); }} > - {fit( - sanitizeTerminalLine( - row.key.endsWith(":header") - ? `${closed.has(row.fileIndex) ? "▸" : "▾"} ${row.label}` - : row.label, - ), - contentWidth, - )} + {fit(sanitizeTerminalLine(row.loadDiff ? ` ${row.label}` : row.label), contentWidth)} , ); else @@ -366,6 +394,7 @@ export function App({ setSelection((s) => (s ? { ...s, end: row.key } : s)); }} onFold={(fold, recursive) => toggleFold(row.fileIndex, fold, recursive)} + onJump={(target) => jumpToMove(row.fileIndex, target)} />, ); } @@ -396,15 +425,16 @@ export function App({ File: [["Toggle file tree ⌘B / \\", () => setShowSidebar(v => !v)], ["Copy selection y", copy], ["Quit q", onQuit]], View: [[`Layout: ${layout} s`, () => { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }], [`Wrap: ${wrap ? "on" : "off"} w`, () => setWrap(v => !v)], - [`Context: ${fullContext ? "all" : "compact"} c`, () => { setFullContext(v => !v); setSelection(null); }], + ["Toggle context gaps c", () => { toggleContext(); setSelection(null); }], ["Fold all zM", () => foldAll(true)], ["Unfold all zR", () => foldAll(false)]], Navigate: [["Previous change [", () => navigateHunk(-1)], ["Next change ]", () => navigateHunk(1)], ["First file Home", () => setScroll(0)], ["Last file End", () => setScroll(maxScroll)]], - Theme: [["Dark", () => setLight(false)], ["Light", () => setLight(true)]], + Theme: [[`Dark (${themes.dark.name}) t`, () => setTheme(themes.dark)], [`Light (${themes.light.name}) t`, () => setTheme(themes.light)]], Help: [["Scroll: j/k · h/l · gg/G", () => setMessage("j/k scroll · h/l pan · gg first · G last")], ["Half page: Ctrl-D / Ctrl-U", () => setMessage("d / Ctrl-D: half down · u / Ctrl-U: half up")], ["Full page: Ctrl-F / Ctrl-B", () => setMessage("Ctrl-F: page down · Ctrl-B: page up")], ["Drag to select · y to copy", () => setMessage("Drag source lines; y copies original source")], + ["Change breakdown i", () => setShowBreakdown(true)], ["Folds: click ▾ · za zo zc · zM zR", () => setMessage("Click the chevron or ⋯ · za toggle, zo open, zc close the top fold (zA zO zC recursive) · zM/zR fold/unfold all · zj/zk next/previous fold")]], }; return ( @@ -434,17 +464,29 @@ export function App({ sidebarDrag.current = null; }} > - + {["File", "View", "Navigate", "Theme", "Help"].map(name => ( setMenu(old => old === name ? null : name)}> {` ${name} `} ))} - { + { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }}>{` ${layout} [s] `} - {` · ${snapshot.total || snapshot.inventory.length} files`} + + + + {` ${snapshot.comparison ? comparisonLabel(snapshot.comparison.lhs, snapshot.comparison.rhs) : "diffr"}`} + + {` · ${snapshot.total || snapshot.inventory.length} files · `} + setShowBreakdown((v) => !v)}>{`+${totals.visible.added}`} + setShowBreakdown((v) => !v)}>{` −${totals.visible.removed}`} + {snapshot.complete ? " " : "… "} + {blockBar(totals.visible).map((block, i) => ( + setShowBreakdown((v) => !v)}>{block === "neutral" ? "□" : "■"} + ))} ( { if (node.fileIndex !== undefined) { - const identity = fileIdentity(snapshot.inventory[node.fileIndex]); + const identity = fileIdentity(snapshot.inventory[node.fileIndex].file); const loaded = loadedByIdentity.get(identity); if (loaded !== undefined) { setPendingFile(null); setMessage(""); jump(loaded); } else if (snapshot.failedFiles.has(identity)) setMessage(snapshot.failedFiles.get(identity)!); @@ -490,8 +532,8 @@ export function App({ }); }}> {fit(sanitizeTerminalLine(" ".repeat(depth) + (node.fileIndex === undefined - ? (closedDirectories.has(node.key) ? "▸ " : "▾ ") : snapshot.failedFiles.has(fileIdentity(snapshot.inventory[node.fileIndex])) ? "! " - : loadedByIdentity.has(fileIdentity(snapshot.inventory[node.fileIndex])) ? " " : "◌ ") + node.name), sidebar - 1)} + ? (closedDirectories.has(node.key) ? "▸ " : "▾ ") : snapshot.failedFiles.has(fileIdentity(snapshot.inventory[node.fileIndex].file)) ? "! " + : loadedByIdentity.has(fileIdentity(snapshot.inventory[node.fileIndex].file)) ? " " : "◌ ") + node.name), sidebar - 1)} ))} @@ -545,9 +587,30 @@ export function App({ /> + {showBreakdown && (() => { + const file = snapshot.files[currentFile]; + const fallback = file && counts[currentFile].fallback; + const sections: [string, LineCounts, LineCounts, string | null][] = [ + ["All files", totals.visible, totals.textual, totals.fallbacks ? `line diff: ${totals.fallbacks} files` : null], + ...(file ? [[filePath(file.file), counts[currentFile].visible, counts[currentFile].textual, + fallback ? `line diff: ${fallback.code}` : null] as [string, LineCounts, LineCounts, string | null]] : []), + ]; + const boxWidth = Math.min(width, 44); + const rowCount = sections.reduce((n, s) => n + 3 + (s[3] ? 1 : 0), 0); + return + {sections.flatMap(([title, visible, textual, note]) => [ + {fit(` ${title}`, boxWidth)}, + {fit(` visible ${plusMinus(visible)}`, boxWidth)}, + {fit(` textual ${plusMinus(textual)}`, boxWidth)}, + ...(note ? [{fit(` ${note}`, boxWidth)}] : []), + ])} + {fit(" esc close", boxWidth)} + ; + })()} {menu && + backgroundColor={theme.chrome}> {menuItems[menu].map(([label, action]) => ( { action(); setMenu(null); }}> @@ -557,7 +620,7 @@ export function App({ } {fit( - `${snapshot.files.length}/${snapshot.total} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · drag selects lines · y copy · q quit ${message}`, + `${snapshot.files.length}/${snapshot.total} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · i breakdown · drag selects lines · y copy · q quit ${message}`, width, )} diff --git a/tui/packages/hunk/src/ui/Settings.test.tsx b/tui/packages/hunk/src/ui/Settings.test.tsx new file mode 100644 index 000000000..62d92970e --- /dev/null +++ b/tui/packages/hunk/src/ui/Settings.test.tsx @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { Settings, displayValue, isSecret, nextValue } from "./Settings"; +import { schemaFixture, valuesFixture } from "../diffr/config.test"; +import { flattenSchema, type ConfigClient } from "../diffr/config"; + +test("values show as not set, secrets only as stored, and toggles flip or cycle", () => { + const [minLines, collapse, , provider, apiKey] = flattenSchema(schemaFixture, valuesFixture); + expect(displayValue(minLines)).toBe("12"); + expect(displayValue({ ...minLines, value: null })).toBe("not set"); + expect(displayValue(apiKey)).toBe("not set"); + expect(displayValue({ ...apiKey, value: "abc" })).toBe("✓ stored"); + expect(isSecret("summarize.api_key")).toBe(true); + expect(isSecret("folds.min_lines")).toBe(false); + expect(nextValue(collapse)).toBe("false"); + expect(nextValue(provider)).toBe("none"); + expect(nextValue({ ...provider, value: "none" })).toBe("gemini"); +}); + +test("rows lead with titles under group headings; toggles change in place and typed values open a prompt", async () => { + const writes: [string, string][] = []; + const client: ConfigClient = { + schema: () => schemaFixture, + show: () => structuredClone(valuesFixture), + set: (key, value) => { + writes.push([key, value]); + }, + }; + let quit = false; + const t = await testRender( { quit = true; }} />, { width: 100, height: 24 }); + const press = async (key: string) => { + await act(async () => { t.mockInput.pressKey(key); }); + await act(async () => { await t.renderOnce(); }); + }; + const type = async (text: string) => { + for (const char of text) await press(char); + }; + const clear = async () => { + for (let i = 0; i < 12; i++) await press("BACKSPACE"); + }; + const escape = async () => { + await press("ESCAPE"); + // A lone escape is only recognised once the parser's escape-sequence timeout passes. + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); + await act(async () => { await t.renderOnce(); }); + }; + const frame = () => t.captureCharFrame(); + const line = (text: string) => frame().split("\n").find((l) => l.includes(text)) ?? ""; + try { + await act(async () => { await t.renderOnce(); }); + // Groups in schema order, each setting under its heading, titles first and keys only in the detail line. + const order = ["Collapsed code", "Shortest body to collapse", "Collapse deleted functions", "Hidden files", "Hide test files", "Summaries", "Provider"] + .map((text) => frame().split("\n").findIndex((l) => l.includes(text))); + expect(order).toEqual([...order].sort((a, b) => a - b)); + expect(order.every((index) => index >= 0)).toBe(true); + expect(line("Shortest body to collapse")).toContain("→"); + expect(line("Shortest body to collapse")).not.toContain("folds.min_lines"); + expect(frame()).toContain("folds.min_lines · default 12"); + expect(frame()).toContain("Bodies shorter than this are never summarized or collapsed."); + expect(frame()).toContain("(1/6)"); + expect(frame()).toContain("Type to search · Enter/Space to change · Esc to quit"); + expect(line("API key")).toContain("not set"); + + // Boolean: space flips it in place and writes through the CLI. + await type("hidetest"); + expect(frame()).toContain("(1/1)"); + expect(line("Hide test files")).toContain("false"); + await press(" "); + expect(writes).toEqual([["folds.collapse_tests", "true"]]); + expect(line("Hide test files")).toContain("true"); + expect(frame()).toContain("Hide test files: true"); + + // Enum: enter cycles to the next option. + await clear(); + await type("provider"); + await press("RETURN"); + expect(writes.at(-1)).toEqual(["summarize.provider", "none"]); + expect(line("Provider")).toContain("none"); + + // Number: enter opens a prompt titled by the setting; escape discards, enter saves. + await clear(); + await type("shortest"); + await press("RETURN"); + expect(frame()).toContain("Shortest body to collapse"); + expect(frame()).toContain("> 12"); + expect(frame()).toContain("(escape/ctrl+c to cancel, enter to submit)"); + await press("BACKSPACE"); + await type("9"); + await escape(); + expect(frame()).toContain("Type to search"); + expect(writes).toHaveLength(2); + await press("RETURN"); + await press("BACKSPACE"); + await press("BACKSPACE"); + await type("2.5"); + await press("RETURN"); + expect(frame()).toContain("Expected an integer"); + expect(writes).toHaveLength(2); + await press("BACKSPACE"); + await press("BACKSPACE"); + await press("BACKSPACE"); + await type("20"); + await press("RETURN"); + expect(writes.at(-1)).toEqual(["folds.min_lines", "20"]); + expect(line("Shortest body to collapse")).toContain("20"); + + // Secret: the prompt starts empty and masks typing; the row only says it is stored. + await clear(); + await type("api"); + await press("RETURN"); + expect(frame()).toContain("API key for the provider."); + // Keys that arrive in one burst, before any re-render, all land, including the enter after them. + await act(async () => { + for (const key of ["a", "b", "c"]) t.mockInput.pressKey(key); + }); + await act(async () => { await t.renderOnce(); }); + expect(frame()).toContain("> •••"); + expect(frame()).not.toContain("abc"); + await act(async () => { + t.mockInput.pressKey("d"); + t.mockInput.pressKey("RETURN"); + }); + await act(async () => { await t.renderOnce(); }); + expect(writes.at(-1)).toEqual(["summarize.api_key", "abcd"]); + expect(line("API key")).toContain("✓ stored"); + expect(frame()).not.toContain("abc"); + + await escape(); + expect(quit).toBe(true); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); diff --git a/tui/packages/hunk/src/ui/Settings.tsx b/tui/packages/hunk/src/ui/Settings.tsx new file mode 100644 index 000000000..52020bcb4 --- /dev/null +++ b/tui/packages/hunk/src/ui/Settings.tsx @@ -0,0 +1,249 @@ +/** A searchable, grouped settings screen over diffr's config schema; every change writes through the CLI. */ +import { useReducer, useRef } from "react"; +import { TextAttributes } from "@opentui/core"; +import { useKeyboard, usePaste, useTerminalDimensions } from "@opentui/react"; +import { + filterSettings, + flattenSchema, + formatValue, + isDefault, + parseValue, + type ConfigClient, + type Setting, +} from "../diffr/config"; +import { dark } from "../diffr/rows"; +import { sliceTextByWidth } from "./lib/text"; + +const fit = (text: string, width: number) => sliceTextByWidth(text, 0, Math.max(0, width)).text; +const pad = (text: string, width: number) => fit(text, width).padEnd(Math.max(0, width)); +/** Schema descriptions keep their source line breaks; the screen shows them as one line. */ +const oneLine = (text: string) => text.replace(/\s*\n\s*/g, " "); +/** Word-wrap a description into lines of at most `width` cells, keeping at most `max` lines. */ +export function wrap(text: string, width: number, max: number): string[] { + const lines: string[] = []; + let line = ""; + for (const word of oneLine(text).split(" ")) { + if (line && line.length + 1 + word.length > width) { + lines.push(line); + line = word; + } else line = line ? `${line} ${word}` : word; + } + if (line) lines.push(line); + return lines.length > max ? [...lines.slice(0, max - 1), fit(lines.slice(max - 1).join(" "), width - 1) + "…"] : lines; +} + +/** Keys that look like credentials are typed masked and shown only as stored or not. */ +export const isSecret = (key: string) => /(api_key|secret|token|password)/i.test(key); + +/** The value column: secrets never show their text, and an unset value says so. */ +export const displayValue = (setting: Setting) => { + const text = formatValue(setting.value); + if (isSecret(setting.key)) return text === "" ? "not set" : "✓ stored"; + return text === "" ? "not set" : text; +}; + +/** Booleans and enums change in place; everything else needs typed input. */ +const toggles = (setting: Setting) => setting.type === "boolean" || setting.type === "enum"; + +/** The value a toggle moves to next: booleans flip, enums cycle through their options. */ +export function nextValue(setting: Setting): string { + if (setting.type === "boolean") return setting.value === true ? "false" : "true"; + const options = setting.options!; + return options[(options.indexOf(formatValue(setting.value)) + 1) % options.length]; +} + +interface Prompt { + setting: Setting; + draft: string; + error: string; +} + +interface ScreenState { + settings: Setting[]; + query: string; + cursor: number; + prompt: Prompt | null; + status: { text: string; error: boolean } | null; +} + +type Line = { kind: "gap"; group: string } | { kind: "group"; group: string } | { kind: "setting"; setting: Setting; index: number }; + +export function Settings({ client, onQuit, initialQuery = "" }: { client: ConfigClient; onQuit: () => void; initialQuery?: string }) { + const { width, height } = useTerminalDimensions(); + // Keys can arrive in one burst before React re-renders (a fast typist, a paste followed by + // enter), so the handlers read and write this ref and only then ask for a render. + const state = useRef({ + settings: flattenSchema(client.schema(), client.show()), + query: initialQuery, + cursor: 0, + prompt: null, + status: null, + }); + const [, render] = useReducer((n: number) => n + 1, 0); + const update = (change: Partial) => { + state.current = { ...state.current, ...change }; + render(); + }; + const { settings, query, prompt, status } = state.current; + const visible = filterSettings(settings, query); + const selected = Math.min(state.current.cursor, Math.max(0, visible.length - 1)); + const current = visible[selected]; + const theme = dark; + + /** Write through the CLI first; the row changes only once the write succeeded. */ + const save = (setting: Setting, text: string) => { + const value = parseValue(setting, text); + client.set(setting.key, text); + update({ + settings: state.current.settings.map((s) => (s.key === setting.key ? { ...s, value } : s)), + status: { text: `${setting.title}: ${displayValue({ ...setting, value })}`, error: false }, + }); + }; + const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); + const type = (text: string) => { + const { prompt, query } = state.current; + if (prompt) update({ prompt: { ...prompt, draft: prompt.draft + text, error: "" } }); + else update({ query: query + text, cursor: 0 }); + }; + + useKeyboard((key) => { + const { prompt, query, settings, cursor } = state.current; + if (prompt) { + if (key.name === "escape" || (key.ctrl && key.name === "c")) update({ prompt: null }); + else if (key.name === "return") { + try { + save(prompt.setting, prompt.draft); + update({ prompt: null }); + } catch (error) { + update({ prompt: { ...prompt, error: message(error) } }); + } + } else if (key.name === "backspace") update({ prompt: { ...prompt, draft: prompt.draft.slice(0, -1), error: "" } }); + else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) type(key.sequence); + return; + } + const matches = filterSettings(settings, query); + const at = Math.min(cursor, Math.max(0, matches.length - 1)); + const here = matches[at]; + if (key.name === "escape" || (key.ctrl && key.name === "c")) onQuit(); + else if (key.name === "down" || (key.ctrl && key.name === "n")) update({ cursor: Math.min(at + 1, matches.length - 1) }); + else if (key.name === "up" || (key.ctrl && key.name === "p")) update({ cursor: Math.max(at - 1, 0) }); + else if ((key.name === "return" || key.name === "space") && here) { + if (toggles(here)) { + try { + save(here, nextValue(here)); + } catch (error) { + update({ status: { text: message(error), error: true } }); + } + } else update({ prompt: { setting: here, draft: isSecret(here.key) ? "" : formatValue(here.value), error: "" } }); + } else if (key.name === "backspace") update({ query: query.slice(0, -1), cursor: 0 }); + else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) type(key.sequence); + }); + // A pasted value arrives as one event; line breaks around it are not part of the value. + usePaste((event) => type(new TextDecoder().decode(event.bytes).replace(/[\r\n]+/g, ""))); + + const rule = {"─".repeat(width)}; + const blank = (id: string) => {" "}; + + if (prompt) { + const { setting, draft, error } = prompt; + const field = isSecret(setting.key) ? "•".repeat(draft.length) : draft; + return ( + + + {rule} + {blank("top")} + {fit(` ${setting.title}`, width)} + {blank("title")} + {wrap(setting.description, width - 2, 3).map((text, i) => ( + {` ${text}`} + ))} + + {"> "} + {fit(field, width - 3)} + {" "} + + {error + ? {fit(` ${error}`, width)} + : + {" ("} + {"escape/ctrl+c"} + {" to cancel, "} + {"enter"} + {" to submit)"} + } + {blank("bottom")} + {rule} + + ); + } + + const lines: Line[] = []; + visible.forEach((setting, index) => { + if (setting.group !== visible[index - 1]?.group) { + if (index > 0) lines.push({ kind: "gap", group: setting.group }); + lines.push({ kind: "group", group: setting.group }); + } + lines.push({ kind: "setting", setting, index }); + }); + // Two rules, search, blank, count, blank, up to three detail lines, status, hint. + const listHeight = Math.max(3, height - 11); + const cursorLine = lines.findIndex((line) => line.kind === "setting" && line.index === selected); + // Keep the selected row's group heading on screen with it where possible. + const start = Math.max(0, Math.min(cursorLine - Math.floor(listHeight / 2), lines.length - listHeight)); + const shown = lines.slice(start, start + listHeight); + // A heading whose rows are cut off below says nothing; drop it with its gap. + while (shown.length && shown.at(-1)!.kind !== "setting") shown.pop(); + // The arrow gutter, the longest title, then a two-cell gap before values. + const titleWidth = Math.min(width - 16, 3 + Math.max(...settings.map((s) => s.title.length)) + 2); + const valueWidth = Math.max(8, width - titleWidth - 4); + const detail = current + ? `${current.key} · default ${displayValue({ ...current, value: current.default })}` + : ""; + + return ( + + {rule} + + {"> "} + {fit(query, width - 3)} + {" "} + + {blank("search")} + {shown.map((line) => { + if (line.kind === "gap") return blank(`gap:${line.group}`); + if (line.kind === "group") + return ( + + {fit(` ${line.group}`, width)} + + ); + const { setting, index } = line; + const isSelected = index === selected; + const changed = !isDefault(setting); + return ( + + + {pad(fit(`${isSelected ? " → " : " "}${setting.title}`, titleWidth - 2), titleWidth)} + + + {fit(displayValue(setting), valueWidth)} + + + ); + })} + {visible.length === 0 && {" No settings match"}} + + {fit(` (${visible.length ? selected + 1 : 0}/${visible.length})`, width)} + + {blank("count")} + {wrap(current?.description ?? "", width - 4, 2).map((text, i) => ( + {` ${text}`} + ))} + {fit(` ${detail}`, width)} + + {fit(` ${status?.text ?? ""}`, width)} + {fit(" Type to search · Enter/Space to change · Esc to quit", width)} + {rule} + + ); +} diff --git a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index 73c71606f..20d66a646 100644 --- a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -2,13 +2,14 @@ import { memo } from "react"; import { StyledText, parseColor } from "@opentui/core"; import type { + MoveJump, RenderSpan, SplitLineCell, UnifiedLineCell, } from "./diffRowModel"; import type { Geometry, MeasuredRow } from "../../diffr/geometry"; -import type { Palette } from "../../diffr/rows"; -import type { RowFold } from "../../diffr/folds"; +import type { Palette } from "../../diffr/theme"; +import type { FoldTint, RowFold } from "../../diffr/regions"; import { measureTextWidth } from "../lib/text"; const colors = new Map>(); function color(value: string) { @@ -29,13 +30,19 @@ function styled(spans: RenderSpan[], theme: Palette, bg: string) { })), ); } +/** A collapsed fold takes its side's change tint when it is one-sided, like Review's bands. */ +export function foldBackground(theme: Palette, tint: FoldTint | undefined) { + if (tint === undefined) throw new Error("A collapsed fold row has no tint"); + return tint === "inserted" ? theme.addition : tint === "removed" ? theme.deletion : theme.foldBackground; +} /** VS Code's showFoldingControls "always": expandable rows keep their chevron visible. */ function chevron(fold: RowFold | undefined) { if (!fold) return " "; return fold.collapsed ? "▸" : "▾"; } +/** A multi-line label (pseudocode) hangs under the header, so the header shows only the ellipsis. */ const placeholderText = (fold: RowFold) => - fold.placeholder === "…" ? " ⋯" : ` ⋯ ${fold.placeholder}`; + fold.label === "" || fold.label.includes("\n") ? " ⋯" : ` ⋯ ${fold.label}`; export const CodeRowView = memo(function CodeRowView({ measured, visualLine, @@ -45,6 +52,7 @@ export const CodeRowView = memo(function CodeRowView({ onSelect, onExtend, onFold, + onJump, }: { measured: MeasuredRow; visualLine: number; @@ -54,6 +62,8 @@ export const CodeRowView = memo(function CodeRowView({ onSelect: (side: "left" | "right") => void; onExtend: () => void; onFold: (fold: RowFold, recursive: boolean) => void; + /** Jump to the other copy of moved code. */ + onJump: (jump: MoveJump) => void; }) { const row = measured.row; const lastLine = visualLine === measured.height - 1; @@ -67,13 +77,15 @@ export const CodeRowView = memo(function CodeRowView({ const fold = value.fold; const bg = selectedSide === side - ? "#264f78" - : value.kind === "addition" - ? theme.addition - : value.kind === "deletion" - ? theme.deletion - : fold?.collapsed - ? theme.foldBackground + ? theme.highlight + : value.foldLabel || fold?.collapsed + ? foldBackground(theme, value.foldTint ?? fold?.tint) + : value.moveKind === "moved" + ? theme.moved + : value.kind === "addition" + ? theme.addition + : value.kind === "deletion" + ? theme.deletion : theme.bg; // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. const digits = geometry.gutter - 4; @@ -95,7 +107,10 @@ export const CodeRowView = memo(function CodeRowView({ flexDirection="row" backgroundColor={bg} onMouseDown={(event) => { - if (event.button === 0) onSelect(side); + if (event.button === 0 && !value.moveLabel) onSelect(side); + }} + onMouseUp={(event) => { + if (event.button === 0 && value.moveLabel && value.jump) onJump(value.jump); }} onMouseMove={onExtend} > diff --git a/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/tui/packages/hunk/src/ui/diff/diffRowModel.ts index d517665c6..28c1aaf07 100644 --- a/tui/packages/hunk/src/ui/diff/diffRowModel.ts +++ b/tui/packages/hunk/src/ui/diff/diffRowModel.ts @@ -6,7 +6,7 @@ * column math, the highlight worker, geometry — can share these types without importing * the builders themselves. */ -import type { RowFold } from "../../diffr/folds"; +import type { FoldTint, RowFold } from "../../diffr/regions"; type DiffLineMoveKind = "moved"; export interface RenderSpan { @@ -17,13 +17,27 @@ export interface RenderSpan { transformFg?: (sourceFg: string | undefined, renderedBg: string) => string; } +/** Where a moved copy's counterpart starts: the other side and its 1-based line. */ +export interface MoveJump { + side: "left" | "right"; + line: number; +} + export interface SplitLineCell { kind: "context" | "addition" | "deletion" | "empty"; sign: string; lineNumber?: number; moveKind?: DiffLineMoveKind; + /** Moved code: the counterpart line to jump to. */ + jump?: MoveJump; + /** The "moved from/to line N" row at the top of a moved copy. */ + moveLabel?: boolean; /** This cell starts a fold region; the chevron and placeholder come from here. */ fold?: RowFold; + /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ + foldLabel?: boolean; + /** The tint of the fold a label line belongs to. */ + foldTint?: FoldTint; spans: RenderSpan[]; } @@ -33,6 +47,10 @@ export interface UnifiedLineCell { oldLineNumber?: number; newLineNumber?: number; moveKind?: DiffLineMoveKind; + jump?: MoveJump; + moveLabel?: boolean; fold?: RowFold; + foldLabel?: boolean; + foldTint?: FoldTint; spans: RenderSpan[]; } diff --git a/tui/test/fixtures/after.ts b/tui/test/fixtures/after.ts deleted file mode 100644 index 1399899f4..000000000 --- a/tui/test/fixtures/after.ts +++ /dev/null @@ -1,4 +0,0 @@ -function greet(name: string) { - console.log(name); - return "hi " + name; -} diff --git a/tui/test/fixtures/before.ts b/tui/test/fixtures/before.ts deleted file mode 100644 index 539b2cc79..000000000 --- a/tui/test/fixtures/before.ts +++ /dev/null @@ -1,3 +0,0 @@ -function greet(name: string) { - return "hello " + name; -} diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index cb798b4ea..94dde99fe 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,3 @@ -{"after":{"kind":"file","path":"tui/test/fixtures/after.ts"},"before":{"kind":"file","path":"tui/test/fixtures/before.ts"},"files":[{"class":null,"new_path":"tui/test/fixtures/after.ts","old_path":"tui/test/fixtures/before.ts","status":"modified"}],"total":1,"type":"start","version":1} -{"type":"file","file":{"old_path":"tui/test/fixtures/before.ts","new_path":"tui/test/fixtures/after.ts","status":"modified","class":null},"diff":{"aligned_rows":[[0,0],[null,1],[1,2],[2,3]],"display_path":"tui/test/fixtures/after.ts","extra_info":null,"file_format":{"SupportedLanguage":"TypeScript"},"has_byte_changes":[59,77],"has_syntactic_changes":true,"hunks":[{"lines":[[0,0],[null,1],[1,2],[2,3]],"novel_lhs":[1],"novel_rhs":[1,2]}],"lhs_folds":[{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":0,"line":3},"start":{"byte_column":30,"line":0}}}},"placeholder":"Body","range":{"end":{"byte_column":0,"line":2},"start":{"byte_column":30,"line":0}},"tags":["body"]},{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":14,"line":2},"start":{"byte_column":9,"line":2}}}},"placeholder":"String","range":{"end":{"byte_column":17,"line":1},"start":{"byte_column":9,"line":1}},"tags":["string"]}],"lhs_positions":[{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":0,"start_col":0}],"self_pos":[{"end_col":8,"line":0,"start_col":0}]}},"pos":{"end_col":8,"line":0,"start_col":0}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":14,"line":0,"start_col":9}],"self_pos":[{"end_col":14,"line":0,"start_col":9}]}},"pos":{"end_col":14,"line":0,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":15,"line":0,"start_col":14}],"self_pos":[{"end_col":15,"line":0,"start_col":14}]}},"pos":{"end_col":15,"line":0,"start_col":14}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":19,"line":0,"start_col":15}],"self_pos":[{"end_col":19,"line":0,"start_col":15}]}},"pos":{"end_col":19,"line":0,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":20,"line":0,"start_col":19}],"self_pos":[{"end_col":20,"line":0,"start_col":19}]}},"pos":{"end_col":20,"line":0,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Type"},"opposite_pos":[{"end_col":27,"line":0,"start_col":21}],"self_pos":[{"end_col":27,"line":0,"start_col":21}]}},"pos":{"end_col":27,"line":0,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":28,"line":0,"start_col":27}],"self_pos":[{"end_col":28,"line":0,"start_col":27}]}},"pos":{"end_col":28,"line":0,"start_col":27}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":30,"line":0,"start_col":29}],"self_pos":[{"end_col":30,"line":0,"start_col":29}]}},"pos":{"end_col":30,"line":0,"start_col":29}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":2,"start_col":2}],"self_pos":[{"end_col":8,"line":1,"start_col":2}]}},"pos":{"end_col":8,"line":1,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":{"String":"StringLiteral"}}}},"pos":{"end_col":17,"line":1,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":16,"line":2,"start_col":15}],"self_pos":[{"end_col":19,"line":1,"start_col":18}]}},"pos":{"end_col":19,"line":1,"start_col":18}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":21,"line":2,"start_col":17}],"self_pos":[{"end_col":24,"line":1,"start_col":20}]}},"pos":{"end_col":24,"line":1,"start_col":20}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":22,"line":2,"start_col":21}],"self_pos":[{"end_col":25,"line":1,"start_col":24}]}},"pos":{"end_col":25,"line":1,"start_col":24}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":1,"line":3,"start_col":0}],"self_pos":[{"end_col":1,"line":2,"start_col":0}]}},"pos":{"end_col":1,"line":2,"start_col":0}}],"lhs_src":{"Text":"function greet(name: string) {\n return \"hello \" + name;\n}\n"},"rhs_folds":[{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":0,"line":2},"start":{"byte_column":30,"line":0}}}},"placeholder":"Body","range":{"end":{"byte_column":0,"line":3},"start":{"byte_column":30,"line":0}},"tags":["body"]},{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":17,"line":1},"start":{"byte_column":9,"line":1}}}},"placeholder":"String","range":{"end":{"byte_column":14,"line":2},"start":{"byte_column":9,"line":2}},"tags":["string"]}],"rhs_positions":[{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":0,"start_col":0}],"self_pos":[{"end_col":8,"line":0,"start_col":0}]}},"pos":{"end_col":8,"line":0,"start_col":0}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":14,"line":0,"start_col":9}],"self_pos":[{"end_col":14,"line":0,"start_col":9}]}},"pos":{"end_col":14,"line":0,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":15,"line":0,"start_col":14}],"self_pos":[{"end_col":15,"line":0,"start_col":14}]}},"pos":{"end_col":15,"line":0,"start_col":14}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":19,"line":0,"start_col":15}],"self_pos":[{"end_col":19,"line":0,"start_col":15}]}},"pos":{"end_col":19,"line":0,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":20,"line":0,"start_col":19}],"self_pos":[{"end_col":20,"line":0,"start_col":19}]}},"pos":{"end_col":20,"line":0,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Type"},"opposite_pos":[{"end_col":27,"line":0,"start_col":21}],"self_pos":[{"end_col":27,"line":0,"start_col":21}]}},"pos":{"end_col":27,"line":0,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":28,"line":0,"start_col":27}],"self_pos":[{"end_col":28,"line":0,"start_col":27}]}},"pos":{"end_col":28,"line":0,"start_col":27}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":30,"line":0,"start_col":29}],"self_pos":[{"end_col":30,"line":0,"start_col":29}]}},"pos":{"end_col":30,"line":0,"start_col":29}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":9,"line":1,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":10,"line":1,"start_col":9}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":13,"line":1,"start_col":10}},{"kind":{"Novel":{"highlight":"Delimiter"}},"pos":{"end_col":14,"line":1,"start_col":13}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":18,"line":1,"start_col":14}},{"kind":{"Novel":{"highlight":"Delimiter"}},"pos":{"end_col":19,"line":1,"start_col":18}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":20,"line":1,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":1,"start_col":2}],"self_pos":[{"end_col":8,"line":2,"start_col":2}]}},"pos":{"end_col":8,"line":2,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":{"String":"StringLiteral"}}}},"pos":{"end_col":14,"line":2,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":19,"line":1,"start_col":18}],"self_pos":[{"end_col":16,"line":2,"start_col":15}]}},"pos":{"end_col":16,"line":2,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":24,"line":1,"start_col":20}],"self_pos":[{"end_col":21,"line":2,"start_col":17}]}},"pos":{"end_col":21,"line":2,"start_col":17}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":25,"line":1,"start_col":24}],"self_pos":[{"end_col":22,"line":2,"start_col":21}]}},"pos":{"end_col":22,"line":2,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":1,"line":2,"start_col":0}],"self_pos":[{"end_col":1,"line":3,"start_col":0}]}},"pos":{"end_col":1,"line":3,"start_col":0}}],"rhs_src":{"Text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n"}}} +{"type":"start","version":3,"lhs":{"type":"path","path":"test/fixtures/before.ts"},"rhs":{"type":"path","path":"test/fixtures/after.ts"},"files":[{"file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"status":"modified","category":"test","language":"TypeScript","visibility":{"collapsed":true,"label":"Test file · hidden by default"}}]} +{"type":"file","file":{"lhs":{"path":"test/fixtures/before.ts","oid":"","mode":""},"rhs":{"path":"test/fixtures/after.ts","oid":"","mode":""}},"diff":{"type":"text","lhs":{"text":"function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"alignment_id":0,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"alignment_id":1,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"alignment_id":2,"fold_state_id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":9,"end_column":17}]}]},{"alignment_id":3,"fold_state_id":3,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf"}]},"rhs":{"text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":8,"capture":"keyword"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":14,"end_column":15,"capture":"punctuation.bracket"},{"line":0,"start_column":15,"end_column":19,"capture":"variable"},{"line":0,"start_column":21,"end_column":27,"capture":"type.builtin"},{"line":0,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":0,"start_column":29,"end_column":30,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":9,"capture":"variable.builtin"},{"line":1,"start_column":9,"end_column":10,"capture":"punctuation.delimiter"},{"line":1,"start_column":10,"end_column":13,"capture":"property"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.bracket"},{"line":1,"start_column":14,"end_column":18,"capture":"variable"},{"line":1,"start_column":18,"end_column":19,"capture":"punctuation.bracket"},{"line":1,"start_column":19,"end_column":20,"capture":"punctuation.delimiter"},{"line":2,"start_column":2,"end_column":8,"capture":"keyword"},{"line":2,"start_column":9,"end_column":14,"capture":"string"},{"line":2,"start_column":15,"end_column":16,"capture":"operator"},{"line":2,"start_column":17,"end_column":21,"capture":"variable"},{"line":2,"start_column":21,"end_column":22,"capture":"punctuation.delimiter"},{"line":3,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"alignment_id":0,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":3,"column":0},"tags":["body","function"],"visibility":{"label":"Body"},"kind":"fold","children":[{"alignment_id":1,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf"},{"alignment_id":4,"fold_state_id":4,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","changed":[{"line":1,"start_column":0,"end_column":20}]},{"alignment_id":2,"fold_state_id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","changed":[{"line":2,"start_column":9,"end_column":14}]}]},{"alignment_id":3,"fold_state_id":3,"start":{"line":3,"column":0},"end":{"line":4,"column":0},"kind":"leaf"}]},"stats":{"textual":{"added":2,"removed":1},"visible":{"added":2,"removed":1}}}} {"type":"complete","succeeded":1,"failed":0} diff --git a/tui/test/pty/launch-integration.test.ts b/tui/test/pty/launch-integration.test.ts deleted file mode 100644 index c63877ab3..000000000 --- a/tui/test/pty/launch-integration.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { expect, test } from "bun:test"; -import { resolve } from "node:path"; -import { spawnSync } from "node:child_process"; -import { readDiffStream } from "../../packages/hunk/src/diffr/stream"; -import { rowsForFile, dark } from "../../packages/hunk/src/diffr/rows"; -const root = resolve(import.meta.dir, "../../.."), - binary = process.env.DIFFR_TEST_BIN ?? resolve(root, "target/debug/diffr"); -const before = resolve(import.meta.dir, "../fixtures/before.ts"), - after = resolve(import.meta.dir, "../fixtures/after.ts"); -test.skipIf(process.platform === "win32")( - "actual CLI terminal lifecycle and mouse controls", - () => { - const result = spawnSync( - "python3", - [ - resolve(import.meta.dir, "launch.py"), - binary, - process.execPath, - before, - after, - ], - { encoding: "utf8", timeout: 25000 }, - ); - expect(result.stderr).toBe(""); - expect(result.status).toBe(0); - }, - 30000, -); -test("actual Rust output satisfies wire schema and renders both layouts", async () => { - const process = Bun.spawn( - [binary, "--no-index", "--format", "ndjson", "--", before, after], - { stdout: "pipe", stderr: "pipe" }, - ); - const events = await Array.fromAsync(readDiffStream(process.stdout)); - expect(await process.exited).toBe(0); - const file = events.find((e) => e.type === "file"); - expect(file?.type).toBe("file"); - if (file?.type === "file") { - expect(rowsForFile(file, 0, "split", dark).some((r) => r.left)).toBe(true); - expect(rowsForFile(file, 0, "unified", dark).some((r) => r.cell)).toBe( - true, - ); - } -}); -test("redirected output and explicit text bypass the TUI even without Bun", () => { - for (const format of [[], ["--format", "text"]]) { - const result = spawnSync( - binary, - ["--no-index", ...format, "--", before, after], - { - encoding: "utf8", - env: { ...process.env, DIFFR_BUN: "nonexistent-bun-for-test" }, - }, - ); - expect(result.status).toBe(1); - expect(result.stdout).not.toContain("\x1b[?1049h"); - expect(result.stderr).not.toContain("launch terminal"); - } -}); - -test.skipIf(process.platform === "win32")( - "interactive exit-code preserves the comparison status", - () => { - const result = spawnSync( - "python3", - [ - resolve(import.meta.dir, "launch.py"), - binary, - process.execPath, - before, - after, - ], - { - encoding: "utf8", - timeout: 25000, - env: { ...process.env, DIFFR_TEST_EXIT_CODE: "1" }, - }, - ); - expect(result.stderr).toBe(""); - expect(result.status).toBe(0); - }, - 30000, -); diff --git a/tui/test/pty/launch.py b/tui/test/pty/launch.py deleted file mode 100644 index 8dc47b903..000000000 --- a/tui/test/pty/launch.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Exercise Rust -> Bun -> NDJSON -> terminal, including real mouse escape sequences.""" -import errno -import fcntl -import os -import pty -import select -import signal -import struct -import sys -import termios -import time - -binary, bun, before, after = sys.argv[1:] -pid, master = pty.fork() -if pid == 0: - os.environ['DIFFR_BUN'] = bun - os.environ['TERM'] = 'xterm-256color' - os.execv(binary, [binary, '--no-index', *(['--exit-code'] if os.environ.get('DIFFR_TEST_EXIT_CODE') else []), '--', before, after]) -fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 100, 0, 0)) -output = bytearray() -def until(token): - deadline = time.monotonic() + 12 - while time.monotonic() < deadline: - if token in output: - output.clear() - return - if select.select([master], [], [], 0.1)[0]: - try: - data = os.read(master, 65536) - except OSError as error: - if error.errno == errno.EIO: - raise AssertionError(f'TUI exited before {token!r}: {bytes(output)[-2000:]!r}') - raise - if not data: - break - output.extend(data) - raise AssertionError(f'Missing {token!r}: {bytes(output)[-2000:]!r}') -try: - until(b'console.log(name);') - os.write(master, b's') - until(b'split [s]') - # Width 100 has a 28-column file sidebar. The header is at x=30,y=2 (1-based). - os.write(master, b'\x1b[<0;31;2M\x1b[<0;31;2m') - until('▸'.encode()) - os.write(master, b'\x1b[<0;31;2M\x1b[<0;31;2m') - until('▾'.encode()) - os.write(master, b'q') - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - if select.select([master], [], [], 0)[0]: - try: os.read(master, 65536) - except OSError: pass - result, status = os.waitpid(pid, os.WNOHANG) - if result: - assert os.waitstatus_to_exitcode(status) == (1 if os.environ.get('DIFFR_TEST_EXIT_CODE') else 0), status - pid = 0 - print('PTY launch, streamed rendering, layout switch, mouse file toggle, and clean exit passed') - break - time.sleep(0.05) - else: - raise AssertionError('TUI did not exit') -finally: - if pid: - os.kill(pid, signal.SIGKILL) - os.waitpid(pid, 0) - os.close(master) diff --git a/tui/themes/LICENSE b/tui/themes/LICENSE new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/tui/themes/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/tui/themes/README.md b/tui/themes/README.md new file mode 100644 index 000000000..05d1a2e35 --- /dev/null +++ b/tui/themes/README.md @@ -0,0 +1,14 @@ +# Bundled themes + +These are Helix editor themes, vendored unchanged from +https://github.com/helix-editor/helix/tree/master/runtime/themes and licensed under the +Mozilla Public License 2.0 (see LICENSE in this directory). + +| name | file | +| ----------------- | --------------------- | +| `default-dark` | `onedark.toml` | +| `default-light` | `onelight.toml` | +| `gruvbox` | `gruvbox.toml` | +| `solarized_light` | `solarized_light.toml` | + +Any other Helix theme works through `theme.path` in diffr's config. diff --git a/tui/themes/gruvbox.toml b/tui/themes/gruvbox.toml new file mode 100644 index 000000000..6c5bcd098 --- /dev/null +++ b/tui/themes/gruvbox.toml @@ -0,0 +1,153 @@ +# Author : Jakub Bartodziej +# The theme uses the gruvbox dark palette with standard contrast: github.com/morhetz/gruvbox + +"annotation" = { fg = "fg1" } + +"attribute" = { fg = "aqua1", modifiers = ["italic"] } + +"comment" = { fg = "gray", modifiers = ["italic"] } + +"constant" = { fg = "purple1" } +"constant.character" = { fg = "aqua1" } +"constant.character.escape" = { fg = "orange1" } +"constant.macro" = { fg = "aqua1" } +"constructor" = { fg = "purple1" } + +"definition" = { underline = { color = "aqua1" } } + +"diagnostic" = { underline = { color = "orange1", style = "curl" } } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } +"diagnostic.error" = { underline = { color = "red1", style = "curl" } } +"diagnostic.hint" = { underline = { color = "blue1", style = "curl" } } +"diagnostic.info" = { underline = { color = "aqua1", style = "curl" } } +"diagnostic.warning" = { underline = { color = "yellow1", style = "curl" } } +# "diagnostic.unnecessary" = { modifiers = ["dim"] } # do not remove this for future resolving + +"error" = { fg = "red1" } +"hint" = { fg = "blue1" } +"info" = { fg = "aqua1" } +"warning" = { fg = "yellow1" } + +"diff.delta" = { fg = "yellow1" } +"diff.minus" = { fg = "red1" } +"diff.plus" = { fg = "green1" } + +"function" = { fg = "green1" } +"function.builtin" = { fg = "yellow1" } +"function.macro" = { fg = "blue1" } + +"keyword" = { fg = "red1" } +"keyword.control.import" = { fg = "aqua1" } + +"label" = { fg = "red1" } + +"markup.bold" = { modifiers = ["bold"] } +"markup.heading" = "aqua1" +"markup.italic" = { modifiers = ["italic"] } +"markup.link.text" = "red1" +"markup.link.url" = { fg = "green1", modifiers = ["underlined"] } +"markup.raw" = "red1" +"markup.strikethrough" = { modifiers = ["crossed_out"] } + +"module" = { fg = "aqua1" } + +"namespace" = { fg = "fg1" } + +"operator" = { fg = "purple1" } + +"punctuation" = { fg = "orange1" } + +"special" = { fg = "purple0" } + +"string" = { fg = "green1" } +"string.regexp" = { fg = "orange1" } +"string.special" = { fg = "orange1" } +"string.symbol" = { fg = "yellow1" } + +"tag" = { fg = "aqua1" } + +"type" = { fg = "yellow1" } +"type.enum.variant" = { modifiers = ["italic"] } + +"ui.background" = { bg = "bg0" } +"ui.bufferline" = { fg = "fg1", bg = "bg1" } +"ui.bufferline.active" = { fg = "bg0", bg = "yellow0" } +"ui.bufferline.background" = { bg = "bg2" } + +"ui.cursor" = { fg = "bg1", bg = "bg2" } +"ui.cursor.insert" = { fg = "bg1", bg = "blue0" } +"ui.cursor.normal" = { fg = "bg1", bg = "gray" } +"ui.cursor.select" = { fg = "bg1", bg = "orange0" } +"ui.cursor.match" = { fg = "fg3", bg = "bg3" } + +"ui.cursor.primary" = { bg = "fg3", fg = "bg1" } +"ui.cursor.primary.insert" = { fg = "bg1", bg = "blue1" } +"ui.cursor.primary.normal" = { fg = "bg1", bg = "fg3" } +"ui.cursor.primary.select" = { fg = "bg1", bg = "orange1" } + +"ui.cursorline" = { bg = "bg0_s" } +"ui.cursorline.primary" = { bg = "bg1" } + +"ui.help" = { bg = "bg1", fg = "fg1" } +"ui.linenr" = { fg = "bg3" } +"ui.linenr.selected" = { fg = "yellow1" } +"ui.menu" = { fg = "fg1", bg = "bg2" } +"ui.menu.selected" = { fg = "green1", bg = "bg1", modifiers = ["bold"] } +"ui.popup" = { bg = "bg1" } +"ui.picker.header.column" = { underline.style = "line" } +"ui.picker.header.column.active" = { modifiers = ["bold"], underline.style = "line" } +"ui.selection" = { bg = "bg2" } +"ui.selection.primary" = { bg = "bg3" } + +"ui.statusline" = { fg = "fg1", bg = "bg2" } +"ui.statusline.inactive" = { fg = "fg4", bg = "bg2" } +"ui.statusline.insert" = { fg = "bg1", bg = "blue1", modifiers = ["bold"] } +"ui.statusline.normal" = { fg = "bg1", bg = "fg3", modifiers = ["bold"] } +"ui.statusline.select" = { fg = "bg1", bg = "orange1", modifiers = ["bold"] } + +"ui.text" = { fg = "fg1" } +"ui.text.focus" = { fg = "green1", bg="bg1" } +"ui.text.directory" = { fg = "blue1" } +"ui.virtual.inlay-hint" = { fg = "gray" } +"ui.virtual.jump-label" = { fg = "purple0", modifiers = ["bold"] } +"ui.virtual.ruler" = { bg = "bg1" } +"ui.virtual.whitespace" = "bg2" +"ui.virtual.wrap" = { fg = "bg2" } +"ui.window" = { bg = "bg1" } + +"variable" = { fg = "fg1" } +"variable.builtin" = { fg = "orange1", modifiers = ["italic"] } +"variable.other.member" = { fg = "blue1" } +"variable.parameter" = { fg = "blue1", modifiers = ["italic"] } + + +[palette] +bg0 = "#282828" # main background +bg0_s = "#32302f" +bg1 = "#3c3836" +bg2 = "#504945" +bg3 = "#665c54" +bg4 = "#7c6f64" + +fg0 = "#fbf1c7" +fg1 = "#ebdbb2" # main foreground +fg2 = "#d5c4a1" +fg3 = "#bdae93" +fg4 = "#a89984" + +gray = "#928374" + +red0 = "#cc241d" # neutral +red1 = "#fb4934" # bright +green0 = "#98971a" +green1 = "#b8bb26" +yellow0 = "#d79921" +yellow1 = "#fabd2f" +blue0 = "#458588" +blue1 = "#83a598" +purple0 = "#b16286" +purple1 = "#d3869b" +aqua0 = "#689d6a" +aqua1 = "#8ec07c" +orange0 = "#d65d0e" +orange1 = "#fe8019" diff --git a/tui/themes/onedark.toml b/tui/themes/onedark.toml new file mode 100644 index 000000000..9349f02d3 --- /dev/null +++ b/tui/themes/onedark.toml @@ -0,0 +1,118 @@ +# Author : Gokul Soumya + +"tag" = { fg = "red" } +"attribute" = { fg = "yellow" } +"comment" = { fg = "light-gray", modifiers = ["italic"] } +"constant" = { fg = "cyan" } +"constant.numeric" = { fg = "gold" } +"constant.builtin" = { fg = "gold" } +"constant.character.escape" = { fg = "gold" } +"constructor" = { fg = "blue" } +"function" = { fg = "blue" } +"function.builtin" = { fg = "blue" } +"function.macro" = { fg = "purple" } +"keyword" = { fg = "red" } +"keyword.control" = { fg = "purple" } +"keyword.control.import" = { fg = "red" } +"keyword.directive" = { fg = "purple" } +"keyword.storage" = { fg = "purple" } +"label" = { fg = "purple" } +"namespace" = { fg = "blue" } +"operator" = { fg = "purple" } +"keyword.operator" = { fg = "purple" } +"special" = { fg = "blue" } +"string" = { fg = "green" } +"type" = { fg = "yellow" } +"variable" = { fg = "white" } +"variable.builtin" = { fg = "blue" } +"variable.parameter" = { fg = "red" } +"variable.other.member" = { fg = "red" } +"punctuation" = { fg = "white" } +"punctuation.special" = { fg = "purple" } + +"markup.heading" = { fg = "red" } +"markup.raw.inline" = { fg = "green" } +"markup.bold" = { fg = "gold", modifiers = ["bold"] } +"markup.italic" = { fg = "purple", modifiers = ["italic"] } +"markup.strikethrough" = { modifiers = ["crossed_out"] } +"markup.list" = { fg = "red" } +"markup.quote" = { fg = "yellow" } +"markup.link.url" = { fg = "cyan", modifiers = ["underlined"]} +"markup.link.text" = { fg = "purple" } + +"diff.plus" = "green" +"diff.delta" = "gold" +"diff.minus" = "red" + +"diagnostic.info".underline = { color = "blue", style = "curl" } +"diagnostic.hint".underline = { color = "green", style = "curl" } +"diagnostic.warning".underline = { color = "yellow", style = "curl" } +"diagnostic.error".underline = { color = "red", style = "curl" } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } +"info" = { fg = "blue", modifiers = ["bold"] } +"hint" = { fg = "green", modifiers = ["bold"] } +"warning" = { fg = "yellow", modifiers = ["bold"] } +"error" = { fg = "red", modifiers = ["bold"] } + +"ui.background" = { bg = "black" } +"ui.virtual" = { fg = "faint-gray" } +"ui.virtual.indent-guide" = { fg = "faint-gray" } +"ui.virtual.whitespace" = { fg = "light-gray" } +"ui.virtual.ruler" = { bg = "gray" } +"ui.virtual.inlay-hint" = { fg = "light-gray" } +"ui.virtual.jump-label" = { fg = "light-gray", modifiers = ["bold"] } + +"ui.cursor" = { fg = "white", modifiers = ["reversed"] } +"ui.cursor.primary" = { fg = "white", modifiers = ["reversed"] } +"ui.cursor.match" = { fg = "blue", modifiers = ["underlined"]} + +"ui.selection" = { bg = "faint-gray" } +"ui.selection.primary" = { bg = "light-gray" } +"ui.cursorline.primary" = { bg = "light-black" } + +"ui.highlight" = { bg = "gray" } +"ui.highlight.frameline" = { bg = "#97202a" } + +"ui.linenr" = { fg = "linenr" } +"ui.linenr.selected" = { fg = "white" } + +"ui.statusline" = { fg = "white", bg = "light-black" } +"ui.statusline.inactive" = { fg = "light-gray", bg = "light-black" } +"ui.statusline.normal" = { fg = "light-black", bg = "blue", modifiers = ["bold"] } +"ui.statusline.insert" = { fg = "light-black", bg = "green", modifiers = ["bold"] } +"ui.statusline.select" = { fg = "light-black", bg = "purple", modifiers = ["bold"] } + +"ui.bufferline" = { fg = "light-gray", bg = "light-black" } +"ui.bufferline.active" = { fg = "light-black", bg = "blue", underline = { color = "light-black", style = "line" } } +"ui.bufferline.background" = { bg = "light-black" } + +"ui.text" = { fg = "white" } +"ui.text.directory" = { fg = "blue" } +"ui.text.focus" = { fg = "white", bg = "light-black", modifiers = ["bold"] } + +"ui.help" = { fg = "white", bg = "gray" } +"ui.popup" = { bg = "gray" } +"ui.window" = { fg = "gray" } +"ui.menu" = { fg = "white", bg = "gray" } +"ui.menu.selected" = { fg = "black", bg = "blue" } +"ui.menu.scroll" = { fg = "white", bg = "light-gray" } + +"ui.debug" = { fg = "red" } + +[palette] + +yellow = "#E5C07B" +blue = "#61AFEF" +red = "#E06C75" +purple = "#C678DD" +green = "#98C379" +gold = "#D19A66" +cyan = "#56B6C2" +white = "#ABB2BF" +black = "#282C34" +light-black = "#2C323C" +gray = "#3E4452" +faint-gray = "#3B4048" +light-gray = "#5C6370" +linenr = "#4B5263" diff --git a/tui/themes/onelight.toml b/tui/themes/onelight.toml new file mode 100644 index 000000000..9fc8ef650 --- /dev/null +++ b/tui/themes/onelight.toml @@ -0,0 +1,201 @@ +# Author : erasin + +"attribute" = { fg = "yellow" } +"label" = { fg = "cyan" } +"operator" = { fg = "red" } +"tag" = { fg = "cyan" } +"special" = { fg = "deep-purple" } +"property" = { fg = "purple" } +"constructor" = { fg = "blue" } +"namespace" = { fg = "blue" } +"module" = { fg = "blue" } + +"type" = { fg = "gold" } +"type.builtin" = { fg = "yellow" } +"type.enum" = { fg = "cyan" } +"type.enum.variant" = { fg = "cyan" } + +"constant" = { fg = "cyan", modifiers = ["bold"] } +"constant.builtin" = { fg = "deep-purple" } +"constant.builtin.boolean" = { fg = "purple" , modifiers = ["bold"]} +"constant.character" = { fg = "green" } +"constant.character.escape" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric.integer" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric.float" = { fg = "brown" , modifiers = ["bold"]} + +"string" = { fg = "green" } +"string.regexp" = { fg = "purple" } +"string.special" = { fg = "green" } +"string.special.path" = { fg = "blue" } +"string.special.url" = { fg = "light-blue" } +"string.special.symbol" = { fg = "pink" } + +"comment" = { fg = "grey", modifiers = ["italic"] } +"comment.line" = { fg = "grey", modifiers = ["italic"] } +"comment.line.documentation" = { fg = "grey", modifiers = ["italic"] } +"comment.block" = { fg = "grey", modifiers = ["italic"] } +"comment.block.documentation" = { fg = "grey", modifiers = ["italic"] } + +# "variable" = { fg = "black" } +"variable.builtin" = { fg = "light-blue" } +"variable.parameter" = { fg = "red" } +"variable.other" = { fg = "pink" } +"variable.other.member" = { fg = "pink" } + +"punctuation" = { fg = "black" } +"punctuation.delimiter" = { fg = "purple" } +"punctuation.bracket" = { fg = "brown" } +"punctuation.special" = { fg = "brown" } + +"keyword" = { fg = "purple" } +"keyword.control" = { fg = "purple" } +"keyword.control.conditional" = { fg = "red", modifiers = ["bold"] } +"keyword.control.repeat" = { fg = "pink", modifiers = ["bold"] } +"keyword.control.import" = { fg = "red" } +"keyword.control.return" = { fg = "deep-purple", modifiers = ["bold"] } +"keyword.control.exception" = { fg = "purple" } +"keyword.operator" = { fg = "red" } +"keyword.directive" = { fg = "deep-purple" } +"keyword.function" = { fg = "purple" } +"keyword.storage" = { fg = "purple" } +"keyword.storage.type" = { fg = "purple" } +"keyword.storage.modifier" = { fg = "purple", modifiers = ["bold"] } + +"function" = { fg = "blue" } +"function.builtin" = { fg = "cyan" } +"function.method" = { fg = "light-blue" } +"function.macro" = { fg = "pink", modifiers = ["bold"] } +"function.special" = { fg = "cyan" } + +"markup.heading" = { fg = "red" } +"markup.heading.marker" = { fg = "red" } +"markup.heading.1" = { fg = "red", modifiers = ["bold"] } +"markup.heading.2" = { fg = "gold", modifiers = [ + "bold", +], underline = { style = "line" } } +"markup.heading.3" = { fg = "yellow", modifiers = ["bold"] } +"markup.heading.4" = { fg = "green", modifiers = ["bold"] } +"markup.heading.5" = { fg = "blue", modifiers = ["bold"] } +"markup.heading.6" = { fg = "purple", modifiers = ["bold"] } +"markup.list" = { fg = "light-blue" } +"markup.list.unnumbered" = { fg = "light-blue" } +"markup.list.numbered" = { fg = "light-blue" } +"markup.list.checked" = { fg = "green" } +"markup.list.unchecked" = { fg = "blue" } +"markup.bold" = { fg = "yellow", modifiers = ["bold"] } +"markup.italic" = { fg = "purple", modifiers = ["italic"] } +"markup.strikethrough" = { fg = "red", modifiers = ["crossed_out"] } +"markup.link" = { fg = "light-blue" } +"markup.link.url" = { fg = "cyan", modifiers = ["underlined"] } +"markup.link.text" = { fg = "light-blue" } +"markup.quote" = { fg = "grey" } +"markup.raw" = { fg = "brown" } +"markup.raw.inline" = { fg = "green" } +"markup.raw.block" = { fg = "brown" } + +"diff" = { fg = "red" } +"diff.plus" = { fg = "green" } +"diff.minus" = { fg = "red" } +"diff.delta" = { fg = "cyan" } +"diff.delta.moved" = { fg = "cyan" } +"diff.delta.conflict" = {fg = "blue"} + +"ui.background" = { bg = "white" } +"ui.background.separator" = { bg = "white" } + +"ui.cursor" = { fg = "white", bg = "grey" } +"ui.cursor.normal" = { fg = "white", bg = "grey" } +"ui.cursor.insert" = { fg = "white", bg = "grey" } +"ui.cursor.select" = { fg = "white", bg = "grey" } +"ui.cursor.match" = { bg = "grey-300", modifiers = ["bold"] } +"ui.cursor.primary" = { fg = "white", bg = "black" } +"ui.cursor.primary.normal" = { fg = "white", bg = "black" } +"ui.cursor.primary.insert" = { fg = "red", bg = "black" } +"ui.cursor.primary.select" = { fg = "white", bg = "black" } + +"ui.gutter" = { fg = "grey-500" } +"ui.gutter.selected" = { fg = "black" } + +"ui.linenr" = { fg = "grey-500" } +"ui.linenr.selected" = { fg = "black", modifiers = ["bold"] } + +"ui.statusline" = { fg = "black", bg = "grey-300" } +"ui.statusline.inactive" = { fg = "grey", bg = "grey-200" } +"ui.statusline.normal" = { fg = "grey-300", bg = "light-blue" } +"ui.statusline.insert" = { fg = "grey-300", bg = "green" } +"ui.statusline.select" = { fg = "grey-300", bg = "purple" } + +"ui.popup" = { fg = "black", bg = "grey-200" } +"ui.popup.info" = { fg = "black", bg = "grey-200" } +"ui.window" = { fg = "grey-500", bg = "grey-100" } +"ui.help" = { fg = "black", bg = "grey-200" } + +"ui.text" = { fg = "black" } +"ui.text.focus" = { fg = "red", bg = "grey-300", modifiers = ["bold"] } +"ui.text.inactive" = { fg = "grey" } +"ui.text.info" = { fg = "black" } +"ui.text.directory" = { fg = "blue", underline = { style = "line" } } + +"ui.virtual" = { fg = "grey-500" } +"ui.virtual.ruler" = { bg = "grey-200" } +"ui.virtual.wrap" = { fg = "grey-500" } +"ui.virtual.whitespace" = { fg = "grey-400" } +"ui.virtual.indent-guide" = { fg = "grey-500" } +"ui.virtual.inlay-hint" = { fg = "grey-500" } +"ui.virtual.inlay-hint.parameter" = { fg = "grey-500", modifiers = ["italic"] } +"ui.virtual.inlay-hint.type" = { fg = "grey-500" } +"ui.virtual.jump-label" = { fg = "black", bg = "grey-200", modifiers = ["bold" ] } + +"ui.menu" = { fg = "black", bg = "grey-300" } +"ui.menu.selected" = { fg = "white", bg = "light-blue" } +"ui.menu.scroll" = { fg = "light-blue", bg = "white" } + +"ui.selection" = { bg = "grey-300", modifiers = ["dim"] } +"ui.selection.primary" = { bg = "grey-300" } + +"ui.cursorline.primary" = { fg = "white", bg = "grey-100" } +"ui.cursorline.secondary" = { fg = "white", bg = "grey-200" } + +"ui.cursorcolumn.primary" = { fg = "white", bg = "grey-100" } +"ui.cursorcolumn.secondary" = { fg = "white", bg = "grey-200" } + +"ui.highlight" = { bg = "grey-300" } + +"ui.picker.header" = { fg = "purple"} +"ui.picker.header.column.active" = { fg = "blue"} + +"diagnostic.info" = { underline = { color = "blue", style = "dotted" } } +"diagnostic.hint" = { underline = { color = "green", style = "dashed" } } +"diagnostic.warning" = { underline = { color = "yellow", style = "curl" } } +"diagnostic.error" = { underline = { color = "red", style = "curl" } } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } + +"info" = { fg = "blue", modifiers = ["bold"] } +"hint" = { fg = "green", modifiers = ["bold"] } +"warning" = { fg = "yellow", modifiers = ["bold"] } +"error" = { fg = "red", modifiers = ["bold"] } + +"tabstop" = { modifiers = ["italic"], bg = "grey-300" } + +[palette] +white = "#FAFAFA" +yellow = "#FF6F00" +gold = "#D35400" +brown = "#795548" +blue = "#0061FF" +light-blue = "#0091EA" +red = "#D50000" +pink = "#C2185B" +purple = "#B500A9" +deep-purple = "#651FFF" +green = "#24A443" +cyan = "#0086C1" +black = "#282C34" +grey = "#5C6370" +grey-500 = "#9E9E9E" +grey-400 = "#BDBDBD" +grey-300 = "#E0E0E0" +grey-200 = "#EEEEEE" +grey-100 = "#F2F2F2" diff --git a/tui/themes/solarized_light.toml b/tui/themes/solarized_light.toml new file mode 100644 index 000000000..2db54ca16 --- /dev/null +++ b/tui/themes/solarized_light.toml @@ -0,0 +1,155 @@ +"attribute" = { fg = "violet" } +"keyword" = { fg = "green" } +"keyword.directive" = { fg = "orange" } +"namespace" = { fg = "violet" } +"operator" = { fg = "green" } +"special" = { fg = "orange" } +"variable.builtin" = { fg = "cyan", modifiers = ["bold"] } +"variable.function" = { fg = "blue" } +"type" = { fg = "yellow" } +"type.builtin" = { fg = "yellow", modifiers = ["bold"] } +"constructor" = { fg = "blue" } +"function" = { fg = "blue" } +"function.macro" = { fg = "magenta" } +"function.builtin" = { fg = "blue", modifiers = ["bold"] } +"function.special" = { fg = "magenta" } +"comment" = { fg = "base01" } +"string" = { fg = "cyan" } +"constant" = { fg = "cyan" } +"constant.builtin" = { fg = "cyan", modifiers = ["bold"] } +"constant.character.escape" = { fg = "red", modifiers = ["bold"] } +"label" = { fg = "green" } +"module" = { fg = "violet" } +"tag" = { fg = "magenta" } + +# TODO +"markup.heading" = "blue" +"markup.list" = "red" +"markup.bold" = { fg = "yellow", modifiers = ["bold"] } +"markup.italic" = { fg = "magenta", modifiers = ["italic"] } +"markup.strikethrough" = { modifiers = ["crossed_out"] } +"markup.link.url" = { fg = "yellow", modifiers = ["underlined"] } +"markup.link.text" = "red" +"markup.quote" = "cyan" +"markup.raw" = "green" + +"diff.plus" = { fg = "green" } +"diff.delta" = { fg = "yellow" } +"diff.minus" = { fg = "red" } + +# 背景 +# background +"ui.background" = { bg = "base03" } + +"ui.virtual.whitespace" = { fg = "base01" } +"ui.virtual.inlay-hint" = { fg = "base01", modifiers = ["italic"] } +"ui.virtual.jump-label" = { fg = "red", modifiers = ["bold"] } + +# 行号栏 +# line number column +"ui.linenr" = { fg = "base0", bg = "base02" } +# 当前行号栏 +# current line number column +"ui.linenr.selected" = { fg = "blue", modifiers = ["bold"] } +# cursorline +"ui.cursorline" = { bg = "base0" } + +# 状态栏 +# status bar +"ui.statusline" = { fg = "base03", bg = "base0" } +"ui.statusline.normal" = { bg = "blue" } +"ui.statusline.insert" = { bg = "green" } +"ui.statusline.select" = { bg = "yellow" } + +# 非活动状态栏 +# inactive status bar +"ui.statusline.inactive" = { fg = "base1", bg = "base01" } + +# 补全窗口, preview窗口 +# Completion window, preview window +"ui.popup" = { bg = "base02" } +# 影响 补全选中 cmd弹出信息选中 +# Affect completion selection, cmd pop-up information selection +"ui.menu.selected" = { fg = "base02", bg = "base2"} +"ui.menu" = { fg = "base0", bg = "base02" } +# ?? +"ui.window" = { fg = "base3" } +# 命令行 补全的帮助信息 +# Command line completion help information +"ui.help" = { modifiers = ["reversed"] } + +# 快捷键窗口 +# Shortcut window +"ui.popup.info" = { bg = "base1" } +# 快捷键字体 +# Shortcut font +"ui.text.info" = {fg = "base02", modifiers = ["bold"]} + +# 普通ui的字体样式 +# Normal ui font style +"ui.text" = { fg = "base1" } +# 影响 picker列表选中, 快捷键帮助窗口文本 +# Affects picker list selection, shortcut key help window text +"ui.text.focus" = { fg = "blue", modifiers = ["bold"]} + +# 主光标/selection +# main cursor/selection +"ui.cursor.primary" = { fg = "base03", bg = "base1" } +"ui.cursor.select" = { fg = "base02", bg = "cyan" } + +"ui.cursorline.primary" = { bg = "base02" } +"ui.cursorline.secondary" = { bg = "base025" } + +"ui.selection" = { bg = "base0175" } +"ui.selection.primary" = { bg = "base015" } + +"ui.virtual.indent-guide" = { fg = "base02" } +"ui.virtual.ruler" = { bg = "base02" } + +# normal模式的光标 +# normal mode cursor +"ui.cursor" = {fg = "base02", bg = "cyan"} +"ui.cursor.insert" = {fg = "base03", bg = "base3"} +# 当前光标匹配的标点符号 +# The punctuation character matched by the current cursor +"ui.cursor.match" = { fg = "base02", bg = "base015" } + +"warning" = { fg = "orange", modifiers= ["bold", "underlined"] } +"error" = { fg = "red", modifiers= ["bold", "underlined"] } +"info" = { fg = "blue", modifiers= ["bold", "underlined"] } +"hint" = { fg = "base01", modifiers= ["bold", "underlined"] } + +"diagnostic.warning" = { underline = { style = "curl", color = "orange" } } +"diagnostic.error" = { underline = { style = "curl", color = "red" } } +"diagnostic.info" = { underline = { style = "curl", color = "blue" } } +"diagnostic.hint" = { underline = { style = "curl", color = "base01" } } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } + + +[palette] +red = '#dc322f' +green = '#859900' +yellow = '#b58900' +blue = '#268bd2' +magenta = '#d33682' +cyan = '#2aa198' +orange = '#cb4b16' +violet = '#6c71c4' + +# 深色 越来越深 +# dark getting darker +base0 = '#657b83' +base1 = '#586e75' +base2 = '#073642' +base3 = '#002b36' + +# 浅色 越來越浅 +# Lighter and lighter +base00 = '#839496' +base01 = '#93a1a1' +base015 = '#c5c8bd' +base0175 = '#dddbcc' +base02 = '#eee8d5' +base025 = '#f5eedb' +base03 = '#fdf6e3'