diff --git a/Cargo.lock b/Cargo.lock
index 1cba9554e..0bdd79a32 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -83,14 +83,12 @@ dependencies = [
]
[[package]]
-name = "async-trait"
-version = "0.1.92"
+name = "atomic"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 3.0.5",
+ "bytemuck",
]
[[package]]
@@ -105,12 +103,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"
@@ -134,6 +161,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"
@@ -152,18 +185,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 +236,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 +277,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"
@@ -269,14 +331,15 @@ dependencies = [
"bumpalo",
"cc",
"clap",
+ "dirs",
"encoding_rs",
+ "figment",
"git2",
"glob",
"hashbrown 0.17.1",
"humansize",
"ignore",
"imara-diff",
- "jsonrpsee",
"lazy_static",
"libc",
"line-numbers",
@@ -288,17 +351,21 @@ dependencies = [
"radix-heap",
"rayon",
"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",
@@ -365,6 +432,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"
@@ -382,6 +470,18 @@ 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 = "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"
@@ -418,12 +518,32 @@ 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]]
+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]]
@@ -474,6 +594,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 +634,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 +646,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 +833,6 @@ dependencies = [
"http",
"hyper",
"hyper-util",
- "log",
"rustls",
"tokio",
"tokio-rustls",
@@ -705,13 +845,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 +1000,18 @@ 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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
[[package]]
name = "is-terminal"
version = "0.4.12"
@@ -882,27 +1037,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 2.0.20",
"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 +1094,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]]
@@ -1046,6 +1134,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"
@@ -1070,6 +1167,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"
@@ -1082,6 +1185,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"
@@ -1135,12 +1244,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"
@@ -1158,26 +1296,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 +1368,82 @@ 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 = "proc-macro2-diagnostics"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
- "unicode-ident",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.106",
+ "version_check",
+ "yansi",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.20",
+ "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 2.0.20",
+ "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 +1455,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"
@@ -1302,6 +1513,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"
@@ -1331,6 +1573,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 +1619,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
- "getrandom",
+ "getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -1351,6 +1631,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"
@@ -1360,19 +1649,31 @@ 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"
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 +1698,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 +1719,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 +1735,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"
@@ -1462,6 +1771,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"
@@ -1485,6 +1819,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"
@@ -1515,6 +1855,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"
@@ -1544,6 +1895,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 +2005,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"
@@ -1650,6 +2020,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"
@@ -1665,7 +2048,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",
]
@@ -1745,6 +2128,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"
@@ -1823,18 +2221,20 @@ dependencies = [
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
+ "toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
-version = "0.25.14+spec-1.1.0"
+version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2195eec204e2764644a4ea619704f9fbe5e0673038eded55ad9956f24fca0cc"
+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
+ "toml_writer",
"winnow 1.0.4",
]
@@ -1847,6 +2247,18 @@ 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"
@@ -1857,10 +2269,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 +2311,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"
@@ -2527,6 +2946,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"
@@ -2616,12 +3044,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 = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
+dependencies = [
+ "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 = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
dependencies = [
- "webpki-root-certs 1.0.9",
+ "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]]
@@ -2650,11 +3144,11 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
-version = "0.45.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
- "windows-targets 0.42.2",
+ "windows-targets 0.48.5",
]
[[package]]
@@ -2686,17 +3180,17 @@ dependencies = [
[[package]]
name = "windows-targets"
-version = "0.42.2"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
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",
+ "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]]
@@ -2717,9 +3211,9 @@ dependencies = [
[[package]]
name = "windows_aarch64_gnullvm"
-version = "0.42.2"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
@@ -2729,9 +3223,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
-version = "0.42.2"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
@@ -2741,9 +3235,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
-version = "0.42.2"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
@@ -2759,9 +3253,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
-version = "0.42.2"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
@@ -2771,9 +3265,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
-version = "0.42.2"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
@@ -2783,9 +3277,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
-version = "0.42.2"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
@@ -2795,9 +3289,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
-version = "0.42.2"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
diff --git a/Cargo.toml b/Cargo.toml
index c1605e89a..690a7a747 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -50,8 +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"
-jsonrpsee = { version = "0.26", default-features = false, features = ["http-client", "macros"] }
-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"
@@ -134,7 +133,12 @@ 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 = "1.2.2"
+dirs = "5"
+toml_edit = "0.25.15"
+reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "http2"] }
[target.'cfg(not(any(windows, target_os = "illumos", target_os = "freebsd")))'.dependencies]
@@ -145,6 +149,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 cfe066595..726a72f50 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -28,11 +28,19 @@ 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;
-`--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`.
+one domain object per line. `--format ndjson` emits 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 ndjson-v1` is the previous stream, kept while frontends
+migrate. `--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/docs/streaming.md b/docs/streaming.md
index 2a0062344..0e1ac5bb8 100644
--- a/docs/streaming.md
+++ b/docs/streaming.md
@@ -2,22 +2,31 @@
```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 json` remains the bare domain-object output; `ndjson` is the stream
+described here. `ndjson-v1` is the previous stream and will be removed once every
+frontend reads this one. NDJSON 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 +35,255 @@ 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.
-Stdout contains only UTF-8 newline-delimited JSON records. 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.
-| Event | Fields | Consumer action |
+## Records
+
+| 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`.
+- 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, dense, and assigned in lhs preorder then rhs preorder. They
+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,29 +293,32 @@ 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:
+The static viewer under `examples/review/viewer` still reads the v1 stream;
+its build script captures `--format ndjson-v1`:
```sh
cargo build --locked
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/examples/review/viewer/build.py b/examples/review/viewer/build.py
index ee7466ac6..a9d8f5b41 100644
--- a/examples/review/viewer/build.py
+++ b/examples/review/viewer/build.py
@@ -82,7 +82,7 @@ def git(repo, *args, input=None):
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],
+ *commits, '--format', 'ndjson-v1', '--', *paths],
stdout=output, env=ENV, check=True)
view['request'] = 'data/' + stream_path.name
path.write_text(json.dumps(view))
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 c67e44727..ae84fd477 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,7 +1,7 @@
//! 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::hook::Hook;
+use crate::mutate::Mutations;
use crate::options::{DiffOptions, DisplayMode, DisplayOptions};
use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command};
use git2::{DiffStatsFormat, Repository};
@@ -34,7 +34,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")
@@ -59,8 +60,9 @@ 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("format").long("format").value_parser(["text", "json", "ndjson", "snapshot"]).default_value("text"))
+ .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", "json", "ndjson", "ndjson-v1", "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"))
@@ -70,19 +72,44 @@ 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";
+ let format = args.get_one::("format").unwrap().as_str();
+ let streaming = matches!(format, "ndjson" | "ndjson-v1");
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()
@@ -90,7 +117,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()
@@ -110,25 +137,12 @@ 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,
+ stream_options,
);
}
if args.get_flag("null") && !args.get_flag("name-only") && !args.get_flag("name-status") {
@@ -163,12 +177,19 @@ pub(crate) fn run() -> Result {
}
changed
} else {
- let params = Arc::new(
- Config::load(workspace, args.get_one::("config").map(Path::new))?.compile()?,
- );
- let hook = fold_hook(¶ms, workspace)?;
+ 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 && format == "ndjson" {
+ 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 {
@@ -176,13 +197,28 @@ 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 = if format == "ndjson-v1" {
+ crate::stream::write(session, jobs, &mut io::stdout().lock())?
+ } else {
+ let ended = crate::protocol::stream::write(
+ session,
+ jobs,
+ mutations,
+ stream_options,
+ &mut io::stdout().lock(),
+ )?;
+ 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)?;
@@ -409,7 +445,7 @@ fn no_index(
args: &ArgMatches,
paths: Vec,
display: &DisplayOptions,
- options: &DiffOptions,
+ stream_options: crate::protocol::stream::Options,
) -> Result {
if paths.len() != 2 {
return Err("--no-index requires two file paths".into());
@@ -437,11 +473,16 @@ 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 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 = || {
@@ -460,29 +501,131 @@ 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(
- &paths[0].to_string_lossy(),
- &paths[1].to_string_lossy(),
- compute,
- hook.as_deref(),
- &mut io::stdout().lock(),
- )?;
- Ok(i32::from(changed && args.get_flag("exit-code")))
- } else {
- render(&compute(), args, display)?;
- Ok(i32::from(changed))
+ match args.get_one::("format").map(String::as_str) {
+ Some("ndjson-v1") => {
+ crate::stream::write_file(
+ &paths[0].to_string_lossy(),
+ &paths[1].to_string_lossy(),
+ compute,
+ &mut io::stdout().lock(),
+ )?;
+ Ok(i32::from(changed && args.get_flag("exit-code")))
+ }
+ Some("ndjson") => {
+ 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,
+ &config,
+ &mutations,
+ stream_options,
+ &mut io::stdout().lock(),
+ )?;
+ Ok(if ended.failed || ended.aborted {
+ 2
+ } else {
+ i32::from(changed && args.get_flag("exit-code"))
+ })
+ }
+ _ => {
+ render(&compute(), args, display)?;
+ Ok(i32::from(changed))
+ }
}
}
-/// 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()
+/// 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 {
+ 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)) => {
+ 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.
@@ -490,7 +633,9 @@ fn opens_tui(explicit_format: bool, metadata_or_quiet: bool, terminal: 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(|| {
@@ -503,9 +648,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 fe29ee81c..a51a515a6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,40 +1,225 @@
-//! 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`.
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,
+ /// Pseudocode summaries for large new function bodies.
+ pub(crate) summarize: SummarizeConfig,
+ /// 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.
+ 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.
+ pub(crate) graph_limit: usize,
+ /// Files with more tree-sitter parse errors than this get a line diff.
+ 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, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct FoldsConfig {
+ /// Bodies shorter than this are never summarized or collapsed by a rule.
+ pub(crate) min_lines: usize,
+ /// Collapse deleted function bodies, keeping their header line visible.
+ 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.
+ pub(crate) collapse_removed_lines: usize,
+ /// Hide files that were deleted outright.
+ pub(crate) collapse_deleted_files: bool,
+ /// Hide files classified as generated, such as lockfiles and build output.
+ pub(crate) collapse_generated: bool,
+ /// Hide files classified as tests.
+ pub(crate) collapse_tests: bool,
+ /// Collapse the bodies of test functions on both sides, header visible.
+ 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.
+ pub(crate) bundle_docstrings: bool,
+ /// Unchanged lines kept visible on either side of a change. `-U` overrides it.
+ 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.
+ pub(crate) enabled: bool,
+ /// Which model API to call.
+ pub(crate) provider: Provider,
+ /// The model name sent to the provider.
+ 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.
+ 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.
+ pub(crate) api_key: Option,
+ /// Override the provider's base URL, for proxies and tests.
+ pub(crate) endpoint: Option,
+ /// Per-request limit in milliseconds.
+ pub(crate) timeout_ms: u64,
+ /// Requests in flight at once across files.
+ pub(crate) max_concurrency: usize,
+ /// Retries after a timeout, rate limit, or server error before the run
+ /// is aborted.
+ 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 {
+ /// A bundled theme name.
+ pub(crate) name: String,
+ /// A Helix-style theme file that replaces the bundled theme.
+ 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,
/// 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,
@@ -51,7 +236,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.
@@ -70,7 +255,10 @@ impl std::error::Error for ConfigError {}
pub(crate) struct Params {
languages: DftHashMap>>,
+ pub(crate) folds: FoldsConfig,
+ pub(crate) summarize: SummarizeConfig,
pub(crate) hook: Option,
+ pub(crate) diff: DiffConfig,
}
pub(crate) struct LanguageParams {
@@ -96,33 +284,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() {
@@ -132,6 +412,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 {
@@ -167,6 +452,9 @@ impl Config {
}
Ok(Params {
languages,
+ folds: self.folds.clone(),
+ summarize: self.summarize,
+ diff: self.diff,
hook: self.folds.hook,
})
}
@@ -211,6 +499,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 = ''")
@@ -301,7 +610,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()
@@ -481,6 +790,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() {
use crate::parse::folds::FoldMatch;
@@ -493,9 +838,111 @@ 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!(matches!(&result.lhs_folds[0].match_kind,
FoldMatch::Unchanged { opposite } if *opposite == result.rhs_folds[0].range));
}
}
+
+#[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"),
+ "[folds]\nmin_lines = 5\ncollapse_tests = false\n[summarize]\nmodel = 'global'\n",
+ )
+ .unwrap();
+ 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(), &["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(), &["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")),
+ 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 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 => folds,
+ };
+ let min_lines = &folds["properties"]["min_lines"];
+ assert_eq!(min_lines["default"], 12);
+ assert!(min_lines["description"]
+ .as_str()
+ .unwrap()
+ .contains("never summarized"));
+ assert!(schema["properties"].get("languages").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
new file mode 100644
index 000000000..fb5ecaeea
--- /dev/null
+++ b/src/config/store.rs
@@ -0,0 +1,171 @@
+//! `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.
+/// 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
+/// 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, "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("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.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[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[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/engine.rs b/src/engine.rs
index cb9f32439..370a86127 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,11 +220,22 @@ 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);
(
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,
@@ -296,6 +309,27 @@ 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,
+ );
+ 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)
@@ -306,8 +340,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
),
};
@@ -356,6 +391,17 @@ pub(crate) fn diff_file_content(
Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len()))
};
+ // A structural diff's folds carry the partner the matcher recorded. A
+ // line-diff fallback's folds arrive unpaired; pair them through the line
+ // alignment and record the partner on the folds the same way.
+ 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/git.rs b/src/git.rs
index 2d4c9ec6c..a45ded503 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -1,9 +1,12 @@
//! Git comparison selection and lazy source loading used by the CLI and its stdout stream.
use crate::config::Params;
+use crate::parse::guess_language::{guess, language_name};
+use crate::protocol;
use crate::summary::DiffResult;
use git2::{AttrCheckFlags, AttrValue, Delta, Diff, DiffFindOptions, DiffOptions, Oid, Repository};
use serde::{Deserialize, Serialize};
use std::{
+ fmt,
path::{Path, PathBuf},
sync::Arc,
};
@@ -130,6 +133,11 @@ pub(crate) struct FileChange {
pub(crate) new_path: Option,
pub(crate) status: FileStatus,
pub(crate) class: Option,
+ /// Git's delta sides. Only the v2 wire carries them.
+ #[serde(skip)]
+ pub(crate) sides: protocol::Pairing,
+ #[serde(skip)]
+ pub(crate) language: Option,
}
impl FileChange {
@@ -139,6 +147,119 @@ 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,
+ protocol::Pairing::Both {
+ lhs: file_ref(old),
+ rhs: file_ref(new),
+ },
+ ),
+ (Some(old), None) => (
+ FileStatus::Deleted,
+ protocol::Pairing::LeftOnly { lhs: file_ref(old) },
+ ),
+ (None, Some(new)) => (
+ FileStatus::Added,
+ protocol::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,
+ sides,
+ language,
+ }
+ }
+
+ 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,
+ },
+ 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. `code` is stable on the wire.
+#[derive(Debug)]
+pub(crate) struct FileProblem {
+ pub(crate) code: &'static str,
+ pub(crate) message: String,
+}
+
+impl FileProblem {
+ fn new(code: &'static str, message: impl fmt::Display) -> Self {
+ Self {
+ code,
+ message: message.to_string(),
+ }
+ }
+}
+
+impl fmt::Display for FileProblem {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.message)
+ }
+}
+
+impl std::error::Error for FileProblem {}
+
+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 +312,35 @@ impl Source {
})
}
- fn read(&self, repo: &Repository) -> Result {
+ fn read(&self, repo: &Repository) -> std::result::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(FileProblem::new(
+ "unsupported_file_type",
+ "structural diffs currently require regular text files (not symlinks or submodules)",
+ ));
}
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)
+ .map_err(|error| FileProblem::new("read_failed", error))?
+ .content()
+ .to_vec(),
+ Self::WorkingFile { path, .. } => {
+ std::fs::read(path).map_err(|error| FileProblem::new("read_failed", error))?
+ }
Self::Absent => unreachable!(),
};
if bytes.contains(&0) {
- return Err("structural diffs currently support text files only".into());
+ return Err(FileProblem::new(
+ "binary",
+ "structural diffs currently support text files only",
+ ));
}
- Ok(String::from_utf8(bytes)?)
+ String::from_utf8(bytes).map_err(|error| FileProblem::new("not_utf8", error))
}
}
@@ -267,28 +400,45 @@ 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)) => protocol::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) => protocol::Pairing::LeftOnly {
+ lhs: file_ref(delta.old_file(), old),
},
+ (None, Some(new)) => protocol::Pairing::RightOnly {
+ rhs: file_ref(delta.new_file(), new),
+ },
+ (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(),
@@ -334,12 +484,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 +511,16 @@ 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, std::result::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(FileProblem::new(
+ "unmerged",
+ "unmerged index entry: resolve the conflict before requesting a structural diff",
+ ));
}
Ok(LoadedFile {
before: pending.before.read(&self.repo)?,
@@ -380,6 +539,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(|problem| Box::new(problem) as _),
+ ))
}
}
diff --git a/src/hook.rs b/src/hook.rs
index 742caca34..bfbbaabd9 100644
--- a/src/hook.rs
+++ b/src/hook.rs
@@ -1,59 +1,81 @@
//! 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::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_json::Value;
+use crate::mutate::{
+ collapse, ids, is_fold, line_count, summary_label, walk, walk_mut, FoldMutation,
+};
+use crate::protocol::{FileChange, Pairing, Problem, Region, Source, SourceRange};
+use serde::{Deserialize, Serialize};
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};
#[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 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>;
+#[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,
+ min_lines: usize,
child: Mutex,
runtime: tokio::runtime::Runtime,
- client: HttpClient,
+ client: reqwest::Client,
+ url: String,
+ next_id: AtomicU64,
}
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,
+ ) -> crate::git::Result {
let port = free_port()?;
let mut child = Command::new(&config.command[0])
.args(&config.command[1..])
@@ -76,93 +98,130 @@ 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(),
+ min_lines: config.min_lines.unwrap_or(default_min_lines),
child: Mutex::new(child),
runtime,
client,
+ url: format!("http://127.0.0.1:{port}/"),
+ next_id: AtomicU64::new(1),
})
}
- /// Fill in summaries for this file's qualifying folds, blocking on the 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))
- .map(|(index, _)| index)
- .collect();
- if selected.is_empty() {
- return Ok(());
+ fn qualifies(&self, region: &Region, lhs_ids: &crate::hash::DftHashSet) -> bool {
+ if !is_fold(region) || lhs_ids.contains(®ion.alignment_id) {
+ return false;
}
- let FileContent::Text(src) = &diff.rhs_src else {
- return Ok(());
+ 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<'_>) -> Result, String> {
+ let request = Request {
+ jsonrpc: "2.0",
+ id: self.next_id.fetch_add(1, Ordering::Relaxed),
+ method: "summarize",
+ params,
};
- let language = match &diff.file_format {
- FileFormat::SupportedLanguage(language) => {
- Some(crate::parse::guess_language::language_name(*language).to_owned())
+ 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()),
}
- _ => None,
+ })
+ }
+}
+
+impl FoldMutation for Hook {
+ /// Files without qualifying folds never reach the hook.
+ fn apply(&self, file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> {
+ let lhs_ids = sides.lhs().map(|lhs| ids(&lhs.regions)).unwrap_or_default();
+ let Some(rhs) = sides.rhs() else {
+ return Ok(());
};
- let folds = selected
- .iter()
- .map(|&index| {
- let fold = &diff.rhs_folds[index];
- RequestFold {
- id: index,
- range: wire::range(&fold.range),
- tags: fold.tags.clone(),
- placeholder: fold.placeholder.clone(),
- }
- })
- .collect();
+ 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(),
+ });
+ }
+ });
+ if folds.is_empty() {
+ return Ok(());
+ }
+ let selected: Vec = folds.iter().map(|fold| fold.id).collect();
+ let language = file.language.as_deref();
let texts = self
- .runtime
- .block_on(FoldHookClient::summarize(
- &self.client,
- diff.display_path.clone(),
+ .call(Params {
+ path: file
+ .file
+ .rhs()
+ .map(|side| side.path.as_str())
+ .unwrap_or_default(),
language,
- src.clone(),
+ src: &rhs.text,
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}"),
+ })
+ .map_err(|message| Problem {
+ code: "hook_failed".to_owned(),
+ message,
})?;
+ 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(|| Problem {
+ code: "hook_failed".to_owned(),
+ message: format!("fold hook answered for unknown fold {key:?}"),
+ })?;
+ by_id.insert(id, text);
}
+ let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = sides else {
+ unreachable!("rhs regions were selected");
+ };
+ walk_mut(&mut rhs.regions, &mut |region| {
+ if let Some(text) = by_id.get(®ion.alignment_id) {
+ collapse(region, summary_label(language, text));
+ }
+ });
Ok(())
}
-
- fn qualifies(&self, fold: &crate::parse::folds::Fold) -> bool {
- if !matches!(fold.match_kind, FoldMatch::Novel) {
- 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
@@ -202,7 +261,7 @@ 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 {
@@ -214,36 +273,40 @@ 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();
+ walk(&sides.rhs().unwrap().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]
@@ -253,24 +316,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]
@@ -283,23 +348,24 @@ 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!(error.message.contains("timed out"), "{}", error.message);
let error = hook("error", 5000)
.unwrap()
- .summarize(&mut result)
+ .apply(&file, &mut sides)
.unwrap_err();
- assert!(error.contains("declined"), "{error}");
+ assert!(error.message.contains("declined"), "{}", error.message);
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!(error.message.starts_with("fold hook:"), "{}", error.message);
+ assert_eq!(error.code, "hook_failed");
+ assert!(fold_labels(&sides).iter().all(|(collapsed, _)| !collapsed));
}
}
diff --git a/src/line_folds.rs b/src/line_folds.rs
new file mode 100644
index 000000000..7390ca31b
--- /dev/null
+++ b/src/line_folds.rs
@@ -0,0 +1,293 @@
+//! 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::pair_matched`). 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::lines::SourceRange;
+use crate::parse::folds::{Fold, FoldMatch};
+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, as `(lhs index, rhs index)`.
+///
+/// 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_index, rhs_index) in lhs_pair.into_iter().enumerate() {
+ let Some(rhs_index) = rhs_index else {
+ continue;
+ };
+ let (lhs_range, rhs_range) = (lhs_folds[lhs_index].range, rhs_folds[rhs_index].range);
+ lhs_folds[lhs_index].match_kind = FoldMatch::Unchanged {
+ opposite: rhs_range,
+ };
+ rhs_folds[rhs_index].match_kind = FoldMatch::Unchanged {
+ opposite: lhs_range,
+ };
+ }
+}
+
+/// 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) = line_span(&fold.range, line_count);
+ (end - start >= 2).then_some((start, end))
+ })
+ .collect()
+}
+
+/// Half-open whole lines a range touches, clamped to the file.
+pub(crate) fn line_span(range: &SourceRange, line_count: usize) -> (usize, usize) {
+ 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)),
+ )
+}
+
+/// 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/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..256a728c6 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;
@@ -55,10 +56,13 @@ mod git;
mod gitattributes;
mod hash;
mod hook;
+mod line_folds;
mod line_parser;
mod lines;
+mod mutate;
mod options;
mod parse;
+pub(crate) mod protocol;
mod review;
mod stream;
mod summary;
diff --git a/src/mutate/collapse.rs b/src/mutate/collapse.rs
new file mode 100644
index 000000000..1e8aa4cf8
--- /dev/null
+++ b/src/mutate/collapse.rs
@@ -0,0 +1,658 @@
+//! 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::protocol::{
+ FileChange, FileStatus, Node, Pairing, Problem, 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) -> Result<(), Problem> {
+ 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) -> Result<(), Problem> {
+ let rhs_ids = sides.rhs().map(|rhs| ids(&rhs.regions)).unwrap_or_default();
+ let Some(lhs) = lhs_mut(sides) else {
+ 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) -> Result<(), Problem> {
+ 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) -> Result<(), Problem> {
+ let rhs_ids = sides.rhs().map(|rhs| ids(&rhs.regions)).unwrap_or_default();
+ let mut next_id = next_id(sides);
+ let Some(lhs) = lhs_mut(sides) else {
+ 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(),
+ ),
+ ]
+}
+
+fn lhs_mut(sides: &mut Pairing) -> Option<&mut Source> {
+ match sides {
+ Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => Some(lhs),
+ Pairing::RightOnly { .. } => None,
+ }
+}
+
+#[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();
+ for source in [sides.lhs().unwrap(), sides.rhs().unwrap()] {
+ 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();
+ walk(&sides.rhs().unwrap().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();
+ walk(&sides.rhs().unwrap().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();
+ assert_eq!(
+ shape(&sides.lhs().unwrap().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 middle = &sides.lhs().unwrap().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();
+ assert_eq!(sides.lhs().unwrap().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();
+ assert_eq!(sides.lhs().unwrap().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 lhs = &sides.lhs().unwrap().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 lhs = &sides.lhs().unwrap().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 lhs = &sides.lhs().unwrap().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 lhs = sides.lhs().unwrap();
+ 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..d0f748bac
--- /dev/null
+++ b/src/mutate/docstrings.rs
@@ -0,0 +1,539 @@
+//! 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::protocol::{FileChange, Node, Pairing, Problem, 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) -> Result<(), Problem> {
+ let language = file.language.as_deref();
+ let lhs_docs = sides
+ .lhs()
+ .map(|source| usable(source, find(source, language)))
+ .unwrap_or_default();
+ let rhs_docs = sides
+ .rhs()
+ .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 [(sides.lhs(), &lhs_docs), (sides.rhs(), &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 next = next_id(sides);
+ let mut fresh: BTreeMap<(u32, usize), u32> = BTreeMap::new();
+ let (mut lhs, mut rhs) = sides_mut(sides);
+ 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) -> Result<(), Problem> {
+ 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 rhs = sides.rhs().unwrap();
+ 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();
+ assert!(docstrings(sides.rhs().unwrap()).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 rhs = sides.rhs().unwrap();
+ 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 rhs = sides.rhs().unwrap();
+ 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 (lhs, rhs) = (sides.lhs().unwrap(), sides.rhs().unwrap());
+ 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..e145f82db
--- /dev/null
+++ b/src/mutate/group.rs
@@ -0,0 +1,574 @@
+//! 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::protocol::{
+ FileChange, Node, Pairing, Problem, Region, Source, SourceRange, Visibility,
+};
+
+pub(crate) struct GroupCollapsed;
+
+impl FoldMutation for GroupCollapsed {
+ fn apply(&self, _file: &FileChange, sides: &mut Pairing) -> Result<(), Problem> {
+ let mut next_id = next_id(sides);
+ merge_gaps(sides);
+ group_folds(sides, &mut next_id);
+ Ok(())
+ }
+}
+
+pub(super) fn next_id(sides: &Pairing) -> u32 {
+ let mut max = None;
+ for source in [sides.lhs(), sides.rhs()].into_iter().flatten() {
+ 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();
+ if let Some(lhs) = sides.lhs() {
+ adjacent_gaps(&lhs.regions, &mut lhs_adjacent);
+ }
+ if let Some(rhs) = sides.rhs() {
+ adjacent_gaps(&rhs.regions, &mut rhs_adjacent);
+ }
+ let ids = |source: Option<&Source>| {
+ source
+ .map(|source| super::ids(&source.regions))
+ .unwrap_or_default()
+ };
+ (
+ lhs_adjacent,
+ rhs_adjacent,
+ ids(sides.lhs()),
+ ids(sides.rhs()),
+ )
+ };
+ // 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();
+ let lhs_ids = sides.lhs().map(|lhs| ids(&lhs.regions)).unwrap_or_default();
+ let rhs_ids = sides.rhs().map(|rhs| ids(&rhs.regions)).unwrap_or_default();
+ if let Some(lhs) = sides.lhs() {
+ collapsed_fold_runs(&lhs.regions, &rhs_ids, &mut lhs_runs);
+ }
+ if let Some(rhs) = sides.rhs() {
+ collapsed_fold_runs(&rhs.regions, &lhs_ids, &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;
+ use crate::mutate::summarize::tests::project;
+
+ #[test]
+ fn removed_groups_only_wrap_one_sided_folds() {
+ use crate::protocol::SourcePos;
+ 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 lhs = &sides.lhs().unwrap().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 lhs = sides.lhs().unwrap();
+ 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 rhs_ids = super::super::ids(&sides.rhs().unwrap().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() {
+ use crate::mutate::collapse::TestBodies;
+ use crate::mutate::docstrings::{DocstringVisibility, 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 rhs = sides.rhs().unwrap();
+ 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;
+ walk(&sides.lhs().unwrap().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);
+ for source in [sides.lhs().unwrap(), sides.rhs().unwrap()] {
+ 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);
+ assert_eq!(sides.lhs().unwrap().regions.len(), 2);
+ assert_eq!(sides.rhs().unwrap().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..395f899f8
--- /dev/null
+++ b/src/mutate/mod.rs
@@ -0,0 +1,204 @@
+//! 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::protocol::{FileChange, Node, Pairing, Problem, Region, Source, Visibility};
+use std::path::Path;
+
+pub(crate) trait FileMutation: Send + Sync {
+ fn apply(&self, file: &mut FileChange) -> Result<(), Problem>;
+}
+
+pub(crate) trait FoldMutation: Send + Sync {
+ fn apply(&self, file: &FileChange, sides: &mut Pairing) -> Result<(), Problem>;
+}
+
+/// 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) -> crate::git::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) -> Result<(), Problem> {
+ for mutation in &self.file {
+ mutation.apply(file)?;
+ }
+ Ok(())
+ }
+
+ pub(crate) fn apply_fold(
+ &self,
+ file: &FileChange,
+ sides: &mut Pairing,
+ ) -> Result<(), Problem> {
+ 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..d6d01c31d
--- /dev/null
+++ b/src/mutate/summarize.rs
@@ -0,0 +1,731 @@
+//! 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, FoldMutation};
+use crate::config::{Provider, SummarizeConfig};
+use crate::protocol::{FileChange, Pairing, Problem, Source};
+use crate::protocol::{Node, Region};
+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) -> crate::git::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 problem.
+ fn complete(
+ &self,
+ path: &str,
+ language: Option<&str>,
+ src: &str,
+ folds: &[Request],
+ ) -> Result, Problem> {
+ 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 problem = |code: &str, message: String| Problem {
+ code: code.to_owned(),
+ message: format!("{}: {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| problem("summarizer_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(problem(
+ "summarizer_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(problem("summarizer_failed", error.to_string())),
+ };
+ if attempt >= self.config.retries {
+ return Err(problem(
+ "summarizer_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(|| problem("summarizer_failed", "no text in the response".to_owned()))?;
+ let answers: Vec = serde_json::from_str(content)
+ .map_err(|error| problem("summarizer_failed", format!("{error}: {content}")))?;
+ let mut texts = BTreeMap::new();
+ for answer in answers {
+ if !folds.iter().any(|fold| fold.id == answer.id) {
+ return Err(problem(
+ "summarizer_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 Some(rhs) = sides.rhs() else {
+ return Vec::new();
+ };
+ let lhs_ids = sides.lhs().map(|lhs| ids(&lhs.regions)).unwrap_or_default();
+ let mut selected = Vec::new();
+ fn visit(
+ regions: &[Region],
+ lhs_ids: &crate::hash::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) -> Result<(), Problem> {
+ let rhs_source = sides.rhs();
+ let folds: Vec = select(sides, self.min_lines)
+ .into_iter()
+ .map(|(id, first_line, last_line)| Request {
+ id,
+ first_line,
+ last_line,
+ doc: rhs_source.and_then(|rhs| docstrings::text_for(rhs, fold_state_of(rhs, id))),
+ })
+ .collect();
+ if folds.is_empty() {
+ return Ok(());
+ }
+ let path = file
+ .file
+ .rhs()
+ .map(|side| side.path.as_str())
+ .unwrap_or_default();
+ let language = file.language.as_deref();
+ let texts = {
+ let rhs = sides.rhs().expect("selection found rhs regions");
+ let lines: Vec<&str> = rhs.text.split_terminator('\n').collect();
+ let mut texts = self.complete(path, language, &rhs.text, &folds)?;
+ 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)
+ });
+ texts
+ };
+ let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = sides else {
+ unreachable!("selection found rhs regions");
+ };
+ 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()),
+ },
+ )
+ .unwrap();
+ 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();
+ walk(&sides.rhs().unwrap().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 rhs = sides.rhs().unwrap();
+ 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 rhs = sides.rhs().unwrap();
+ 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_problems() {
+ let (file, mut sides) = project("a.py", "", LARGE);
+ let (endpoint, server) = serve(vec![(400, "{\"error\": \"bad key\"}".to_owned())]);
+ let problem = summarizer(&endpoint, 3)
+ .apply(&file, &mut sides)
+ .unwrap_err();
+ server.join().unwrap();
+ assert_eq!(problem.code, "summarizer_failed");
+ assert!(problem.message.contains("HTTP 400"), "{}", problem.message);
+ let (endpoint, server) = serve(vec![(500, "{}".to_owned()), (500, "{}".to_owned())]);
+ let problem = summarizer(&endpoint, 1)
+ .apply(&file, &mut sides)
+ .unwrap_err();
+ server.join().unwrap();
+ assert!(
+ problem.message.contains("after 2 attempts"),
+ "{}",
+ problem.message
+ );
+ assert!(!sides.rhs().unwrap().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 8714add33..36b9575ae 100644
--- a/src/parse/folds.rs
+++ b/src/parse/folds.rs
@@ -22,7 +22,9 @@ pub(crate) struct Fold {
#[derive(Debug, Clone)]
pub(crate) enum FoldMatch {
- /// A corresponding fold, whose contents may differ. Collapsed state is client-owned.
+ /// A corresponding fold, whose contents may differ: recorded by the syntax
+ /// matcher, or by the line alignment when the file fell back to a line diff.
+ /// Collapsed state is client-owned.
Unchanged {
opposite: SourceRange,
},
@@ -144,6 +146,50 @@ fn range(node: &Syntax<'_>) -> Option {
Some(region)
}
+/// Pair the folds of a structural diff, as `(lhs index, rhs index)`.
+///
+/// The syntax matcher already decided correspondence: `project` records a
+/// fold's matched node as `FoldMatch::Unchanged { opposite }`. A lhs fold
+/// pairs with the rhs fold whose range is that opposite range, when the rhs
+/// fold records the lhs fold back. The matcher may pair folds out of reading
+/// order; such a pair is kept, and consumers show it as a move.
+pub(crate) fn pair_matched(lhs: &[Fold], rhs: &[Fold]) -> Vec<(usize, usize)> {
+ let mut rhs_by_range: DftHashMap = DftHashMap::default();
+ for (index, fold) in rhs.iter().enumerate() {
+ rhs_by_range.entry(fold.range).or_insert(index);
+ }
+ let mut taken: DftHashSet = DftHashSet::default();
+ let mut pairs = Vec::new();
+ for (lhs_index, fold) in lhs.iter().enumerate() {
+ let FoldMatch::Unchanged { opposite } = &fold.match_kind else {
+ continue;
+ };
+ let Some(&rhs_index) = rhs_by_range.get(opposite) else {
+ continue;
+ };
+ let mutual = matches!(
+ &rhs[rhs_index].match_kind,
+ FoldMatch::Unchanged { opposite } if *opposite == fold.range
+ );
+ if mutual && taken.insert(rhs_index) {
+ pairs.push((lhs_index, rhs_index));
+ }
+ }
+ pairs
+}
+
+/// Every fold in a parsed side, without any correspondence. Used when the
+/// AST match did not run: `line_folds::pair` then records partners on them
+/// through the line alignment.
+pub(crate) fn unmatched(nodes: &[&Syntax<'_>], folds: &mut Vec) {
+ for node in nodes {
+ folds.extend(project(node, ChangeKind::Novel));
+ if let Syntax::List { children, .. } = node {
+ unmatched(children, folds);
+ }
+ }
+}
+
/// 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()?;
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/mod.rs b/src/protocol/mod.rs
new file mode 100644
index 000000000..439cd66f3
--- /dev/null
+++ b/src/protocol/mod.rs
@@ -0,0 +1,625 @@
+//! 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::de::Error as _;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+pub(crate) mod project;
+pub(crate) mod stream;
+
+/// The current wire version. Changes within a version are additive.
+pub const VERSION: u32 = 3;
+
+fn is_default(value: &T) -> bool {
+ *value == T::default()
+}
+
+// ── 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. This is
+/// `Result` in the shape serde needs; convert with `into`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum Outcome {
+ Diff { diff: Diff },
+ Error { error: Problem },
+}
+
+impl From> for Outcome {
+ fn from(result: Result) -> Self {
+ match result {
+ Ok(diff) => Self::Diff { diff },
+ Err(error) => Self::Error { error },
+ }
+ }
+}
+
+impl From for Result {
+ fn from(outcome: Outcome) -> Self {
+ match outcome {
+ Outcome::Diff { diff } => Ok(diff),
+ Outcome::Error { error } => Err(error),
+ }
+ }
+}
+
+/// 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.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Problem {
+ pub code: String,
+ pub message: String,
+}
+
+/// Which sides a thing exists on. Serializes by presence.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Pairing {
+ Both { lhs: T, rhs: T },
+ LeftOnly { lhs: T },
+ RightOnly { rhs: T },
+}
+
+impl Pairing {
+ pub fn as_ref(&self) -> Pairing<&T> {
+ match self {
+ Self::Both { lhs, rhs } => Pairing::Both { lhs, rhs },
+ Self::LeftOnly { lhs } => Pairing::LeftOnly { lhs },
+ Self::RightOnly { rhs } => Pairing::RightOnly { rhs },
+ }
+ }
+
+ pub 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) },
+ }
+ }
+
+ pub fn lhs(&self) -> Option<&T> {
+ match self {
+ Self::Both { lhs, .. } | Self::LeftOnly { lhs } => Some(lhs),
+ Self::RightOnly { .. } => None,
+ }
+ }
+
+ pub fn rhs(&self) -> Option<&T> {
+ match self {
+ Self::Both { rhs, .. } | Self::RightOnly { rhs } => Some(rhs),
+ Self::LeftOnly { .. } => None,
+ }
+ }
+}
+
+#[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")),
+ }
+ }
+}
+
+// ── 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