From 908887d8797ccd520945524e0268c498858451e1 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 01/20] Stream diffs as wire v3 region trees `--format ndjson` now writes protocol v3 (src/protocol/): a `start` record with per-side file identity (path, oid, mode), one `file` record per diff holding each side's text and a strict tree of regions, and a `complete` footer. The projection (src/protocol/project.rs) builds the trees from the diff as it is: leaves come from the full-file row alignment and tile each side, folds come from the parse, and nothing is hidden or starts collapsed. Leaves are split at fold edges, mirrored on the paired side, so every fold's children tile it and paired leaves stay the same length. Every region carries an `id` unique within the file across both sides. The projection numbers regions as it builds them, lhs preorder then rhs preorder, with `id` dense from 1: id 0 (`ROOT`) names the file itself. Only leaves carry an `alignment_id`, from a counter of their own, and it is row alignment exactly as difftastic's line alignment produces rows. The two regions of a paired leaf or a matched fold share one `fold_state_id`, so they open and close together. A fold is a region of a file. Its lines are exactly the lines collapsing it hides: `folds::line_span` rounds its byte range in to whole lines, so a body fold starts on the line after the `{` or `:` that opens it and stops above the line its `}` sits on, whatever column that brace is in. Code before a fold on the line it opens on, or after it on the line it closes on, belongs to the leaf beside it. A fold is owned by a syntax node: `Fold` carries the node's `SyntaxId`, a node has at most one fold, and two folds align exactly when the matcher paired their nodes, both ways (`folds::partner`). Flattening a wrapper moves its fold onto a child that has none, and keeps the wrapper when the child has a fold of its own, so every fold still has a node. Two captures that cover the same lines are one fold whose tags are the union of theirs, owned by the innermost node whose extent that region is (`folds::merge_spans`); folds of fewer than two lines hide nothing and merge with nothing. A file diffed by line records which limit it hit as a `FallbackCause`; the projection maps it to the `stats.fallback` code and keeps the engine's prose, with the numbers, as the message. The stream writer diffs files on `--jobs` workers and emits each record as it finishes. Errors are `anyhow::Error` until the writer turns one into a wire record. `Pairing` moves to src/pairing.rs so line layout can use it for run sides without depending on the protocol. The projection replaces the old outputs, which are deleted: the previous stream (src/stream.rs) and its Python checker, `--format json` with the domain-object encoding behind it (src/review/wire.rs), and `--format snapshot` with the rest of the experimental src/review module, its golden CLI tests (tests/review.rs, tests/review_support, the fixtures' review.snap files) and the static fixture viewer that read the old stream. The review module's fold checks move to src/parse/fold_tests.rs; the checks that asserted on snapshot text or terminal hunks are dropped. The old stream writer was the only caller of the JSON-RPC fold hook, so the hook goes with it: src/hook.rs, its `folds.hook` settings, the jsonrpsee dependency, and the example and test servers. docs/streaming.md describes the wire. Two fold query patterns that capture one syntax node with different ranges are a query conflict: that file is not diffed and its record carries a `query_conflict` error naming the node, the line and both pattern indexes, rather than silently dropping the fold and letting the result depend on query order. Other files in the run keep diffing. Written with AI assistance (Claude Code). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 Agent-Session: 01a0a925-9df2-7c02-9334-147bb9c9f780 --- Cargo.lock | 890 +------------ Cargo.toml | 2 +- README.md | 3 +- diffr.1 | 4 +- diffr.1.md | 4 +- docs/cli.md | 7 +- docs/streaming.md | 267 ++-- examples/hooks/summarize.py | 121 -- examples/review/README.md | 78 +- examples/review/real/01-review-108/README.md | 2 +- .../review/real/01-review-108/review.snap | 339 ----- examples/review/real/02-review-175/README.md | 4 +- .../review/real/02-review-175/review.snap | 40 - examples/review/real/03-review-160/README.md | 2 +- .../review/real/03-review-160/review.snap | 189 --- examples/review/real/04-flask-6096/README.md | 2 +- .../review/real/04-flask-6096/review.snap | 53 - examples/review/real/05-flask-6133/README.md | 2 +- .../review/real/05-flask-6133/review.snap | 19 - .../review/real/06-requests-6965/README.md | 4 +- .../review/real/06-requests-6965/review.snap | 19 - .../review/real/07-ripgrep-3487/README.md | 4 +- .../review/real/07-ripgrep-3487/review.snap | 41 - .../review/real/08-ripgrep-3496/README.md | 2 +- .../review/real/08-ripgrep-3496/review.snap | 104 -- examples/review/real/09-cli-11038/README.md | 4 +- examples/review/real/09-cli-11038/review.snap | 27 - examples/review/real/10-go-git-1492/README.md | 2 +- .../review/real/10-go-git-1492/review.snap | 84 -- examples/review/real/11-flask-5526/README.md | 2 +- .../review/real/11-flask-5526/review.snap | 55 - examples/review/viewer/.gitignore | 1 - examples/review/viewer/README.md | 14 - examples/review/viewer/app.js | 419 ------ examples/review/viewer/build.py | 88 -- examples/review/viewer/index.html | 13 - examples/review/viewer/inline-folds.mjs | 33 - examples/review/viewer/inline-folds.test.mjs | 24 - examples/review/viewer/layout.mjs | 137 -- examples/review/viewer/stream.mjs | 46 - examples/review/viewer/stream.test.mjs | 29 - examples/review/viewer/style.css | 5 - src/cli.rs | 50 +- src/config.rs | 145 +-- src/config/README.md | 25 +- src/config/defaults.toml | 18 +- src/diff/sliders.rs | 8 +- src/diff/unchanged.rs | 66 +- src/display/line_layout.rs | 116 +- src/display/syntax_context.rs | 4 +- src/engine.rs | 111 +- src/git.rs | 209 ++- src/hook.rs | 305 ----- src/lines.rs | 4 +- src/main.rs | 128 +- src/pairing.rs | 96 ++ src/parse/fold_tests.rs | 409 ++++++ src/parse/folds.rs | 380 +++++- src/parse/mod.rs | 2 + src/parse/syntax.rs | 91 +- src/parse/tree_sitter_parser.rs | 82 +- src/protocol/mod.rs | 457 +++++++ src/protocol/project.rs | 1146 +++++++++++++++++ src/protocol/stream.rs | 267 ++++ src/review/mod.rs | 6 - src/review/render.rs | 157 --- src/review/tests.rs | 591 --------- src/review/wire.rs | 154 --- src/stream.rs | 229 ---- src/summary.rs | 20 +- tests/hooks/rpc_server.py | 71 - tests/review.rs | 107 -- tests/review_support/git.rs | 84 -- tests/review_support/mod.rs | 165 --- tests/streaming/check.py | 246 ---- 75 files changed, 3614 insertions(+), 5520 deletions(-) delete mode 100755 examples/hooks/summarize.py delete mode 100644 examples/review/real/01-review-108/review.snap delete mode 100644 examples/review/real/02-review-175/review.snap delete mode 100644 examples/review/real/03-review-160/review.snap delete mode 100644 examples/review/real/04-flask-6096/review.snap delete mode 100644 examples/review/real/05-flask-6133/review.snap delete mode 100644 examples/review/real/06-requests-6965/review.snap delete mode 100644 examples/review/real/07-ripgrep-3487/review.snap delete mode 100644 examples/review/real/08-ripgrep-3496/review.snap delete mode 100644 examples/review/real/09-cli-11038/review.snap delete mode 100644 examples/review/real/10-go-git-1492/review.snap delete mode 100644 examples/review/real/11-flask-5526/review.snap delete mode 100644 examples/review/viewer/.gitignore delete mode 100644 examples/review/viewer/README.md delete mode 100644 examples/review/viewer/app.js delete mode 100644 examples/review/viewer/build.py delete mode 100644 examples/review/viewer/index.html delete mode 100644 examples/review/viewer/inline-folds.mjs delete mode 100644 examples/review/viewer/inline-folds.test.mjs delete mode 100644 examples/review/viewer/layout.mjs delete mode 100644 examples/review/viewer/stream.mjs delete mode 100644 examples/review/viewer/stream.test.mjs delete mode 100644 examples/review/viewer/style.css delete mode 100644 src/hook.rs create mode 100644 src/pairing.rs create mode 100644 src/parse/fold_tests.rs create mode 100644 src/protocol/mod.rs create mode 100644 src/protocol/project.rs create mode 100644 src/protocol/stream.rs delete mode 100644 src/review/mod.rs delete mode 100644 src/review/render.rs delete mode 100644 src/review/tests.rs delete mode 100644 src/review/wire.rs delete mode 100644 src/stream.rs delete mode 100755 tests/hooks/rpc_server.py delete mode 100644 tests/review.rs delete mode 100644 tests/review_support/git.rs delete mode 100644 tests/review_support/mod.rs delete mode 100755 tests/streaming/check.py diff --git a/Cargo.lock b/Cargo.lock index 1cba9554e..81e4c1bad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "assert_cmd" version = "2.0.17" @@ -82,35 +88,12 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "2.13.2" @@ -134,12 +117,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - [[package]] name = "cc" version = "1.2.54" @@ -152,12 +129,6 @@ 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" @@ -198,32 +169,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" -[[package]] -name = "combine" -version = "4.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "crossbeam-deque" version = "0.8.5" @@ -265,6 +210,7 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" name = "difftastic" version = "0.71.0" dependencies = [ + "anyhow", "assert_cmd", "bumpalo", "cc", @@ -276,7 +222,6 @@ dependencies = [ "humansize", "ignore", "imara-diff", - "jsonrpsee", "lazy_static", "libc", "line-numbers", @@ -418,12 +363,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.8" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -447,12 +392,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -474,57 +413,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-sink", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "git2" version = "0.20.4" @@ -557,25 +445,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -608,45 +477,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "humansize" version = "2.1.3" @@ -662,63 +492,6 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" -[[package]] -name = "hyper" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "log", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -880,50 +653,6 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.106", -] - [[package]] name = "jobserver" version = "0.1.32" @@ -933,89 +662,6 @@ dependencies = [ "libc", ] -[[package]] -name = "jsonrpsee" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" -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", - "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", -] - [[package]] name = "lazy_static" version = "1.4.0" @@ -1088,17 +734,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - [[package]] name = "nom" version = "8.0.0" @@ -1123,18 +758,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "owo-colors" version = "4.2.3" @@ -1158,26 +781,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" @@ -1249,15 +852,6 @@ dependencies = [ "log", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.14+spec-1.1.0", -] - [[package]] name = "proc-macro2" version = "1.0.101" @@ -1316,9 +910,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1327,23 +921,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "ring" -version = "0.17.14" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom", - "libc", - "untrusted", - "windows-sys 0.52.0", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc-hash" @@ -1364,80 +944,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustls" -version = "0.23.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "ryu" version = "1.0.17" @@ -1453,38 +959,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "serde" version = "1.0.228" @@ -1544,28 +1018,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1605,12 +1063,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.106" @@ -1633,12 +1085,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - [[package]] name = "synstructure" version = "0.13.2" @@ -1675,46 +1121,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - [[package]] name = "tikv-jemalloc-sys" version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" @@ -1751,36 +1157,7 @@ version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", "pin-project-lite", - "tokio", ] [[package]] @@ -1791,8 +1168,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_datetime", + "toml_edit", ] [[package]] @@ -1804,15 +1181,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -1822,86 +1190,8 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime 0.6.11", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.14+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2195eec204e2764644a4ea619704f9fbe5e0673038eded55ad9956f24fca0cc" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow 1.0.4", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", + "toml_datetime", + "winnow", ] [[package]] @@ -2505,12 +1795,6 @@ dependencies = [ "petgraph", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "ts-parser-perl" version = "1.2.1" @@ -2539,12 +1823,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -2600,39 +1878,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "webpki-root-certs" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" -dependencies = [ - "webpki-root-certs 1.0.9", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi-util" version = "0.1.9" @@ -2648,22 +1893,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -2672,7 +1908,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -2684,67 +1920,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2757,48 +1960,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2814,15 +1993,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - [[package]] name = "writeable" version = "0.6.4" @@ -2879,12 +2049,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index c1605e89a..931b8e337 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,6 @@ ignore = ">= 0.4, < 0.4.30" owo-colors = "4.2.3" imara-diff = { version = "0.2.0", default-features = false } rayon = "1.7.0" -jsonrpsee = { version = "0.26", default-features = false, features = ["http-client", "macros"] } tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time"] } tree_magic_mini = "3.2.0" @@ -63,6 +62,7 @@ hashbrown = "0.17.0" humansize = "2.1.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +anyhow = "1.0" line-numbers = "0.3.0" smallvec = "1.13.2" streaming-iterator = "0.1.9" diff --git a/README.md b/README.md index 8781fdfe2..0d2015f3a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ diffr main...HEAD -- src/ # merge-base comparison 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). +See the [CLI reference](docs/cli.md) and the [streaming API](docs/streaming.md). The executable is `diffr`; the Cargo package remains `difftastic`. The upstream installation commands below install upstream Difftastic, not this fork. diff --git a/diffr.1 b/diffr.1 index 10be49321..66915d7e9 100644 --- a/diffr.1 +++ b/diffr.1 @@ -31,8 +31,8 @@ Print metadata without syntax matching. Stat counts are textual. .B --quiet, --exit-code Exit 1 for changes. Quiet also suppresses output. Errors exit 2. .TP -.B --format text|json|ndjson|snapshot -Terminal rendering, domain JSON records, or fixture text. +.B --format text|ndjson +Terminal rendering, or the event stream described under STREAMING. .TP .B -U N Ordinary context padding (default 3). diff --git a/diffr.1.md b/diffr.1.md index 0a78256da..03a0fc12d 100644 --- a/diffr.1.md +++ b/diffr.1.md @@ -36,8 +36,8 @@ compare their trees. A...B compares merge-base(A,B) to B; A..B compares A to B. **--quiet**, **--exit-code** : Exit 1 for changes. Quiet also suppresses output. Errors exit 2. -**--format** text|json|ndjson|snapshot -: Terminal rendering, domain JSON records, or fixture text. +**--format** text|ndjson +: Terminal rendering, or the event stream described under STREAMING. **-U** N : Select ordinary context padding (default 3). diff --git a/docs/cli.md b/docs/cli.md index cfe066595..866a29561 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,10 +27,9 @@ Text output does not implement Git's path quoting or compact rename formatting. for changes; ordinary output exits 0. Errors exit 2. `--no-index` supports two files, implies change exit status, and does not yet support metadata options. -Structural output uses the existing terminal renderer. `--format json` emits -one domain object per line. `--format ndjson` emits a buffered event stream, -diffing `--jobs N` files at once (default 16) and emitting each as it finishes; -`--format snapshot` is the fixture text adapter. +Structural output uses the existing terminal renderer. `--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. `-U N` selects ordinary context padding. Matching limits, `--ignore-comments`, color, width and inline/split display remain configurable; see `--help`. diff --git a/docs/streaming.md b/docs/streaming.md index 2a0062344..4c5fad0cf 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -3,13 +3,16 @@ ```sh diffr main HEAD --format ndjson diffr --cached --format ndjson --order source,test -- src/ +diffr --no-index --format ndjson -- before.rs after.rs ``` Spawn one process per comparison and consume stdout line by line. The comparison arguments are the same as the [ordinary CLI](cli.md). There is no HTTP server. -`--format json` remains the bare domain-object output; `ndjson` adds file metadata, -progress, recoverable errors and a completion record. NDJSON currently requires a -repository comparison; it rejects `--no-index`, `--quiet` and metadata output flags. +`--format ndjson` is the stream described here. It requires a repository +comparison or `--no-index`; it rejects `--quiet` and metadata output flags. + +The Rust types behind this document are `src/protocol/mod.rs`; the projection +from the internal diff is `src/protocol/project.rs`. ## Configuration and ordering @@ -36,124 +39,184 @@ magic pathspecs. Only changed files are selected. An unmatched path yields an em stream. Filtering precedes rename detection, so selecting one side of a rename can appear as an addition or deletion. `--no-renames` skips rename detection. -## Output contract +## Conventions + +Stdout carries UTF-8 newline-delimited JSON, one record per line. Read complete +lines; pipe reads can split records or contain several. + +- Every enum is tagged: `type` on records, snapshots and diffs; `kind` on regions. + Tags and enum values are `snake_case`. +- Optional, empty, and default fields are omitted, never `null`. Read a missing + `visibility` as open, a missing `tags` as none, a missing `changed` as none. +- Consumers ignore unknown fields and tolerate unknown enum strings. Additions + within a `version` never change the meaning of existing fields. +- Sides are `lhs` (before) and `rhs` (after). Anything that can exist on one side + only is a *pairing*, written by presence: `{"lhs": …, "rhs": …}`, `{"lhs": …}`, + or `{"rhs": …}`. +- Lines are 0-based and split on `\n` only: an empty file has zero lines, and a + file without a trailing newline still counts its last line. Columns are 0-based + byte offsets into the UTF-8 text on the wire. Ranges are half-open. -Stdout contains only UTF-8 newline-delimited JSON records. Read complete lines; -pipe reads can split records or contain several. +## Records -| Event | Fields | Consumer action | +| Record | Fields | Consumer action | | --- | --- | --- | -| `start` | `version: 1`, `before`, `after`, `total`, `files` | Lay out every file up front. | -| `file` | `file`, `diff`, optional `hook_error` | Render a result. | -| `file_error` | `file`, `message` | Report failure and keep reading. | -| `complete` | `succeeded`, `failed` | Mark complete, including partial failures. | - -`before` and `after` identify the operands: `{"kind":"revision","ref":""}`, -`{"kind":"index"}`, `{"kind":"working_tree"}`, or `{"kind":"empty_tree"}`. -Revision refs resolve once. Index source is pinned by blob ID during discovery. -Worktree files are read as results are computed, not as an atomic snapshot. - -The file descriptor contains nullable `old_path`, `new_path`, `class`, and `status` -(added/deleted/modified/renamed/type_changed/conflicted). `diff` is the existing -domain JSON: complete sources, token correspondence, folds and context hunks. -There is no display layout in the response. - -`files` lists every selected file descriptor in priority order. File results -arrive in completion order, not manifest order, since files are diffed -concurrently (`--jobs`, default 16). Match results to the manifest by identity. - -At completion, `succeeded + failed == total`; every selected file has one result -or file error. EOF without `complete` means interrupted/incomplete output. -Setup failures write to stderr and exit 2 before producing any records. -Per-file failures emit `file_error`, allow subsequent results, and finish with -`complete` and exit 2. Success exits 0, or 1 with `--exit-code` if changes exist. -Unexpected computation or output failures can terminate without `complete`. - -Regular UTF-8 text files are supported. Binary/non-UTF-8 files, symlinks, -submodules and unmerged index entries produce per-file errors. Non-UTF-8 paths -fail discovery. +| `start` | `version: 3`, `lhs`, `rhs`, `files` | Lay out every file up front. | +| `file` | `file`, then `diff` or `error` | Render a result, or mark the file failed. | +| `complete` | `succeeded`, `failed` | Mark complete. | -## Computation and output +`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. -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 -without collecting the entire comparison. In-flight files are bounded by the -pool size, not their individual size. `--jobs 1` restores priority order. +`files` lists every selected file in priority order: -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. +```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 +``` -## Fold hooks +`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`. -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: +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. EOF without +`complete` means the output was cut off. -```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 -timeout_ms = 5000 # optional; per call -startup_timeout_ms = 30000 # optional; time allowed to start listening -``` +### Errors -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. +One shape everywhere: `{"code": "", "message": ""}`. -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. +- On a `file` record, `error` replaces `diff` and the run continues. Codes: + `binary`, `not_utf8`, `unsupported_file_type` (symlinks, submodules), + `unmerged`, `read_failed`, `query_conflict` (two fold query patterns capture + one syntax node with different fold ranges; the message names the line and + both patterns), or `internal` for a failure diffr did not classify. +- Setup failures (bad revision, unreadable config, a query that does not + compile) write to stderr and exit 2 before any record. -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 -requests concurrently rather than one at a time. +Exit status is 0 on success, 1 with `--exit-code` when there are changes, 2 when +any file failed. + +## The diff ```jsonc -// 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}}, - "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"}} +{"type": "text", + "lhs": {"text": "…", "regions": [...]}, + "rhs": {"text": "…", "regions": [...]}, + "stats": {"textual": {"added": 4, "removed": 1}}} // + "fallback": {code, message} on a line diff ``` -`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 `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. -`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, -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. +`text` is the complete source. -## Fixture viewer +`stats.textual` counts lines with any byte change. -The viewer reads captured CLI streams from static files: +`stats.fallback` is present when the AST match did not run: `unsupported_language`, +`too_large`, `too_complex`, `parse_error`. Its `message` is the engine's own +account of why, such as the size a file reached and the limit it exceeded. A +fallback diff is aligned by a line diff, its `changed` spans are word-level, +and it has leaves alone. -```sh -cargo build --locked -python3 examples/review/viewer/build.py -python3 -m http.server 4176 --bind 127.0.0.1 --directory examples/review/viewer -python3 tests/streaming/check.py +### Regions + +Each side carries a tree of regions. A region is a line range on that side with +identities that never stand in for one another. `id` names the region: it is +unique within the file, across both sides. +`fold_state_id` says what the region opens and closes with: regions sharing it +open and close together, on the same side or across sides. Paired leaves and +matched folds share it across sides. Only a leaf has an +`alignment_id`, and it is row alignment: the same value on the other side marks +the leaf whose rows line up with this one, line for line. Consumers key the row +zip by leaf `alignment_id`, collapse state by `fold_state_id`, and anything +about the region itself by `id`. + +```jsonc +{"id": 7, "fold_state_id": 7, "kind": "fold", + "start": {"line": 18, "column": 0}, "end": {"line": 53, "column": 0}, + "tags": ["body"], + "visibility": {"collapsed": false, "label": "Body"}, + "children": [ + {"id": 8, "fold_state_id": 8, "kind": "leaf", "alignment_id": 5, "start": {"line": 18, "column": 0}, "end": {"line": 30, "column": 0}}, + {"id": 9, "fold_state_id": 9, "kind": "leaf", "alignment_id": 6, "start": {"line": 30, "column": 0}, "end": {"line": 31, "column": 0}, + "changed": [{"line": 30, "start_column": 8, "end_column": 9}]}, + {"id": 10, "fold_state_id": 10, "kind": "leaf", "alignment_id": 7, "start": {"line": 31, "column": 0}, "end": {"line": 53, "column": 0}}]} ``` -Rebuild captures after backend changes. Static HTTP serves the example assets -only; it does not compute diffs. +**Leaves** tile the file: read in order, their line ranges cover every line once. +They always start and end at column 0. A leaf whose `alignment_id` a leaf on the +other side also carries 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`. Paired leaves come in the same order on both sides. + +`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: exactly +the lines collapsing it hides. A fold covers only the lines it holds whole. A +body fold starts on the line after the `{` or `:` that opens it, because the +header line holds code the fold does not cover; that line is the last line of +the leaf before the fold. The same at the end: a fold stops above the line its +`}` sits on, whether the brace is in column 0 or not. A fold whose node begins +its line, such as a comment block or an import, starts on that line. Regions form +a strict tree: every child lies inside its parent's range and siblings never +overlap; where the parser still hands over two folds that cross on one line, +the earlier one gives that line to the later. Two queries that fold the same lines, +from one plugin or two, make one fold carrying both their tags: a fold is a +region of the file, and it belongs to the innermost syntax node that covers +that region, whose pairing it takes. Two folds that share `fold_state_id` +across sides are matched: the syntax nodes they were built on are the same node +on both sides, as the matcher paired them, whose contents may differ. A fold is +paired exactly when a region on the other side shares its `fold_state_id`. A +syntactic region that +spans a single line is not a region: it hides nothing. + +`tags` name what a region is. A leaf carries none. A fold carries +the tags the fold queries set on it (`body`, `import`, `test`). Tags describe +syntax; how a region starts out is `visibility`, and frontends need no +knowledge of tags to render it. `visibility` is `collapsed` and the `label` to +show while collapsed: a placeholder named after the fold's first tag. Nothing +starts collapsed yet. Absent means open. + +Ids are per file. The projection numbers the regions as it builds them, in lhs +preorder then rhs preorder, from two counters: `id` starts at 1 and +`alignment_id` at 0. Every region takes the next `id`, and every leaf takes +the next `alignment_id` unless +its counterpart on the other side was numbered first, whose `alignment_id` it +takes instead. A region's `fold_state_id` is its own `id`, except that a paired +leaf or matched fold numbered second takes its counterpart's `fold_state_id`. +No region has `id` 0: it names the file itself. Ids mean nothing across files +or runs. + +## Computation and output + +Discovery and rename detection finish before `start`; syntax matching is lazy. +A pool of `--jobs` workers pulls files from the iterator: each worker reads the +next file's sources under a lock, then diffs them while other workers pull +further files. The calling thread serializes, writes and flushes each 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. + +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. + +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. diff --git a/examples/hooks/summarize.py b/examples/hooks/summarize.py deleted file mode 100755 index 6821423e7..000000000 --- a/examples/hooks/summarize.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = ["aiohttp>=3.9", "httpx>=0.27", "jsonrpcserver>=5"] -# /// -"""Reference diffr fold hook: rewrite large novel folds as Python-style pseudocode. - -diffr starts this server once per invocation with the port in DIFFR_HOOK_PORT and -calls the JSON-RPC 2.0 method `summarize` once per file, concurrently. Each call -awaits one Gemini request on a shared httpx client. Log to stderr; stdout is -discarded by diffr. - -Environment: - DIFFR_HOOK_PORT set by diffr - GOOGLE_API_KEY required - DIFFR_SUMMARY_MODEL default gemini-3.8-flash - DIFFR_SUMMARY_WORKERS concurrent model requests, default 16 -""" - -import asyncio -import json -import os - -import httpx -from aiohttp import web -from jsonrpcserver import Error, Result, Success, async_dispatch, method - -API_KEY = os.environ["GOOGLE_API_KEY"] -PORT = int(os.environ["DIFFR_HOOK_PORT"]) -MODEL = os.environ.get("DIFFR_SUMMARY_MODEL", "gemini-3.8-flash") -WORKERS = int(os.environ.get("DIFFR_SUMMARY_WORKERS", "16")) -URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent" - -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. " - "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 " - "one {id, pseudocode} object per fold." -) -SCHEMA = { - "type": "ARRAY", - "items": { - "type": "OBJECT", - "properties": {"id": {"type": "INTEGER"}, "pseudocode": {"type": "STRING"}}, - "required": ["id", "pseudocode"], - }, -} - -client = httpx.AsyncClient(headers={"x-goog-api-key": API_KEY}, timeout=60) -limit = asyncio.Semaphore(WORKERS) - - -def prompt(path, language, src, folds): - numbered = "\n".join( - f"{n:5d} | {line}" for n, line in enumerate(src.splitlines(), 1) - ) - ranges = "\n".join( - f"- fold {fold['id']}: lines {fold['range']['start']['line'] + 1}-" - f"{fold['range']['end']['line'] + 1}" - for fold in folds - ) - return f"File {path} ({language or 'unknown language'}):\n\n{numbered}\n\nFolds:\n{ranges}" - - -async def complete(path, language, src, folds): - body = { - "systemInstruction": {"parts": [{"text": SYSTEM}]}, - "contents": [ - {"role": "user", "parts": [{"text": prompt(path, language, src, folds)}]} - ], - "generationConfig": { - "temperature": 0, - "maxOutputTokens": 160 * len(folds) + 100, - "thinkingConfig": {"thinkingBudget": 0}, - "responseMimeType": "application/json", - "responseSchema": SCHEMA, - }, - } - async with limit: - response = await client.post(URL, json=body) - response.raise_for_status() - content = response.json()["candidates"][0]["content"]["parts"][-1]["text"] - expected = {fold["id"] for fold in folds} - texts = {} - for item in json.loads(content): - if item["id"] not in expected: - raise ValueError( - f"model answered for unknown fold {item['id']}: {content[:200]}" - ) - if item["pseudocode"].strip(): - texts[str(item["id"])] = item["pseudocode"].strip() - return texts - - -@method -async def summarize(path, language, src, folds) -> Result: - try: - return Success(await complete(path, language, src, folds)) - except httpx.HTTPStatusError as error: - return Error( - -32000, - f"{MODEL}: HTTP {error.response.status_code} {error.response.text[:200]}", - ) - except (httpx.HTTPError, ValueError, KeyError) as error: - return Error(-32000, f"{MODEL}: {error}") - - -async def handle(request: web.Request) -> web.Response: - return web.Response( - text=await async_dispatch(await request.text()), content_type="application/json" - ) - - -app = web.Application() -app.router.add_post("/", handle) - -if __name__ == "__main__": - web.run_app(app, host="127.0.0.1", port=PORT, print=None, access_log=None) diff --git a/examples/review/README.md b/examples/review/README.md index b017e1a34..30bb0f04b 100644 --- a/examples/review/README.md +++ b/examples/review/README.md @@ -2,10 +2,6 @@ Eleven merged PRs, one complete source file per PR: three Review, four Python, two Rust, two Go. Files are copied byte-for-byte from each PR's merge-base and head commits, with original paths and blob hashes recorded. No code is invented or cropped. -For interactive comparison, run the [local fixture viewer](viewer/README.md). It shows Git/Myers beside our generated syntax-aware diff, including import-fold toggles and domain JSON download. - -For actual output, start with [the typed-parameter golden](real/11-flask-5526/review.snap), [the return-object golden](real/02-review-175/review.snap), and [the Rust tail-expression golden](real/07-ripgrep-3487/review.snap). Every real fixture has a `review.snap`. - For a changed typed parameter and multiline signature, see [Flask #5526](real/11-flask-5526/README.md). Start with [Flask's changed imports and enclosing method](real/04-flask-6096/README.md), [a new function in Review](real/03-review-160/README.md), and [Go's nested replacement](real/10-go-git-1492/README.md). @@ -86,74 +82,8 @@ Each example deliberately specifies a small selected set, not every possible fol ## Checks -The v0 implementation lives in `src/review/`: - -- `domain.rs`: review types and source coordinates. -- `src/lines.rs`: source coordinates shared by parsing and display. -- `src/parse/folds.rs` and `fold_queries/`: fold kinds, query captures, and projection through existing matches. -- `src/display/syntax_context.rs`: select and compact paired hunk context. -- `src/display/hunks.rs`: hunk/context types and hunk merging. -- `src/display/line_layout.rs`: aligned rows and line selection. -- `src/review/`: experimental Git-ref CLI, text snapshots, and JSON adapters. - -Unit tests live in the modules they exercise. The implementation reuses -Difftastic's parse trees and matched positions; it does not parse the files again. - -Run the implementation against a local Git repository: - -```sh -cargo run -- review --repo /path/to/repo --base BASE_REF --head HEAD_REF --path src/file.py -``` - -Both refs are resolved to commits before loading regular-file blobs. An absent -side becomes empty source; invalid refs, paths absent on both sides, symlinks, -submodules, binary data and non-UTF-8 input are rejected. The same relative file -path is used on both sides; rename discovery is not implemented. - -### Golden integration tests - -```sh -cargo test --test review -``` - -Each test creates a temporary Git repository with two commits containing the -pinned original source blobs. It verifies their Git blob IDs against provenance, -then invokes the real CLI with the generated base/head commit refs and original -path. These are local fixture commits, **not** the original upstream commit IDs; -no upstream history or network access is needed. - -The complete stdout is compared to the fixture's `review.snap`. Independently, -tests verify printed source text, unique source rows and every `keep_visible` -requirement, so recording a new golden cannot bless a missing required return or -closing delimiter. To deliberately regenerate snapshots: - -```sh -UPDATE_REVIEW_GOLDENS=1 cargo test --test review -``` - -Review the changed snapshot files afterward. `expected.json` is still a set of -hand-selected annotation examples, not an exact oracle for the automatic -candidate detector. Arbitrary pseudocode replacements and collapsed rendering -remain outside this v0. - -### Snapshot behavior - -- Three aligned rows of local context around novel syntax, plus generated - enclosing context. Line correspondence comes from Difftastic. -- Headers and closing braces for supported scopes; enclosing loop/switch - boundaries; selected returns and Rust tail expressions. Short results up to - 12 lines are shown completely; longer results show their boundaries. -- Matched scope-header/closing tokens bring the corresponding scope into view - when only the other side contains novel syntax. Python has no synthetic closer. -- Fold candidates are generated but ignored by this printer. Visible import - lines stay expanded; no placeholders are emitted. -- Left/right line numbers, grouped deletions/additions and explicit gaps. `~` marks matched syntax whose indentation differs; both exact source lines are retained in text snapshots. This - is a review snapshot, not an applicable Git patch. Token-level colors and split - view are not implemented. -- Unsupported languages or Difftastic limit fallbacks retain a textual diff - without syntax annotations. The context rules currently target the corpus's - Python, TypeScript/TSX, Rust and Go node shapes; they are not a universal - language-independent folding algorithm. +`src/parse/fold_tests.rs` diffs these sources through the engine and checks +that every fold range is valid and every matched fold is reciprocal. Run from the repository root: @@ -163,8 +93,8 @@ python3 examples/review/validate.py The offline validator checks distinct PRs, byte-identical source hashes, the annotation shape, source bounds, nonempty ranges, UTF-8 endpoint boundaries, exact selected excerpts, and preserved signatures, selected return statements/tail expressions, and enclosing closing braces that are present in the visible diff or extra context. For these examples, one-sided fold ranges must consist entirely of added/deleted lines. Extra context must be outside the ordinary U3 visible rows. No network or Rust build is required. -The Python validator checks the hand-authored fixture contract. Rust tests additionally exercise the live parser/matcher, annotation bounds, import pairing, fallback, and snapshot rendering. They do not establish semantic correctness of every syntax match or split-view alignment. Partial-overlap policy and moved-region layout remain unspecified; do not infer them from these fixtures. All source pairs currently use LF; CRLF and UTF-8 byte-boundary behavior need targeted edge coverage alongside the real PR cases. +The Python validator checks the hand-authored fixture contract. Rust tests additionally exercise the live parser/matcher, fold bounds, import pairing and fallback. They do not establish semantic correctness of every syntax match or split-view alignment. Partial-overlap policy and moved-region layout remain unspecified; do not infer them from these fixtures. All source pairs currently use LF; CRLF and UTF-8 byte-boundary behavior need targeted edge coverage alongside the real PR cases. The original [synthetic examples](synthetic/README.md) remain available as small explanatory cases; the real corpus is the primary reference. Review fixtures are private-repository code and this corpus is local; nothing has been pushed or published. -Difftastic baseline: `274d0a8f57291477cfbfb27bace0d82395d15c97`. The matching algorithm remains unchanged; the review path adds an annotation collection hook. Runtime/memory benchmarks have not been added. +Difftastic baseline: `274d0a8f57291477cfbfb27bace0d82395d15c97`. The matching algorithm remains unchanged. Runtime/memory benchmarks have not been added. diff --git a/examples/review/real/01-review-108/README.md b/examples/review/real/01-review-108/README.md index 3764b4e05..15bfb4d4b 100644 --- a/examples/review/real/01-review-108/README.md +++ b/examples/review/real/01-review-108/README.md @@ -2,7 +2,7 @@ Multiline import groups remain paired while one large context object is split. Preserve the complete enclosing function signature as extra context; do not pair a whole old interface with two new interfaces automatically. -[Original PR](https://github.com/devdotfast/review/pull/108) · [Before](before.tsx) · [After](after.tsx) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/devdotfast/review/pull/108) · [Before](before.tsx) · [After](after.tsx) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. diff --git a/examples/review/real/01-review-108/review.snap b/examples/review/real/01-review-108/review.snap deleted file mode 100644 index 597cd6bdf..000000000 --- a/examples/review/real/01-review-108/review.snap +++ /dev/null @@ -1,339 +0,0 @@ ---- a/packages/progressive-review/app/src/review-context.tsx -+++ b/packages/progressive-review/app/src/review-context.tsx - | ... - 129 129 nonce: number; - 130 130 } - 131 131 - 132 - interface ReviewContextValue { - 132 + export interface ReviewActionsValue { - 133 133 softwareMapEnabled: boolean; - 134 - historicalRevision: string | null; - 135 134 listVersions: () => Promise; - 136 - resolvedBaseRef: string | null; - 137 - resolvedHeadRef: string | null; - 138 - focusedThreadId: string | null; - 139 - threadFocusRequest: ThreadFocusRequest | null; - 140 135 /** Focus a thread: sets focusedThreadId and enqueues a focus request. - 141 136 * scroll and inline default to true. Always bumps nonce so repeated - 142 137 * clicks re-trigger. */ - | ... - 148 143 blurThread: () => void; - 149 144 /** Consumer (the annotations layer) acknowledges the request after handling it. */ - 150 145 clearThreadFocusRequest: () => void; - 151 - commentThreads: ReadonlyMap; - 152 - allCommentThreads: () => CommentThreadView[]; - 153 - pendingCommentCount: number; - 154 146 submitPendingComments: ( - 155 147 decision: "approve" | "request-changes", - 156 148 summary?: string, - | ... - 158 150 dismissReview: () => Promise; - 159 151 openCommentDraft: (target: OpenCommentDraftTarget) => void; - 160 152 closeCommentDraft: () => void; - 161 - draftTarget: CommentDraftTarget | null; - 162 153 askAgent: (input: CreateReviewCommentInput) => Promise; - 163 - softwareMapFocusRequest: SoftwareMapFocusRequest | null; - 164 154 openSoftwareMapElement: (elementPath: string) => void; - 165 155 openTraceSession?: (input: { - 166 156 sessionId: string; - | ... - 176 166 deleteComment: (threadId: string) => Promise; - 177 167 deleteCommentMessage: (threadId: string, messageId: string) => Promise; - 178 168 setCommentResolved: (threadId: string, resolved: boolean) => Promise; - 179 - /** Resolved comment threads, for the Threads sidebar's Resolved section. */ - 180 - resolvedCommentThreads: () => CommentThreadView[]; - 181 - submissionOutcome: ReviewSubmissionOutcome | null; - 182 169 deleteLocalComment: (threadId: string) => void; - 183 - commentsForTarget: (target: ThreadTarget) => CommentThreadView[]; - 184 - commentsForAnchor: (anchor: AnchorRef) => CommentThreadView[]; - 185 - lineCommentsForAnchor: (anchor: AnchorRef) => SourceLineComment[]; - 186 170 createAnchorCommentTarget: (anchor: AnchorRef) => OpenCommentDraftTarget; - 187 - createAstLineCommentTarget: ( - 188 - anchor: AnchorRef, - 189 - input: AstLineCommentRange, - 190 - ) => OpenCommentDraftTarget; - 191 171 } - 172 + - 173 + export interface ReviewStateValue { - 174 + historicalRevision: string | null; - 175 + resolvedBaseRef: string | null; - 176 + resolvedHeadRef: string | null; - 177 + focusedThreadId: string | null; - 178 + threadFocusRequest: ThreadFocusRequest | null; - 179 + commentThreads: ReadonlyMap; - 180 + allCommentThreads: () => CommentThreadView[]; - 181 + /** Resolved comment threads, for the Threads sidebar's Resolved section. */ - 182 + resolvedCommentThreads: () => CommentThreadView[]; - 183 + pendingCommentCount: number; - 184 + draftTarget: CommentDraftTarget | null; - 185 + softwareMapFocusRequest: SoftwareMapFocusRequest | null; - 186 + submissionOutcome: ReviewSubmissionOutcome | null; - 187 + commentsForTarget: (target: ThreadTarget) => CommentThreadView[]; - 188 + commentsForAnchor: (anchor: AnchorRef) => CommentThreadView[]; - 189 + lineCommentsForAnchor: (anchor: AnchorRef) => SourceLineComment[]; - 190 + createAstLineCommentTarget: ( - 191 + anchor: AnchorRef, - 192 + input: AstLineCommentRange, - 193 + ) => OpenCommentDraftTarget; - 194 + } - 195 + - 196 + export type ReviewContextValue = ReviewActionsValue & ReviewStateValue; - 192 197 - 193 198 interface PendingSubmission { - 194 199 key: string; - | ... - 196 201 summaryComment?: CreateReviewCommentInput; - 197 202 } - 198 203 - 199 - const ReviewContext = createContext(null); - 204 + const ReviewActionsContext = createContext(null); - 205 + const ReviewStateContext = createContext(null); - 200 206 export function ReviewProvider({ - 201 207 documentRoute, - 202 208 softwareMapEnabled = false, - | ... - 226 232 function ReviewCoordinator({ - 227 233 documentRoute, - 228 234 softwareMapEnabled, - 229 235 openTraceSession, - 230 236 children, - 231 237 }: { - 232 238 documentRoute?: string; - 233 239 softwareMapEnabled: boolean; - 234 240 openTraceSession?: (input: { - 235 241 sessionId: string; - 236 242 trace?: string; - 237 243 eventIndex?: number; - 238 244 }) => void; - 239 245 children: ReactNode; - 240 246 }) { - | ... - 262 268 const [historicalRevision, setHistoricalRevision] = useState( - 263 269 null, - 264 270 ); - 271 + const historicalRevisionRef = useRef(null); - 272 + historicalRevisionRef.current = historicalRevision; - 265 273 const pendingSubmissionRef = useRef(null); - 266 274 const threadFocusNonceRef = useRef(0); - 267 275 - | ... - 269 277 const localComments = commentSnapshot.localComments; - 270 278 const agentActivities = commentSnapshot.agentActivities; - 271 279 const pendingCommentCount = commentSnapshot.pendingCommentCount; - 280 + const commentThreadsRef = useRef(commentThreads); - 281 + commentThreadsRef.current = commentThreads; - 272 282 const commentTargetIndex = useMemo( - 273 283 () => buildThreadTargetIndex(commentThreads.values()), - 274 284 [commentThreads], - | ... - 386 396 - 387 397 const saveComment = useCallback( - 388 398 async (input: CreateReviewCommentInput) => { - 389 - const existing = commentSnapshot.commentThreads.get(input.threadId); - 399 + const existing = commentThreadsRef.current.get(input.threadId); - 390 400 await commentStore.saveComment(input); - 391 401 if (input.body.trim()) { - 392 402 captureUiEvent(session, "comment_created", { - | ... - 394 404 }); - 395 405 } - 396 406 }, - 397 - [commentSnapshot.commentThreads, commentStore, session], - 407 + [commentStore, session], - 398 408 ); - 409 + const openCommentDraft = useCallback( - 410 + (target: OpenCommentDraftTarget) => { - 411 + if (historicalRevisionRef.current) return; - 412 + captureUiEvent(session, "thread_draft_opened", { - 413 + intent: target.intent ?? "comment", - 414 + }); - 415 + setDraftTarget({ - 416 + ...target, - 417 + draftSurface: target.draftSurface ?? "document", - 418 + threadId: target.threadId ?? createClientId(), - 419 + messageId: createClientId(), - 420 + placement: target.placement ?? commentDraftPlacementFromActiveElement(), - 421 + }); - 422 + }, - 423 + [session], - 424 + ); - 425 + const closeCommentDraft = useCallback(() => setDraftTarget(null), []); - 399 426 const deleteLocalComment = useCallback( - 400 427 (threadId: string) => commentStore.deleteLocalComment(threadId), - 401 428 [commentStore], - | ... - 572 599 }; - 573 600 }, [documentRoute, reviewFetch, session]); - 574 601 - 575 - const value = useMemo( - 602 + const actions = useMemo( - 576 603 () => ({ - 577 604 softwareMapEnabled, - 578 - historicalRevision, - 579 605 listVersions, - 580 - resolvedBaseRef, - 581 - resolvedHeadRef, - 582 - focusedThreadId, - 583 - threadFocusRequest, - 584 606 focusThread, - 585 607 blurThread, - 586 608 clearThreadFocusRequest, - 587 - commentThreads, - 588 - allCommentThreads, - 589 - pendingCommentCount, - 590 609 submitPendingComments, - 591 610 dismissReview, - 592 - openCommentDraft: (target) => { - 593 - if (historicalRevision) return; - 594 - captureUiEvent(session, "thread_draft_opened", { - 595 - intent: target.intent ?? "comment", - 596 - }); - 597 - setDraftTarget({ - 598 - ...target, - 599 - draftSurface: target.draftSurface ?? "document", - 600 - threadId: target.threadId ?? createClientId(), - 601 - messageId: createClientId(), - 602 - placement: - 603 - target.placement ?? commentDraftPlacementFromActiveElement(), - 604 - }); - 605 - }, - 606 - closeCommentDraft: () => setDraftTarget(null), - 607 - draftTarget, - 611 + openCommentDraft, - 612 + closeCommentDraft, - 608 613 askAgent, - 609 - softwareMapFocusRequest, - 610 614 openSoftwareMapElement, - 611 615 openTraceSession, - 612 616 saveComment, - | ... - 614 618 deleteComment, - 615 619 deleteCommentMessage, - 616 620 setCommentResolved, - 617 - resolvedCommentThreads, - 618 - submissionOutcome, - 619 621 deleteLocalComment, - 620 - commentsForTarget, - 621 - commentsForAnchor, - 622 - lineCommentsForAnchor, - 623 622 createAnchorCommentTarget, - 624 - createAstLineCommentTarget: createAstLineCommentTargetBound, - 625 623 }), - 626 624 [ - 625 + askAgent, - 627 626 blurThread, - 628 627 clearThreadFocusRequest, - 629 - commentThreads, - 630 - allCommentThreads, - 631 - askAgent, - 632 - commentsForAnchor, - 633 - commentsForTarget, - 628 + closeCommentDraft, - 634 629 createAnchorCommentTarget, - 635 - createAstLineCommentTargetBound, - 636 630 deleteComment, - 637 631 deleteCommentMessage, - 638 632 deleteLocalComment, - 639 - draftTarget, - 640 - focusedThreadId, - 633 + dismissReview, - 641 634 focusThread, - 642 - historicalRevision, - 643 - lineCommentsForAnchor, - 644 635 listVersions, - 636 + openCommentDraft, - 645 637 openSoftwareMapElement, - 646 638 openTraceSession, - 647 - pendingCommentCount, - 648 - resolvedBaseRef, - 649 - resolvedHeadRef, - 650 639 saveComment, - 651 - session, - 640 + setCommentResolved, - 652 641 softwareMapEnabled, - 653 - setCommentResolved, - 654 - resolvedCommentThreads, - 655 - submissionOutcome, - 656 - softwareMapFocusRequest, - 657 642 submitPendingComments, - 658 - dismissReview, - 659 - threadFocusRequest, - 660 643 updateComment, - 661 644 ], - 662 645 ); - 646 + - 647 + const state = useMemo( - 648 + () => ({ - 649 + historicalRevision, - 650 + resolvedBaseRef, - 651 + resolvedHeadRef, - 652 + focusedThreadId, - 653 + threadFocusRequest, - 654 + commentThreads, - 655 + allCommentThreads, - 656 + resolvedCommentThreads, - 657 + pendingCommentCount, - 658 + draftTarget, - 659 + softwareMapFocusRequest, - 660 + submissionOutcome, - 661 + commentsForTarget, - 662 + commentsForAnchor, - 663 + lineCommentsForAnchor, - 664 + createAstLineCommentTarget: createAstLineCommentTargetBound, - 665 + }), - 666 + [ - 667 + allCommentThreads, - 668 + commentThreads, - 669 + commentsForAnchor, - 670 + commentsForTarget, - 671 + createAstLineCommentTargetBound, - 672 + draftTarget, - 673 + focusedThreadId, - 674 + historicalRevision, - 675 + lineCommentsForAnchor, - 676 + pendingCommentCount, - 677 + resolvedBaseRef, - 678 + resolvedCommentThreads, - 679 + resolvedHeadRef, - 680 + softwareMapFocusRequest, - 681 + submissionOutcome, - 682 + threadFocusRequest, - 683 + ], - 684 + ); - 663 685 - 664 686 return ( - 665 - {children} - 687 + - 688 + - 689 + {children} - 690 + - 691 + - 666 692 ); - 667 693 } - 668 694 - | ... - 915 941 console.error(cause instanceof Error ? cause : new Error(String(cause))); - 916 942 } - 917 943 - 918 - export function useReview(): ReviewContextValue { - 919 - const value = useContext(ReviewContext); - 944 + export function useReviewActions(): ReviewActionsValue { - 945 + const value = useContext(ReviewActionsContext); - 920 946 if (!value) - 921 947 throw new Error("Review components must render inside ReviewProvider"); - 922 948 return value; - 923 949 } - 950 + - 951 + export function useReviewState(): ReviewStateValue { - 952 + const value = useContext(ReviewStateContext); - 953 + if (!value) - 954 + throw new Error("Review components must render inside ReviewProvider"); - 955 + return value; - 956 + } - 957 + - 958 + /** Merged view for consumers that need both halves. Re-renders on state changes. */ - 959 + export function useReview(): ReviewContextValue { - 960 + const actions = useReviewActions(); - 961 + const state = useReviewState(); - 962 + return useMemo(() => ({ ...actions, ...state }), [actions, state]); - 963 + } diff --git a/examples/review/real/02-review-175/README.md b/examples/review/real/02-review-175/README.md index 5f79f55a1..d12339e71 100644 --- a/examples/review/real/02-review-175/README.md +++ b/examples/review/real/02-review-175/README.md @@ -2,7 +2,7 @@ Diff parser fix with a multi-statement paired replacement and enclosing function context. The neutral placeholder describes both versions; changed code remains present underneath it. The return object and closing function brace are additional context outside the U3 patch. -[Original PR](https://github.com/devdotfast/review/pull/175) · [Before](before.ts) · [After](after.ts) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/devdotfast/review/pull/175) · [Before](before.ts) · [After](after.ts) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. @@ -161,4 +161,4 @@ function parseReviewDiffFile(section: string): ReviewDiffFile | null { Context policy: distant return values are no longer required. Preserve enclosing signatures and closing delimiters; return boundaries require a change inside the -return expression. The updated case assertions and snapshot reflect this rule. +return expression. The updated case assertions reflect this rule. diff --git a/examples/review/real/02-review-175/review.snap b/examples/review/real/02-review-175/review.snap deleted file mode 100644 index ffe1c4a94..000000000 --- a/examples/review/real/02-review-175/review.snap +++ /dev/null @@ -1,40 +0,0 @@ ---- a/packages/progressive-review/src/review-diff-files.ts -+++ b/packages/progressive-review/src/review-diff-files.ts - | ... - 229 229 function parseReviewDiffFile(section: string): ReviewDiffFile | null { - | ... - 236 236 let additions = 0; - 237 237 let deletions = 0; - 238 238 - 239 + let inHunk = false; - 239 240 for (const line of lines) { - 241 + if (line.startsWith("@@")) { - 242 + inHunk = true; - 243 + continue; - 244 + } - 245 + if (!inHunk) { - 240 ~ const parsedOldPath = parseGitFileLine(line, "--- "); - 246 ~ const parsedOldPath = parseGitFileLine(line, "--- "); - 241 ~ const parsedNewPath = parseGitFileLine(line, "+++ "); - 247 ~ const parsedNewPath = parseGitFileLine(line, "+++ "); - 242 ~ if (parsedOldPath !== undefined) oldPath = parsedOldPath; - 248 ~ if (parsedOldPath !== undefined) oldPath = parsedOldPath; - | ... - 247 ~ if (line.startsWith("rename to ")) { - 253 ~ if (line.startsWith("rename to ")) { - 248 ~ renameTo = unquoteGitPath(line.slice("rename to ".length).trim()); - 254 ~ renameTo = unquoteGitPath(line.slice("rename to ".length).trim()); - 249 ~ } - 255 ~ } - 250 - if (line.startsWith("+") && !line.startsWith("+++ ")) additions += 1; - 251 - if (line.startsWith("-") && !line.startsWith("--- ")) deletions += 1; - 256 + continue; - 257 + } - 258 + if (line.startsWith("+")) additions += 1; - 259 + else if (line.startsWith("-")) deletions += 1; - 252 260 } - 253 261 - 254 262 const status = section.includes("\nnew file mode ") - | ... - 275 283 } - | ... diff --git a/examples/review/real/03-review-160/README.md b/examples/review/real/03-review-160/README.md index 0f7e5663c..3b1761107 100644 --- a/examples/review/real/03-review-160/README.md +++ b/examples/review/real/03-review-160/README.md @@ -2,7 +2,7 @@ A genuinely new file: head-only multiline imports and a supplied replacement for the export function body. Keep the multiline signature visible. No extra context is emitted because the whole file is added. -[Original PR](https://github.com/devdotfast/review/pull/160) · [Before](before.ts) · [After](after.ts) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/devdotfast/review/pull/160) · [Before](before.ts) · [After](after.ts) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. diff --git a/examples/review/real/03-review-160/review.snap b/examples/review/real/03-review-160/review.snap deleted file mode 100644 index c58c7d02e..000000000 --- a/examples/review/real/03-review-160/review.snap +++ /dev/null @@ -1,189 +0,0 @@ ---- a/packages/progressive-review/src/opencode-trace-export.ts -+++ b/packages/progressive-review/src/opencode-trace-export.ts - 1 + import { spawn } from "node:child_process"; - 2 + import { - 3 + closeSync, - 4 + mkdirSync, - 5 + openSync, - 6 + readFileSync, - 7 + renameSync, - 8 + rmSync, - 9 + writeFileSync, - 10 + } from "node:fs"; - 11 + import path from "node:path"; - 12 + - 13 + import { - 14 + type JsonObject, - 15 + type JsonValue, - 16 + jsonArray, - 17 + jsonObject, - 18 + parseJsonText, - 19 + } from "@dev.fast/review-protocol"; - 20 + - 21 + export const OPENCODE_SESSION_RECORD = "opencode_session"; - 22 + export const OPENCODE_MESSAGE_RECORD = "opencode_message"; - 23 + - 24 + /** OpenCode session ids are `ses_` followed by a sortable id. */ - 25 + export function isOpenCodeSessionId(sessionId: string): boolean { - 26 + return sessionId.startsWith("ses_"); - 27 + } - 28 + - 29 + /** - 30 + * OpenCode keeps sessions in its own database rather than a transcript file. - 31 + * `opencode export` renders one as JSON; this writes that as the JSONL trace - 32 + * the rest of the pipeline expects: one session header record, then one - 33 + * record per message with its parts stripped of provider metadata and inline - 34 + * file data. Returns null when OpenCode has no such session. - 35 + */ - 36 + export async function exportOpenCodeTrace(input: { - 37 + sessionId: string; - 38 + root: string; - 39 + }): Promise { - 40 + mkdirSync(input.root, { recursive: true }); - 41 + const destination = path.join(input.root, `${input.sessionId}.jsonl`); - 42 + const staging = `${destination}.tmp-${process.pid}`; - 43 + const raw = await runOpenCodeExport(input.sessionId, `${staging}.json`); - 44 + if (raw === null) return null; - 45 + const exported = jsonObject(parseJsonText(raw)); - 46 + const info = jsonObject(exported?.info); - 47 + if (!exported || !info) { - 48 + throw new Error( - 49 + `opencode export ${input.sessionId} returned no session info.`, - 50 + ); - 51 + } - 52 + const messages = jsonArray(exported.messages); - 53 + if (!messages) { - 54 + throw new Error( - 55 + `opencode export ${input.sessionId} returned no message list.`, - 56 + ); - 57 + } - 58 + const header: JsonObject = { - 59 + type: OPENCODE_SESSION_RECORD, - 60 + ...pick(info, ["id", "parentID", "directory", "title", "version", "time"]), - 61 + }; - 62 + const lines = [ - 63 + JSON.stringify(header), - 64 + ...messages.map((message) => JSON.stringify(traceMessageRecord(message))), - 65 + ]; - 66 + writeFileSync(staging, `${lines.join("\n")}\n`, "utf8"); - 67 + renameSync(staging, destination); - 68 + return destination; - 69 + } - 70 + - 71 + /** - 72 + * Runs `opencode export` with stdout on a file. OpenCode exits before a pipe - 73 + * drains, so a piped export is cut off at 128 KiB; a file descriptor is not. - 74 + * Resolves to the exported JSON, or null when OpenCode has no such session. - 75 + */ - 76 + function runOpenCodeExport( - 77 + sessionId: string, - 78 + stdoutPath: string, - 79 + ): Promise { - 80 + const fd = openSync(stdoutPath, "w"); - 81 + return new Promise((resolve, reject) => { - 82 + // `--pure` skips user plugins, so the Review trace plugin cannot fire - 83 + // hooks from inside the export it triggered. - 84 + const child = spawn("opencode", ["export", sessionId, "--pure"], { - 85 + stdio: ["ignore", fd, "pipe"], - 86 + windowsHide: true, - 87 + }); - 88 + let stderr = ""; - 89 + const stderrStream = child.stderr; - 90 + if (!stderrStream) { - 91 + throw new Error("opencode export did not open a stderr pipe."); - 92 + } - 93 + stderrStream.setEncoding("utf8"); - 94 + stderrStream.on("data", (chunk: string) => { - 95 + stderr += chunk; - 96 + }); - 97 + child.once("error", (error) => { - 98 + closeSync(fd); - 99 + rmSync(stdoutPath, { force: true }); - 100 + reject( - 101 + new Error(`opencode export ${sessionId} failed: ${error.message}`), - 102 + ); - 103 + }); - 104 + child.once("exit", (code) => { - 105 + closeSync(fd); - 106 + try { - 107 + if (code === 0) { - 108 + resolve(readFileSync(stdoutPath, "utf8")); - 109 + } else if (stderr.includes("Session not found")) { - 110 + resolve(null); - 111 + } else { - 112 + reject( - 113 + new Error( - 114 + `opencode export ${sessionId} exited with ${code}: ${stderr.trim()}`, - 115 + ), - 116 + ); - 117 + } - 118 + } finally { - 119 + rmSync(stdoutPath, { force: true }); - 120 + } - 121 + }); - 122 + }); - 123 + } - 124 + - 125 + function traceMessageRecord(message: JsonValue) { - 126 + const record = jsonObject(message); - 127 + const info = jsonObject(record?.info); - 128 + if (!record || !info) { - 129 + throw new Error("opencode export returned a message without info."); - 130 + } - 131 + const parts = jsonArray(record.parts) ?? []; - 132 + const trace: JsonObject = { - 133 + type: OPENCODE_MESSAGE_RECORD, - 134 + info: pick(info, [ - 135 + "id", - 136 + "sessionID", - 137 + "role", - 138 + "time", - 139 + "error", - 140 + "agent", - 141 + "modelID", - 142 + "providerID", - 143 + ]), - 144 + parts: parts.flatMap((part) => { - 145 + const object = jsonObject(part); - 146 + return object ? [tracePart(object)] : []; - 147 + }), - 148 + }; - 149 + return trace; - 150 + } - 151 + - 152 + function tracePart(part: JsonObject) { - 153 + const common = pick(part, ["id", "type"]); - 154 + switch (part.type) { - 155 + case "text": - 156 + return { ...common, ...pick(part, ["text", "ignored", "synthetic"]) }; - 157 + case "reasoning": - 158 + return { ...common, ...pick(part, ["text", "time"]) }; - 159 + case "tool": { - 160 + const state = jsonObject(part.state); - 161 + return { - 162 + ...common, - 163 + ...pick(part, ["tool", "callID"]), - 164 + state: state - 165 + ? pick(state, ["status", "input", "output", "error", "title", "time"]) - 166 + : {}, - 167 + }; - 168 + } - 169 + case "file": - 170 + return { ...common, ...pick(part, ["mime", "filename"]) }; - 171 + case "patch": - 172 + return { ...common, ...pick(part, ["hash", "files"]) }; - 173 + case "compaction": - 174 + return { ...common, ...pick(part, ["auto"]) }; - 175 + default: - 176 + return common; - 177 + } - 178 + } - 179 + - 180 + function pick(value: JsonObject, keys: readonly string[]) { - 181 + const picked: JsonObject = {}; - 182 + for (const key of keys) { - 183 + const entry = value[key]; - 184 + if (entry !== undefined) picked[key] = entry; - 185 + } - 186 + return picked; - 187 + } diff --git a/examples/review/real/04-flask-6096/README.md b/examples/review/real/04-flask-6096/README.md index 0c59de91c..081c5a448 100644 --- a/examples/review/real/04-flask-6096/README.md +++ b/examples/review/real/04-flask-6096/README.md @@ -2,7 +2,7 @@ Changed paired imports plus a one-to-many statement replacement for IPv6 host/port parsing. Extra context preserves the class and full multiline method signature, leaving the long docstring and unrelated setup hidden. -[Original PR](https://github.com/pallets/flask/pull/6096) · [Before](before.py) · [After](after.py) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/pallets/flask/pull/6096) · [Before](before.py) · [After](after.py) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. diff --git a/examples/review/real/04-flask-6096/review.snap b/examples/review/real/04-flask-6096/review.snap deleted file mode 100644 index 516e13145..000000000 --- a/examples/review/real/04-flask-6096/review.snap +++ /dev/null @@ -1,53 +0,0 @@ ---- a/src/flask/app.py -+++ b/src/flask/app.py - | ... - 12 12 from itertools import chain - 13 13 from types import TracebackType - 14 14 from urllib.parse import quote as _url_quote - 15 + from urllib.parse import urlsplit - 15 16 - 16 17 import click - 17 18 from werkzeug.datastructures import Headers - | ... - 109 110 class Flask(App): - | ... - 632 633 def run( - 633 634 self, - 634 635 host: str | None = None, - 635 636 port: int | None = None, - 636 637 debug: bool | None = None, - 637 638 load_dotenv: bool = True, - 638 639 **options: t.Any, - 639 640 ) -> None: - | ... - 721 722 sn_host = sn_port = None - 722 723 - 723 724 if server_name: - 724 - sn_host, _, sn_port = server_name.partition(":") - 725 + server_url = urlsplit(f"//{server_name}") - 726 + sn_host = server_url.hostname - 727 + sn_port = server_url.port - 725 728 - 726 729 if not host: - 727 730 if sn_host: - | ... - 731 734 - 732 735 if port or port == 0: - 733 736 port = int(port) - 734 - elif sn_port: - 735 - port = int(sn_port) - 737 + elif sn_port is not None: - 738 + port = sn_port - 736 739 else: - 737 740 port = 5000 - 738 741 - | ... - 745 748 from werkzeug.serving import run_simple - 746 749 - 747 750 try: - 748 - run_simple(t.cast(str, host), port, self, **options) - 751 + run_simple(host, port, self, **options) - 749 752 finally: - 750 753 # reset the first request information if the development server - 751 754 # reset normally. This makes it possible to restart the server - | ... diff --git a/examples/review/real/05-flask-6133/README.md b/examples/review/real/05-flask-6133/README.md index da6f8ff0a..016f9ef59 100644 --- a/examples/review/real/05-flask-6133/README.md +++ b/examples/review/real/05-flask-6133/README.md @@ -2,7 +2,7 @@ New decorated method: fold its added docstring, preserving the decorator, complete signature and return expression. The enclosing class is paired extra context; added lines are already visible in the diff. -[Original PR](https://github.com/pallets/flask/pull/6133) · [Before](before.py) · [After](after.py) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/pallets/flask/pull/6133) · [Before](before.py) · [After](after.py) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. diff --git a/examples/review/real/05-flask-6133/review.snap b/examples/review/real/05-flask-6133/review.snap deleted file mode 100644 index 14fd3e227..000000000 --- a/examples/review/real/05-flask-6133/review.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- a/src/flask/sansio/scaffold.py -+++ b/src/flask/sansio/scaffold.py - | ... - 52 52 class Scaffold: - | ... - 300 300 """ - 301 301 return self._method_route("GET", rule, options) - 302 + - 303 + @setupmethod - 304 + def query(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]: - 305 + """Shortcut for :meth:`route` with ``methods=["QUERY"]``. - 306 + - 307 + .. versionadded:: 3.2 - 308 + """ - 309 + return self._method_route("QUERY", rule, options) - 302 310 - 303 311 @setupmethod - 304 312 def post(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]: - | ... diff --git a/examples/review/real/06-requests-6965/README.md b/examples/review/real/06-requests-6965/README.md index ff6e7ef57..1f8765e4e 100644 --- a/examples/review/real/06-requests-6965/README.md +++ b/examples/review/real/06-requests-6965/README.md @@ -2,7 +2,7 @@ A deleted legacy compatibility block becomes a base-only fold. The replacement hostname expression stays visible on head. Show the enclosing function and outer try/import context, without inventing an opposite range for the deleted block. -[Original PR](https://github.com/psf/requests/pull/6965) · [Before](before.py) · [After](after.py) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/psf/requests/pull/6965) · [Before](before.py) · [After](after.py) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. @@ -93,4 +93,4 @@ def get_netrc_auth(url, raise_errors=False): Context policy: distant return values are no longer required. Preserve enclosing signatures and closing delimiters; return boundaries require a change inside the -return expression. The updated case assertions and snapshot reflect this rule. +return expression. The updated case assertions reflect this rule. diff --git a/examples/review/real/06-requests-6965/review.snap b/examples/review/real/06-requests-6965/review.snap deleted file mode 100644 index c767c7a6f..000000000 --- a/examples/review/real/06-requests-6965/review.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- a/src/requests/utils.py -+++ b/src/requests/utils.py - | ... - 207 207 def get_netrc_auth(url, raise_errors=False): - | ... - 237 237 - 238 238 ri = urlparse(url) - 239 - - 240 - # Strip port numbers from netloc. This weird `if...encode`` dance is - 241 - # used for Python 3.2, which doesn't support unicode literals. - 242 - splitstr = b":" - 243 - if isinstance(url, str): - 244 - splitstr = splitstr.decode("ascii") - 245 - host = ri.netloc.split(splitstr)[0] - 239 + host = ri.hostname - 246 240 - 247 241 try: - 248 242 _netrc = netrc(netrc_path).authenticators(host) - | ... diff --git a/examples/review/real/07-ripgrep-3487/README.md b/examples/review/real/07-ripgrep-3487/README.md index b80474fbd..e3d3e38f2 100644 --- a/examples/review/real/07-ripgrep-3487/README.md +++ b/examples/review/real/07-ripgrep-3487/README.md @@ -2,7 +2,7 @@ One match arm changes from a single expression to a braced block. Pair those regions despite different syntax shapes; leave adjacent match arms visible. The unchanged run signature is extra context. This pairing is a manual target, not a claim that Difftastic already identifies a match-arm node. -[Original PR](https://github.com/BurntSushi/ripgrep/pull/3487) · [Before](before.rs) · [After](after.rs) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/BurntSushi/ripgrep/pull/3487) · [Before](before.rs) · [After](after.rs) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. @@ -105,4 +105,4 @@ fn run(result: crate::flags::ParseResult) -> anyhow::Result { Context policy: distant return values are no longer required. Preserve enclosing signatures and closing delimiters; return boundaries require a change inside the -return expression. The updated case assertions and snapshot reflect this rule. +return expression. The updated case assertions reflect this rule. diff --git a/examples/review/real/07-ripgrep-3487/review.snap b/examples/review/real/07-ripgrep-3487/review.snap deleted file mode 100644 index 8013b46fd..000000000 --- a/examples/review/real/07-ripgrep-3487/review.snap +++ /dev/null @@ -1,41 +0,0 @@ ---- a/crates/core/main.rs -+++ b/crates/core/main.rs - | ... - 13 13 - 14 14 mod flags; - 15 15 mod haystack; - 16 + mod index; - 16 17 mod logger; - 17 18 mod search; - 18 19 - | ... - 68 69 /// The main entry point for ripgrep. - 69 70 /// - 70 71 /// The given parse result determines ripgrep's behavior. The parse - 71 72 /// result should be the result of parsing CLI arguments in a low level - 72 73 /// representation, and then followed by an attempt to convert them into a - 73 74 /// higher level representation. The higher level representation has some nicer - 74 75 /// abstractions, for example, instead of representing the `-g/--glob` flag - 75 76 /// as a `Vec` (as in the low level representation), the globs are - 76 77 /// converted into a single matcher. - 77 78 fn run(result: crate::flags::ParseResult) -> anyhow::Result { - | ... - 84 85 }; - 85 86 let matched = match args.mode() { - 86 87 Mode::Search(_) if !args.matches_possible() => false, - 88 + Mode::Search(mode) if args.index() > 0 => index::read(&args, mode)?, - 87 89 Mode::Search(mode) if args.threads() == 1 => search(&args, mode)?, - 88 90 Mode::Search(mode) => search_parallel(&args, mode)?, - 89 - Mode::Index(_) => anyhow::bail!("indexing not yet implemented"), - 91 + Mode::Index(_) => { - 92 + index::write(&args)?; - 93 + return Ok(ExitCode::from(0)); - 94 + } - 90 95 Mode::Files if args.threads() == 1 => files(&args)?, - 91 96 Mode::Files => files_parallel(&args)?, - 92 97 Mode::Types => return types(&args), - | ... - 94 99 }; - | ... - 102 107 } - | ... diff --git a/examples/review/real/08-ripgrep-3496/README.md b/examples/review/real/08-ripgrep-3496/README.md index 695812615..25979cdc9 100644 --- a/examples/review/real/08-ripgrep-3496/README.md +++ b/examples/review/real/08-ripgrep-3496/README.md @@ -2,7 +2,7 @@ Production changes inside an iterator plus a new regression test. Paired use groups, an added test-body fold with its attribute/signature retained, and file-level context for both the iterator and the distant test module. One annotation model covers both regions. -[Original PR](https://github.com/BurntSushi/ripgrep/pull/3496) · [Before](before.rs) · [After](after.rs) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/BurntSushi/ripgrep/pull/3496) · [Before](before.rs) · [After](after.rs) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. diff --git a/examples/review/real/08-ripgrep-3496/review.snap b/examples/review/real/08-ripgrep-3496/review.snap deleted file mode 100644 index d8b91045f..000000000 --- a/examples/review/real/08-ripgrep-3496/review.snap +++ /dev/null @@ -1,104 +0,0 @@ ---- a/crates/ignore/src/walk.rs -+++ b/crates/ignore/src/walk.rs - | ... - 545 545 impl WalkBuilder { - | ... - 592 592 /// Build a new `Walk` iterator. - 593 593 pub fn build(&self) -> Walk { - | ... - 634 634 Walk { - | ... - 636 636 it: None, - 637 637 ig_root: ig_root.clone(), - 638 638 ig: ig_root.clone(), - 639 + max_depth: self.max_depth, - 639 640 max_filesize: self.max_filesize, - 640 641 skip: self.skip.clone(), - 641 642 filter: self.filter.clone(), - 642 643 } - 643 644 } - | ... -1108 1109 } - | ... -1110 1111 /// Walk is a recursive directory iterator over file paths in one or more -1111 1112 /// directories. -1112 1113 /// -1113 1114 /// Only file and directory paths matching the rules are returned. By default, -1114 1115 /// ignore files like `.gitignore` are respected. The precise matching rules -1115 1116 /// and precedence is explained in the documentation for `WalkBuilder`. -1116 1117 pub struct Walk { - | ... -1118 1119 it: Option, -1119 1120 ig_root: Ignore, -1120 1121 ig: Ignore, - 1122 + max_depth: Option, -1121 1123 max_filesize: Option, -1122 1124 skip: Option>, -1123 1125 filter: Option, -1124 1126 } - | ... -1184 1186 impl Iterator for Walk { - | ... -1187 1189 #[inline(always)] -1188 1190 fn next(&mut self) -> Option> { -1189 1191 loop { - | ... -1214 1216 match ev { - | ... -1228 1230 self.it.as_mut().unwrap().it.skip_current_dir(); -1229 1231 // Still need to push this on the stack because -1230 1232 // we'll get a WalkEvent::Exit event for this dir. -1231 - // We don't care if it errors though. -1232 - let (igtmp, _) = self.ig.add_child(ent.path()); - 1233 + // Its ignore files cannot apply to any visited entry. - 1234 + let (igtmp, _) = - 1235 + self.ig.add_child_with_entries(ent.path(), &[]); -1233 1236 self.ig = igtmp; -1234 1237 continue; -1235 1238 } -1236 - let (igtmp, err) = self.ig.add_child(ent.path()); - 1239 + let (igtmp, err) = if self.max_depth == Some(ent.depth()) { - 1240 + self.ig.add_child_with_entries(ent.path(), &[]) - 1241 + } else { - 1242 + self.ig.add_child(ent.path()) - 1243 + }; -1237 1244 self.ig = igtmp; -1238 1245 ent.err = err; -1239 1246 return Some(Ok(ent)); - | ... -1252 1259 } -1253 1260 } -1254 1261 } -1255 1262 } - | ... -2186 2193 #[cfg(test)] -2187 2194 mod tests { - | ... -2427 2434 ); -2428 2435 } - 2436 + - 2437 + #[test] - 2438 + fn max_depth_does_not_load_unreachable_ignore_files() { - 2439 + let td = tmpdir(); - 2440 + let leaf = td.path().join("leaf"); - 2441 + mkdirp(&leaf); - 2442 + wfile(leaf.join(".ignore"), "{invalid\n"); - 2443 + - 2444 + let mut builder = WalkBuilder::new(td.path()); - 2445 + builder.max_depth(Some(1)); - 2446 + let entry = builder - 2447 + .build() - 2448 + .find_map(|result| { - 2449 + let entry = result.unwrap(); - 2450 + (entry.path() == leaf).then_some(entry) - 2451 + }) - 2452 + .unwrap(); - 2453 + - 2454 + assert!(entry.error().is_none()); - 2455 + assert_paths(td.path(), &builder, &["leaf"]); - 2456 + } -2429 2457 -2430 2458 #[test] -2431 2459 fn min_depth() { - | ... -2712 2740 } diff --git a/examples/review/real/09-cli-11038/README.md b/examples/review/real/09-cli-11038/README.md index 203280020..b73347b1a 100644 --- a/examples/review/real/09-cli-11038/README.md +++ b/examples/review/real/09-cli-11038/README.md @@ -2,7 +2,7 @@ A paired fallback block grows nested branches and explanatory comments. Tabs count as one byte, not display-width columns. Preserve the receiver-method signature as extra context and the existing return statement outside the fold. -[Original PR](https://github.com/cli/cli/pull/11038) · [Before](before.go) · [After](after.go) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/cli/cli/pull/11038) · [Before](before.go) · [After](after.go) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. @@ -126,4 +126,4 @@ func (c *AuthConfig) ActiveToken(hostname string) (string, string) { Context policy: distant return values are no longer required. Preserve enclosing signatures and closing delimiters; return boundaries require a change inside the -return expression. The updated case assertions and snapshot reflect this rule. +return expression. The updated case assertions reflect this rule. diff --git a/examples/review/real/09-cli-11038/review.snap b/examples/review/real/09-cli-11038/review.snap deleted file mode 100644 index 64b238e2f..000000000 --- a/examples/review/real/09-cli-11038/review.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- a/internal/config/config.go -+++ b/internal/config/config.go - | ... - 231 231 func (c *AuthConfig) ActiveToken(hostname string) (string, string) { - | ... - 234 234 } - 235 235 token, source := ghauth.TokenFromEnvOrConfig(hostname) - 236 236 if token == "" { - 237 + var user string - 237 238 var err error - 239 + if user, err = c.ActiveUser(hostname); err == nil { - 240 + token, err = c.TokenFromKeyringForUser(hostname, user) - 241 + } - 242 + if err != nil { - 243 + // We should generally be able to find a token for the active user, - 244 + // but in some cases such as if the keyring was set up in a very old - 245 + // version of the CLI, it may only have a unkeyed token, so fallback - 246 + // to it. - 238 ~ token, err = c.TokenFromKeyring(hostname) - 247 ~ token, err = c.TokenFromKeyring(hostname) - 248 + } - 239 249 if err == nil { - 240 250 source = "keyring" - 241 251 } - | ... - 244 254 } - | ... diff --git a/examples/review/real/10-go-git-1492/README.md b/examples/review/real/10-go-git-1492/README.md index c8aaffba8..97f8a0624 100644 --- a/examples/review/real/10-go-git-1492/README.md +++ b/examples/review/real/10-go-git-1492/README.md @@ -2,7 +2,7 @@ The both-have-nodes branch changes from sequential guards to a nested switch. Pair several statements while retaining other outer cases. Extra context shows the complete function signature, enclosing loop/switch openers and closing braces. The success return is already in the diff. -[Original PR](https://github.com/go-git/go-git/pull/1492) · [Before](before.go) · [After](after.go) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/go-git/go-git/pull/1492) · [Before](before.go) · [After](after.go) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. diff --git a/examples/review/real/10-go-git-1492/review.snap b/examples/review/real/10-go-git-1492/review.snap deleted file mode 100644 index a3516c631..000000000 --- a/examples/review/real/10-go-git-1492/review.snap +++ /dev/null @@ -1,84 +0,0 @@ ---- a/utils/merkletrie/difftree.go -+++ b/utils/merkletrie/difftree.go - | ... - 277 277 func DiffTreeContext(ctx context.Context, fromTree, toTree noder.Noder, - 278 278 hashEqual noder.Equal) (Changes, error) { - | ... - 286 286 for { - | ... - 296 296 switch r := ii.remaining(); r { - 297 297 case noMoreNoders: - 298 298 return ret, nil - 299 299 case onlyFromRemains: - 300 + if !from.Skip() { - 300 ~ if err = ret.AddRecursiveDelete(from); err != nil { - 301 ~ if err = ret.AddRecursiveDelete(from); err != nil { - 301 ~ return nil, err - 302 ~ return nil, err - 302 ~ } - 303 ~ } - 304 + } - 303 305 if err = ii.nextFrom(); err != nil { - 304 306 return nil, err - 305 307 } - 306 308 case onlyToRemains: - 307 - if to.Skip() { - 308 - if err = ret.AddRecursiveDelete(to); err != nil { - 309 - return nil, err - 310 - } - 311 - } else { - 309 + if !to.Skip() { - 312 310 if err = ret.AddRecursiveInsert(to); err != nil { - 313 311 return nil, err - 314 312 } - | ... - 317 315 return nil, err - 318 316 } - 319 317 case bothHaveNodes: - 320 - if from.Skip() { - 321 - if err = ret.AddRecursiveDelete(from); err != nil { - 322 - return nil, err - 323 - } - 324 - if err := ii.nextBoth(); err != nil { - 325 - return nil, err - 326 - } - 327 - break - 328 - } - 329 - if to.Skip() { - 330 - if err = ret.AddRecursiveDelete(to); err != nil { - 331 - return nil, err - 332 - } - 333 - if err := ii.nextBoth(); err != nil { - 334 - return nil, err - 335 - } - 336 - break - 337 - } - 338 - - 339 - if err = diffNodes(&ret, ii); err != nil { - 318 + var err error - 319 + switch { - 320 + case from.Skip(): - 321 + if from.Name() == to.Name() { - 322 + err = ii.nextBoth() - 323 + } else { - 324 + err = ii.nextFrom() - 325 + } - 326 + case to.Skip(): - 327 + if from.Name() == to.Name() { - 328 + err = ii.nextBoth() - 329 + } else { - 330 + err = ii.nextTo() - 331 + } - 332 + default: - 333 + err = diffNodes(&ret, ii) - 334 + } - 335 + - 336 + if err != nil { - 340 337 return nil, err - 341 338 } - 342 339 default: - | ... - 344 341 } - 345 342 } - 346 343 } - | ... diff --git a/examples/review/real/11-flask-5526/README.md b/examples/review/real/11-flask-5526/README.md index bfa37725a..80855b301 100644 --- a/examples/review/real/11-flask-5526/README.md +++ b/examples/review/real/11-flask-5526/README.md @@ -2,7 +2,7 @@ An existing method gains a typed encoding parameter; its signature grows from one line to three. Pair the docstring and setup as one fold, keeping complete signatures and final return expressions visible. The enclosing class is extra context; signatures and returns are already in the diff. -[Original PR](https://github.com/pallets/flask/pull/5526) · [Before](before.py) · [After](after.py) · [Git patch](change.patch) · [Generated review golden](review.snap) · [Expected annotations](expected.json) · [Provenance](provenance.json) +[Original PR](https://github.com/pallets/flask/pull/5526) · [Before](before.py) · [After](after.py) · [Git patch](change.patch) · [Expected annotations](expected.json) · [Provenance](provenance.json) Annotations are manually selected targets, not recorded matcher output. Coordinates below are zero-based UTF-8 byte positions with exclusive ends. diff --git a/examples/review/real/11-flask-5526/review.snap b/examples/review/real/11-flask-5526/review.snap deleted file mode 100644 index 00db6808a..000000000 --- a/examples/review/real/11-flask-5526/review.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- a/src/flask/blueprints.py -+++ b/src/flask/blueprints.py - | ... - 18 18 class Blueprint(SansioBlueprint): - | ... - 101 101 t.cast(str, self.static_folder), filename, max_age=max_age - 102 102 ) - 103 103 - 104 - def open_resource(self, resource: str, mode: str = "rb") -> t.IO[t.AnyStr]: - 105 - """Open a resource file relative to :attr:`root_path` for - 106 - reading. - 107 - - 108 - For example, if the file ``schema.sql`` is next to the file - 109 - ``app.py`` where the ``Flask`` app is defined, it can be opened - 110 - with: - 111 - - 112 - .. code-block:: python - 113 - - 114 - with app.open_resource("schema.sql") as f: - 115 - conn.executescript(f.read()) - 104 + def open_resource( - 105 + self, resource: str, mode: str = "rb", encoding: str | None = "utf-8" - 106 + ) -> t.IO[t.AnyStr]: - 107 + """Open a resource file relative to :attr:`root_path` for reading. The - 108 + blueprint-relative equivalent of the app's :meth:`~.Flask.open_resource` - 109 + method. - 110 + - 111 + :param resource: Path to the resource relative to :attr:`root_path`. - 112 + :param mode: Open the file in this mode. Only reading is supported, - 113 + valid values are ``"r"`` (or ``"rt"``) and ``"rb"``. - 114 + :param encoding: Open the file with this encoding when opening in text - 115 + mode. This is ignored when opening in binary mode. - 116 116 - 117 - :param resource: Path to the resource relative to - 118 - :attr:`root_path`. - 119 - :param mode: Open the file in this mode. Only reading is - 120 - supported, valid values are "r" (or "rt") and "rb". - 121 - - 122 - Note this is a duplicate of the same method in the Flask - 123 - class. - 124 - - 125 - """ - 117 + .. versionchanged:: 3.1 - 118 + Added the ``encoding`` parameter. - 119 + """ - 126 120 if mode not in {"r", "rt", "rb"}: - 127 121 raise ValueError("Resources can only be opened for reading.") - 128 122 - 129 - return open(os.path.join(self.root_path, resource), mode) - 123 + path = os.path.join(self.root_path, resource) - 124 + - 125 + if mode == "rb": - 126 + return open(path, mode) - 127 + - 128 + return open(path, mode, encoding=encoding) diff --git a/examples/review/viewer/.gitignore b/examples/review/viewer/.gitignore deleted file mode 100644 index 8fce60300..000000000 --- a/examples/review/viewer/.gitignore +++ /dev/null @@ -1 +0,0 @@ -data/ diff --git a/examples/review/viewer/README.md b/examples/review/viewer/README.md deleted file mode 100644 index 008c05c02..000000000 --- a/examples/review/viewer/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Fixture viewer - -Build the CLI, capture its streams, then serve the static files: - -```sh -cargo build --locked -python3 examples/review/viewer/build.py -python3 -m http.server 4176 --bind 127.0.0.1 --directory examples/review/viewer -``` - -Open http://127.0.0.1:4176/. Git patches and CLI NDJSON captures are fixture data. -Re-run the builder after backend changes. No diff server is required. - -[CLI stream contract](../../../docs/streaming.md). diff --git a/examples/review/viewer/app.js b/examples/review/viewer/app.js deleted file mode 100644 index 983daefb2..000000000 --- a/examples/review/viewer/app.js +++ /dev/null @@ -1,419 +0,0 @@ -import { deriveLayout } from "./layout.mjs"; -import { diffEvents } from "./stream.mjs"; -import { foldLine } from "./inline-folds.mjs"; -const $ = (id) => document.getElementById(id); -const escape = (s) => - String(s) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """); -const encoder = new TextEncoder(), - decoder = new TextDecoder(); -let activeRequest; -let inlineFolds = [[], []]; -let viewFolds = []; -let fixtures = [], - current, - source, - tokens, - closed = new Set(), - expanded = new Set(), - generation = 0; -const reviewed = new Set( - JSON.parse(localStorage.getItem("diff-lab-reviewed") || "[]"), -); -const rowsOf = (range) => { - const end = range.end.line + (range.end.byte_column > 0 ? 1 : 0); - return Array.from( - { length: Math.max(0, end - range.start.line) }, - (_, i) => range.start.line + i, - ); -}; -// One UI toggle for each match; the domain keeps annotations on each side. -function foldCandidates(domain) { - const left = domain.lhs_folds.map((fold) => ({ - ...fold, - regions: fold.match_kind.Unchanged - ? { Paired: { lhs: fold.range, rhs: fold.match_kind.Unchanged.opposite } } - : { Deleted: fold.range }, - })); - const right = domain.rhs_folds - .filter((fold) => fold.match_kind === "Novel") - .map((fold) => ({ ...fold, regions: { Added: fold.range } })); - return [...left, ...right]; -} -function regions(c) { - if (c.Paired) return [c.Paired.lhs, c.Paired.rhs]; - return c.Added ? [null, c.Added] : [c.Deleted, null]; -} -function highlight(side, row, emphasize, from = 0, to = Infinity) { - const bytes = encoder.encode(source[side][row] ?? ""); - let end = from, - html = ""; - to = Math.min(to, bytes.length); - for (const token of tokens[side].get(row) || []) { - const p = token.pos, - start = Math.max(end, p.start_col), - stop = Math.min(to, p.end_col); - if (stop <= start) continue; - html += escape(decoder.decode(bytes.slice(end, start))); - const [kind, value] = Object.entries(token.kind)[0]; - const atom = value.highlight?.Atom; - const type = - typeof atom === "object" - ? "string" - : { Keyword: "keyword", Type: "type", Comment: "comment" }[atom] || ""; - const novel = emphasize && (kind === "Novel" || kind === "NovelWord"); - html += `${escape(decoder.decode(bytes.slice(start, stop)))}`; - end = stop; - } - return html + escape(decoder.decode(bytes.slice(end, to))); -} -function sourceLine(side, row, semantic) { - if (!semantic) return highlight(side, row, false); - return foldLine( - encoder.encode(source[side][row] ?? "").length, - row, - inlineFolds[side], - closed, - ) - .map((part) => { - if (part.text) return highlight(side, row, true, ...part.text); - const label = viewFolds[part.fold].placeholder; - return ``; - }) - .join(""); -} -function line(l, r, kind, { semantic = false } = {}) { - const side = r == null ? 0 : 1, - row = r ?? l; - const sign = { add: "+", del: "−", reindent: "↳" }[kind] || " "; - const title = - kind === "reindent" - ? `Matched syntax. Base: ${source[0][l]}\nHead: ${source[1][r]}` - : ""; - return `
${l == null ? "" : l + 1}${r == null ? "" : r + 1}${sign}${sourceLine(side, row, semantic)}
`; -} -function renderGit() { - let l = 0, - r = 0, - active = false, - html = ""; - const patch = $("all").checked ? current.full_patch : current.patch; - for (const text of patch.split("\n")) { - const h = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (h) { - l = +h[1] - 1; - r = +h[2] - 1; - active = true; - html += `
${escape(text)}
`; - continue; - } - if (!active) continue; - if (text.startsWith(" ")) html += line(l++, r++, ""); - else if (text.startsWith("-")) html += line(l++, null, "del"); - else if (text.startsWith("+")) html += line(null, r++, "add"); - } - $("git").innerHTML = html || '
No textual changes
'; -} -function visibleRows(domain, layout, showAll) { - const selected = layout.baseline.map((rows) => new Set(rows)); - if (showAll) { - source.forEach((lines, side) => - lines.forEach((_, row) => selected[side].add(row)), - ); - } - viewFolds.forEach((fold, index) => { - if (!expanded.has(index)) return; - regions(fold.regions).forEach((range, side) => { - if (!range) return; - for (const row of rowsOf(range)) selected[side].add(row); - }); - }); - return selected; -} - -function indexFolds(folds) { - // Keep every enclosing membership, including nested folds. - const membership = [new Map(), new Map()]; - const inline = new Set(); - const inlineRanges = [[], []]; - folds.forEach((fold, index) => { - const sides = regions(fold.regions); - const isInline = - !fold.tags.includes("import") && - sides.some( - (range) => - range && (range.start.byte_column > 0 || range.end.byte_column > 0), - ); - if (isInline) inline.add(index); - sides.forEach((range, side) => { - if (!range) return; - if (isInline) inlineRanges[side].push({ id: index, range }); - for (const row of rowsOf(range)) { - if (!membership[side].has(row)) membership[side].set(row, []); - membership[side].get(row).push(index); - } - }); - }); - return { membership, inline, inlineRanges }; -} - -function coversWholeRow(range, row) { - return row != null && range && row > range.start.line && row < range.end.line; -} - -function foldHeader(fold, index) { - const description = regions(fold.regions) - .map((range, side) => - range - ? `${side ? "head" : "base"} ${range.start.line + 1}–${range.end.line + (range.end.byte_column > 0 ? 1 : 0)}` - : null, - ) - .filter(Boolean) - .join(" ↔ "); - return `
${description}
`; -} - -function renderReview() { - const { domain, layout } = current; - const selected = visibleRows(domain, layout, $("all").checked); - const { membership, inline, inlineRanges } = indexFolds(viewFolds); - inlineFolds = inlineRanges; - const size = (index) => - regions(viewFolds[index].regions).reduce( - (n, r) => n + (r ? rowsOf(r).length : 0), - 0, - ); - const reindented = new Set(layout.reindented.map((pair) => pair.join(":"))); - const seenFolds = new Set(); - let html = "", - gap = false, - removed = "", - added = ""; - const flush = () => { - html += removed + added; - removed = ""; - added = ""; - }; - for (let [l, r] of layout.rows) { - l = selected[0].has(l) ? l : null; - r = selected[1].has(r) ? r : null; - const folds = new Set( - [...(membership[0].get(l) || []), ...(membership[1].get(r) || [])].sort( - (a, b) => size(b) - size(a), - ), - ); - for (const index of folds) { - if ( - !membership[0].get(l)?.includes(index) && - !membership[1].get(r)?.includes(index) - ) - continue; - if (inline.has(index)) { - if (closed.has(index)) { - const [lhsRange, rhsRange] = regions(viewFolds[index].regions); - if (coversWholeRow(lhsRange, l)) l = null; - if (coversWholeRow(rhsRange, r)) r = null; - } - continue; - } - if (!seenFolds.has(index)) { - flush(); - if (gap) { - html += '
⋯ unchanged context
'; - gap = false; - } - seenFolds.add(index); - html += foldHeader(viewFolds[index], index); - } - if (closed.has(index)) { - if (membership[0].get(l)?.includes(index)) l = null; - if (membership[1].get(r)?.includes(index)) r = null; - } - } - if (l == null && r == null) { - if (!folds.size) { - flush(); - gap = true; - } - continue; - } - if (gap) { - flush(); - html += '
⋯ unchanged context
'; - gap = false; - } - if ( - l != null && - r != null && - (source[0][l] === source[1][r] || reindented.has(`${l}:${r}`)) - ) { - flush(); - html += line(l, r, reindented.has(`${l}:${r}`) ? "reindent" : "", { - semantic: true, - }); - } else { - if (l != null) removed += line(l, null, "del", { semantic: true }); - if (r != null) added += line(null, r, "add", { semantic: true }); - } - } - flush(); - if (gap) html += '
⋯ unchanged context
'; - $("review").classList.add("semantic"); - $("review").innerHTML = - html || '
No syntactic changes
'; - $("stats").textContent = - `${viewFolds.length} fold candidates · ${closed.size} collapsed`; -} -function navigation() { - $("cases").innerHTML = fixtures - .map( - (f, i) => - ``, - ) - .join(""); - $("progress").textContent = `${reviewed.size}/${fixtures.length}`; - $("reviewed").textContent = reviewed.has(current?.meta.id) - ? "✓ Reviewed" - : "Mark reviewed"; -} -function indexTokensByLine(positions) { - const result = new Map(); - for (const position of positions) { - const row = position.pos.line; - if (!result.has(row)) result.set(row, []); - result.get(row).push(position); - } - for (const rowTokens of result.values()) { - rowTokens.sort( - (a, b) => - a.pos.start_col - b.pos.start_col || a.pos.end_col - b.pos.end_col, - ); - } - return result; -} - -async function select(id) { - const request = ++generation; - activeRequest?.abort(); - activeRequest = new AbortController(); - const signal = activeRequest.signal; - $("status").textContent = "Requesting streamed diff…"; - const response = await fetch(`data/${encodeURIComponent(id)}.json`); - if (!response.ok) throw Error(`Unable to load ${id}`); - const data = await response.json(); - if (request !== generation) return; - let received = false; - for await (const event of diffEvents(data.request, signal)) { - if (request !== generation) return; - if (event.type === "file_error") throw Error(event.message); - if (event.type !== "file") continue; - received = true; - show({ ...data, domain: event.diff, layout: deriveLayout(event.diff) }); - $("status").textContent = "File received; waiting for stream completion…"; - } - if (!received) throw Error("No file returned"); - $("status").textContent = "Stream complete. Folds and review state stay in this browser."; -} -function show(data) { - current = data; - viewFolds = foldCandidates(data.domain); - expanded = new Set(); - closed = new Set( - viewFolds.flatMap((f, i) => - f.tags.includes("import") ? [i] : [], - ), - ); - source = [data.domain.lhs_src.Text, data.domain.rhs_src.Text].map( - (s) => s.split("\n"), - ); - tokens = [data.domain.lhs_positions, data.domain.rhs_positions].map( - indexTokensByLine, - ); - const m = data.meta; - $("repo").innerHTML = - `${escape(m.language)} / ${escape(m.repo)} #${m.pr} ↗`; - $("title").textContent = m.title; - $("path").textContent = m.path; - $("summary").textContent = m.summary; - history.replaceState(null, "", `#${m.id}`); - navigation(); - renderGit(); - renderReview(); - $("git").scrollTop = 0; - $("review").scrollTop = 0; - $("status").textContent = - "Generated from pinned Git blobs. Blue rows are matched syntax with changed indentation. Review status stays in this browser."; -} -$("cases").onclick = (event) => { - const button = event.target.closest("[data-case]"); - if (button) select(button.dataset.case).catch(fail); -}; -$("review").onclick = (event) => { - const button = event.target.closest("[data-fold]"); - if (button) { - const id = +button.dataset.fold; - if (closed.has(id)) { - closed.delete(id); - expanded.add(id); - } else { - closed.add(id); - expanded.delete(id); - } - renderReview(); - } -}; -$("wrap").onchange = () => { - for (const id of ["git", "review"]) - $(id).classList.toggle("wrap", $("wrap").checked); -}; -$("wrap").onchange(); -$("all").onchange = () => { - renderGit(); - renderReview(); -}; -$("expand").onclick = () => { - closed.clear(); - expanded = new Set(viewFolds.map((_, i) => i)); - renderReview(); -}; -$("collapse").onclick = () => { - closed = new Set(viewFolds.map((_, i) => i)); - renderReview(); -}; -$("reviewed").onclick = () => { - const id = current.meta.id; - reviewed.has(id) ? reviewed.delete(id) : reviewed.add(id); - localStorage.setItem("diff-lab-reviewed", JSON.stringify([...reviewed])); - navigation(); -}; -$("download").onclick = () => { - const url = URL.createObjectURL( - new Blob([JSON.stringify(current.domain, null, 2) + "\n"], { - type: "application/json", - }), - ); - const a = document.createElement("a"); - a.href = url; - a.download = `${current.meta.id}.domain.json`; - a.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); -}; -function fail(error) { - if (arguments[0]?.name === "AbortError") return; - $("status").textContent = error.message; - console.error(error); -} -try { - const response = await fetch("data/index.json"); - if (!response.ok) - throw Error("Run python3 examples/review/viewer/build.py first."); - fixtures = await response.json(); - await select( - fixtures.find((f) => f.id === location.hash.slice(1))?.id || fixtures[0].id, - ); -} catch (error) { - fail(error); -} diff --git a/examples/review/viewer/build.py b/examples/review/viewer/build.py deleted file mode 100644 index ee7466ac6..000000000 --- a/examples/review/viewer/build.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -"""Build local viewer data from verified fixture blobs via the real Git-ref CLI.""" -import hashlib -import json -import os -from pathlib import Path -import subprocess -import tempfile - -ROOT = Path(__file__).resolve().parents[3] -OUT = Path(__file__).parent / 'data' -OUT.mkdir(exist_ok=True) -ENV = dict(os.environ, GIT_CONFIG_GLOBAL='/dev/null', GIT_CONFIG_NOSYSTEM='1', - GIT_AUTHOR_NAME='Fixture', GIT_AUTHOR_EMAIL='fixture@example.invalid', - GIT_COMMITTER_NAME='Fixture', GIT_COMMITTER_EMAIL='fixture@example.invalid', - GIT_AUTHOR_DATE='2000-01-01T00:00:00Z', GIT_COMMITTER_DATE='2000-01-01T00:00:00Z') -ENV.pop('DFT_DBG_KEEP_UNCHANGED', None) - -def git(repo, *args, input=None): - return subprocess.check_output(['git', '-C', str(repo), *args], input=input, env=ENV).decode().strip() - -# Build pinned comparisons and capture the CLI stream for the static viewer. -REPO = OUT / "workspace" -REPO.mkdir(exist_ok=True) -git(REPO, 'init', '-q') -sides = [[], []] -index = [] -for directory in sorted((ROOT / 'examples/review/real').iterdir()): - provenance = json.loads((directory / 'provenance.json').read_text()) - case = json.loads((directory / 'case.json').read_text()) - sources = provenance['sources'] - path = sources['rhs']['path'] or sources['lhs']['path'] - served_path = directory.name + "/" + path - for i, side in enumerate(('lhs', 'rhs')): - entry = sources[side] - if entry['path'] is not None: - data = (directory / entry['file']).read_bytes() - blob = git(REPO, 'hash-object', '-w', '--stdin', input=data) - sides[i].append((served_path, blob)) - with tempfile.TemporaryDirectory(prefix='difft-viewer-') as repo: - git(repo, 'init', '-q') - commits = [] - for side in ('lhs', 'rhs'): - entry = sources[side] - data = (directory / entry['file']).read_bytes() - assert hashlib.sha256(data).hexdigest() == entry['sha256'] - git(repo, 'read-tree', '--empty') - if entry['path'] is not None: - blob = git(repo, 'hash-object', '-w', '--stdin', input=data) - assert blob == entry['git_blob_sha'] - git(repo, 'update-index', '--add', '--cacheinfo', '100644', blob, path) - else: - assert not data - tree = git(repo, 'write-tree') - parent = ['-p', commits[0]] if commits else [] - commits.append(git(repo, 'commit-tree', tree, '-m', 'Pinned fixture', *parent)) - view = {} - view['patch'] = git(repo, 'diff', '--no-ext-diff', '--no-textconv', '--no-color', '--diff-algorithm=myers', '-U3', *commits, '--', path) - view['full_patch'] = git(repo, 'diff', '--no-ext-diff', '--no-textconv', '--no-color', '--diff-algorithm=myers', '-U999999', *commits, '--', path) - meta = dict(id=directory.name, repo=provenance['repo'], pr=provenance['pr_number'], - title=provenance['pr_title'], url=provenance['pr_url'], language=provenance['language'], - path=path, summary=case['summary']) - view['meta'] = meta - view['request'] = {'files': {'paths': [served_path]}} - (OUT / f'{directory.name}.json').write_text(json.dumps(view)) - index.append(meta) - print(directory.name) -(OUT / 'index.json').write_text(json.dumps(index)) - -commits = [] -for files in sides: - git(REPO, 'read-tree', '--empty') - for path, blob in files: - git(REPO, 'update-index', '--add', '--cacheinfo', '100644', blob, path) - tree = git(REPO, 'write-tree') - parent = ['-p', commits[0]] if commits else [] - commits.append(git(REPO, 'commit-tree', tree, '-m', 'Fixture stream snapshot', *parent)) -for meta in index: - path = OUT / (meta['id'] + '.json') - view = json.loads(path.read_text()) - paths = view['request']['files']['paths'] - stream_path = OUT / (meta['id'] + '.ndjson') - with stream_path.open('wb') as output: - subprocess.run([str(ROOT / 'target/debug/diffr'), '--repo', str(REPO), - *commits, '--format', 'ndjson', '--', *paths], - stdout=output, env=ENV, check=True) - view['request'] = 'data/' + stream_path.name - path.write_text(json.dumps(view)) diff --git a/examples/review/viewer/index.html b/examples/review/viewer/index.html deleted file mode 100644 index fa55f7f43..000000000 --- a/examples/review/viewer/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - -Diff lab · Fixture review - - -
-
Loading fixtures…

Compare the review experience

-
-
Added / novelDeleted / novelMatched, reindentedSyntax colors come from Difftastic’s token annotations on both sides.
-
Git diffText / Myers · unified
Review diffSyntax matching + context + folds
-
Generated output, not a hand-authored mockup. Review status is saved in this browser.
-
- diff --git a/examples/review/viewer/inline-folds.mjs b/examples/review/viewer/inline-folds.mjs deleted file mode 100644 index de4f7bc1a..000000000 --- a/examples/review/viewer/inline-folds.mjs +++ /dev/null @@ -1,33 +0,0 @@ -// Project byte ranges onto a source line. Offsets remain UTF-8 byte offsets. -export function foldLine(length, row, folds, closed) { - const spans = folds - .filter( - ({ range: r }) => - r.start.line <= row && - (row < r.end.line || (row === r.end.line && r.end.byte_column > 0)), - ) - .map(({ id, range: r }) => ({ - id, - start: r.start.line === row ? r.start.byte_column : 0, - end: r.end.line === row ? r.end.byte_column : length, - startsOnRow: r.start.line === row, - })) - .sort((a, b) => a.start - b.start || b.end - a.end || a.id - b.id); - const result = []; - // Cursor tracks emitted source; hiddenUntil also suppresses nested controls. - let cursor = 0, - hiddenUntil = -1; - for (const span of spans) { - if (span.start < hiddenUntil) continue; - if (span.start > cursor) result.push({ text: [cursor, span.start] }); - cursor = Math.max(cursor, span.start); - if (span.startsOnRow) - result.push({ fold: span.id, collapsed: closed.has(span.id) }); - if (closed.has(span.id)) { - cursor = Math.max(cursor, span.end); - hiddenUntil = span.end; - } - } - if (cursor < length) result.push({ text: [cursor, length] }); - return result; -} diff --git a/examples/review/viewer/inline-folds.test.mjs b/examples/review/viewer/inline-folds.test.mjs deleted file mode 100644 index 0082608e2..000000000 --- a/examples/review/viewer/inline-folds.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import {foldLine} from './inline-folds.mjs'; -const range=(a,b,c=a,d=b)=>({start:{line:a,byte_column:b},end:{line:c,byte_column:d}}); -const render=(source,row,folds,closed)=>foldLine(Buffer.byteLength(source),row,folds,new Set(closed)).map(p=>p.text?Buffer.from(source).subarray(...p.text).toString():p.collapsed?'…':'').join(''); -test('inline fold preserves prefix, suffix, unicode and expansion',()=>{ - const src='call("☕", value);'; - const folds=[{id:0,range:range(0,5,0,17)}]; - assert.equal(render(src,0,folds,[0]),'call(…);'); - assert.equal(render(src,0,folds,[]),src); -}); -test('nested fold state survives folding its parent',()=>{ - const src='f(g(a), b)'; - const folds=[{id:0,range:range(0,2,0,9)},{id:1,range:range(0,4,0,5)}]; - assert.equal(render(src,0,folds,[1]),'f(g(…), b)'); - assert.equal(render(src,0,folds,[0,1]),'f(…)'); - assert.equal(render(src,0,folds,[1]),'f(g(…), b)'); -}); -test('multiline partial range retains both boundary fragments',()=>{ - const folds=[{id:0,range:range(0,3,2,4)}]; - assert.equal(render('abc hidden',0,folds,[0]),'abc…'); - assert.equal(render('all hidden',1,folds,[0]),''); - assert.equal(render('hide suffix',2,folds,[0]),' suffix'); -}); diff --git a/examples/review/viewer/layout.mjs b/examples/review/viewer/layout.mjs deleted file mode 100644 index 10bfea97b..000000000 --- a/examples/review/viewer/layout.mjs +++ /dev/null @@ -1,137 +0,0 @@ -// Browser-owned adaptation of Difftastic's display/context.rs line alignment. -const flip = (rows) => rows.map(([left, right]) => [right, left]); -const lines = (text) => text === "" ? [] : text.replace(/\n$/, "").split("\n"); -const allLines = (positions) => [...new Set(positions.map((p) => p.pos.line))].sort((a, b) => a - b); -const match = (p) => p.kind.UnchangedToken ?? p.kind.UnchangedPartOfNovelItem; -const novel = (p) => "Novel" in p.kind || "NovelWord" in p.kind; - -function mergeOpposite(rows, opposite) { - const result = []; - let index = 0; - for (const [left, right] of rows) { - while (right !== null && index < opposite.length && opposite[index] <= right) { - if (opposite[index] < right) result.push([null, opposite[index]]); - index++; - } - result.push([left, right]); - } - for (; index < opposite.length; index++) result.push([null, opposite[index]]); - return result; -} - -function pairedRange(left, right, leftEnd, rightEnd) { - const result = []; - while (left < leftEnd || right < rightEnd) { - result.push([left < leftEnd ? left++ : null, right < rightEnd ? right++ : null]); - } - return result; -} - -function alignedRows(domain, lhs, rhs) { - if (domain.lhs_src.Text === domain.rhs_src.Text) return lhs.map((_, i) => [i, i]); - let rows = [], highestLeft = -1, highestRight = -1; - for (const position of domain.lhs_positions) { - const right = match(position)?.opposite_pos.find((span) => span.line > highestRight)?.line; - if (right === undefined || position.pos.line <= highestLeft) continue; - highestLeft = position.pos.line; - highestRight = right; - rows.push([highestLeft, highestRight]); - } - rows = mergeOpposite(rows, allLines(domain.rhs_positions)); - rows = flip(mergeOpposite(flip(rows), allLines(domain.lhs_positions))); - - const firstLeft = rows.find(([l]) => l !== null)?.[0]; - const firstRight = rows.find(([, r]) => r !== null)?.[1]; - const lastLeft = rows.findLast(([l]) => l !== null)?.[0]; - const lastRight = rows.findLast(([, r]) => r !== null)?.[1]; - if (firstLeft !== undefined && firstRight !== undefined) { - rows = [...pairedRange(0, 0, firstLeft, firstRight), ...rows]; - } - if (lastLeft !== undefined && lastRight !== undefined) { - rows.push(...pairedRange(lastLeft + 1, lastRight + 1, lhs.length, rhs.length)); - } - - // Blank lines just before a matched row align from the bottom of the gap. - const blanks = []; - let prevLeft = null, prevRight = null; - for (const [left, right] of rows) { - if (left !== null && right !== null) { - const before = []; - const first = prevLeft === null && prevRight === null; - const later = prevLeft !== null && prevRight !== null; - if (first || later) { - for (let l = left - 1, r = right - 1; - l >= 0 && r >= 0 && (first || (l > prevLeft && r > prevRight)); l--, r--) { - if (lhs[l] !== "" || rhs[r] !== "") break; - before.push([l, r]); - } - } - blanks.push(...before.reverse()); - } - blanks.push([left, right]); - if (left !== null) prevLeft = left; - if (right !== null) prevRight = right; - } - - const contiguous = []; - prevLeft = prevRight = null; - for (const [left, right] of blanks) { - if (left !== null && prevLeft !== null) { - for (let l = prevLeft + 1; l < left; l++) contiguous.push([l, null]); - } - if (right !== null && prevRight !== null) { - for (let r = prevRight + 1; r < right; r++) contiguous.push([null, r]); - } - contiguous.push([left, right]); - if (left !== null) prevLeft = left; - if (right !== null) prevRight = right; - } - - const compact = [], pending = []; - for (const [left, right] of contiguous) { - if (left !== null && right === null && pending[0]?.[0] === null) { - compact.push([left, pending.shift()[1]]); - } else if (right !== null && left === null && pending[0]?.[1] === null) { - compact.push([pending.shift()[0], right]); - } else if (left === null || right === null) { - pending.push([left, right]); - } else { - compact.push(...pending.splice(0), [left, right]); - } - } - compact.push(...pending); - const seenLeft = new Set(), seenRight = new Set(); - return compact.map(([left, right]) => { - if (left !== null && (left >= lhs.length || seenLeft.has(left))) left = null; - if (right !== null && (right >= rhs.length || seenRight.has(right))) right = null; - if (left !== null) seenLeft.add(left); - if (right !== null) seenRight.add(right); - return [left, right]; - }).filter(([l, r]) => l !== null || r !== null); -} - -export function deriveLayout(domain) { - const lhs = lines(domain.lhs_src.Text), rhs = lines(domain.rhs_src.Text); - const baseline = [new Set(), new Set()]; - for (const hunk of domain.hunks) { - for (const [left, right] of hunk.lines) { - if (left !== null) baseline[0].add(left); - if (right !== null) baseline[1].add(right); - } - } - const novelLeft = new Set(domain.lhs_positions.filter(novel).map((p) => p.pos.line)); - const novelRight = new Set(domain.rhs_positions.filter(novel).map((p) => p.pos.line)); - const reindented = new Map(); - for (const p of domain.lhs_positions) { - const unchanged = p.kind.UnchangedToken; - if (!unchanged || novelLeft.has(p.pos.line)) continue; - for (const opposite of unchanged.opposite_pos) { - const l = p.pos.line, r = opposite.line; - if (novelRight.has(r) || lhs[l] === undefined || rhs[r] === undefined) continue; - if (lhs[l] !== rhs[r] && lhs[l].trimStart() === rhs[r].trimStart()) { - reindented.set(`${l}:${r}`, [l, r]); - } - } - } - return { rows: alignedRows(domain, lhs, rhs), baseline: baseline.map((s) => [...s]), reindented: [...reindented.values()] }; -} diff --git a/examples/review/viewer/stream.mjs b/examples/review/viewer/stream.mjs deleted file mode 100644 index 779486bed..000000000 --- a/examples/review/viewer/stream.mjs +++ /dev/null @@ -1,46 +0,0 @@ -// Read the captured CLI stream. EOF without "complete" means incomplete. -export async function* diffEvents(request, signal) { - const response = await fetch(request, { signal }); - if (!response.ok) throw Error(await response.text()); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let pending = "", started = false, complete = false, total = 0, succeeded = 0, failed = 0; - try { - while (true) { - const { value, done } = await reader.read(); - pending += decoder.decode(value, { stream: !done }); - let end; - while ((end = pending.indexOf("\n")) >= 0) { - const line = pending.slice(0, end); - pending = pending.slice(end + 1); - if (!line.trim()) continue; - const event = JSON.parse(line); - if (complete) throw Error("Data after stream completion"); - if (!started && (event.type !== "start" || event.version !== 1)) throw Error("Unsupported stream"); - if (event.type === "start") { - if (started) throw Error("Repeated start event"); - total = event.total; - } else if (event.type === "file") { - succeeded++; - } else if (event.type === "file_error") { - failed++; - } else if (event.type === "error") { - throw Error(event.message); - } else if (event.type === "complete") { - if (event.succeeded !== succeeded || event.failed !== failed || succeeded + failed !== total) - throw Error("Inconsistent completion counts"); - complete = true; - } else { - throw Error("Unknown stream event"); - } - started = true; - yield event; - } - if (done) break; - } - if (pending.trim() || !complete) throw Error("Diff stream ended before completion"); - } finally { - await reader.cancel(); - reader.releaseLock(); - } -} diff --git a/examples/review/viewer/stream.test.mjs b/examples/review/viewer/stream.test.mjs deleted file mode 100644 index 19370850f..000000000 --- a/examples/review/viewer/stream.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { diffEvents } from "./stream.mjs"; - -function response(text) { - const bytes = new TextEncoder().encode(text); - return new Response(new ReadableStream({ - start(controller) { - // Split inside both UTF-8 characters and JSON records. - for (const byte of bytes) controller.enqueue(new Uint8Array([byte])); - controller.close(); - }, - })); -} -test("decodes split records and UTF-8, verifies completion", async () => { - const events = [ - { type: "start", version: 1, total: 1 }, - { type: "file", diff: "☕" }, - { type: "complete", succeeded: 1, failed: 0 }, - ]; - globalThis.fetch = async () => response(events.map(JSON.stringify).join("\n") + "\n"); - assert.deepEqual(await Array.fromAsync(diffEvents({})), events); -}); -test("rejects truncation and inconsistent counts", async () => { - for (const tail of ["", '{"type":"complete","succeeded":0,"failed":0}\n']) { - globalThis.fetch = async () => response('{"type":"start","version":1,"total":1}\n' + tail); - await assert.rejects(async () => { for await (const event of diffEvents({})) void event; }); - } -}); diff --git a/examples/review/viewer/style.css b/examples/review/viewer/style.css deleted file mode 100644 index db4c311c9..000000000 --- a/examples/review/viewer/style.css +++ /dev/null @@ -1,5 +0,0 @@ -:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#243044;background:#f6f8fa;font-size:13px}*{box-sizing:border-box}body{margin:0;display:flex;height:100vh;overflow:hidden}aside{width:230px;flex-shrink:0;background:#fff;border-right:1px solid #dce2ea;display:flex;flex-direction:column;padding:25px 14px}.brand{font-size:21px;padding:0 10px 25px;color:#213852}.brand small{font-size:9px;letter-spacing:2px;display:block;margin:8px 0 0 26px;color:#788396}.nav-title{font-size:9px;color:#738197;letter-spacing:1.4px;padding:0 10px 12px;display:flex;justify-content:space-between}nav{overflow:auto}nav button{display:block;width:100%;text-align:left;border:0;background:transparent;padding:11px 10px;border-radius:7px;margin-bottom:4px;line-height:1.5;cursor:pointer;color:#475369}nav button strong{display:block;font-size:12px}nav button small{display:block;font-size:10px;color:#7b8798;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}nav button.active{background:#eaf1ff;color:#2458a6;box-shadow:inset 3px 0 #3777cb}.aside-note{font-size:11px;line-height:1.8;color:#718093;margin-top:auto;padding:22px 10px 0}.aside-note span{font-size:10px;color:#9aa3b2}main{min-width:0;flex:1;display:flex;flex-direction:column}header{padding:22px 25px 12px}.eyebrow{font-size:11px;color:#59738f;font-weight:600}a{color:#376ead;text-decoration:none}h1{font-size:20px;font-weight:600;margin:7px 0 9px;letter-spacing:-.4px}.file{font:12px ui-monospace,SFMono-Regular,monospace;color:#667183;overflow-wrap:anywhere}#summary{font-size:12px;color:#7a8493;line-height:1.5;margin:8px 0 0;max-width:1100px}.toolbar{display:flex;gap:8px;align-items:center;padding:10px 25px;border-bottom:1px solid #e0e5ed}.toolbar label{display:flex;align-items:center;gap:6px;margin-right:12px;font-size:12px}button{font:inherit;border:1px solid #d5dde8;color:#455670;border-radius:5px;background:white;padding:7px 10px;cursor:pointer}button:hover{border-color:#81a7d3;background:#f1f6fd}button.primary{background:#2d609b;color:#fff;border-color:#2d609b}.spacer{flex:1}.legend{display:flex;align-items:center;gap:17px;padding:10px 25px;font-size:10px;color:#7d8896}.legend span{display:flex;align-items:center;gap:5px}.legend i{width:8px;height:8px;border-radius:2px}.add-dot{background:#b5e7c5}.del-dot{background:#f4bbc0}.match-dot{background:#b9d3f5}.panels{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px;padding:0 18px;flex:1;min-height:0}.panels section{display:flex;flex-direction:column;min-width:0;border:1px solid #d8e0e9;border-radius:7px;background:#fff;overflow:hidden}.panel-title{padding:12px 14px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dfe5ed;flex-shrink:0}.panel-title span{color:#8290a0;font-size:10px}.code{overflow:auto;flex:1;padding-bottom:30px;font:11px/1.75 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;tab-size:4}.row{display:flex;min-width:max-content;min-height:20px;white-space:pre}.num{width:41px;text-align:right;flex-shrink:0;color:#9aa3af;user-select:none;padding-right:8px;background:#f6f8fa}.sign{width:21px;text-align:center;flex-shrink:0;color:#94a0b0}.source{padding-right:20px}.add{background:#e9f8ed}.del{background:#fff0f1}.add .sign{color:#3d9460}.del .sign{color:#c66570}.semantic .add .num{background:#e9f8ed}.semantic .del .num{background:#fff0f1}.novel.added{background:#b9ecc9;border-radius:2px}.novel.deleted{background:#fac6cd;border-radius:2px}.reindent{background:#f2f7fe}.reindent .sign{color:#4c87c7}.keyword{color:#a3438c}.string{color:#31735d}.comment{color:#8a969f;font-style:italic}.type{color:#9b6722}.gap{background:#f2f6fb;border-block:1px solid #e7edf5;color:#8292a7;padding:5px 12px;font:10px ui-monospace,monospace;white-space:pre;min-width:max-content}.fold{background:#edf3fc;border-block:1px solid #dce6f4;padding:5px 10px;position:sticky;left:0;display:flex;align-items:center;gap:8px;min-height:31px}.fold button{border:0;background:none;color:#426999;padding:0;font-size:11px}.fold small{color:#8194ad;font-size:10px}.empty{padding:24px;color:#8090a0}footer{padding:9px 24px;font-size:10px;color:#8995a5}@media(max-width:1050px){aside{width:175px}.legend .spacer+span{display:none}.toolbar{padding:8px 15px;gap:5px}h1{font-size:17px}.toolbar button{font-size:11px}.code{font-size:10px}.num{width:33px}} - -.code.wrap .row{min-width:0;align-items:flex-start}.code.wrap .source{white-space:pre-wrap;overflow-wrap:anywhere;min-width:0;flex:1}.code.wrap .gap{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere} -.inline-fold { display:inline-block; padding:0 3px; margin:0 2px; border:1px solid #a8b7cc; border-radius:3px; background:#e8eef7; color:#455b79; font:inherit; line-height:1; cursor:pointer; vertical-align:baseline; } -.inline-fold[aria-expanded="false"] { background:#d6e2f3; } diff --git a/src/cli.rs b/src/cli.rs index c67e44727..95e121f17 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,7 +1,6 @@ //! Git-style CLI input; rendering and NDJSON remain adapters over the same engine. use crate::config::Config; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; -use crate::hook::Hook; use crate::options::{DiffOptions, DisplayMode, DisplayOptions}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use git2::{DiffStatsFormat, Repository}; @@ -60,7 +59,7 @@ pub(crate) fn run() -> Result { .arg(flag("no-renames")) .arg(flag("find-renames").short('M').conflicts_with("no-renames")) .arg(Arg::new("unified").short('U').long("unified").default_value("3").value_parser(clap::value_parser!(u32))) - .arg(Arg::new("format").long("format").value_parser(["text", "json", "ndjson", "snapshot"]).default_value("text")) + .arg(Arg::new("format").long("format").value_parser(["text", "ndjson"]).default_value("text")) .arg(Arg::new("display").long("display").value_parser(["inline", "side-by-side", "side-by-side-show-both"]).default_value("side-by-side")) .arg(Arg::new("color").long("color").num_args(0..=1).require_equals(true).default_missing_value("always").default_value("auto").value_parser(["auto", "always", "never"])) .arg(flag("no-color")) @@ -166,9 +165,7 @@ pub(crate) fn run() -> Result { let params = Arc::new( Config::load(workspace, args.get_one::("config").map(Path::new))?.compile()?, ); - let hook = fold_hook(¶ms, workspace)?; let mut session = DiffSession::open(workspace, comparison, params, &files)?; - session.context_lines = display.num_context_lines; session.diff_options = diff_options; let changed = session.remaining() > 0; if streaming { @@ -176,16 +173,18 @@ pub(crate) fn run() -> Result { if jobs == 0 { return Err("--jobs must be at least 1".into()); } - let failed = crate::stream::write(session, jobs, hook, &mut io::stdout().lock())?; + let failed = crate::protocol::stream::write(session, jobs, &mut io::stdout().lock())?; return Ok(if failed { 2 } else { i32::from(changed && args.get_flag("exit-code")) }); } - for (file, result) in session { - let result = result.map_err(|error| format!("{}: {error}", file.path()))?; - render(&result, &args, &display)?; + while let Some((file, loaded)) = session.load() { + let result = loaded + .and_then(|loaded| loaded.diff(&display)) + .map_err(|error| format!("{}: {error:#}", file.path()))?; + crate::print_diff_result(&display, &result); } changed }; @@ -194,19 +193,6 @@ pub(crate) fn run() -> Result { )) } -fn render( - diff: &crate::summary::DiffResult, - args: &ArgMatches, - display: &DisplayOptions, -) -> Result<()> { - match args.get_one::("format").unwrap().as_str() { - "json" => println!("{}", diff.domain_json()), - "snapshot" => print!("{}", diff.snapshot()), - _ => crate::print_diff_result(display, diff), - } - Ok(()) -} - fn select( repo: &Repository, location: &Path, @@ -461,30 +447,24 @@ 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( + let failed = crate::protocol::stream::write_file( &paths[0].to_string_lossy(), &paths[1].to_string_lossy(), + (before.len() as u64, after.len() as u64), compute, - hook.as_deref(), &mut io::stdout().lock(), )?; - Ok(i32::from(changed && args.get_flag("exit-code"))) + Ok(if failed { + 2 + } else { + i32::from(changed && args.get_flag("exit-code")) + }) } else { - render(&compute(), args, display)?; + crate::print_diff_result(display, &crate::diff_or_die(compute(), display.use_color)); 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() -} - /// Explicit machine/text modes and redirected output must never enter the alternate screen. fn opens_tui(explicit_format: bool, metadata_or_quiet: bool, terminal: bool) -> bool { terminal && !explicit_format && !metadata_or_quiet diff --git a/src/config.rs b/src/config.rs index fe29ee81c..526bde592 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,7 @@ use crate::parse::{guess_language::Language, tree_sitter_parser}; use query::AnnotationQuery; use serde::Deserialize; use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::{Arc, OnceLock}; use strum::IntoEnumIterator; @@ -13,42 +13,6 @@ use strum::IntoEnumIterator; #[serde(default, deny_unknown_fields)] pub(crate) struct Config { pub(crate) languages: BTreeMap, - pub(crate) folds: FoldsConfig, -} - -#[derive(Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct FoldsConfig { - pub(crate) hook: Option, -} - -/// A trusted subprocess that supplies summaries for large novel folds. -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct HookConfig { - /// Relative command paths resolve against the config file, wherever it lives. - #[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>, - #[serde(default)] - pub(crate) min_lines: usize, - /// Per-call limit once the hook is listening. - #[serde(default = "default_timeout_ms")] - pub(crate) timeout_ms: u64, - /// How long the hook may take to start listening on its port. - #[serde(default = "default_startup_timeout_ms")] - pub(crate) startup_timeout_ms: u64, -} - -fn default_timeout_ms() -> u64 { - 5000 -} - -fn default_startup_timeout_ms() -> u64 { - 30_000 } #[derive(Default, Deserialize)] @@ -70,7 +34,6 @@ impl std::error::Error for ConfigError {} pub(crate) struct Params { languages: DftHashMap>>, - pub(crate) hook: Option, } pub(crate) struct LanguageParams { @@ -102,16 +65,7 @@ impl Config { .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(); - } - Ok(config) - } + Ok(source) => Self::from_toml(&source), Err(error) if explicit.is_none() && error.kind() == std::io::ErrorKind::NotFound => { Ok(Self::default()) } @@ -124,14 +78,6 @@ impl Config { } pub(crate) fn compile(self) -> Result { - if let Some(hook) = &self.folds.hook { - if hook.command.is_empty() { - return Err(ConfigError("folds.hook.command must not be empty".into())); - } - if hook.timeout_ms == 0 || hook.startup_timeout_ms == 0 { - return Err(ConfigError("folds.hook timeouts 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 { @@ -165,10 +111,7 @@ impl Config { })), ); } - Ok(Params { - languages, - hook: self.folds.hook, - }) + Ok(Params { languages }) } } @@ -288,39 +231,6 @@ mod tests { assert_eq!(result.rhs_folds[0].tags, ["embedded"]); } - #[test] - fn parses_fold_hook_settings() { - let params = Config::from_toml( - "[folds.hook]\ncommand = ['uv', 'run', 'summarize.py']\ntags = ['body']\nmin_lines = 30", - ) - .unwrap() - .compile() - .unwrap(); - let hook = params.hook.unwrap(); - assert_eq!(hook.command, ["uv", "run", "summarize.py"]); - 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) - ); - assert!(Config::from_toml("") - .unwrap() - .compile() - .unwrap() - .hook - .is_none()); - for input in [ - "[folds.hook]\ncommand = []", - "[folds.hook]\ncommand = ['x']\ntimeout_ms = 0", - ] { - assert!( - Config::from_toml(input).unwrap().compile().is_err(), - "{input}" - ); - } - assert!(Config::from_toml("[folds.hook]\ncommand = ['x']\nunknown = 1").is_err()); - } - #[test] fn rejects_unknown_settings_languages_and_invalid_queries() { assert!(Config::from_toml("typo = true").is_err()); @@ -444,6 +354,7 @@ mod query_tests { #[cfg(test)] mod tag_tests { use super::*; + use crate::parse::folds::FoldMatch; use crate::summary::DiffResult; #[test] @@ -464,7 +375,27 @@ mod tag_tests { } #[test] - fn conflicting_ranges_do_not_depend_on_query_order() { + fn an_opening_capture_alone_folds_to_the_end_of_the_fold_node() { + let query = + r#"((function_definition ":" @fold.open body: (block) @fold) (#set! tag "body"))"#; + let params = Config::from_toml(&format!("[languages.python]\nfolds = '''{query}'''")) + .unwrap() + .compile() + .unwrap(); + let rhs = "def f(a):\n x = a\n return x\n"; + let result = DiffResult::from_sources_with_params("a.py", "", rhs, ¶ms); + assert_eq!(result.rhs_folds.len(), 1); + let range = &result.rhs_folds[0].range; + // From just after the `:` to the end of the block. + assert_eq!( + (range.start.line.as_usize(), range.start.byte_column), + (0, 9) + ); + assert_eq!((range.end.line.as_usize(), range.end.byte_column), (2, 12)); + } + + #[test] + fn a_node_captured_with_two_ranges_is_a_query_conflict_naming_both_patterns() { let whole = "((block) @fold (#set! tag \"whole\"))"; let interior = "((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"inside\"))"; for query in [ @@ -475,15 +406,27 @@ mod tag_tests { .unwrap() .compile() .unwrap(); - let result = - DiffResult::from_sources_with_params("a.rs", "", "fn f() { work(); }", ¶ms); - assert!(result.rhs_folds.is_empty()); + let conflict = DiffResult::try_from_sources_with_params( + "src/lib.rs", + "", + "fn f() {\n work();\n}\n", + ¶ms, + ) + .expect_err("a conflict"); + assert_eq!( + conflict.to_string(), + "src/lib.rs:1: fold query patterns 0 and 1 capture the same block \ + with different fold ranges" + ); + // Other files diff as usual. + assert!( + DiffResult::try_from_sources_with_params("a.py", "", "x = 1\n", ¶ms).is_ok() + ); } } #[test] fn test_bodies_keep_both_tags_and_remain_paired() { - use crate::parse::folds::FoldMatch; let params = Params::default(); let result = DiffResult::from_sources_with_params( "a.rs", @@ -495,7 +438,9 @@ mod tag_tests { assert_eq!(result.rhs_folds.len(), 1); assert_eq!(result.lhs_folds[0].tags, ["body", "test"]); assert_eq!(result.rhs_folds[0].tags, ["body", "test"]); - assert!(matches!(&result.lhs_folds[0].match_kind, - FoldMatch::Unchanged { opposite } if *opposite == result.rhs_folds[0].range)); + assert!(matches!( + result.lhs_folds[0].match_kind, + FoldMatch::Matched { .. } + )); } } diff --git a/src/config/README.md b/src/config/README.md index 6d3dc419e..8c479fa63 100644 --- a/src/config/README.md +++ b/src/config/README.md @@ -35,18 +35,33 @@ See `defaults.toml` for the complete bundled rules. `@fold` selects a node's full source range. To fold an interior instead, capture its delimiters as `@fold.open` and `@fold.close`. The hidden range runs -from the opening node's end to the closing node's start. These are direct +from the opening node's end to the closing node's start. A fold covers only +the lines it holds whole: code before it on the line it opens on, or after it +on the line it closes on, belongs to the leaf beside it, so collapsing a fold +hides its lines and nothing else. These are direct Tree-sitter coordinates; labels and comments before the opening brace stay visible. -Both delimiter captures must be present, ordered, and contained in the fold node. +With both delimiter captures, they must be ordered and contained in the fold node. +A `@fold.open` without `@fold.close` hides from the opening node's end to the end +of the fold node; the opening node may precede the fold node, so +`(function_definition ":" @fold.open body: (block) @fold)` folds a Python body +from its header's `:`. A `@fold.close` without `@fold.open` selects no fold. These fold-boundary captures are our convention. There are no arbitrary byte or line offsets, and no `#offset!` or `#make-range!` directives. `#set! tag "name"` supplies application metadata. Tag names are arbitrary strings; dots have no special meaning. Multiple rules selecting the same node and range -accumulate sorted, unique tags. A node whose rules select conflicting ranges is -omitted rather than choosing a rule by execution order. Tags do not control context -selection or initial UI collapse state. +accumulate sorted, unique tags. A node whose rules select different ranges is a +query conflict: the file is not diffed, and its record carries a +`query_conflict` error naming both patterns, so the result never depends on +query order. Tags do not control context selection or initial UI collapse +state. + +A node has at most one fold, so two folds align exactly when the matcher +paired their nodes. When a node that has a fold is flattened into its only +child during conversion (a Python `block` holding one statement, say), the +fold moves onto the child; a child that has a fold of its own keeps it, and +the wrapper node stays so that each fold still has a node. ## Context diff --git a/src/config/defaults.toml b/src/config/defaults.toml index 2bc207200..65b4bd59a 100644 --- a/src/config/defaults.toml +++ b/src/config/defaults.toml @@ -41,7 +41,21 @@ context = ''' [languages.python] folds = ''' -((block) @fold +([ + (function_definition ":" @fold.open body: (block) @fold) + (class_definition ":" @fold.open body: (block) @fold) + (if_statement ":" @fold.open consequence: (block) @fold) + (elif_clause ":" @fold.open consequence: (block) @fold) + (else_clause ":" @fold.open body: (block) @fold) + (for_statement ":" @fold.open body: (block) @fold) + (while_statement ":" @fold.open body: (block) @fold) + (with_statement ":" @fold.open body: (block) @fold) + (try_statement ":" @fold.open body: (block) @fold) + (except_clause ":" @fold.open (block) @fold) + (finally_clause ":" @fold.open (block) @fold) + (match_statement ":" @fold.open body: (block) @fold) + (case_clause ":" @fold.open consequence: (block) @fold) +] (#set! tag "body")) ([ (list "[" @fold.open "]" @fold.close) @@ -60,7 +74,7 @@ folds = ''' (#set! tag "comment")) ((string) @fold (#set! tag "string")) -(function_definition name: (identifier) @_name body: (block) @fold +(function_definition name: (identifier) @_name ":" @fold.open body: (block) @fold (#match? @_name "^test_") (#set! tag "test")) ''' context = ''' diff --git a/src/diff/sliders.rs b/src/diff/sliders.rs index 5dc1dca44..03b56c99d 100644 --- a/src/diff/sliders.rs +++ b/src/diff/sliders.rs @@ -802,8 +802,8 @@ mod tests { let params = Params::default(); let config = params.language(guess_language::Language::EmacsLisp); - let lhs = parse(&arena, "A B", config, false); - let rhs = parse(&arena, "A B X\n A B", config, false); + let lhs = parse(&arena, "A B", config, false).unwrap(); + let rhs = parse(&arena, "A B X\n A B", config, false).unwrap(); init_all_info(&lhs, &rhs); let mut change_map = ChangeMap::default(); @@ -828,8 +828,8 @@ mod tests { let params = Params::default(); let config = params.language(guess_language::Language::EmacsLisp); - let lhs = parse(&arena, "(A B) X \n (A B)", config, false); - let rhs = parse(&arena, "((novel) A B)", config, false); + let lhs = parse(&arena, "(A B) X \n (A B)", config, false).unwrap(); + let rhs = parse(&arena, "((novel) A B)", config, false).unwrap(); init_all_info(&lhs, &rhs); let lhs_first_list_children = match lhs[0] { diff --git a/src/diff/unchanged.rs b/src/diff/unchanged.rs index 1b0e23612..33b85fb52 100644 --- a/src/diff/unchanged.rs +++ b/src/diff/unchanged.rs @@ -499,8 +499,8 @@ mod tests { let params = Params::default(); let config = params.language(guess_language::Language::EmacsLisp); - let lhs_nodes = parse(&arena, "unchanged A B", config, false); - let rhs_nodes = parse(&arena, "unchanged X", config, false); + let lhs_nodes = parse(&arena, "unchanged A B", config, false).unwrap(); + let rhs_nodes = parse(&arena, "unchanged X", config, false).unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -526,8 +526,8 @@ mod tests { let params = Params::default(); let config = params.language(guess_language::Language::EmacsLisp); - let lhs_nodes = parse(&arena, "A B unchanged", config, false); - let rhs_nodes = parse(&arena, "X unchanged", config, false); + let lhs_nodes = parse(&arena, "A B unchanged", config, false).unwrap(); + let rhs_nodes = parse(&arena, "X unchanged", config, false).unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -558,13 +558,15 @@ mod tests { "unchanged-before (more-unchanged (A))", config, false, - ); + ) + .unwrap(); let rhs_nodes = parse( &arena, "unchanged-before (more-unchanged (B))", config, false, - ); + ) + .unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -595,13 +597,15 @@ mod tests { "(unchanged (1 2 3 4 5 6 7 8 9 10)) A B", config, false, - ); + ) + .unwrap(); let rhs_nodes = parse( &arena, "(unchanged (1 2 3 4 5 6 7 8 9 10)) X", config, false, - ); + ) + .unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -634,13 +638,15 @@ mod tests { "A B (unchanged (1 2 3 4 5 6 7 8 9 10))", config, false, - ); + ) + .unwrap(); let rhs_nodes = parse( &arena, "X (unchanged (1 2 3 4 5 6 7 8 9 10))", config, false, - ); + ) + .unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -668,8 +674,8 @@ mod tests { let params = Params::default(); let config = params.language(guess_language::Language::EmacsLisp); - let lhs_nodes = parse(&arena, "(A)", config, false); - let rhs_nodes = parse(&arena, "(B)", config, false); + let lhs_nodes = parse(&arena, "(A)", config, false).unwrap(); + let rhs_nodes = parse(&arena, "(B)", config, false).unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -701,13 +707,15 @@ mod tests { "novel-lhs (unchanged (1 2 3 4 5 6 7 8 9 10)) novel-lhs-2", config, false, - ); + ) + .unwrap(); let rhs_nodes = parse( &arena, "novel-rhs (unchanged (1 2 3 4 5 6 7 8 9 10)) novel-rhs-2", config, false, - ); + ) + .unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -740,12 +748,12 @@ mod tests { &arena, "novel-lhs (unchanged-1 (1 2 3 4 5 6 7 8 9 10)) (unchanged-2 (1 2 3 4 5 6 7 8 9 10)) novel-lhs-2", config, false, - ); + ).unwrap(); let rhs_nodes = parse( &arena, "novel-rhs (unchanged-1 (1 2 3 4 5 6 7 8 9 10)) (unchanged-2 (1 2 3 4 5 6 7 8 9 10)) novel-rhs-2", config, false, - ); + ).unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -770,13 +778,15 @@ mod tests { "(novel-lhs-before (1 2 3 4 5 6 7 8 9 10) novel-lhs-after)", config, false, - ); + ) + .unwrap(); let rhs_nodes = parse( &arena, "(novel-rhs-before (1 2 3 4 5 6 7 8 9 10) novel-rhs-after)", config, false, - ); + ) + .unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let mut change_map = ChangeMap::default(); @@ -800,13 +810,15 @@ mod tests { "(1 2 3 4 5 6 7 8 9 10) (91 92 93 94 95 96 97 98 99 100)", config, false, - ); + ) + .unwrap(); let rhs_nodes = parse( &arena, "(1 2 3 4 5 novel-1 6 7 8 9 10) (91 92 93 94 95 novel-2 96 97 98 99 100)", config, false, - ); + ) + .unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); let split = split_mostly_unchanged_toplevel(&lhs_nodes, &rhs_nodes); @@ -829,13 +841,15 @@ mod tests { "(shared-1 (shared-2a shared-2b) not-unique not-unique)", config, false, - ); + ) + .unwrap(); let rhs_nodes = parse( &arena, "(shared-1 (shared-2a shared-2b) not-unique)", config, false, - ); + ) + .unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); assert_eq!(count_common_unique(lhs_nodes[0], rhs_nodes[0]), 2); @@ -847,8 +861,8 @@ mod tests { let params = Params::default(); let config = params.language(guess_language::Language::EmacsLisp); - let lhs_nodes = parse(&arena, "((novel-lhs 1 2 3 4 5)) x", config, false); - let rhs_nodes = parse(&arena, "((novel-rhs 1 2 3 4 5)) y", config, false); + let lhs_nodes = parse(&arena, "((novel-lhs 1 2 3 4 5)) x", config, false).unwrap(); + let rhs_nodes = parse(&arena, "((novel-rhs 1 2 3 4 5)) y", config, false).unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); assert_eq!( @@ -862,8 +876,8 @@ mod tests { let params = Params::default(); let config = params.language(guess_language::Language::EmacsLisp); - let lhs_nodes = parse(&arena, "(novel-lhs 1 2 3 4 5) x", config, false); - let rhs_nodes = parse(&arena, "[novel-rhs 1 2 3 4 5] y", config, false); + let lhs_nodes = parse(&arena, "(novel-lhs 1 2 3 4 5) x", config, false).unwrap(); + let rhs_nodes = parse(&arena, "[novel-rhs 1 2 3 4 5] y", config, false).unwrap(); init_all_info(&lhs_nodes, &rhs_nodes); assert_eq!( diff --git a/src/display/line_layout.rs b/src/display/line_layout.rs index 89ed43c5a..8db2ac07a 100644 --- a/src/display/line_layout.rs +++ b/src/display/line_layout.rs @@ -1,8 +1,7 @@ -//! Line alignment, ordinary context padding, and indentation classification. -use super::hunks::Hunk; +//! Line alignment and ordinary context padding. use crate::display::context::all_matched_lines_filled; +use crate::pairing::Pairing; use crate::parse::syntax::MatchedPos; -use crate::summary::{DiffResult, FileContent}; use std::collections::BTreeSet; #[derive(Default)] @@ -11,26 +10,8 @@ pub(crate) struct LineSelection { pub(crate) rhs: BTreeSet, } -impl LineSelection { - pub(crate) fn from_hunks(hunks: &[Hunk]) -> Self { - let mut selection = Self::default(); - for (lhs, rhs) in hunks.iter().flat_map(|hunk| &hunk.lines) { - selection.lhs.extend(lhs.map(|line| line.as_usize())); - selection.rhs.extend(rhs.map(|line| line.as_usize())); - } - selection - } -} - pub(crate) type Row = (Option, Option); -pub(crate) fn sources(diff: &DiffResult) -> (&str, &str) { - match (&diff.lhs_src, &diff.rhs_src) { - (FileContent::Text(lhs), FileContent::Text(rhs)) => (lhs, rhs), - _ => unreachable!("review entry point accepts text only"), - } -} - pub(crate) fn novel_lines(positions: &[MatchedPos]) -> BTreeSet { positions .iter() @@ -101,38 +82,77 @@ pub(crate) fn aligned_rows( rows } -/// Only treat indentation as formatting when the matcher confirms a pairing -/// and neither line contains novel syntax. Never normalize string contents. -pub(crate) fn reindented_pairs(diff: &DiffResult) -> BTreeSet<(usize, usize)> { - use crate::parse::syntax::MatchKind; - let (lhs_src, rhs_src) = sources(diff); - let lhs_lines: Vec<_> = lhs_src.split_terminator('\n').collect(); - let rhs_lines: Vec<_> = rhs_src.split_terminator('\n').collect(); - let lhs_novel = novel_lines(&diff.lhs_positions); - let rhs_novel = novel_lines(&diff.rhs_positions); - let mut pairs = BTreeSet::new(); - for position in &diff.lhs_positions { - let MatchKind::UnchangedToken { opposite_pos, .. } = &position.kind else { - continue; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RunKind { + Unchanged, + Novel, +} + +/// A maximal run of aligned rows of one kind before fold splitting. Each +/// side present holds a half-open line span. +#[derive(Clone, Debug)] +pub(crate) struct Run { + pub(crate) kind: RunKind, + pub(crate) sides: Pairing<(usize, usize)>, +} + +impl Run { + pub(crate) fn len(&self) -> usize { + let (start, end) = match self.sides { + Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => lhs, + Pairing::RightOnly { rhs } => rhs, }; - let lhs = position.pos.line.as_usize(); - if lhs_novel.contains(&lhs) || lhs >= lhs_lines.len() { - continue; - } - for opposite in opposite_pos { - let rhs = opposite.line.as_usize(); - if rhs_novel.contains(&rhs) || rhs >= rhs_lines.len() { - continue; + end - start + } +} + +/// Rows in order, run-length encoded by kind and side presence. +pub(crate) fn runs( + rows: &[Row], + lhs_novel: &BTreeSet, + rhs_novel: &BTreeSet, +) -> Vec { + let mut runs: Vec = Vec::new(); + for &row in rows { + let row = match row { + (Some(lhs), Some(rhs)) => Pairing::Both { lhs, rhs }, + (Some(lhs), None) => Pairing::LeftOnly { lhs }, + (None, Some(rhs)) => Pairing::RightOnly { rhs }, + (None, None) => unreachable!("aligned_rows only emits rows with a line on some side"), + }; + let kind = match row { + Pairing::Both { lhs, rhs } + if !lhs_novel.contains(&lhs) && !rhs_novel.contains(&rhs) => + { + RunKind::Unchanged + } + _ => RunKind::Novel, + }; + let last = runs.last_mut().filter(|run| run.kind == kind); + match (last.map(|run| &mut run.sides), row) { + ( + Some(Pairing::Both { + lhs: (_, lhs_end), + rhs: (_, rhs_end), + }), + Pairing::Both { lhs, rhs }, + ) if *lhs_end == lhs && *rhs_end == rhs => { + *lhs_end += 1; + *rhs_end += 1; } - let lhs_text = lhs_lines[lhs]; - let rhs_text = rhs_lines[rhs]; - if lhs_text == rhs_text || lhs_text.trim_start() != rhs_text.trim_start() { - continue; + (Some(Pairing::LeftOnly { lhs: (_, end) }), Pairing::LeftOnly { lhs: line }) + | (Some(Pairing::RightOnly { rhs: (_, end) }), Pairing::RightOnly { rhs: line }) + if *end == line => + { + *end += 1; } - pairs.insert((lhs, rhs)); + _ => runs.push(Run { + kind, + sides: row.map(|line| (line, line + 1)), + }), } } - pairs + runs } #[cfg(test)] diff --git a/src/display/syntax_context.rs b/src/display/syntax_context.rs index cb7fa11c5..5b768338b 100644 --- a/src/display/syntax_context.rs +++ b/src/display/syntax_context.rs @@ -109,6 +109,7 @@ mod tests { Params::default().language(Language::Python), false, ) + .unwrap() }; let selected = select_candidates( &candidates(&syntax), @@ -125,7 +126,8 @@ mod tests { "fn run() -> i32 {\n answer\n}\n", Params::default().language(Language::Rust), false, - ); + ) + .unwrap(); let mut pending = syntax; let mut found_tail = false; while let Some(node) = pending.pop() { diff --git a/src/engine.rs b/src/engine.rs index cb9f32439..ac96eac42 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -11,20 +11,53 @@ use crate::display::hunks::{matched_pos_to_hunks, merge_adjacent}; 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; -use crate::summary::{DiffResult, FileContent, FileFormat}; +use crate::summary::{DiffResult, FallbackCause, FileContent, FileFormat}; use humansize::{format_size, FormatSizeOptions, BINARY}; -use std::{env, path::Path}; +use std::{env, fmt, path::Path}; use typed_arena::Arena; +/// A file whose fold query captured one syntax node with two different +/// ranges. The file is not diffed; the stream reports it as a +/// `query_conflict` error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct QueryConflict { + /// The file's display path. + pub(crate) path: String, + pub(crate) side: Side, + pub(crate) conflict: folds::Conflict, +} + +impl fmt::Display for QueryConflict { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (first, second) = self.conflict.patterns; + write!( + f, + "{}:{}{}: fold query patterns {first} and {second} capture the same {} with different fold ranges", + self.path, + self.conflict.line + 1, + match self.side { + Side::Left => " (before)", + Side::Right => "", + }, + self.conflict.kind, + ) + } +} + +impl std::error::Error for QueryConflict {} + impl DiffResult { #[cfg(test)] pub(crate) fn from_sources(path: &str, lhs: &str, rhs: &str) -> Self { Self::from_sources_with_params(path, lhs, rhs, &Params::default()) } + /// A diff with the given parameters, for tests whose queries cannot + /// conflict. #[cfg(test)] pub(crate) fn from_sources_with_params( path: &str, @@ -32,6 +65,17 @@ impl DiffResult { rhs: &str, params: &Params, ) -> Self { + Self::try_from_sources_with_params(path, lhs, rhs, params) + .expect("the test's fold queries do not conflict") + } + + #[cfg(test)] + pub(crate) fn try_from_sources_with_params( + path: &str, + lhs: &str, + rhs: &str, + params: &Params, + ) -> Result { Self::from_sources_with_options( path, lhs, @@ -49,7 +93,7 @@ impl DiffResult { params: &Params, display: &DisplayOptions, options: &DiffOptions, - ) -> Self { + ) -> Result { let file = crate::options::FileArgument::NamedPath(path.into()); diff_file_content( params, @@ -105,7 +149,7 @@ pub(crate) fn diff_file_content( display_options: &DisplayOptions, diff_options: &DiffOptions, overrides: &[(LanguageOverride, Vec)], -) -> DiffResult { +) -> Result { let mut annotations = display::syntax_context::SyntaxAnnotations::default(); let guess_src = match rhs_path { FileArgument::DevNull => &lhs_src, @@ -123,7 +167,7 @@ pub(crate) fn diff_file_content( // If the two files are byte-for-byte identical, return early // rather than doing any more work. - return DiffResult { + return Ok(DiffResult { extra_info, display_path: display_path.to_owned(), file_format, @@ -136,7 +180,7 @@ pub(crate) fn diff_file_content( rhs_folds: vec![], has_byte_changes: None, has_syntactic_changes: false, - }; + }); } let mut lhs_folds = Vec::new(); @@ -145,7 +189,13 @@ pub(crate) fn diff_file_content( None => { let file_format = FileFormat::PlainText; if diff_options.check_only { - return check_only_text(&file_format, display_path, extra_info, lhs_src, rhs_src); + return Ok(check_only_text( + &file_format, + display_path, + extra_info, + lhs_src, + rhs_src, + )); } let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); @@ -174,7 +224,7 @@ pub(crate) fn diff_file_content( Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len())) }; - return DiffResult { + return Ok(DiffResult { extra_info, display_path: display_path.to_owned(), file_format: FileFormat::SupportedLanguage(language), @@ -187,7 +237,7 @@ pub(crate) fn diff_file_content( rhs_folds: vec![], has_byte_changes, has_syntactic_changes, - }; + }); } let mut change_map = ChangeMap::default(); @@ -222,6 +272,7 @@ pub(crate) fn diff_file_content( line_parser::change_positions(lhs_src, rhs_src); ( FileFormat::TextFallback { + cause: FallbackCause::GraphLimit, reason: "exceeded DFT_GRAPH_LIMIT".into(), }, lhs_positions, @@ -257,10 +308,19 @@ pub(crate) fn diff_file_content( ) } } - Err(tsp::ExceededParseErrorLimit { - error_count, - first_error_pos, - }) => { + Err(tsp::ToSyntaxError::QueryConflict(conflict, side)) => { + return Err(QueryConflict { + path: display_path.to_owned(), + side, + conflict, + }); + } + Err(tsp::ToSyntaxError::ExceededParseErrorLimit( + tsp::ExceededParseErrorLimit { + error_count, + first_error_pos, + }, + )) => { let location = match first_error_pos { Some((line, column, side)) => { let in_initial = match side { @@ -277,6 +337,7 @@ pub(crate) fn diff_file_content( None => "".to_owned(), }; let file_format = FileFormat::TextFallback { + cause: FallbackCause::ParseErrorLimit, reason: format!( "{} {} parse error{}, exceeded DFT_PARSE_ERROR_LIMIT{}", error_count, @@ -287,13 +348,13 @@ pub(crate) fn diff_file_content( }; if diff_options.check_only { - return check_only_text( + return Ok(check_only_text( &file_format, display_path, extra_info, lhs_src, rhs_src, - ); + )); } let (lhs_positions, rhs_positions) = @@ -305,6 +366,7 @@ pub(crate) fn diff_file_content( Err(tsp::ExceededByteLimit(num_bytes)) => { let format_options = FormatSizeOptions::from(BINARY).decimal_places(1); let file_format = FileFormat::TextFallback { + cause: FallbackCause::ByteLimit, reason: format!( "{} exceeded DFT_BYTE_LIMIT", format_size(num_bytes, format_options) @@ -312,13 +374,13 @@ pub(crate) fn diff_file_content( }; if diff_options.check_only { - return check_only_text( + return Ok(check_only_text( &file_format, display_path, extra_info, lhs_src, rhs_src, - ); + )); } let (lhs_positions, rhs_positions) = @@ -329,6 +391,17 @@ pub(crate) fn diff_file_content( } }; + // Folds are regions of the file, so two nodes that cover the same lines + // hold one fold between them. + folds::merge_spans( + &mut lhs_folds, + &lhs_src.split_terminator('\n').collect::>(), + ); + folds::merge_spans( + &mut rhs_folds, + &rhs_src.split_terminator('\n').collect::>(), + ); + let opposite_to_lhs = opposite_positions(&lhs_positions); let opposite_to_rhs = opposite_positions(&rhs_positions); @@ -356,7 +429,7 @@ pub(crate) fn diff_file_content( Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len())) }; - DiffResult { + Ok(DiffResult { extra_info, display_path: display_path.to_owned(), file_format, @@ -369,5 +442,5 @@ pub(crate) fn diff_file_content( rhs_folds, has_byte_changes, has_syntactic_changes, - } + }) } diff --git a/src/git.rs b/src/git.rs index 2d4c9ec6c..0ab9098b9 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,16 +1,20 @@ //! Git comparison selection and lazy source loading used by the CLI and its stdout stream. use crate::config::Params; +use crate::pairing::Pairing; +use crate::protocol; use crate::summary::DiffResult; +use anyhow::Context as _; use git2::{AttrCheckFlags, AttrValue, Delta, Diff, DiffFindOptions, DiffOptions, Oid, Repository}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::{ + fmt, path::{Path, PathBuf}, sync::Arc, }; pub(crate) type Result = std::result::Result>; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub(crate) enum Operand { Revision { r#ref: String }, @@ -113,8 +117,7 @@ impl Comparison { } } -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "snake_case")] +#[derive(Clone, Debug)] pub(crate) enum FileStatus { Added, Deleted, @@ -124,12 +127,14 @@ pub(crate) enum FileStatus { Conflicted, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug)] pub(crate) struct FileChange { pub(crate) old_path: Option, pub(crate) new_path: Option, pub(crate) status: FileStatus, pub(crate) class: Option, + /// Git's delta sides. + pub(crate) sides: Pairing, } impl FileChange { @@ -139,6 +144,106 @@ impl FileChange { .or(self.old_path.as_deref()) .expect("changed file has a path") } + + /// A standalone comparison of two paths, outside any repository. + pub(crate) fn standalone(before: &str, after: &str) -> Self { + let file_ref = |path: &str| protocol::FileRef { + path: path.to_owned(), + oid: String::new(), + mode: String::new(), + }; + let old_path = (before != "/dev/null").then(|| before.to_owned()); + let new_path = (after != "/dev/null").then(|| after.to_owned()); + let (status, sides) = match (&old_path, &new_path) { + (Some(old), Some(new)) => ( + FileStatus::Modified, + Pairing::Both { + lhs: file_ref(old), + rhs: file_ref(new), + }, + ), + (Some(old), None) => ( + FileStatus::Deleted, + Pairing::LeftOnly { lhs: file_ref(old) }, + ), + (None, Some(new)) => (FileStatus::Added, Pairing::RightOnly { rhs: file_ref(new) }), + (None, None) => panic!("a standalone comparison needs at least one path"), + }; + Self { + old_path, + new_path, + status, + class: None, + sides, + } + } + + pub(crate) fn manifest_entry(&self) -> protocol::FileChange { + protocol::FileChange { + file: self.sides.clone(), + status: match self.status { + FileStatus::Added => protocol::FileStatus::Added, + FileStatus::Deleted => protocol::FileStatus::Deleted, + FileStatus::Modified => protocol::FileStatus::Modified, + FileStatus::Renamed => protocol::FileStatus::Renamed, + FileStatus::TypeChanged => protocol::FileStatus::TypeChanged, + // Both sides exist; the file record carries the unmerged error. + FileStatus::Conflicted => protocol::FileStatus::Modified, + }, + } + } +} + +/// Why one file could not be diffed. Loading attaches it to the error, and +/// the stream turns it into the record's `code`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FileError { + UnsupportedFileType, + ReadFailed, + Binary, + NotUtf8, + Unmerged, +} + +impl FileError { + pub(crate) fn code(self) -> &'static str { + match self { + Self::UnsupportedFileType => "unsupported_file_type", + Self::ReadFailed => "read_failed", + Self::Binary => "binary", + Self::NotUtf8 => "not_utf8", + Self::Unmerged => "unmerged", + } + } +} + +impl fmt::Display for FileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::UnsupportedFileType => { + "structural diffs currently require regular text files (not symlinks or submodules)" + } + Self::ReadFailed => "could not read the source", + Self::Binary => "structural diffs currently support text files only", + Self::NotUtf8 => "the source is not valid UTF-8", + Self::Unmerged => { + "unmerged index entry: resolve the conflict before requesting a structural diff" + } + }) + } +} + +impl std::error::Error for FileError {} + +impl From<&Operand> for protocol::Snapshot { + fn from(operand: &Operand) -> Self { + match operand { + Operand::Revision { r#ref } => Self::Revision { rev: r#ref.clone() }, + Operand::Index => Self::Index, + Operand::WorkingTree => Self::WorkingTree, + Operand::EmptyTree => Self::EmptyTree, + } + } } #[derive(Debug, Deserialize)] @@ -191,23 +296,27 @@ impl Source { }) } - fn read(&self, repo: &Repository) -> Result { + fn read(&self, repo: &Repository) -> anyhow::Result { let mode = match self { Self::Absent => return Ok(String::new()), Self::Blob { mode, .. } | Self::WorkingFile { mode, .. } => mode, }; if !matches!(mode, git2::FileMode::Blob | git2::FileMode::BlobExecutable) { - return Err("structural diffs currently require regular text files (not symlinks or submodules)".into()); + return Err(FileError::UnsupportedFileType.into()); } let bytes = match self { - Self::Blob { id, .. } => repo.find_blob(*id)?.content().to_vec(), - Self::WorkingFile { path, .. } => std::fs::read(path)?, + Self::Blob { id, .. } => repo + .find_blob(*id) + .context(FileError::ReadFailed)? + .content() + .to_vec(), + Self::WorkingFile { path, .. } => std::fs::read(path).context(FileError::ReadFailed)?, Self::Absent => unreachable!(), }; if bytes.contains(&0) { - return Err("structural diffs currently support text files only".into()); + return Err(FileError::Binary.into()); } - Ok(String::from_utf8(bytes)?) + String::from_utf8(bytes).context(FileError::NotUtf8) } } @@ -222,7 +331,6 @@ pub(crate) struct DiffSession { pub(crate) comparison: Comparison, params: Arc, files: std::vec::IntoIter, - pub(crate) context_lines: u32, pub(crate) diff_options: crate::options::DiffOptions, } @@ -267,19 +375,40 @@ impl DiffSession { .ok_or("non-UTF-8 Git paths are unsupported")? .to_owned()) }; - let mut file = FileChange { - old_path: if delta.status() == Delta::Added { - None - } else { - Some(path(delta.old_file())?) + let old_path = if delta.status() == Delta::Added { + None + } else { + Some(path(delta.old_file())?) + }; + let new_path = if delta.status() == Delta::Deleted { + None + } else { + Some(path(delta.new_file())?) + }; + let file_ref = |file: git2::DiffFile<'_>, path: &str| protocol::FileRef { + path: path.to_owned(), + oid: file.id().to_string(), + mode: format!("{:o}", u32::from(file.mode())), + }; + let sides = match (&old_path, &new_path) { + (Some(old), Some(new)) => Pairing::Both { + lhs: file_ref(delta.old_file(), old), + rhs: file_ref(delta.new_file(), new), + }, + (Some(old), None) => Pairing::LeftOnly { + lhs: file_ref(delta.old_file(), old), }, - new_path: if delta.status() == Delta::Deleted { - None - } else { - Some(path(delta.new_file())?) + (None, Some(new)) => Pairing::RightOnly { + rhs: file_ref(delta.new_file(), new), }, + (None, None) => unreachable!("a delta has a path"), + }; + let mut file = FileChange { + old_path, + new_path, status, class: None, + sides, }; file.class = match AttrValue::from_string(repo.get_attr( Path::new(file.path()), @@ -323,7 +452,6 @@ impl DiffSession { comparison, params, files: pending.into_iter(), - context_lines: 3, diff_options: crate::options::DiffOptions::default(), }) } @@ -334,52 +462,49 @@ pub(crate) struct LoadedFile { pub(crate) file: FileChange, before: String, after: String, - params: Arc, - context_lines: u32, + pub(crate) params: Arc, diff_options: crate::options::DiffOptions, } impl LoadedFile { - pub(crate) fn diff(&self) -> DiffResult { - DiffResult::from_sources_with_options( + pub(crate) fn sizes(&self) -> (u64, u64) { + (self.before.len() as u64, self.after.len() as u64) + } + + /// A fold query conflict fails this file alone. `display` sets the + /// unchanged lines around each of difftastic's terminal hunks; nothing + /// else in the result depends on it. + pub(crate) fn diff( + &self, + display: &crate::options::DisplayOptions, + ) -> anyhow::Result { + Ok(DiffResult::from_sources_with_options( self.file.path(), &self.before, &self.after, &self.params, - &crate::options::DisplayOptions { - num_context_lines: self.context_lines, - ..Default::default() - }, + display, &self.diff_options, - ) + )?) } } impl DiffSession { /// Read the next file's sources without diffing them. - pub(crate) fn load(&mut self) -> Option<(FileChange, Result)> { + pub(crate) fn load(&mut self) -> Option<(FileChange, anyhow::Result)> { let pending = self.files.next()?; let result = (|| { if matches!(pending.file.status, FileStatus::Conflicted) { - return Err("unmerged index entry: resolve the conflict before requesting a structural diff".into()); + return Err(FileError::Unmerged.into()); } Ok(LoadedFile { before: pending.before.read(&self.repo)?, after: pending.after.read(&self.repo)?, file: pending.file.clone(), params: Arc::clone(&self.params), - context_lines: self.context_lines, diff_options: self.diff_options.clone(), }) })(); Some((pending.file, result)) } } - -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()))) - } -} diff --git a/src/hook.rs b/src/hook.rs deleted file mode 100644 index 742caca34..000000000 --- a/src/hook.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Trusted fold-summary hook: a JSON-RPC 2.0 server that diffr starts once per -//! session and calls over loopback HTTP. -//! -//! 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. -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 std::collections::BTreeMap; -use std::net::{TcpListener, TcpStream}; -use std::path::Path; -use std::process::{Child, Command, Stdio}; -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, - tags: Vec, - 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>; -} - -pub(crate) struct Hook { - config: HookConfig, - child: Mutex, - runtime: tokio::runtime::Runtime, - client: HttpClient, -} - -impl Hook { - pub(crate) fn spawn(config: &HookConfig, workspace: &Path) -> crate::git::Result { - let port = free_port()?; - let mut child = Command::new(&config.command[0]) - .args(&config.command[1..]) - .current_dir(&config.dir) - .env("DIFFR_HOOK_PORT", port.to_string()) - .env("DIFFR_WORKSPACE", workspace) - .stdin(Stdio::null()) - // Stdout belongs to diffr's own stream; hooks log to stderr. - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|error| format!("could not start fold hook {:?}: {error}", config.command))?; - if let Err(error) = await_listening(&mut child, port, config.startup_timeout_ms) { - let _ = child.kill(); - let _ = child.wait(); - return Err(error.into()); - } - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .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}")) - })?; - Ok(Self { - config: config.clone(), - child: Mutex::new(child), - runtime, - client, - }) - } - - /// 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(()); - } - let FileContent::Text(src) = &diff.rhs_src else { - return Ok(()); - }; - let language = match &diff.file_format { - FileFormat::SupportedLanguage(language) => { - Some(crate::parse::guess_language::language_name(*language).to_owned()) - } - _ => None, - }; - 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 texts = self - .runtime - .block_on(FoldHookClient::summarize( - &self.client, - diff.display_path.clone(), - language, - src.clone(), - folds, - )) - .map_err(|error| match error { - ClientError::Call(error) => format!("fold hook reported: {}", error.message()), - ClientError::RequestTimeout => { - format!("fold hook timed out after {}ms", self.config.timeout_ms) - } - other => format!("fold hook: {other}"), - })?; - for (key, text) in texts { - let index: usize = 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); - } - 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 -/// hook starts, which is the usual small race on a single machine. -fn free_port() -> std::io::Result { - Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) -} - -/// Poll until the hook accepts connections, or fail early if it exits. -fn await_listening(child: &mut Child, port: u16, startup_timeout_ms: u64) -> Result<(), String> { - let deadline = Instant::now() + Duration::from_millis(startup_timeout_ms); - let address = std::net::SocketAddr::from(([127, 0, 0, 1], port)); - loop { - if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_ok() { - return Ok(()); - } - if let Some(status) = child.try_wait().map_err(|error| error.to_string())? { - return Err(format!("fold hook exited during startup with {status}")); - } - if Instant::now() >= deadline { - return Err(format!( - "fold hook did not listen on port {port} within {startup_timeout_ms}ms" - )); - } - std::thread::sleep(Duration::from_millis(20)); - } -} - -impl Drop for Hook { - fn drop(&mut self) { - let mut child = self.child.lock().unwrap(); - let _ = child.kill(); - let _ = child.wait(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use std::sync::Arc; - - fn hook(mode: &str, timeout_ms: u64) -> crate::git::Result { - let config = HookConfig { - dir: env!("CARGO_MANIFEST_DIR").into(), - command: vec![ - "python3".into(), - "tests/hooks/rpc_server.py".into(), - mode.into(), - ], - tags: Some(vec!["body".into()]), - min_lines: 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(), - ) - } - - const LARGE: &str = "def f():\n a()\n b()\n c()\n\ndef g():\n d()\n"; - - #[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())); - } - - #[test] - fn concurrent_calls_share_one_client() { - let hook = Arc::new(hook("echo", 5000).unwrap()); - let workers: Vec<_> = (0..8) - .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() - }) - }) - .collect(); - for worker in workers { - assert_eq!(worker.join().unwrap().as_deref(), Some("pseudo Body")); - } - } - - #[test] - fn small_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(); - } - - #[test] - fn startup_failures_are_reported() { - let error = hook("exit", 5000) - .err() - .expect("exit must fail") - .to_string(); - assert!(error.contains("exited during startup"), "{error}"); - } - - #[test] - fn call_failures_are_reported_without_losing_the_diff() { - let mut result = diff(LARGE); - let error = hook("slow", 200) - .unwrap() - .summarize(&mut result) - .unwrap_err(); - assert!(error.contains("timed out"), "{error}"); - let error = hook("error", 5000) - .unwrap() - .summarize(&mut result) - .unwrap_err(); - assert!(error.contains("declined"), "{error}"); - let error = hook("bad", 5000) - .unwrap() - .summarize(&mut result) - .unwrap_err(); - assert!(error.starts_with("fold hook:"), "{error}"); - assert!(result.rhs_folds.iter().all(|fold| fold.summary.is_none())); - } -} 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..c2d327391 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,13 +54,12 @@ mod files; mod git; mod gitattributes; mod hash; -mod hook; mod line_parser; mod lines; mod options; +mod pairing; mod parse; -mod review; -mod stream; +pub(crate) mod protocol; mod summary; mod version; mod words; @@ -118,7 +117,9 @@ use rayon::prelude::*; use strum::IntoEnumIterator; use typed_arena::Arena; +use crate::engine::QueryConflict; use crate::options::{DiffOptions, DisplayMode, DisplayOptions, FileArgument, Mode}; +use crate::parse::folds::Conflict; use crate::parse::syntax::init_all_info; use crate::parse::tree_sitter_parser as tsp; use crate::summary::{DiffResult, FileContent, FileFormat}; @@ -198,7 +199,7 @@ fn run_debug() { Some(lang) => { let ts_lang = params.language(lang); let arena = Arena::new(); - let ast = tsp::parse(&arena, &src, ts_lang, ignore_comments); + let ast = conflict_or_die(tsp::parse(&arena, &src, ts_lang, ignore_comments)); init_all_info(&ast, &[]); println!("{:#?}", ast); } @@ -221,7 +222,7 @@ fn run_debug() { Some(lang) => { let ts_lang = params.language(lang); let arena = Arena::new(); - let ast = tsp::parse(&arena, &src, ts_lang, ignore_comments); + let ast = conflict_or_die(tsp::parse(&arena, &src, ts_lang, ignore_comments)); init_all_info(&ast, &[]); syntax::print_as_dot(&ast); } @@ -272,14 +273,17 @@ fn run_debug() { language_overrides, binary_overrides, } => { - let diff_result = diff_conflicts_file( - params, - &display_path, - &path, - &display_options, - &diff_options, - &language_overrides, - &binary_overrides, + let diff_result = diff_or_die( + diff_conflicts_file( + params, + &display_path, + &path, + &display_options, + &diff_options, + &language_overrides, + &binary_overrides, + ), + display_options.use_color, ); print_diff_result(&display_options, &diff_result); @@ -380,19 +384,22 @@ fn run_debug() { } } _ => { - let diff_result = diff_file( - params, - &display_path, - renamed, - &lhs_path, - &rhs_path, - lhs_permissions.as_ref(), - rhs_permissions.as_ref(), - &display_options, - &diff_options, - false, - &language_overrides, - &binary_overrides, + let diff_result = diff_or_die( + diff_file( + params, + &display_path, + renamed, + &lhs_path, + &rhs_path, + lhs_permissions.as_ref(), + rhs_permissions.as_ref(), + &display_options, + &diff_options, + false, + &language_overrides, + &binary_overrides, + ), + display_options.use_color, ); if diff_result.has_reportable_change() { encountered_changes = true; @@ -436,7 +443,7 @@ fn diff_file( missing_as_empty: bool, overrides: &[(LanguageOverride, Vec)], binary_overrides: &[glob::Pattern], -) -> DiffResult { +) -> Result { let (lhs_bytes, rhs_bytes) = read_files_or_die(lhs_path, rhs_path, missing_as_empty); let (mut lhs_src, mut rhs_src) = match ( @@ -452,7 +459,7 @@ fn diff_file( } else { Some((lhs_bytes.len(), rhs_bytes.len())) }; - return DiffResult { + return Ok(DiffResult { extra_info: renamed, display_path: display_path.to_owned(), file_format: FileFormat::Binary, @@ -465,7 +472,7 @@ fn diff_file( rhs_folds: vec![], has_byte_changes, has_syntactic_changes: false, - }; + }); } (ProbableFileKind::Text(lhs_src), ProbableFileKind::Text(rhs_src), _) => (lhs_src, rhs_src), }; @@ -531,7 +538,7 @@ fn diff_conflicts_file( diff_options: &DiffOptions, overrides: &[(LanguageOverride, Vec)], binary_overrides: &[glob::Pattern], -) -> DiffResult { +) -> Result { let bytes = read_file_or_die(path); let mut src = match guess_content(&bytes, path, binary_overrides) { ProbableFileKind::Text(src) => src, @@ -633,23 +640,55 @@ fn diff_directories<'a>( let lhs_path = FileArgument::NamedPath(Path::new(lhs_dir).join(&rel_path)); let rhs_path = FileArgument::NamedPath(Path::new(rhs_dir).join(&rel_path)); - diff_file( - params, - &rel_path.display().to_string(), - None, - &lhs_path, - &rhs_path, - lhs_path.permissions().as_ref(), - rhs_path.permissions().as_ref(), - &display_options, - &diff_options, - true, - &overrides, - &binary_overrides, + diff_or_die( + diff_file( + params, + &rel_path.display().to_string(), + None, + &lhs_path, + &rhs_path, + lhs_path.permissions().as_ref(), + rhs_path.permissions().as_ref(), + &display_options, + &diff_options, + true, + &overrides, + &binary_overrides, + ), + display_options.use_color, ) }) } +/// The difftastic-style modes stop at a query conflict, as they do at an +/// unreadable file. +fn diff_or_die(result: Result, use_color: bool) -> DiffResult { + match result { + Ok(diff) => diff, + Err(conflict) => { + print_error(&conflict.to_string(), use_color); + std::process::exit(EXIT_BAD_ARGUMENTS); + } + } +} + +/// The syntax dumps stop at a fold query conflict. +fn conflict_or_die(result: Result) -> T { + match result { + Ok(value) => value, + Err(conflict) => { + eprintln!( + "line {}: fold query patterns {} and {} capture the same {} with different fold ranges", + conflict.line + 1, + conflict.patterns.0, + conflict.patterns.1, + conflict.kind + ); + std::process::exit(EXIT_BAD_ARGUMENTS); + } + } +} + fn print_diff_result(display_options: &DisplayOptions, summary: &DiffResult) { match (&summary.lhs_src, &summary.rhs_src) { (FileContent::Text(lhs_src), FileContent::Text(rhs_src)) => { @@ -823,7 +862,8 @@ mod tests { &DisplayOptions::default(), &DiffOptions::default(), &[], - ); + ) + .unwrap(); assert_eq!(res.lhs_positions, vec![]); assert_eq!(res.rhs_positions, vec![]); diff --git a/src/pairing.rs b/src/pairing.rs new file mode 100644 index 000000000..701a5588e --- /dev/null +++ b/src/pairing.rs @@ -0,0 +1,96 @@ +//! Which sides of a comparison a thing exists on. +//! +//! Sides are always `lhs` (before) and `rhs` (after). On the wire a +//! `Pairing` serializes by presence: `{lhs, rhs}`, `{lhs}`, or `{rhs}`. + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Which sides a thing exists on. Serializes by presence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pairing { + Both { lhs: T, rhs: T }, + LeftOnly { lhs: T }, + RightOnly { rhs: T }, +} + +impl Pairing { + /// The same sides, each value transformed by `f`. + pub(crate) fn map(self, mut f: impl FnMut(T) -> U) -> Pairing { + match self { + Self::Both { lhs, rhs } => Pairing::Both { + lhs: f(lhs), + rhs: f(rhs), + }, + Self::LeftOnly { lhs } => Pairing::LeftOnly { lhs: f(lhs) }, + Self::RightOnly { rhs } => Pairing::RightOnly { rhs: f(rhs) }, + } + } +} + +#[derive(Serialize, Deserialize)] +struct PairingRepr { + #[serde(skip_serializing_if = "Option::is_none")] + lhs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rhs: Option, +} + +impl Serialize for Pairing { + fn serialize(&self, serializer: S) -> Result { + let repr = match self { + Self::Both { lhs, rhs } => PairingRepr { + lhs: Some(lhs), + rhs: Some(rhs), + }, + Self::LeftOnly { lhs } => PairingRepr { + lhs: Some(lhs), + rhs: None, + }, + Self::RightOnly { rhs } => PairingRepr { + lhs: None, + rhs: Some(rhs), + }, + }; + repr.serialize(serializer) + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Pairing { + fn deserialize>(deserializer: D) -> Result { + match PairingRepr::deserialize(deserializer)? { + PairingRepr { + lhs: Some(lhs), + rhs: Some(rhs), + } => Ok(Self::Both { lhs, rhs }), + PairingRepr { + lhs: Some(lhs), + rhs: None, + } => Ok(Self::LeftOnly { lhs }), + PairingRepr { + lhs: None, + rhs: Some(rhs), + } => Ok(Self::RightOnly { rhs }), + PairingRepr { + lhs: None, + rhs: None, + } => Err(D::Error::custom("a pairing needs at least one side")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_pairing_needs_a_side() { + let error = serde_json::from_value::>(json!({})).unwrap_err(); + assert!(error.to_string().contains("at least one side")); + assert_eq!( + serde_json::from_value::>(json!({"rhs": 7})).unwrap(), + Pairing::RightOnly { rhs: 7 } + ); + } +} diff --git a/src/parse/fold_tests.rs b/src/parse/fold_tests.rs new file mode 100644 index 000000000..479cb3e9c --- /dev/null +++ b/src/parse/fold_tests.rs @@ -0,0 +1,409 @@ +//! End-to-end fold checks: folds found by the parse, paired by the matcher, +//! over small sources and the real PR fixtures under examples/review/real. +use crate::lines::{SourcePosition, SourceRange}; + +fn text<'a>(source: &'a str, range: &SourceRange) -> &'a str { + let offset = |p: &SourcePosition| { + let line = p.line.as_usize(); + let prefix = source + .split_inclusive('\n') + .take(line) + .map(str::len) + .sum::(); + prefix + p.byte_column + }; + let start = offset(&range.start); + let end = offset(&range.end); + assert!(start < end, "nonempty ranges"); + &source[start..end] // Bounds and UTF-8 boundary validation. +} + +mod folds { + use super::text; + use crate::lines::SourceRange; + use crate::parse::folds::{Fold, FoldMatch}; + use crate::summary::DiffResult; + use std::fmt::Write as _; + + /// The fold on the other side matched with `fold`, if any. + fn counterpart<'a>(fold: &Fold, other_side: &'a [Fold]) -> Option<&'a Fold> { + let FoldMatch::Matched { opposite } = fold.match_kind else { + return None; + }; + let other = other_side + .iter() + .find(|other| other.syntax_id == opposite) + .expect("a matched fold's opposite is a fold"); + Some(other) + } + /// A fold's range and its counterpart's, when it has one. + fn paired<'a>( + fold: &'a Fold, + other_side: &'a [Fold], + ) -> Option<(&'a SourceRange, &'a SourceRange)> { + counterpart(fold, other_side).map(|other| (&fold.range, &other.range)) + } + /// A fold's range, when it has no counterpart. + fn added<'a>(fold: &'a Fold, other_side: &[Fold]) -> Option<&'a SourceRange> { + match counterpart(fold, other_side) { + Some(_) => None, + None => Some(&fold.range), + } + } + + #[test] + fn renamed_function_with_changed_body_uses_structural_correspondence() { + let lhs = "fn run() {\n old_work();\n}\n"; + let rhs = "fn execute() {\n old_work();\n new_work();\n}\n"; + let diff = DiffResult::from_sources("a.rs", lhs, rhs); + assert!(diff.lhs_folds.iter().any(|f| { + paired(f, &diff.rhs_folds).is_some_and(|(l, r)| { + l.start.line.as_usize() == 0 + && l.end.line.as_usize() == 2 + && r.end.line.as_usize() == 3 + }) + })); + } + + #[test] + fn call_arguments_are_not_fold_candidates() { + let diff = + DiffResult::from_sources("a.rs", "", "fn run() {\n call(123, other(456));\n}\n"); + assert_eq!(diff.rhs_folds.len(), 1); + } + + #[test] + fn multiline_atoms_and_test_bodies_keep_fold_metadata() { + let src = "def test_read():\n value = \"\"\"first\nsecond\"\"\"\n return value\n"; + let result = DiffResult::from_sources("a.py", "", src); + assert!(result + .rhs_folds + .iter() + .any(|fold| fold.tags.iter().any(|tag| tag == "test"))); + assert!(result + .rhs_folds + .iter() + .any(|fold| fold.placeholder == "String" + && added(fold, &result.lhs_folds) + .is_some_and(|r| text(src, r) == "\"\"\"first\nsecond\"\"\""))); + } + + #[test] + fn flattened_test_body_keeps_the_existing_string_match_on_both_sides() { + let lhs = "def test_doc():\n \"\"\"some shared words before\"\"\"\n"; + let rhs = "def test_doc():\n \"\"\"some shared words after\"\"\"\n"; + let result = DiffResult::from_sources("a.py", lhs, rhs); + // The body and the docstring it holds are one flattened node, which + // keeps both folds, the body's first. + assert_eq!(result.lhs_folds.len(), 2); + assert_eq!(result.rhs_folds.len(), 2); + let fold = &result.lhs_folds[0]; + assert_eq!(fold.tags, ["body", "test"]); + let (left, right) = + paired(fold, &result.rhs_folds).expect("reuse the replaced-string correspondence"); + assert_eq!( + text(lhs, left), + "\n \"\"\"some shared words before\"\"\"" + ); + assert_eq!( + text(rhs, right), + "\n \"\"\"some shared words after\"\"\"" + ); + } + + #[test] + fn imports_remain_individual_typed_folds() { + let source = "import os\nimport sys\n"; + for (lhs, rhs) in [ + ( + "import os\nimport sys\nx = 1\n", + "import os\nimport sys\nx = 2\n", + ), + ("", source), + (source, ""), + ] { + let diff = DiffResult::from_sources("a.py", lhs, rhs); + for (folds, own, opposite, opposite_folds) in [ + (&diff.lhs_folds, lhs, rhs, &diff.rhs_folds), + (&diff.rhs_folds, rhs, lhs, &diff.lhs_folds), + ] { + if own.is_empty() { + assert!(folds.is_empty()); + continue; + } + assert_eq!(folds.len(), 2); + for (fold, expected) in folds.iter().zip(["import os", "import sys"]) { + assert_eq!(fold.tags, ["import"]); + assert_eq!(text(own, &fold.range), expected); + match counterpart(fold, opposite_folds) { + Some(other) => assert_eq!(text(opposite, &other.range), expected), + None => assert!(opposite.is_empty()), + } + } + } + } + } + + #[test] + fn imports_outside_displayed_context_remain_available() { + let mut body = String::new(); + for i in 0..30 { + writeln!(body, " value_{i} = {i}").unwrap(); + } + let lhs = format!("import os\n\ndef run():\n{body}\n"); + let rhs = lhs.replace("value_29 = 29", "value_29 = 999"); + let diff = DiffResult::from_sources("a.py", &lhs, &rhs); + assert!(diff + .lhs_folds + .iter() + .any(|f| matches!(f.placeholder.as_str(), "Import" | "Imports"))); + } + + #[test] + fn import_folds_remain_one_sided_when_file_added_or_deleted() { + let source = "import os\n\ndef f():\n return os.getcwd()\n"; + let added_diff = DiffResult::from_sources("a.py", "", source); + assert!(added_diff.lhs_folds.is_empty()); + assert_eq!( + text( + source, + added(&added_diff.rhs_folds[0], &added_diff.lhs_folds).unwrap() + ), + "import os" + ); + let deleted_diff = DiffResult::from_sources("a.py", source, ""); + assert!(deleted_diff.rhs_folds.is_empty()); + assert_eq!( + text( + source, + added(&deleted_diff.lhs_folds[0], &deleted_diff.rhs_folds).unwrap() + ), + "import os" + ); + } + + #[test] + fn nested_try_catch_bodies_pair_and_keep_delimiters() { + let lhs = "function run() {\n try {\n work(1);\n } catch (error) {\n report(error);\n }\n}\n"; + let rhs = lhs.replace("work(1)", "work(2)"); + let review = DiffResult::from_sources("a.ts", lhs, &rhs); + for expected in [ + "\n try {\n work(1);\n } catch (error) {\n report(error);\n }\n", + "\n work(1);\n ", + "\n report(error);\n ", + ] { + let expected_rhs = expected.replace("work(1)", "work(2)"); + assert!( + review.lhs_folds.iter().any(|f| { + paired(f, &review.rhs_folds).is_some_and(|(l, r)| { + text(lhs, l) == expected && text(&rhs, r) == expected_rhs + }) + }), + "missing paired fold for {expected:?}" + ); + } + } + + #[test] + fn added_rust_test_body_is_foldable_without_hiding_signature_or_brace() { + let lhs = include_str!("../../examples/review/real/08-ripgrep-3496/before.rs"); + let rhs = include_str!("../../examples/review/real/08-ripgrep-3496/after.rs"); + let review = DiffResult::from_sources("walk.rs", lhs, rhs); + let signature = rhs + .lines() + .position(|l| l.contains("fn max_depth_does_not_load_unreachable_ignore_files()")) + .unwrap(); + assert!(review.rhs_folds.iter().any(|f| { + added(f, &review.lhs_folds).is_some_and(|r| { + r.start.line.as_usize() == signature + && text(rhs, r).contains("let td = tmpdir();") + && rhs.lines().nth(r.end.line.as_usize()).unwrap().trim() == "}" + }) + })); + } + + #[test] + fn python_suites_and_go_blocks_are_foldable() { + for (path, src, expected) in [ + ( + "a.py", + "def run():\n try:\n work()\n except Exception:\n recover()\n", + "\n work()", + ), + ( + "a.go", + "package main\nfunc run() {\n if ready {\n work()\n }\n}\n", + "\n work()\n ", + ), + ] { + let review = DiffResult::from_sources(path, "", src); + assert!( + review + .rhs_folds + .iter() + .any(|f| added(f, &review.lhs_folds).is_some_and(|r| text(src, r) == expected)), + "missing body fold in {path}" + ); + } + } + + #[test] + fn inline_collections_have_exact_paired_byte_ranges() { + let lhs = "const x = [\"☕\", oldValue];\n"; + let rhs = "const x = [\"☕\", newValue];\n"; + let review = DiffResult::from_sources("a.ts", lhs, rhs); + assert!(review.lhs_folds.iter().any(|f| { + paired(f, &review.rhs_folds).is_some_and(|(l, r)| { + text(lhs, l) == "\"☕\", oldValue" && text(rhs, r) == "\"☕\", newValue" + }) + })); + } +} + +mod matcher { + use crate::parse::folds::FoldMatch; + use crate::parse::syntax::MatchKind; + use crate::summary::DiffResult; + + #[test] + fn a_reindented_rename_target_stays_an_unchanged_token() { + let lhs = include_str!("../../examples/review/real/02-review-175/before.ts"); + let rhs = include_str!("../../examples/review/real/02-review-175/after.ts"); + let review = DiffResult::from_sources("parser.ts", lhs, rhs); + let token = review + .lhs_positions + .iter() + .find(|p| p.pos.line.0 == 247 && p.pos.start_col == 6) + .unwrap(); + let MatchKind::UnchangedToken { opposite_pos, .. } = &token.kind else { + panic!( + "the renamed binding is an unchanged token: {:?}", + token.kind + ); + }; + assert_eq!( + (opposite_pos[0].line.0, opposite_pos[0].start_col), + (253, 8) + ); + assert!(matches!( + review.lhs_folds[0].match_kind, + FoldMatch::Matched { .. } + )); + } +} + +mod results { + use super::text; + use crate::display::line_layout::aligned_rows; + use crate::parse::folds::FoldMatch; + use crate::summary::{DiffResult, FileContent, FileFormat}; + + #[test] + fn new_and_deleted_functions_have_folds() { + let source = "fn run() {\n work();\n}\n"; + for (lhs, rhs) in [("", source), (source, "")] { + let diff = DiffResult::from_sources("a.rs", lhs, rhs); + assert!(!diff.lhs_folds.is_empty() || !diff.rhs_folds.is_empty()); + } + } + + #[test] + fn unicode_and_crlf_sources_are_kept_exactly() { + let lhs = + "def café():\r\n x = 1\r\n return (\r\n x,\r\n\r\n '☕',\r\n )\r\n"; + let rhs = lhs.replace("x = 1", "x = 2"); + let result = DiffResult::from_sources("a.py", lhs, &rhs); + assert!(matches!(&result.rhs_src, FileContent::Text(s) if s == &rhs)); + } + + #[test] + fn identical_files_keep_alignment_for_showing_hidden_source() { + let source = "fn unchanged() {\n work();\n}\n"; + let diff = DiffResult::from_sources("a.rs", source, source); + let rows = aligned_rows((source, source), (&diff.lhs_positions, &diff.rhs_positions)); + assert_eq!( + rows, + [(Some(0), Some(0)), (Some(1), Some(1)), (Some(2), Some(2))] + ); + } + + #[test] + fn unsupported_language_uses_text_diff_without_folds() { + let result = DiffResult::from_sources("a.txt", "hello old\n", "hello new\n"); + assert!(matches!(result.file_format, FileFormat::PlainText)); + assert!(result.lhs_folds.is_empty() && result.rhs_folds.is_empty()); + } + + #[test] + fn real_fixture_folds_have_valid_reciprocal_ranges() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/review/real"); + for dir in std::fs::read_dir(root).unwrap() { + let dir = dir.unwrap().path(); + let p: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.join("provenance.json")).unwrap()) + .unwrap(); + let read_source = |side: &str| { + std::fs::read_to_string(dir.join(p["sources"][side]["file"].as_str().unwrap())) + .unwrap() + }; + let (lhs_src, rhs_src) = (read_source("lhs"), read_source("rhs")); + let path = p["sources"]["rhs"]["path"].as_str().unwrap(); + let result = DiffResult::from_sources(path, &lhs_src, &rhs_src); + for (folds, own_src, opposite_src, opposite_folds) in [ + (&result.lhs_folds, &lhs_src, &rhs_src, &result.rhs_folds), + (&result.rhs_folds, &rhs_src, &lhs_src, &result.lhs_folds), + ] { + for fold in folds { + text(own_src, &fold.range); + let counterparts: Vec<_> = opposite_folds + .iter() + .filter(|other| { + fold.match_kind + == FoldMatch::Matched { + opposite: other.syntax_id, + } + }) + .collect(); + assert!( + counterparts.len() <= 1, + "a fold has at most one counterpart" + ); + for other in counterparts { + text(opposite_src, &other.range); + assert_eq!( + other.match_kind, + FoldMatch::Matched { + opposite: fold.syntax_id + }, + "matched folds must be reciprocal" + ); + } + } + } + } + } + + #[test] + fn byte_limit_fallback_has_no_folds() { + let file = crate::options::FileArgument::NamedPath("a.py".into()); + let options = crate::options::DiffOptions { + byte_limit: 1, + ..Default::default() + }; + let diff = crate::diff_file_content( + &crate::config::Params::default(), + "a.py", + None, + &file, + &file, + "x = 1\n", + "x = 2\n", + &Default::default(), + &options, + &[], + ) + .unwrap(); + assert!(matches!(diff.file_format, FileFormat::TextFallback { .. })); + assert!(diff.lhs_folds.is_empty() && diff.rhs_folds.is_empty()); + } +} diff --git a/src/parse/folds.rs b/src/parse/folds.rs index 8714add33..0a0d9eb0b 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -1,10 +1,19 @@ //! Fold metadata is attached during parsing; pairing reuses syntax identity. +//! +//! A fold is a region of a file, not a syntax node: two captures that cover +//! the same lines — from one query, or from two plugins' queries — are one +//! fold whose tags are the union of theirs (see [`merge_spans`]). The merged +//! fold is owned by the innermost node that produced it, the one whose +//! extent really is that region, and carries that node's `syntax_id` and its +//! pairing, so alignment follows the node the matcher paired. use super::query::node_range; use crate::config::query::AnnotationQuery; -use crate::diff::changes::ChangeKind; -use crate::hash::{DftHashMap, DftHashSet}; +use crate::diff::changes::{ChangeKind, ChangeMap}; +use crate::hash::DftHashMap; use crate::lines::{SourcePosition, SourceRange}; -use crate::parse::syntax::{FoldMetadata, Syntax}; +use crate::parse::syntax::{FoldMetadata, Syntax, SyntaxId}; +use hashbrown::hash_map::Entry; +use std::collections::BTreeSet; use streaming_iterator::StreamingIterator as _; use tree_sitter::{QueryCursor, Tree}; @@ -13,34 +22,53 @@ pub(crate) struct Fold { pub(crate) tags: Vec, /// Source on this side; may span multiple syntax nodes. pub(crate) range: SourceRange, + /// The syntax node the fold was built on. Engine-internal identity: + /// syntax ids are unique across both sides of a file and never reach the + /// wire, where the projection numbers regions itself. + pub(crate) syntax_id: SyntaxId, pub(crate) match_kind: FoldMatch, - /// Text shown in place of the source, including supplied pseudocode. + /// Text shown in place of the source. pub(crate) placeholder: String, - /// Longer replacement text supplied by a configured fold hook. - pub(crate) summary: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FoldMatch { - /// A corresponding fold, whose contents may differ. Collapsed state is client-owned. - Unchanged { - opposite: SourceRange, + /// The node of the mutually matched fold on the other side, whose + /// contents may differ. It records this fold back as its own `opposite`. + Matched { + opposite: SyntaxId, }, Novel, } +/// Two query patterns captured the same syntax node with different fold +/// ranges. Which range wins would depend on query order, so the file is not +/// diffed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Conflict { + /// 0-based line the node starts on. + pub(crate) line: usize, + /// The node's tree-sitter kind, such as `function_item`. + pub(crate) kind: String, + /// The two patterns, by index in the language's fold query, sorted. The + /// query is one text here, so an index is all a pattern can be named by. + pub(crate) patterns: (usize, usize), +} + /// Interpret configurable fold captures in their own query traversal. +/// +/// Captures of the same node merge their tags when their ranges agree; when +/// they disagree the result is a [`Conflict`]. pub(crate) fn classify( tree: &Tree, src: &str, compiled: Option<&AnnotationQuery>, -) -> DftHashMap { - let mut kinds = DftHashMap::default(); +) -> Result, Conflict> { + let mut kinds: DftHashMap = DftHashMap::default(); let Some(compiled) = compiled else { - return kinds; + return Ok(DftHashMap::default()); }; let query = &compiled.query; - let mut ambiguous_folds = DftHashSet::default(); let mut cursor = QueryCursor::new(); let mut matches = cursor.matches(query, tree.root_node(), src.as_bytes()); while let Some(matched) = matches.next() { @@ -68,27 +96,44 @@ pub(crate) fn classify( end: node_range(close.node).start, } } + // No closing delimiter: the fold runs to the end of its + // node, from an opening token that may precede the node, + // such as the `:` ahead of a Python block. + (Some(open), None) if open.node.end_byte() <= fold.node.end_byte() => SourceRange { + start: node_range(open.node).end, + end: node_range(fold.node).end, + }, (None, None) => node_range(fold.node), _ => continue, }; - if region.start == region.end || ambiguous_folds.contains(&fold.node.id()) { + if region.start == region.end { continue; } - let metadata = kinds.entry(fold.node.id()).or_insert_with(|| FoldMetadata { - tags: Vec::new(), - range_override: Some(region), - }); + let (metadata, first_pattern) = kinds.entry(fold.node.id()).or_insert(( + FoldMetadata { + tags: Vec::new(), + range_override: Some(region), + }, + matched.pattern_index, + )); if metadata.range_override != Some(region) { - kinds.remove(&fold.node.id()); - ambiguous_folds.insert(fold.node.id()); - continue; + let mut patterns = [*first_pattern, matched.pattern_index]; + patterns.sort(); + return Err(Conflict { + line: fold.node.start_position().row, + kind: fold.node.kind().to_owned(), + patterns: (patterns[0], patterns[1]), + }); } metadata.tags.extend(pattern.tags.iter().cloned()); metadata.tags.sort(); metadata.tags.dedup(); } } - kinds + Ok(kinds + .into_iter() + .map(|(id, (metadata, _))| (id, metadata)) + .collect()) } /// Lists already retain the two edges of their interior, even without delimiters. @@ -110,9 +155,7 @@ pub(crate) fn interior_range( } } -fn range(node: &Syntax<'_>) -> Option { - let metadata = node.info().fold.borrow(); - let metadata = metadata.as_ref()?; +fn range(node: &Syntax<'_>, metadata: &FoldMetadata) -> Option { let region = match (metadata.range_override, node) { (Some(region), _) => region, ( @@ -144,25 +187,151 @@ fn range(node: &Syntax<'_>) -> Option { Some(region) } -/// Project a side-local annotation using the same correspondence as MatchedPos. -pub(crate) fn project(node: &Syntax<'_>, change: ChangeKind<'_>) -> Option { - let own = node.info().fold.borrow().clone()?; - let own_range = range(node)?; - let opposite = match change { +/// Half-open whole lines a fold hides, clamped to the file. +/// +/// A fold covers only the lines it holds whole, so collapsing it hides +/// exactly those and no code the reader needs. A fold that opens mid-line — +/// after the `{`, `:` or `(` of a header — starts on the line after: the +/// header is code the fold does not cover, and belongs to the leaf before +/// it. A fold that opens at the start of its line's text, such as a comment +/// block or an import, starts on that line. The end is the mirror: the line +/// a fold closes on is inside it only when nothing but whitespace follows +/// the close, so `] {`, which ends a collection and opens the body after it, +/// is in neither fold. +pub(crate) fn line_span(fold: &Fold, lines: &[&str]) -> (usize, usize) { + let line_count = lines.len(); + let range = &fold.range; + let first = range.start.line.as_usize(); + // Code before the fold opens on its first line, or after it closes on + // its last, is code the fold does not cover: the line stays outside. + let leading = lines.get(first).is_some_and(|line| { + let column = range.start.byte_column.min(line.len()); + !line[..column].trim().is_empty() + }); + let start = if leading { first + 1 } else { first }; + let last = range.end.line.as_usize(); + let trailing = lines.get(last).is_some_and(|line| { + let column = range.end.byte_column.min(line.len()); + !line[column..].trim().is_empty() + }); + let end = if trailing { last } else { last + 1 }; + ( + start.min(line_count), + end.min(line_count).max(start.min(line_count)), + ) +} + +/// Make fold line spans a strict tree: nested or disjoint, never crossing. +/// +/// Leaves tile whole lines, so two folds can share a line only if one +/// contains the other. The parser can hand over folds that cross on one +/// line, such as a collection whose closer sits on the line that opens the +/// next body (`for x in [ … ] {`). The rule: the earlier fold gives the +/// shared line to the later one, so its span ends where the later fold +/// starts. A span left with fewer than two lines hides nothing and is +/// dropped (`None`). Output is in input order. +pub(crate) fn nested_spans(spans: &[(usize, usize)]) -> Vec> { + let mut order: Vec = (0..spans.len()).collect(); + order.sort_by_key(|&index| (spans[index].0, std::cmp::Reverse(spans[index].1))); + let mut out: Vec> = spans.iter().map(|&span| Some(span)).collect(); + let mut open: Vec = Vec::new(); + let closed = |out: &[Option<(usize, usize)>], top: usize, start: usize| { + out[top].is_none_or(|(_, top_end)| top_end <= start) + }; + for index in order { + let (start, end) = spans[index]; + while open.last().is_some_and(|&top| closed(&out, top, start)) { + open.pop(); + } + // Every open fold that ends before this one does crosses it: clip + // each to hand over the shared lines. + for &top in open.iter().rev() { + let Some((top_start, top_end)) = out[top] else { + continue; + }; + if top_end >= end { + break; + } + out[top] = (start > top_start).then_some((top_start, start)); + } + while open.last().is_some_and(|&top| closed(&out, top, start)) { + open.pop(); + } + open.push(index); + } + for span in &mut out { + if span.is_some_and(|(start, end)| end - start < 2) { + *span = None; + } + } + out +} + +/// Every line where a span starts or ends: where leaves must split. +pub(crate) fn split_lines(spans: impl IntoIterator) -> BTreeSet { + spans + .into_iter() + .flat_map(|(start, end)| [start, end]) + .collect() +} + +/// The node the matcher paired `node` with, when the pairing is mutual. +/// +/// Every matcher step records a pair on both nodes, but the nested slider +/// fixes run one side at a time and move a pair's record from a list onto +/// its child (or parent) on that side only: afterwards the child names the +/// opposite list, while the opposite list still names the original. Such a +/// one-way record is not a pair. +pub(crate) fn partner<'a>( + node: &Syntax<'_>, + change: ChangeKind<'a>, + change_map: &ChangeMap<'a>, +) -> Option<&'a Syntax<'a>> { + let other = match change { ChangeKind::Unchanged(other) | ChangeKind::ReplacedComment(_, other) - | ChangeKind::ReplacedString(_, other) => range(other), - _ => None, + | ChangeKind::ReplacedString(_, other) => other, + ChangeKind::IgnoredPunctuation | ChangeKind::Novel => return None, }; - let match_kind = match opposite { - Some(opposite) => FoldMatch::Unchanged { opposite }, - None => FoldMatch::Novel, + let back = match change_map + .get(other) + .expect("the matcher records a change on every node") + { + ChangeKind::Unchanged(back) + | ChangeKind::ReplacedComment(_, back) + | ChangeKind::ReplacedString(_, back) => back, + ChangeKind::IgnoredPunctuation | ChangeKind::Novel => return None, }; + (back.id() == node.id()).then_some(other) +} + +/// The fold annotated on `node`, matched with the fold of the `partner` the +/// matcher paired `node` with (see `partner`). A fold is owned by its syntax +/// node, so two folds align exactly when the matcher paired their nodes. +pub(crate) fn project(node: &Syntax<'_>, partner: Option<&Syntax<'_>>) -> Option { + let own = node.info().fold.borrow(); + let metadata = own.as_ref()?; + let own_range = range(node, metadata)?; + // A fold whose range is empty is no fold, on either side. + let opposite = partner.filter(|partner| { + partner + .info() + .fold + .borrow() + .as_ref() + .is_some_and(|metadata| range(partner, metadata).is_some()) + }); Some(Fold { - tags: own.tags.clone(), + tags: metadata.tags.clone(), range: own_range, - match_kind, - placeholder: own + syntax_id: node.id(), + match_kind: match opposite { + Some(partner) => FoldMatch::Matched { + opposite: partner.id(), + }, + None => FoldMatch::Novel, + }, + placeholder: metadata .tags .first() .map(|tag| { @@ -173,6 +342,137 @@ pub(crate) fn project(node: &Syntax<'_>, change: ChangeKind<'_>) -> Option .unwrap_or_default() }) .unwrap_or_else(|| "…".into()), - summary: None, }) } + +/// Fold the folds of one side that cover the same lines into one. +/// +/// Two captures — one query's, or two plugins' — can fold the same region +/// of the file from different syntax nodes, such as a Rust `Self { … }` as +/// a function's tail expression and the `{ … }` field list inside it. That +/// is one fold: its tags are the union of theirs, sorted and deduplicated. +/// (Two ranges on the *same* node are a query conflict instead, and the +/// file is not diffed; see [`classify`].) +/// +/// The merged fold is owned by the innermost node that produced it — the +/// one whose range lies inside the others', and among equal ranges the one +/// the preorder walk reached last. It keeps that node's `syntax_id` and its +/// pairing, so alignment follows the node whose extent is the fold's +/// region. `folds` is in preorder and stays in it, each merged fold where +/// its outermost contributor stood. +/// +/// Only folds that hide something merge: a fold of fewer than two lines is +/// no region (see `nested_spans`), and two of them covering one line are +/// two byte ranges the reader never sees. +pub(crate) fn merge_spans(folds: &mut Vec, lines: &[&str]) { + let inside = |inner: &SourceRange, outer: &SourceRange| { + let at = |position: &SourcePosition| (position.line.as_usize(), position.byte_column); + at(&inner.start) >= at(&outer.start) && at(&inner.end) <= at(&outer.end) + }; + let mut kept: Vec = Vec::with_capacity(folds.len()); + let mut by_span: DftHashMap<(usize, usize), usize> = DftHashMap::default(); + for fold in folds.drain(..) { + let span = line_span(&fold, lines); + if span.1 - span.0 < 2 { + kept.push(fold); + continue; + } + match by_span.entry(span) { + Entry::Vacant(entry) => { + entry.insert(kept.len()); + kept.push(fold); + } + Entry::Occupied(entry) => { + let merged = &mut kept[*entry.get()]; + if inside(&fold.range, &merged.range) { + merged.range = fold.range; + merged.syntax_id = fold.syntax_id; + merged.match_kind = fold.match_kind; + } + merged.tags.extend(fold.tags); + merged.tags.sort(); + merged.tags.dedup(); + } + } + } + *folds = kept; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diff::sliders::fix_all_sliders; + use crate::parse::guess_language::Language; + use crate::parse::syntax::{init_all_info, AtomKind}; + use line_numbers::SingleLineSpan; + use typed_arena::Arena; + + #[test] + fn a_pair_moved_by_a_one_sided_slider_fix_is_not_a_pair() { + let span = |line: u32| { + vec![SingleLineSpan { + line: line.into(), + start_col: 0, + end_col: 1, + }] + }; + let arena = Arena::new(); + // lhs `((x))`, rhs `(x)`: the matcher pairs the outer lists, and the + // lhs inner list is novel. + let lhs_x = Syntax::new_atom(&arena, span(0), "x".to_owned(), AtomKind::Normal); + let lhs_inner = Syntax::new_list(&arena, "(", span(0), vec![lhs_x], ")", span(0)); + let lhs_outer = Syntax::new_list(&arena, "(", span(0), vec![lhs_inner], ")", span(0)); + let rhs_x = Syntax::new_atom(&arena, span(1), "x".to_owned(), AtomKind::Normal); + let rhs_outer = Syntax::new_list(&arena, "(", span(1), vec![rhs_x], ")", span(1)); + init_all_info(&[lhs_outer], &[rhs_outer]); + let mut change_map = ChangeMap::default(); + change_map.insert(lhs_outer, ChangeKind::Unchanged(rhs_outer)); + change_map.insert(rhs_outer, ChangeKind::Unchanged(lhs_outer)); + change_map.insert(lhs_inner, ChangeKind::Novel); + change_map.insert(lhs_x, ChangeKind::Unchanged(rhs_x)); + change_map.insert(rhs_x, ChangeKind::Unchanged(lhs_x)); + fn partner_of<'a>(node: &'a Syntax<'a>, change_map: &ChangeMap<'a>) -> Option { + partner(node, change_map.get(node).unwrap(), change_map).map(Syntax::id) + } + assert_eq!(partner_of(lhs_outer, &change_map), Some(rhs_outer.id())); + assert_eq!(partner_of(rhs_outer, &change_map), Some(lhs_outer.id())); + + // Lisp prefers the outer delimiter: on the lhs only, the record moves + // from the outer list onto the inner one. + fix_all_sliders(Language::EmacsLisp, &[lhs_outer], &mut change_map); + assert_eq!( + change_map.get(lhs_inner), + Some(ChangeKind::Unchanged(rhs_outer)) + ); + assert_eq!( + change_map.get(rhs_outer), + Some(ChangeKind::Unchanged(lhs_outer)) + ); + assert_eq!(partner_of(lhs_inner, &change_map), None); + assert_eq!(partner_of(rhs_outer, &change_map), None); + assert_eq!(partner_of(lhs_outer, &change_map), None); + } + + #[test] + fn crossing_folds_give_the_shared_line_to_the_later_fold() { + // A collection closing on line 5 where a body opens: `for x in [ … ] {`. + let spans = [(0, 6), (5, 9), (7, 8)]; + assert_eq!( + nested_spans(&spans), + vec![Some((0, 5)), Some((5, 9)), None], + "the earlier fold ends where the later starts; one-line spans go" + ); + // Clipping cascades through every open ancestor that would cross. + assert_eq!( + nested_spans(&[(0, 10), (2, 6), (4, 8)]), + vec![Some((0, 10)), Some((2, 4)), Some((4, 8))] + ); + // An ancestor that is clipped down to its header line disappears. + assert_eq!(nested_spans(&[(3, 5), (4, 9)]), vec![None, Some((4, 9))]); + // Nested and disjoint spans are untouched, whatever their input order. + assert_eq!( + nested_spans(&[(5, 9), (0, 4), (1, 3)]), + vec![Some((5, 9)), Some((0, 4)), Some((1, 3))] + ); + } +} diff --git a/src/parse/mod.rs b/src/parse/mod.rs index 820748bdd..d4072fcf7 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -1,4 +1,6 @@ pub(crate) mod context; +#[cfg(test)] +mod fold_tests; pub(crate) mod folds; pub(crate) mod guess_language; pub(crate) mod query; diff --git a/src/parse/syntax.rs b/src/parse/syntax.rs index d5ea6d722..7b2bc2067 100644 --- a/src/parse/syntax.rs +++ b/src/parse/syntax.rs @@ -76,7 +76,8 @@ pub(crate) struct FoldMetadata { /// (This is deliberately exchanging correctness-by-construction for /// performance.) pub(crate) struct SyntaxInfo<'a> { - /// Parent fold semantics replace the child's when a wrapper is flattened. + /// The fold this node owns, if a query gave it one. A node has at most + /// one, so two folds align exactly when the matcher paired their nodes. pub(crate) fold: RefCell>, /// Multiple enclosing contexts can survive on one flattened node. pub(crate) context: RefCell>, @@ -280,12 +281,16 @@ impl<'a> Syntax<'a> { // This is a small performance win as it makes the difftastic // syntax tree smaller. It also really helps when looking at // debug output for small inputs. - if children.len() == 1 && open_content.is_empty() && close_content.is_empty() { + // A fold belongs to the node it was found on, so the wrapper only + // goes away when its fold can go with it: onto a child that has none. + if children.len() == 1 + && open_content.is_empty() + && close_content.is_empty() + && !(fold.is_some() && children[0].info().fold.borrow().is_some()) + { let child = children[0]; - if let Some(mut fold) = fold { - fold.range_override - .get_or_insert_with(|| folds::interior_range(&open_position, &close_position)); - child.info().fold.replace(Some(fold)); + if let Some(fold) = fold { + *child.info().fold.borrow_mut() = Some(fold); } return child; } @@ -1088,7 +1093,10 @@ fn change_positions_<'a>( .get(node) .unwrap_or_else(|| panic!("Should have changes set in all nodes: {:#?}", node)); - folds.extend(folds::project(node, change)); + folds.extend(folds::project( + node, + folds::partner(node, change, change_map), + )); if matches!(change, ChangeKind::Unchanged(_)) { *seen_unchanged = true; @@ -1290,7 +1298,7 @@ mod tests { } #[test] - fn flattened_parent_fold_replaces_child_fold() { + fn flattening_moves_a_fold_down_but_never_onto_a_fold() { let arena = Arena::new(); let point = |col| SingleLineSpan { line: 0.into(), @@ -1299,51 +1307,58 @@ mod tests { }; let metadata = |tag: &str| FoldMetadata { tags: vec![tag.to_owned()], - range_override: None, + range_override: Some(folds::interior_range(&[point(0)], &[point(10)])), }; - let atom = Syntax::new_atom_with_fold( - &arena, - vec![SingleLineSpan { - line: 0.into(), - start_col: 4, - end_col: 7, - }], - "abc".into(), - AtomKind::Normal, - Some(metadata("string")), - ); + let atom = |fold| { + Syntax::new_atom_with_fold( + &arena, + vec![SingleLineSpan { + line: 0.into(), + start_col: 4, + end_col: 7, + }], + "abc".into(), + AtomKind::Normal, + fold, + ) + }; + + // A child without a fold takes the wrapper's, and stands for both. + let plain = atom(None); let body = Syntax::new_list_with_fold( &arena, "", vec![point(2)], - vec![atom], + vec![plain], "", vec![point(9)], Some(metadata("body")), ); - let test = Syntax::new_list_with_fold( + assert!(std::ptr::eq(body, plain)); + assert_eq!(body.info().fold.borrow().clone(), Some(metadata("body"))); + + // A child with a fold of its own keeps it: the wrapper stays, so + // each fold still has a node of its own. + let string = atom(Some(metadata("string"))); + let wrapper = Syntax::new_list_with_fold( &arena, "", - vec![point(0)], - vec![body], + vec![point(2)], + vec![string], "", - vec![point(10)], - Some(metadata("test")), - ); - assert!(std::ptr::eq(test, atom)); - assert_eq!( - test.info().fold.borrow().clone(), - Some(FoldMetadata { - tags: vec!["test".to_owned()], - range_override: Some(folds::interior_range(&[point(0)], &[point(10)])), - }) + vec![point(9)], + Some(metadata("body")), ); - let wrapper = Syntax::new_list(&arena, "", vec![], vec![test], "", vec![]); - assert!(std::ptr::eq(wrapper, atom)); + assert!(!std::ptr::eq(wrapper, string)); + assert_eq!(wrapper.info().fold.borrow().clone(), Some(metadata("body"))); assert_eq!( - wrapper.info().fold.borrow().clone(), - test.info().fold.borrow().clone() + string.info().fold.borrow().clone(), + Some(metadata("string")) ); + + // Without a fold to keep, a wrapper still goes away. + let bare = Syntax::new_list(&arena, "", vec![], vec![string], "", vec![]); + assert!(std::ptr::eq(bare, string)); } #[test] diff --git a/src/parse/tree_sitter_parser.rs b/src/parse/tree_sitter_parser.rs index a42b0c095..b2b053315 100644 --- a/src/parse/tree_sitter_parser.rs +++ b/src/parse/tree_sitter_parser.rs @@ -1329,13 +1329,16 @@ pub(crate) fn parse_subtrees( src: &str, config: &LanguageParams, tree: &tree_sitter::Tree, -) -> DftHashMap< - usize, - ( - tree_sitter::Tree, - &'static TreeSitterConfig, - HighlightedNodeIds, - ), +) -> Result< + DftHashMap< + usize, + ( + tree_sitter::Tree, + &'static TreeSitterConfig, + HighlightedNodeIds, + ), + >, + folds::Conflict, > { let mut subtrees = DftHashMap::default(); @@ -1359,13 +1362,13 @@ pub(crate) fn parse_subtrees( .expect("Incompatible tree-sitter version"); let tree = parser.parse(src, None).unwrap(); - let sub_highlights = tree_highlights(&tree, src, subconfig); + let sub_highlights = tree_highlights(&tree, src, subconfig)?; subtrees.insert(node.id(), (tree, subconfig.parser, sub_highlights)); } } - subtrees + Ok(subtrees) } /// Calculate which tree-sitter node IDs should have which syntax @@ -1374,7 +1377,7 @@ fn tree_highlights( tree: &tree_sitter::Tree, src: &str, config: &LanguageParams, -) -> HighlightedNodeIds { +) -> Result { let mut keyword_ish_capture_ids: Vec = vec![]; let mut string_capture_ids = vec![]; let mut type_capture_ids = vec![]; @@ -1458,14 +1461,14 @@ fn tree_highlights( } } - HighlightedNodeIds { - fold_kinds: folds::classify(tree, src, Some(&config.folds)), + Ok(HighlightedNodeIds { + fold_kinds: folds::classify(tree, src, Some(&config.folds))?, contexts: super::context::classify(tree, src, Some(&config.context)), comment_ids, keyword_ids, string_ids, type_ids, - } + }) } pub(crate) fn print_tree(src: &str, tree: &tree_sitter::Tree) { @@ -1510,7 +1513,9 @@ pub(crate) fn comment_positions( let arena = Arena::new(); let ignore_comments = false; - let (nodes, _errors) = to_syntax(tree, src, &arena, config, ignore_comments); + // The caller already converted this tree, so its queries did not conflict. + let (nodes, _errors) = to_syntax(tree, src, &arena, config, ignore_comments) + .expect("a tree that converted once converts again"); let positions = syntax::comment_positions(&nodes); positions @@ -1524,6 +1529,15 @@ pub(crate) fn comment_positions( .collect() } +/// Why two trees did not become syntax to match on. +#[derive(Debug)] +pub(crate) enum ToSyntaxError { + /// The trees converted, but with too many parse errors to match on. + ExceededParseErrorLimit(ExceededParseErrorLimit), + /// The fold query captured one node with two ranges on this side. + QueryConflict(folds::Conflict, Side), +} + #[derive(Debug)] pub(crate) struct ExceededParseErrorLimit { /// The total number of parse errors found across both inputs. @@ -1563,21 +1577,23 @@ pub(crate) fn to_syntax_with_limit<'a>( arena: &'a Arena>, config: &LanguageParams, diff_options: &DiffOptions, -) -> Result<(Vec<&'a Syntax<'a>>, Vec<&'a Syntax<'a>>), ExceededParseErrorLimit> { +) -> Result<(Vec<&'a Syntax<'a>>, Vec<&'a Syntax<'a>>), ToSyntaxError> { let (lhs_nodes, lhs_errors) = to_syntax( lhs_tree, lhs_src, arena, config, diff_options.ignore_comments, - ); + ) + .map_err(|conflict| ToSyntaxError::QueryConflict(conflict, Side::Left))?; let (rhs_nodes, rhs_errors) = to_syntax( rhs_tree, rhs_src, arena, config, diff_options.ignore_comments, - ); + ) + .map_err(|conflict| ToSyntaxError::QueryConflict(conflict, Side::Right))?; syntax::init_all_info(&lhs_nodes, &rhs_nodes); let error_count = lhs_errors.count + rhs_errors.count; @@ -1590,10 +1606,12 @@ pub(crate) fn to_syntax_with_limit<'a>( (None, Some((line, column))) => Some((line, column, Side::Left)), (None, None) => None, }; - return Err(ExceededParseErrorLimit { - error_count, - first_error_pos, - }); + return Err(ToSyntaxError::ExceededParseErrorLimit( + ExceededParseErrorLimit { + error_count, + first_error_pos, + }, + )); } Ok((lhs_nodes, rhs_nodes)) @@ -1605,19 +1623,19 @@ pub(crate) fn to_syntax<'a>( arena: &'a Arena>, config: &LanguageParams, ignore_comments: bool, -) -> (Vec<&'a Syntax<'a>>, ParseErrors) { +) -> Result<(Vec<&'a Syntax<'a>>, ParseErrors), folds::Conflict> { // Don't return anything on an empty input. Most parsers return a // zero-width top-level AST node on empty files, which is // confusing and not useful for diffing. if src.trim().is_empty() { - return (vec![], ParseErrors::default()); + return Ok((vec![], ParseErrors::default())); } - let highlights = tree_highlights(tree, src, config); + let highlights = tree_highlights(tree, src, config)?; // Parse sub-languages, if any, which will be used both for // highlighting and for more precise Syntax nodes where applicable. - let subtrees = parse_subtrees(src, config, tree); + let subtrees = parse_subtrees(src, config, tree)?; let nl_pos = LinePositions::from(src); let mut cursor = tree.walk(); @@ -1643,7 +1661,7 @@ pub(crate) fn to_syntax<'a>( &subtrees, ignore_comments, ); - (nodes, errors) + Ok((nodes, errors)) } /// Parse `src` with tree-sitter and convert to difftastic Syntax. @@ -1652,10 +1670,10 @@ pub(crate) fn parse<'a>( src: &str, config: &LanguageParams, ignore_comments: bool, -) -> Vec<&'a Syntax<'a>> { +) -> Result>, folds::Conflict> { let tree = to_tree(src, config.parser); - let (nodes, _errors) = to_syntax(&tree, src, arena, config, ignore_comments); - nodes + let (nodes, _errors) = to_syntax(&tree, src, arena, config, ignore_comments)?; + Ok(nodes) } fn child_tokens<'a>(src: &'a str, cursor: &mut ts::TreeCursor) -> Vec> { @@ -2122,7 +2140,7 @@ mod tests { let arena = Arena::new(); let params = Params::default(); let css_config = params.language(guess::Language::Css); - parse(&arena, ".foo {}", css_config, false); + parse(&arena, ".foo {}", css_config, false).unwrap(); } #[test] @@ -2130,7 +2148,7 @@ mod tests { let arena = Arena::new(); let params = Params::default(); let config = params.language(guess::Language::EmacsLisp); - let res = parse(&arena, "", config, false); + let res = parse(&arena, "", config, false).unwrap(); let expected: Vec<&Syntax> = vec![]; assert_eq!(res, expected); @@ -2143,7 +2161,7 @@ mod tests { let arena = Arena::new(); let params = Params::default(); let config = params.language(guess::Language::Html); - let res = parse(&arena, "", config, false); + let res = parse(&arena, "", config, false).unwrap(); match res[0] { Syntax::List { children, .. } => { diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 000000000..9c50020ad --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,457 @@ +//! The NDJSON wire protocol between diffr and its frontends. +//! +//! One `Event` per line: a `start` header, one `file` record per changed +//! file in completion order, and a `complete` footer. Every enum on the +//! wire is internally tagged with a `type` (or `kind`) key in snake_case. +//! Optional, empty, and default fields are omitted, never `null`. Consumers +//! ignore unknown fields and tolerate unknown enum strings. +//! +//! Coordinates: lines are 0-based and split on `\n` only, so an empty file +//! has zero lines and a file without a trailing newline still counts its +//! last line. Columns are 0-based byte offsets into the UTF-8 text on the +//! wire. All ranges are half-open. +//! +//! Sides are always `lhs` (before) and `rhs` (after). A `Pairing` says which +//! sides exist and serializes by presence: `{lhs, rhs}`, `{lhs}`, or `{rhs}`. + +use serde::{Deserialize, Serialize}; + +use crate::pairing::Pairing; + +pub(crate) mod project; +pub(crate) mod stream; + +/// The current wire version. Changes within a version are additive. +pub const VERSION: u32 = 3; + +// ── stream ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(clippy::large_enum_variant)] +pub enum Event { + /// The header. Sent once, before any result, so a frontend can lay out + /// every file up front. + Start { + version: u32, + lhs: Snapshot, + rhs: Snapshot, + files: Vec, + }, + /// One result. `file` is byte-identical to the manifest entry it + /// answers; the path pair is the identity. + File { + file: Pairing, + #[serde(flatten)] + outcome: Outcome, + }, + /// The footer. + Complete { succeeded: u32, failed: u32 }, +} + +/// Exactly one of `diff` or `error` appears on a `file` record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Outcome { + Diff { diff: Diff }, + Error { error: Problem }, +} + +/// What one end of the comparison is. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Snapshot { + Revision { + rev: String, + }, + Index, + WorkingTree, + EmptyTree, + /// A file on disk, for `--no-index`. + Path { + path: String, + }, +} + +/// The one error shape, used for a file failure, a run abort, and a +/// structural fallback. `code` is an open snake_case set. Internal code +/// carries `anyhow::Error`; the stream writer builds this record from one +/// just before serializing it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Problem { + pub code: String, + pub message: String, +} + +// ── manifest entry ──────────────────────────────────────────────────────── + +/// One changed file, known before any diffing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileChange { + /// `LeftOnly` is a deletion, `RightOnly` an addition. + pub file: Pairing, + pub status: FileStatus, +} + +/// libgit2's delta status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileStatus { + Added, + Deleted, + Modified, + Renamed, + Copied, + TypeChanged, +} + +/// One side of a git delta. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileRef { + pub path: String, + pub oid: String, + /// Git's octal mode text, e.g. `100644`. + pub mode: String, +} + +/// How a region starts out. `label` is shown while collapsed: the fold's +/// placeholder. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Visibility { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub label: String, +} + +impl Visibility { + /// Open and unlabelled, which is how a missing `visibility` reads. + pub fn is_unset(&self) -> bool { + !self.collapsed && self.label.is_empty() + } +} + +// ── per-file result ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Diff { + Text { + #[serde(flatten)] + sides: Pairing, + stats: Stats, + }, + /// Either side being binary makes the whole diff binary. + Binary { + #[serde(flatten)] + sides: Pairing, + }, +} + +/// One side's text and regions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Source { + pub text: String, + /// The largest regions, in order. Leaves tile the file. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub regions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BinaryRef { + pub size: u64, +} + +/// The `id` that names the file itself rather than a region. No region +/// carries it: region ids start above it. +pub const ROOT: u32 = 0; + +/// A range on one side, carrying identities that must never be conflated. +/// `id` names the region. `fold_state_id` says what the region *opens and +/// closes with*: regions sharing it open and close together, on the same +/// side or across sides. Paired leaves and matched folds have the same +/// value on both sides. A +/// leaf's `alignment_id` (see `Node::Leaf`) is row alignment; folds have +/// none. Consumers key the row zip by leaf `alignment_id`, collapse state by +/// `fold_state_id`, and anything about the region itself by `id`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Region { + /// Names this region, unique within the file (across both sides), and + /// never [`ROOT`]. + pub id: u32, + pub fold_state_id: u32, + #[serde(flatten)] + pub range: SourceRange, + /// On folds, the tags the fold queries set (`body`, `test`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Whether the region starts collapsed, and the label shown while it is. + /// Absent means open. + #[serde(default, skip_serializing_if = "Visibility::is_unset")] + pub visibility: Visibility, + #[serde(flatten)] + pub node: Node, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Node { + /// Tiles the file. `changed` holds the byte ranges painted as changed + /// within it; a fully new line carries one span covering it. + Leaf { + /// Row alignment: the leaf on the other side with the same value lines up with this one. + /// Paired leaves have the same line count, their rows pair line for + /// line, and they come in the same order on both sides. A leaf on + /// one side only has a value no leaf on the other side carries. + alignment_id: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + changed: Vec, + }, + /// A foldable region. Its range is the hull of its children. + Fold { children: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Span { + pub line: u32, + pub start_column: u32, + pub end_column: u32, +} + +/// Half-open. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceRange { + pub start: SourcePos, + pub end: SourcePos, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePos { + pub line: u32, + pub column: u32, +} + +/// Line counts for one file. `fallback` is present exactly when the AST +/// match did not run and the alignment is a line diff, carrying why: +/// `too_complex`, `too_large`, `unsupported_language`, `parse_error`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Stats { + /// Lines with any byte change. + pub textual: LineCounts, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LineCounts { + pub added: u32, + pub removed: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn pos(line: u32, column: u32) -> SourcePos { + SourcePos { line, column } + } + + /// A leaf on the side whose ids start at `first`: the lhs numbers its + /// regions first, and the rhs leaf shares the lhs leaf's fold state. + fn leaf(first: u32, index: u32, start: u32, end: u32, changed: Vec) -> Region { + Region { + id: first + 1 + index, + fold_state_id: 2 + index, + range: SourceRange { + start: pos(start, 0), + end: pos(end, 0), + }, + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { + alignment_id: index, + changed, + }, + } + } + + /// A paired function with one changed word: `fn f() { 1 }` became + /// `fn f() { 1 + 2 }`. Each side numbers its four regions in turn. + fn example_file() -> Event { + let file_ref = |oid: &str| FileRef { + path: "src/lib.rs".to_owned(), + oid: oid.to_owned(), + mode: "100644".to_owned(), + }; + let side = |first: u32, text: &str, changed: Vec| Source { + text: text.to_owned(), + regions: vec![Region { + id: first, + fold_state_id: 1, + range: SourceRange { + start: pos(0, 0), + end: pos(3, 0), + }, + tags: vec!["body".to_owned()], + visibility: Visibility::default(), + node: Node::Fold { + children: vec![ + leaf(first, 0, 0, 1, vec![]), + leaf(first, 1, 1, 2, changed), + leaf(first, 2, 2, 3, vec![]), + ], + }, + }], + }; + Event::File { + file: Pairing::Both { + lhs: file_ref("3b18e5"), + rhs: file_ref("9be2c1"), + }, + outcome: Outcome::Diff { + diff: Diff::Text { + sides: Pairing::Both { + lhs: side(1, "fn f() {\n 1\n}\n", vec![]), + rhs: side( + 5, + "fn f() {\n 1 + 2\n}\n", + vec![Span { + line: 1, + start_column: 5, + end_column: 9, + }], + ), + }, + stats: Stats { + textual: LineCounts { + added: 1, + removed: 1, + }, + fallback: None, + }, + }, + }, + } + } + + #[test] + fn file_record_serializes_to_the_documented_shape() { + let region = |first: u32, changed: serde_json::Value| { + let mut middle = json!({"id": first + 2, "fold_state_id": 3, "kind": "leaf", "alignment_id": 1, "start": {"line": 1, "column": 0}, "end": {"line": 2, "column": 0}}); + if let Some(spans) = changed.as_array().filter(|spans| !spans.is_empty()) { + middle["changed"] = json!(spans); + } + json!({ + "id": first, "fold_state_id": 1, "kind": "fold", "tags": ["body"], + "start": {"line": 0, "column": 0}, "end": {"line": 3, "column": 0}, + "children": [ + {"id": first + 1, "fold_state_id": 2, "kind": "leaf", "alignment_id": 0, "start": {"line": 0, "column": 0}, "end": {"line": 1, "column": 0}}, + middle, + {"id": first + 3, "fold_state_id": 4, "kind": "leaf", "alignment_id": 2, "start": {"line": 2, "column": 0}, "end": {"line": 3, "column": 0}}, + ], + }) + }; + let expected = json!({ + "type": "file", + "file": { + "lhs": {"path": "src/lib.rs", "oid": "3b18e5", "mode": "100644"}, + "rhs": {"path": "src/lib.rs", "oid": "9be2c1", "mode": "100644"}, + }, + "diff": { + "type": "text", + "lhs": {"text": "fn f() {\n 1\n}\n", "regions": [region(1, json!([]))]}, + "rhs": {"text": "fn f() {\n 1 + 2\n}\n", + "regions": [region(5, json!([{"line": 1, "start_column": 5, "end_column": 9}]))]}, + "stats": {"textual": {"added": 1, "removed": 1}}, + }, + }); + assert_eq!(serde_json::to_value(example_file()).unwrap(), expected); + } + + #[test] + fn every_event_round_trips() { + let events = vec![ + Event::Start { + version: VERSION, + lhs: Snapshot::Revision { + rev: "main".to_owned(), + }, + rhs: Snapshot::WorkingTree, + files: vec![FileChange { + file: Pairing::RightOnly { + rhs: FileRef { + path: "gen/schema.json".to_owned(), + oid: "0e1f2a".to_owned(), + mode: "100644".to_owned(), + }, + }, + status: FileStatus::Added, + }], + }, + example_file(), + Event::File { + file: Pairing::LeftOnly { + lhs: FileRef { + path: "old.bin".to_owned(), + oid: "aaaaaa".to_owned(), + mode: "100644".to_owned(), + }, + }, + outcome: Outcome::Diff { + diff: Diff::Binary { + sides: Pairing::LeftOnly { + lhs: BinaryRef { size: 4096 }, + }, + }, + }, + }, + Event::File { + file: Pairing::Both { + lhs: FileRef { + path: "a.txt".to_owned(), + oid: "bbbbbb".to_owned(), + mode: "100644".to_owned(), + }, + rhs: FileRef { + path: "a.txt".to_owned(), + oid: "cccccc".to_owned(), + mode: "100644".to_owned(), + }, + }, + outcome: Outcome::Error { + error: Problem { + code: "not_utf8".to_owned(), + message: "a.txt is not valid UTF-8".to_owned(), + }, + }, + }, + Event::Complete { + succeeded: 2, + failed: 1, + }, + ]; + for event in events { + let line = serde_json::to_string(&event).unwrap(); + assert_eq!( + serde_json::from_str::(&line).unwrap(), + event, + "{line}" + ); + } + } + + #[test] + fn defaults_are_omitted() { + let line = serde_json::to_string(&example_file()).unwrap(); + for absent in [ + "null", + "visibility", + "collapsed", + "fallback", + "\"changed\":[]", + ] { + assert!(!line.contains(absent), "{absent} appeared in {line}"); + } + } +} diff --git a/src/protocol/project.rs b/src/protocol/project.rs new file mode 100644 index 000000000..15535def7 --- /dev/null +++ b/src/protocol/project.rs @@ -0,0 +1,1146 @@ +//! Projection from the internal `DiffResult` onto the wire types. +//! +//! Leaves come from the full-file row alignment: consecutive rows of one +//! kind (paired unchanged, paired novel, one-sided) become one leaf on each +//! side they touch, and a paired leaf shares its `alignment_id` across +//! sides, so zipping leaves by `alignment_id` gives the rows. Folds come +//! from the per-side fold lists and carry no `alignment_id`. A fold whose +//! partner (see `folds::partner`) is also a region is a matched pair, and +//! the two share `fold_state_id`: they open and close together. +//! +//! A fold covers its body alone: `folds::line_span` rounds its byte range in +//! to whole lines, not out, so the header line it opens on — and the line its +//! `}` sits on — belong to the leaves beside it. A fold's lines are then +//! exactly the lines collapsing it hides, and a fold starting a line after +//! the construct it belongs to is a different region from the scope around +//! it. +//! +//! Numbering (see `Ids`) runs as the regions are built, lhs preorder then +//! rhs preorder, with two counters: `id` dense from 1, above [`ROOT`], +//! and `alignment_id` dense from 0. Every region takes the next `id`, on +//! either side, so no id is shared. Every leaf that is not +//! the second of a pair takes the next `alignment_id`; the second takes its +//! counterpart's. A region's `fold_state_id` is its own `id`, except that the +//! second of a paired leaf or a matched fold takes its counterpart's +//! `fold_state_id`. Leaves are split wherever a +//! fold starts or ends so that every fold's children tile its line span +//! exactly, and a split on one side of a paired leaf is mirrored on the +//! other so paired leaves stay equal in length. Nothing starts collapsed. +use super::{ + BinaryRef, Diff, FileRef, LineCounts, Node, Problem, Region, Source, SourcePos, SourceRange, + Span, Stats, Visibility, ROOT, +}; +use crate::display::line_layout::{aligned_rows, novel_lines, runs, Run}; +use crate::hash::DftHashMap; +use crate::line_parser; +use crate::pairing::Pairing; +use crate::parse::folds::{self, Fold, FoldMatch}; +use crate::parse::syntax::{MatchKind, MatchedPos, SyntaxId}; +use crate::summary::{DiffResult, FallbackCause, FileContent, FileFormat}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Everything the projection needs besides the diff itself. +pub(crate) struct Inputs<'a> { + /// Which sides the file exists on; a one-sided file gets one source. + pub(crate) file: &'a Pairing, + /// Byte length of each side's content, for binary files. + pub(crate) sizes: (u64, u64), +} + +pub(crate) fn diff(result: &DiffResult, inputs: Inputs<'_>) -> Diff { + let (lhs_src, rhs_src) = match (&result.lhs_src, &result.rhs_src) { + (FileContent::Text(lhs), FileContent::Text(rhs)) => (lhs.as_str(), rhs.as_str()), + _ => { + let sides = pair( + inputs.file, + BinaryRef { + size: inputs.sizes.0, + }, + BinaryRef { + size: inputs.sizes.1, + }, + ); + return Diff::Binary { sides }; + } + }; + let (lhs_regions, rhs_regions) = regions(result, lhs_src, rhs_src); + let sides = pair( + inputs.file, + Source { + text: lhs_src.to_owned(), + regions: lhs_regions, + }, + Source { + text: rhs_src.to_owned(), + regions: rhs_regions, + }, + ); + Diff::Text { + sides, + stats: stats(result, lhs_src, rhs_src), + } +} + +fn pair(file: &Pairing, lhs: T, rhs: T) -> Pairing { + match file { + Pairing::Both { .. } => Pairing::Both { lhs, rhs }, + Pairing::LeftOnly { .. } => Pairing::LeftOnly { lhs }, + Pairing::RightOnly { .. } => Pairing::RightOnly { rhs }, + } +} + +fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { + let (lhs_lines, rhs_lines) = line_parser::change_positions(lhs_src, rhs_src); + let textual = LineCounts { + added: novel_lines(&rhs_lines).len() as u32, + removed: novel_lines(&lhs_lines).len() as u32, + }; + let fallback = match &result.file_format { + FileFormat::SupportedLanguage(_) => None, + FileFormat::PlainText => Some(Problem { + code: "unsupported_language".to_owned(), + message: "no tree-sitter grammar for this file".to_owned(), + }), + FileFormat::TextFallback { cause, reason } => Some(Problem { + code: fallback_code(*cause).to_owned(), + message: reason.clone(), + }), + FileFormat::Binary => unreachable!("binary files never reach text stats"), + }; + Stats { textual, fallback } +} + +/// The wire code for why a file was diffed by line. The message beside it +/// is the engine's own prose, with the numbers. +fn fallback_code(cause: FallbackCause) -> &'static str { + match cause { + FallbackCause::ByteLimit => "too_large", + FallbackCause::GraphLimit => "too_complex", + FallbackCause::ParseErrorLimit => "parse_error", + } +} + +// ── regions ─────────────────────────────────────────────────────────────── + +/// A leaf after splitting. `key` identifies its counterpart on the other +/// side, when it has one. +#[derive(Clone, Debug)] +struct Leaf { + lines: (usize, usize), + key: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct LeafKey { + run: usize, + piece: usize, +} + +struct SideFold<'a> { + fold: &'a Fold, + lines: (usize, usize), +} + +fn regions(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> (Vec, Vec) { + let lhs_lines: Vec<&str> = lhs_src.split_terminator('\n').collect(); + let rhs_lines: Vec<&str> = rhs_src.split_terminator('\n').collect(); + let lhs_novel = novel_lines(&result.lhs_positions); + let rhs_novel = novel_lines(&result.rhs_positions); + let sources = (lhs_src, rhs_src); + let positions = ( + result.lhs_positions.as_slice(), + result.rhs_positions.as_slice(), + ); + let rows = aligned_rows(sources, positions); + let runs = runs(&rows, &lhs_novel, &rhs_novel); + let lhs_folds = side_folds(&result.lhs_folds, &lhs_lines); + let rhs_folds = side_folds(&result.rhs_folds, &rhs_lines); + let lhs_splits = folds::split_lines(lhs_folds.iter().map(|fold| fold.lines)); + let rhs_splits = folds::split_lines(rhs_folds.iter().map(|fold| fold.lines)); + let (lhs_leaves, rhs_leaves) = split_runs(&runs, &lhs_splits, &rhs_splits); + + let mut ids = Ids::new(); + let lhs = tree( + &lhs_folds, + &lhs_leaves, + &result.lhs_positions, + &lhs_novel, + &lhs_lines, + &mut ids, + ); + let rhs = tree( + &rhs_folds, + &rhs_leaves, + &result.rhs_positions, + &rhs_novel, + &rhs_lines, + &mut ids, + ); + (lhs, rhs) +} + +/// One side's folds as nested line spans. +/// +/// A fold dropped here is never numbered, so its partner on the other side +/// takes an id of its own (see `Ids::fold`). +fn side_folds<'a>(side: &'a [Fold], lines: &[&str]) -> Vec> { + let spans: Vec<(usize, usize)> = side + .iter() + .map(|fold| folds::line_span(fold, lines)) + .collect(); + side.iter() + .zip(folds::nested_spans(&spans)) + // A fold on a single line hides nothing; it is not a region. + .filter_map(|(fold, span)| Some(SideFold { fold, lines: span? })) + .collect() +} + +/// Split every run at its side's fold boundaries, mirroring splits across +/// paired runs so both sides keep equal-length pieces. +fn split_runs( + runs: &[Run], + lhs_splits: &BTreeSet, + rhs_splits: &BTreeSet, +) -> (Vec, Vec) { + let mut lhs_leaves = Vec::new(); + let mut rhs_leaves = Vec::new(); + for (index, run) in runs.iter().enumerate() { + let len = run.len(); + let (lhs, rhs, paired) = match run.sides { + Pairing::Both { lhs, rhs } => (Some(lhs), Some(rhs), true), + Pairing::LeftOnly { lhs } => (Some(lhs), None, false), + Pairing::RightOnly { rhs } => (None, Some(rhs), false), + }; + let mut offsets: BTreeSet = BTreeSet::new(); + if let Some((start, end)) = lhs { + offsets.extend(lhs_splits.range(start + 1..end).map(|line| line - start)); + } + if let Some((start, end)) = rhs { + offsets.extend(rhs_splits.range(start + 1..end).map(|line| line - start)); + } + let mut at = 0; + for (piece, cut) in offsets.into_iter().chain([len]).enumerate() { + let key = paired.then_some(LeafKey { run: index, piece }); + if let Some((start, _)) = lhs { + lhs_leaves.push(Leaf { + lines: (start + at, start + cut), + key, + }); + } + if let Some((start, _)) = rhs { + rhs_leaves.push(Leaf { + lines: (start + at, start + cut), + key, + }); + } + at = cut; + } + } + (lhs_leaves, rhs_leaves) +} + +/// Wire id allocation, in the order regions are built: lhs preorder, then +/// rhs preorder. `id` and leaf `alignment_id` are separate counters: `id` +/// dense from 1, since [`ROOT`] names the file, and `alignment_id` from 0. Every region takes a fresh `id`. A leaf takes a fresh +/// `alignment_id` and its own `id` as `fold_state_id`, unless its +/// counterpart is already numbered, whose pair it then shares. +struct Ids { + next_id: u32, + next_alignment: u32, + /// The `(alignment_id, fold_state_id)` of every paired leaf numbered so + /// far, by its `LeafKey`. + leaves: DftHashMap, + /// The `fold_state_id` of every fold numbered so far, by the fold of the + /// syntax node it was built on. + folds: DftHashMap, +} + +impl Ids { + fn new() -> Self { + Self { + next_id: ROOT + 1, + next_alignment: 0, + leaves: DftHashMap::default(), + folds: DftHashMap::default(), + } + } + + fn fresh_id(&mut self) -> u32 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn fresh_alignment(&mut self) -> u32 { + let alignment = self.next_alignment; + self.next_alignment += 1; + alignment + } + + /// A leaf's `(id, alignment_id, fold_state_id)`. + fn leaf(&mut self, key: Option) -> (u32, u32, u32) { + let id = self.fresh_id(); + let Some(key) = key else { + return (id, self.fresh_alignment(), id); + }; + if let Some(&(alignment, state)) = self.leaves.get(&key) { + return (id, alignment, state); + } + let alignment = self.fresh_alignment(); + self.leaves.insert(key, (alignment, id)); + (id, alignment, id) + } + + /// A fold's `(id, fold_state_id)`. A fold whose opposite is already + /// numbered shares the opposite's `fold_state_id`. An opposite that is + /// never numbered, because its fold was dropped from its side, leaves + /// the survivor a `fold_state_id` of its own. + fn fold(&mut self, fold: &Fold) -> (u32, u32) { + let id = self.fresh_id(); + let numbered = match fold.match_kind { + FoldMatch::Matched { opposite } => self.folds.get(&opposite), + FoldMatch::Novel => None, + }; + let state = match numbered { + Some(&state) => state, + None => id, + }; + self.folds.insert(fold.syntax_id, state); + (id, state) + } +} + +enum Item<'a> { + Fold(&'a SideFold<'a>), + Leaf(&'a Leaf), +} + +impl Item<'_> { + fn lines(&self) -> (usize, usize) { + match self { + Self::Fold(fold) => fold.lines, + Self::Leaf(leaf) => leaf.lines, + } + } + + /// Outer before inner: earlier start, then later end, then folds before + /// leaves, then the wider byte range. + fn order( + &self, + ) -> ( + usize, + std::cmp::Reverse, + u8, + (usize, usize), + std::cmp::Reverse<(usize, usize)>, + ) { + let (start, end) = self.lines(); + match self { + Self::Fold(fold) => ( + start, + std::cmp::Reverse(end), + 0, + ( + fold.fold.range.start.line.as_usize(), + fold.fold.range.start.byte_column, + ), + std::cmp::Reverse(( + fold.fold.range.end.line.as_usize(), + fold.fold.range.end.byte_column, + )), + ), + Self::Leaf(_) => ( + start, + std::cmp::Reverse(end), + 1, + (0, 0), + std::cmp::Reverse((0, 0)), + ), + } + } +} + +/// Nest folds and leaves by containment on line spans and number them in +/// document order, outer before inner. +fn tree( + folds: &[SideFold<'_>], + leaves: &[Leaf], + positions: &[MatchedPos], + novel: &BTreeSet, + lines: &[&str], + ids: &mut Ids, +) -> Vec { + let mut items: Vec> = folds + .iter() + .map(Item::Fold) + .chain(leaves.iter().map(Item::Leaf)) + .collect(); + items.sort_by_key(Item::order); + let by_line = positions_by_line(positions); + + // Build bottom-up with an explicit stack of open folds. + struct Open<'a> { + fold: &'a SideFold<'a>, + ids: (u32, u32), + children: Vec, + } + let mut root: Vec = Vec::new(); + let mut stack: Vec> = Vec::new(); + let close = |stack: &mut Vec>, root: &mut Vec| { + let open = stack.pop().expect("closing an open fold"); + let fold = open.fold.fold; + // The wire range is the hull of the children, which tile whole + // lines; the parser's byte columns inside the header line are not + // carried, since nothing narrower than a line can be hidden. + let (Some(first), Some(last)) = (open.children.first(), open.children.last()) else { + return; + }; + let range = SourceRange { + start: first.range.start, + end: last.range.end, + }; + let region = Region { + id: open.ids.0, + fold_state_id: open.ids.1, + range, + tags: fold.tags.clone(), + visibility: Visibility { + collapsed: false, + label: fold.placeholder.clone(), + }, + node: Node::Fold { + children: open.children, + }, + }; + match stack.last_mut() { + Some(parent) => parent.children.push(region), + None => root.push(region), + } + }; + for item in items { + let (start, _) = item.lines(); + while stack.last().is_some_and(|open| open.fold.lines.1 <= start) { + close(&mut stack, &mut root); + } + match item { + Item::Fold(fold) => { + let fold_ids = ids.fold(fold.fold); + stack.push(Open { + fold, + ids: fold_ids, + children: Vec::new(), + }); + } + Item::Leaf(leaf) => { + if leaf.lines.0 == leaf.lines.1 { + continue; + } + let region = leaf_region(leaf, ids, &by_line, novel, lines); + match stack.last_mut() { + Some(parent) => parent.children.push(region), + None => root.push(region), + } + } + } + } + while !stack.is_empty() { + close(&mut stack, &mut root); + } + root +} + +fn leaf_region( + leaf: &Leaf, + ids: &mut Ids, + by_line: &BTreeMap>, + novel: &BTreeSet, + lines: &[&str], +) -> Region { + let (start, end) = leaf.lines; + let mut changed = Vec::new(); + for line in novel.range(start..end) { + let tokens = by_line.get(line).map(Vec::as_slice).unwrap_or(&[]); + let all_novel = tokens.iter().all(|token| { + matches!( + token.kind, + MatchKind::Novel { .. } | MatchKind::NovelWord { .. } + ) + }); + if all_novel { + // A blank novel line has nothing to paint. + if !lines[*line].is_empty() { + changed.push(Span { + line: *line as u32, + start_column: 0, + end_column: lines[*line].len() as u32, + }); + } + continue; + } + for token in tokens { + if !matches!( + token.kind, + MatchKind::Novel { .. } | MatchKind::NovelWord { .. } + ) { + continue; + } + let span = Span { + line: *line as u32, + start_column: token.pos.start_col, + end_column: token.pos.end_col, + }; + match changed.last_mut() { + Some(last) if last.line == span.line && last.end_column == span.start_column => { + last.end_column = span.end_column; + } + _ => changed.push(span), + } + } + } + let (id, alignment_id, fold_state_id) = ids.leaf(leaf.key); + Region { + id, + fold_state_id, + range: SourceRange { + start: SourcePos { + line: start as u32, + column: 0, + }, + end: SourcePos { + line: end as u32, + column: 0, + }, + }, + tags: Vec::new(), + visibility: Visibility::default(), + node: Node::Leaf { + alignment_id, + changed, + }, + } +} + +fn positions_by_line(positions: &[MatchedPos]) -> BTreeMap> { + let mut by_line: BTreeMap> = BTreeMap::new(); + for position in positions { + by_line + .entry(position.pos.line.as_usize()) + .or_default() + .push(position); + } + for tokens in by_line.values_mut() { + tokens.sort_by_key(|token| token.pos.start_col); + } + by_line +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Params; + use crate::options::{DiffOptions, DisplayOptions}; + + fn refs(lhs: bool, rhs: bool) -> Pairing { + let file_ref = FileRef { + path: "a.py".to_owned(), + oid: String::new(), + mode: String::new(), + }; + match (lhs, rhs) { + (true, true) => Pairing::Both { + lhs: file_ref.clone(), + rhs: file_ref, + }, + (true, false) => Pairing::LeftOnly { lhs: file_ref }, + (false, true) => Pairing::RightOnly { rhs: file_ref }, + (false, false) => panic!("a file has a side"), + } + } + + fn project(path: &str, lhs: &str, rhs: &str) -> Diff { + project_with(path, lhs, rhs, DiffOptions::default()) + } + + /// `graph_limit: 1` forces the text-diff fallback for any real change. + fn project_with(path: &str, lhs: &str, rhs: &str, options: DiffOptions) -> Diff { + let result = DiffResult::from_sources_with_options( + path, + lhs, + rhs, + &Params::default(), + &DisplayOptions::default(), + &options, + ) + .unwrap(); + diff( + &result, + Inputs { + file: &refs(!lhs.is_empty(), !rhs.is_empty()), + sizes: (lhs.len() as u64, rhs.len() as u64), + }, + ) + } + + fn sources(diff: &Diff) -> (Option<&Source>, Option<&Source>) { + match diff { + Diff::Text { sides, .. } => match sides { + Pairing::Both { lhs, rhs } => (Some(lhs), Some(rhs)), + Pairing::LeftOnly { lhs } => (Some(lhs), None), + Pairing::RightOnly { rhs } => (None, Some(rhs)), + }, + Diff::Binary { .. } => panic!("text diff"), + } + } + + fn leaves(regions: &[Region]) -> Vec<&Region> { + let mut out = Vec::new(); + for region in regions { + match ®ion.node { + Node::Leaf { .. } => out.push(region), + Node::Fold { children } => out.extend(leaves(children)), + } + } + out + } + + fn alignment(leaf: &Region) -> u32 { + match leaf.node { + Node::Leaf { alignment_id, .. } => alignment_id, + Node::Fold { .. } => panic!("a fold has no alignment_id: {leaf:?}"), + } + } + + fn all(regions: &[Region]) -> Vec<&Region> { + let mut out = Vec::new(); + for region in regions { + out.push(region); + if let Node::Fold { children } = ®ion.node { + out.extend(all(children)); + } + } + out + } + + fn line_count(text: &str) -> u32 { + text.split_terminator('\n').count() as u32 + } + + fn assert_tiles(source: &Source) { + let mut at = 0; + for leaf in leaves(&source.regions) { + assert_eq!(leaf.range.start.line, at, "gap before {leaf:?}"); + assert_eq!(leaf.range.start.column, 0); + assert_eq!(leaf.range.end.column, 0); + assert!(leaf.range.end.line > at, "empty leaf {leaf:?}"); + at = leaf.range.end.line; + } + assert_eq!(at, line_count(&source.text)); + } + + fn assert_folds_hold_children(regions: &[Region]) { + for region in regions { + if let Node::Fold { children } = ®ion.node { + assert!(!children.is_empty(), "fold without children {region:?}"); + let (start, end) = region.range.lines_spanned(); + let mut at = start; + for child in children { + let (child_start, child_end) = child.range.lines_spanned(); + assert_eq!(child_start, at, "hole inside {region:?}"); + at = child_end; + } + assert_eq!(at, end, "fold {region:?} not tiled by its children"); + assert_folds_hold_children(children); + } + } + } + + impl SourceRange { + /// The lines this range touches, half-open. A range ending at + /// column zero does not touch its end line. + fn lines_spanned(&self) -> (u32, u32) { + let end = if self.end.column == 0 { + self.end.line + } else { + self.end.line + 1 + }; + (self.start.line, end) + } + } + + const RUST_LHS: &str = "fn f(a: u32) -> u32 {\n let x = a + 1;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n"; + const RUST_RHS: &str = "fn f(a: u32, b: u32) -> u32 {\n let x = a + b;\n let y = x * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n\nfn added() -> u32 {\n let p = 3;\n let q = 4;\n p + q\n}\n"; + + fn fold_ids(source: &Source) -> BTreeMap { + all(&source.regions) + .into_iter() + .filter(|r| matches!(r.node, Node::Fold { .. })) + .map(|r| (r.range.start.line, r.id)) + .collect() + } + + fn fold_states(source: &Source) -> BTreeMap { + all(&source.regions) + .into_iter() + .filter(|r| matches!(r.node, Node::Fold { .. })) + .map(|r| (r.range.start.line, r.fold_state_id)) + .collect() + } + + #[test] + fn a_closer_and_an_opener_on_one_line_yield_a_strict_tree() { + let lhs = "fn f() {\n for x in [\n 1,\n 2,\n ] {\n use_it(x);\n more(x);\n }\n}\n"; + let rhs = "fn f() {\n for x in [\n 1,\n 2,\n 3,\n ] {\n use_it(x);\n more(x);\n }\n}\n"; + let diff = project("a.rs", lhs, rhs); + let (lhs, rhs) = sources(&diff); + for source in [lhs.unwrap(), rhs.unwrap()] { + assert_tiles(source); + assert_folds_hold_children(&source.regions); + let folds: Vec<&Region> = all(&source.regions) + .into_iter() + .filter(|region| matches!(region.node, Node::Fold { .. })) + .collect(); + // The array's elements open on line 2; `] {` closes it and + // opens the loop, and belongs to neither fold. + let collection = folds + .iter() + .find(|fold| fold.range.start.line == 2) + .expect("the array is a fold"); + let body = folds + .iter() + .find(|fold| fold.range.start.line > collection.range.end.line) + .expect("the loop body is a fold"); + assert!( + collection.range.end.line < body.range.start.line, + "the line that closes the collection and opens the body is in neither" + ); + assert_eq!(body.range.start.column, 0); + assert_eq!(collection.range.end.column, 0); + } + } + + #[test] + fn structural_folds_pair_exactly_as_the_matcher_recorded() { + let result = DiffResult::from_sources_with_options( + "a.rs", + RUST_LHS, + RUST_RHS, + &Params::default(), + &DisplayOptions::default(), + &DiffOptions::default(), + ) + .unwrap(); + let diff = project("a.rs", RUST_LHS, RUST_RHS); + let (lhs, rhs) = sources(&diff); + let lhs_folds = fold_ids(lhs.unwrap()); + let (lhs_states, rhs_states) = (fold_states(lhs.unwrap()), fold_states(rhs.unwrap())); + let rhs_ids: BTreeSet = all(&rhs.unwrap().regions).iter().map(|r| r.id).collect(); + let rhs_states: BTreeSet = rhs_states.values().copied().collect(); + let lhs_lines: Vec<&str> = RUST_LHS.split_terminator('\n').collect(); + for fold in &result.lhs_folds { + // A fold covers its body: its region starts after the line the + // `{` opens on. + let line = folds::line_span(fold, &lhs_lines).0 as u32; + let (id, state) = (lhs_folds[&line], lhs_states[&line]); + let matcher_paired = matches!(fold.match_kind, FoldMatch::Matched { .. }); + assert_eq!( + rhs_states.contains(&state), + matcher_paired, + "{:?}", + fold.range + ); + // A region's `id` names it alone. + assert!(!rhs_ids.contains(&id), "{:?}", fold.range); + } + } + + #[test] + fn a_fold_dropped_on_one_side_leaves_its_partner_unshared() { + // The matcher pairs the two arrays, but the lhs array sits on one + // line: it hides nothing and is not a region. + let lhs = "fn f() {\n let v = [1, 2];\n work(v);\n}\n"; + let rhs = "fn f() {\n let v = [\n 1,\n 2,\n ];\n work(v);\n}\n"; + let result = DiffResult::from_sources("a.rs", lhs, rhs); + let collection = |folds: &[Fold]| -> (SyntaxId, FoldMatch) { + // The array opens on line 1; the function body on line 0. + let fold = folds + .iter() + .find(|fold| fold.range.start.line.as_usize() == 1) + .expect("the array is a fold"); + (fold.syntax_id, fold.match_kind) + }; + let (lhs_array, lhs_match) = collection(&result.lhs_folds); + let (rhs_array, rhs_match) = collection(&result.rhs_folds); + assert_eq!( + (lhs_match, rhs_match), + ( + FoldMatch::Matched { + opposite: rhs_array + }, + FoldMatch::Matched { + opposite: lhs_array + } + ), + "the matcher pairs the arrays" + ); + let diff = project("a.rs", lhs, rhs); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + let lhs_states: BTreeSet = all(&lhs.regions).iter().map(|r| r.fold_state_id).collect(); + let array = all(&rhs.regions) + .into_iter() + .find(|r| matches!(r.node, Node::Fold { .. }) && r.range.start.line == 2) + .expect("the rhs array is a region"); + assert!( + !lhs_states.contains(&array.fold_state_id), + "no lhs region claims the dropped fold's state" + ); + } + + #[test] + fn swapped_functions_share_fold_state_but_never_ids() { + let lhs = "fn a() {\n one();\n two();\n}\n\nfn b() {\n three();\n four();\n}\n"; + let rhs = "fn b() {\n three();\n four();\n}\n\nfn a() {\n one();\n two();\n}\n"; + let mut result = DiffResult::from_sources("a.rs", lhs, rhs); + // Pair each body with the body of the same function, wherever it is: + // the pairs cross. + let header = |src: &str, fold: &Fold| { + src.lines() + .nth(fold.range.start.line.as_usize()) + .unwrap() + .to_owned() + }; + assert_eq!(result.lhs_folds.len(), 2); + assert_eq!(result.rhs_folds.len(), 2); + for lhs_index in 0..2 { + let rhs_index = result + .rhs_folds + .iter() + .position(|fold| header(rhs, fold) == header(lhs, &result.lhs_folds[lhs_index])) + .unwrap(); + result.lhs_folds[lhs_index].match_kind = FoldMatch::Matched { + opposite: result.rhs_folds[rhs_index].syntax_id, + }; + result.rhs_folds[rhs_index].match_kind = FoldMatch::Matched { + opposite: result.lhs_folds[lhs_index].syntax_id, + }; + } + let diff = diff( + &result, + Inputs { + file: &refs(true, true), + sizes: (lhs.len() as u64, rhs.len() as u64), + }, + ); + let (lhs_src, rhs_src) = sources(&diff); + let (lhs_src, rhs_src) = (lhs_src.unwrap(), rhs_src.unwrap()); + let folds = |source: &Source| -> BTreeMap { + all(&source.regions) + .into_iter() + .filter(|region| matches!(region.node, Node::Fold { .. })) + .map(|region| { + let line = region.range.start.line as usize; + ( + source.text.lines().nth(line).unwrap().to_owned(), + (region.id, region.fold_state_id), + ) + }) + .collect() + }; + let (lhs_folds, rhs_folds) = (folds(lhs_src), folds(rhs_src)); + assert_eq!(lhs_folds.len(), 2, "{lhs_folds:?}"); + assert_eq!(rhs_folds.len(), 2, "{rhs_folds:?}"); + for (name, &(lhs_id, lhs_state)) in &lhs_folds { + let (_, rhs_state) = rhs_folds[name]; + assert_eq!( + lhs_state, rhs_state, + "{name} opens and closes on both sides" + ); + assert!(rhs_folds.values().all(|&(id, _)| id != lhs_id)); + } + // Leaves still tile, and every leaf pair still mirrors its splits. + for source in [lhs_src, rhs_src] { + assert_tiles(source); + assert_folds_hold_children(&source.regions); + } + let lengths = |source: &Source| -> BTreeMap { + leaves(&source.regions) + .into_iter() + .map(|leaf| { + let (start, end) = leaf.range.lines_spanned(); + (alignment(leaf), end - start) + }) + .collect() + }; + let rhs_lengths = lengths(rhs_src); + for (id, length) in lengths(lhs_src) { + if let Some(&other) = rhs_lengths.get(&id) { + assert_eq!(length, other, "paired leaf {id}"); + } + } + } + + #[test] + fn leaves_tile_both_sides_and_paired_leaves_share_ids() { + let lhs = "import os\n\ndef f():\n x = 1\n return x\n"; + let rhs = + "import os\n\ndef f():\n x = 2\n return x\n\ndef g():\n y = 3\n return y\n"; + let diff = project("a.py", lhs, rhs); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + assert_folds_hold_children(&lhs.regions); + assert_folds_hold_children(&rhs.regions); + // Ids are dense, assigned lhs first, and never shared across sides. + let lhs_ids: Vec = all(&lhs.regions).iter().map(|r| r.id).collect(); + let rhs_ids: Vec = all(&rhs.regions).iter().map(|r| r.id).collect(); + let mut ids: Vec = lhs_ids.iter().chain(&rhs_ids).copied().collect(); + ids.sort_unstable(); + assert_eq!(ids, (1..=ids.len() as u32).collect::>()); + assert!(lhs_ids.iter().max() < rhs_ids.iter().min()); + // Leaf alignment ids are dense on their own counter. + let lhs_leaves: BTreeMap = leaves(&lhs.regions) + .into_iter() + .map(|leaf| (alignment(leaf), leaf)) + .collect(); + let rhs_leaves: BTreeMap = leaves(&rhs.regions) + .into_iter() + .map(|leaf| (alignment(leaf), leaf)) + .collect(); + let alignments: BTreeSet = lhs_leaves + .keys() + .chain(rhs_leaves.keys()) + .copied() + .collect(); + assert_eq!( + alignments.into_iter().collect::>(), + (0..=*lhs_leaves.keys().chain(rhs_leaves.keys()).max().unwrap()).collect::>() + ); + let mut paired = 0; + for (id, lhs_leaf) in &lhs_leaves { + if let Some(rhs_leaf) = rhs_leaves.get(id) { + paired += 1; + assert_eq!( + lhs_leaf.range.lines_spanned().1 - lhs_leaf.range.lines_spanned().0, + rhs_leaf.range.lines_spanned().1 - rhs_leaf.range.lines_spanned().0, + "paired leaves have equal length" + ); + assert_eq!(lhs_leaf.fold_state_id, rhs_leaf.fold_state_id); + } + } + assert!(paired > 0); + // Python's body fold covers the body alone: it starts on the line + // after the `def` line, which the leaf before it holds. + let new_fold = all(&rhs.regions) + .into_iter() + .find(|r| matches!(r.node, Node::Fold { .. }) && r.range.start.line == 7) + .expect("the added function is a fold"); + // The new function exists on the rhs only: nothing on the lhs opens + // with it or aligns with its rows. + let lhs_states: BTreeSet = all(&lhs.regions).iter().map(|r| r.fold_state_id).collect(); + assert!(!lhs_states.contains(&new_fold.fold_state_id)); + assert!(leaves(std::slice::from_ref(new_fold)) + .into_iter() + .all(|leaf| !lhs_leaves.contains_key(&alignment(leaf)))); + assert_eq!(new_fold.tags, vec!["body"]); + assert_eq!(new_fold.visibility.label, "Body"); + } + + #[test] + fn the_changed_body_is_a_paired_fold_with_a_novel_leaf_inside() { + let lhs = "def f():\n a = 1\n b = 2\n return a\n"; + let rhs = "def f():\n a = 1\n b = 3\n return a\n"; + let diff = project("a.py", lhs, rhs); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + let fold = |source: &Source| { + let folds: Vec<_> = all(&source.regions) + .into_iter() + .filter(|r| matches!(r.node, Node::Fold { .. })) + .collect(); + assert_eq!(folds.len(), 1); + folds[0].clone() + }; + let lhs_fold = fold(lhs); + let rhs_fold = fold(rhs); + assert_ne!(lhs_fold.id, rhs_fold.id); + assert_eq!(lhs_fold.fold_state_id, rhs_fold.fold_state_id); + assert_eq!(lhs_fold.range.lines_spanned(), (1, 4)); + let novel: Vec<_> = leaves(&rhs.regions) + .into_iter() + .filter(|leaf| matches!(&leaf.node, Node::Leaf { changed, .. } if !changed.is_empty())) + .collect(); + assert_eq!(novel.len(), 1); + assert_eq!(novel[0].range.lines_spanned(), (2, 3)); + let Node::Leaf { changed, .. } = &novel[0].node else { + unreachable!() + }; + // Only the changed token is painted, not the whole line. + assert_eq!( + changed, + &[Span { + line: 2, + start_column: 8, + end_column: 9 + }] + ); + } + + #[test] + fn python_body_folds_pair_through_their_block_nodes() { + // The body fold is the `block` under `def`, opening after the + // header's `:`. The header changed too; the blocks still pair by + // node identity, and their folds share one `fold_state_id`. + let lhs = "def f(a):\n x = a\n y = 2\n return x + y\n"; + let rhs = "def f(a, b):\n x = a\n y = 3\n return x + y\n"; + let result = DiffResult::from_sources("a.py", lhs, rhs); + assert_eq!(result.lhs_folds.len(), 1); + assert_eq!(result.rhs_folds.len(), 1); + let start = |fold: &Fold| { + ( + fold.range.start.line.as_usize(), + fold.range.start.byte_column, + ) + }; + assert_eq!(start(&result.lhs_folds[0]), (0, 9)); + assert_eq!(start(&result.rhs_folds[0]), (0, 12)); + assert_eq!( + result.lhs_folds[0].match_kind, + FoldMatch::Matched { + opposite: result.rhs_folds[0].syntax_id + } + ); + assert_eq!( + result.rhs_folds[0].match_kind, + FoldMatch::Matched { + opposite: result.lhs_folds[0].syntax_id + } + ); + let diff = project("a.py", lhs, rhs); + let (lhs, rhs) = sources(&diff); + let fold = |source: &Source| { + all(&source.regions) + .into_iter() + .find(|r| matches!(r.node, Node::Fold { .. })) + .expect("the body is a fold") + .clone() + }; + let (lhs_fold, rhs_fold) = (fold(lhs.unwrap()), fold(rhs.unwrap())); + assert_eq!(lhs_fold.fold_state_id, rhs_fold.fold_state_id); + assert_ne!(lhs_fold.id, rhs_fold.id); + } + + #[test] + fn a_fully_new_line_is_painted_whole_and_blank_lines_not_at_all() { + let diff = project("a.py", "x = 1\n", "x = 1\n\ny = 2\n"); + let (_, rhs) = sources(&diff); + let changed: Vec = leaves(&rhs.unwrap().regions) + .into_iter() + .filter_map(|leaf| match &leaf.node { + Node::Leaf { changed, .. } => Some(changed.clone()), + Node::Fold { .. } => None, + }) + .flatten() + .collect(); + assert_eq!( + changed, + vec![Span { + line: 2, + start_column: 0, + end_column: 5 + }] + ); + } + + #[test] + fn a_fold_edge_splits_paired_leaves_on_both_sides() { + // The unchanged function forces a split on both sides at the same + // offset, even though only the rhs shifted. + let lhs = "a = 1\n\ndef f():\n return 1\n\nz = 1\n"; + let rhs = "a = 2\n\ndef f():\n return 1\n\nz = 1\n"; + let diff = project("a.py", lhs, rhs); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + assert_folds_hold_children(&lhs.regions); + assert_folds_hold_children(&rhs.regions); + let lhs_leaves: Vec<_> = leaves(&lhs.regions) + .iter() + .map(|l| (alignment(l), l.range.lines_spanned())) + .collect(); + let rhs_leaves: Vec<_> = leaves(&rhs.regions) + .iter() + .map(|l| (alignment(l), l.range.lines_spanned())) + .collect(); + assert_eq!(lhs_leaves, rhs_leaves); + } + + #[test] + fn stats_count_changed_lines_and_flag_unsupported_languages() { + let diff = project("a.py", "x = 1\n", "x = 1 # same\n"); + let Diff::Text { stats, .. } = &diff else { + panic!("text") + }; + assert_eq!( + stats.textual, + LineCounts { + added: 1, + removed: 1 + } + ); + assert!(stats.fallback.is_none()); + let diff = project("a.unknownext", "x\n", "y\n"); + let Diff::Text { stats, .. } = &diff else { + panic!("text") + }; + assert_eq!( + stats.fallback.as_ref().unwrap().code, + "unsupported_language" + ); + } + + #[test] + fn one_sided_files_have_one_source_and_no_shared_ids() { + let diff = project("a.py", "", "def f():\n return 1\n"); + let (lhs, rhs) = sources(&diff); + assert!(lhs.is_none()); + let rhs = rhs.unwrap(); + assert_tiles(rhs); + assert!(leaves(&rhs.regions) + .iter() + .all(|leaf| !leaf.visibility.collapsed)); + } + + #[test] + fn binary_sides_carry_sizes() { + let result = DiffResult { + display_path: "a.bin".to_owned(), + extra_info: None, + file_format: FileFormat::Binary, + lhs_src: FileContent::Binary, + rhs_src: FileContent::Binary, + hunks: vec![], + lhs_folds: vec![], + rhs_folds: vec![], + lhs_positions: vec![], + rhs_positions: vec![], + has_byte_changes: Some((3, 5)), + has_syntactic_changes: false, + }; + let diff = diff( + &result, + Inputs { + file: &refs(true, true), + sizes: (3, 5), + }, + ); + let Diff::Binary { + sides: Pairing::Both { lhs, rhs }, + } = diff + else { + panic!("a binary diff with both sides: {diff:?}"); + }; + assert_eq!((lhs.size, rhs.size), (3, 5)); + } +} diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs new file mode 100644 index 000000000..d1b4d4fa9 --- /dev/null +++ b/src/protocol/stream.rs @@ -0,0 +1,267 @@ +//! The stdout stream: manifest, one record per file as it finishes, footer. +use super::project::{self, Inputs}; +use super::{Event, FileChange, Outcome, Problem, Snapshot, VERSION}; +use crate::engine::QueryConflict; +use crate::git::{DiffSession, FileError, LoadedFile}; +use crate::options::DisplayOptions; +use crate::summary::DiffResult; +use anyhow::anyhow; +use rayon::iter::{ParallelBridge, ParallelIterator}; +use std::io::{BufWriter, Write}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::sync::Arc; +use std::thread; + +/// Returns whether any file failed. Files are diffed on `jobs` workers and +/// emitted as they finish. The queue holds at most one ready record, so +/// computation overlaps output without retaining the whole comparison. +pub(crate) fn write( + session: DiffSession, + jobs: usize, + output: &mut impl Write, +) -> anyhow::Result { + let manifest = manifest(&session); + let (sender, receiver) = sync_channel(1); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(jobs) + .thread_name(|index| format!("diffr-worker-{index}")) + .build()?; + let worker = thread::spawn(move || { + // A disconnected consumer cancels production after the files in flight. + let _ = produce(session, manifest, &pool, sender); + }); + let mut output = BufWriter::new(output); + let result: anyhow::Result = (|| { + let mut failed = false; + for event in &receiver { + if let Event::Complete { failed: count, .. } = &event { + failed = *count > 0; + } + serde_json::to_writer(&mut output, &event)?; + output.write_all(b"\n")?; + output.flush()?; + } + Ok(failed) + })(); + // Wake a producer blocked on a full queue if writing failed. + drop(receiver); + let joined = worker.join(); + let failed = result?; + joined.map_err(|_| anyhow!("diff computation thread panicked"))?; + Ok(failed) +} + +/// The consumer went away; production stops after the files in flight. +struct Disconnected; + +fn manifest(session: &DiffSession) -> Vec { + session + .file_manifest() + .iter() + .map(crate::git::FileChange::manifest_entry) + .collect() +} + +fn produce( + session: DiffSession, + manifest: Vec, + pool: &rayon::ThreadPool, + sender: SyncSender, +) -> Result<(), Disconnected> { + let send = |event: Event| sender.send(event).map_err(|_| Disconnected); + send(Event::Start { + version: VERSION, + lhs: Snapshot::from(&session.comparison.before), + rhs: Snapshot::from(&session.comparison.after), + files: manifest, + })?; + let succeeded = AtomicU32::new(0); + let failed = AtomicU32::new(0); + let cancelled = Arc::new(AtomicBool::new(false)); + let loader = Loader { + session, + cancelled: Arc::clone(&cancelled), + }; + pool.install(|| { + loader.par_bridge().for_each(|(file, loaded)| { + let outcome = match loaded.and_then(|loaded| file_diff(&loaded)) { + Ok(diff) => Outcome::Diff { diff }, + Err(error) => Outcome::Error { + error: wire_error(&error), + }, + }; + match &outcome { + Outcome::Diff { .. } => succeeded.fetch_add(1, Ordering::Relaxed), + Outcome::Error { .. } => failed.fetch_add(1, Ordering::Relaxed), + }; + let event = Event::File { + file: file.sides, + outcome, + }; + if sender.send(event).is_err() { + cancelled.store(true, Ordering::Relaxed); + } + }); + }); + send(Event::Complete { + succeeded: succeeded.into_inner(), + failed: failed.into_inner(), + }) +} + +/// The wire record for an error, built as it is written. The code comes from +/// the typed cause attached where the error arose; an error nothing +/// classified is `internal`. +fn wire_error(error: &anyhow::Error) -> Problem { + let code = if let Some(kind) = error.downcast_ref::() { + kind.code() + } else if error.downcast_ref::().is_some() { + "query_conflict" + } else { + "internal" + }; + Problem { + code: code.to_owned(), + message: format!("{error:#}"), + } +} + +/// The projected diff of one loaded file. `Err` is this file's failure. +fn file_diff(loaded: &LoadedFile) -> anyhow::Result { + let inputs = Inputs { + file: &loaded.file.sides, + sizes: loaded.sizes(), + }; + // The stream reads no terminal hunks, so their context is moot. + let result = loaded.diff(&DisplayOptions::default())?; + Ok(project::diff(&result, inputs)) +} + +/// Reads sources serially on whichever worker pulls next; diffing then +/// proceeds on that worker while others pull further files. +struct Loader { + session: DiffSession, + cancelled: Arc, +} + +impl Iterator for Loader { + type Item = (crate::git::FileChange, anyhow::Result); + fn next(&mut self) -> Option { + if self.cancelled.load(Ordering::Relaxed) { + return None; + } + self.session.load() + } +} + +/// A standalone two-path comparison through the same three records. +pub(crate) fn write_file( + before: &str, + after: &str, + sizes: (u64, u64), + compute: impl FnOnce() -> Result, + output: &mut impl Write, +) -> anyhow::Result { + let file = crate::git::FileChange::standalone(before, after); + let mut output = BufWriter::new(output); + serde_json::to_writer( + &mut output, + &Event::Start { + version: VERSION, + lhs: Snapshot::Path { + path: before.to_owned(), + }, + rhs: Snapshot::Path { + path: after.to_owned(), + }, + files: vec![file.manifest_entry()], + }, + )?; + output.write_all(b"\n")?; + output.flush()?; + let outcome = match compute() { + Ok(result) => Outcome::Diff { + diff: project::diff( + &result, + Inputs { + file: &file.sides, + sizes, + }, + ), + }, + Err(conflict) => Outcome::Error { + error: wire_error(&anyhow::Error::from(conflict)), + }, + }; + let failed = matches!(outcome, Outcome::Error { .. }); + serde_json::to_writer( + &mut output, + &Event::File { + file: file.sides, + outcome, + }, + )?; + output.write_all(b"\n")?; + serde_json::to_writer( + &mut output, + &Event::Complete { + succeeded: u32::from(!failed), + failed: u32::from(failed), + }, + )?; + output.write_all(b"\n")?; + output.flush()?; + Ok(failed) +} + +#[cfg(test)] +mod conflict_tests { + use super::*; + use crate::config::Config; + + #[test] + fn a_query_conflict_is_that_files_error_and_the_run_completes() { + let params = Config::from_toml( + "[languages.rust]\nfolds = '''\n\ + ((block) @fold (#set! tag \"whole\"))\n\ + ((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"inside\"))\n\ + '''", + ) + .unwrap() + .compile() + .unwrap(); + let mut output = Vec::new(); + let failed = write_file( + "a.rs", + "b.rs", + (0, 0), + || { + DiffResult::try_from_sources_with_params( + "src/lib.rs", + "fn f() {\n one();\n}\n", + "fn f() {\n two();\n}\n", + ¶ms, + ) + }, + &mut output, + ) + .unwrap(); + assert!(failed); + let records: Vec = String::from_utf8(output) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let error = &records[1]["error"]; + assert_eq!(error["code"], "query_conflict", "{error}"); + let message = error["message"].as_str().unwrap(); + assert_eq!( + message, + "src/lib.rs:1 (before): fold query patterns 0 and 1 capture the same block \ + with different fold ranges" + ); + assert_eq!(records[2]["succeeded"], 0); + assert_eq!(records[2]["failed"], 1); + } +} diff --git a/src/review/mod.rs b/src/review/mod.rs deleted file mode 100644 index 8ce09f194..000000000 --- a/src/review/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Experimental Git-ref CLI and fixture output adapters. -//! Parsing, fold projection, and hunk context live in the core modules. -mod render; -#[cfg(test)] -mod tests; -pub(crate) mod wire; diff --git a/src/review/render.rs b/src/review/render.rs deleted file mode 100644 index 797823913..000000000 --- a/src/review/render.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Deterministic, uncolored review snapshots. Fold candidates do not hide source. -use std::fmt::Write; - -use crate::display::line_layout::{reindented_pairs, sources, LineSelection}; -use crate::summary::DiffResult; - -impl DiffResult { - pub(crate) fn snapshot(&self) -> String { - snapshot(self) - } -} - -pub(super) fn snapshot(diff: &DiffResult) -> String { - let (lhs_src, rhs_src) = sources(diff); - let lhs_lines: Vec<_> = lhs_src.split_terminator('\n').collect(); - let rhs_lines: Vec<_> = rhs_src.split_terminator('\n').collect(); - let selected = LineSelection::from_hunks(&diff.hunks); - let mut out = format!("--- a/{}\n+++ b/{}\n", diff.display_path, diff.display_path); - if diff.hunks.is_empty() { - out.push_str("(no syntactic changes)\n"); - return out; - } - let reindented = reindented_pairs(diff); - let mut writer = SnapshotWriter::new(out); - for (l, r) in crate::display::line_layout::aligned_rows( - (lhs_src, rhs_src), - (&diff.lhs_positions, &diff.rhs_positions), - ) { - let left = l.filter(|line| selected.lhs.contains(line)); - let right = r.filter(|line| selected.rhs.contains(line)); - if left.is_none() && right.is_none() { - writer.flush_changes(); - writer.gap = true; - continue; - } - writer.write_gap(); - match (left, right) { - (Some(l), Some(r)) if lhs_lines[l] == rhs_lines[r] => { - writer.flush_changes(); - writeln!( - writer.output, - "{:>4} {:>4} {}", - l + 1, - r + 1, - lhs_lines[l] - ) - .unwrap(); - } - (Some(l), Some(r)) if reindented.contains(&(l, r)) => { - writer.flush_changes(); - writeln!(writer.output, "{:>4} ~ {}", l + 1, lhs_lines[l]).unwrap(); - writeln!(writer.output, " {:>4} ~ {}", r + 1, rhs_lines[r]).unwrap(); - } - (l, r) => { - if let Some(l) = l { - writeln!(writer.removed, "{:>4} - {}", l + 1, lhs_lines[l]).unwrap(); - } - if let Some(r) = r { - writeln!(writer.added, " {:>4} + {}", r + 1, rhs_lines[r]).unwrap(); - } - } - } - } - writer.flush_changes(); - writer.write_gap(); - for (side, source) in [("base", lhs_src), ("head", rhs_src)] { - if !source.is_empty() && !source.ends_with('\n') { - writeln!(writer.output, "\\ No newline at end of {} file", side).unwrap(); - } - } - writer.output -} - -/// Buffer deletions before additions within a contiguous change block. -struct SnapshotWriter { - output: String, - removed: String, - added: String, - gap: bool, -} - -impl SnapshotWriter { - fn new(output: String) -> Self { - Self { - output, - removed: String::new(), - added: String::new(), - gap: false, - } - } - - fn flush_changes(&mut self) { - self.output.push_str(&self.removed); - self.output.push_str(&self.added); - self.removed.clear(); - self.added.clear(); - } - - fn write_gap(&mut self) { - if !self.gap { - return; - } - self.output.push_str(" | ...\n"); - self.gap = false; - } -} - -#[cfg(test)] -mod tests { - use crate::summary::DiffResult; - #[test] - fn matched_rename_to_is_reindented_not_deleted_and_added() { - let lhs = include_str!("../../examples/review/real/02-review-175/before.ts"); - let rhs = include_str!("../../examples/review/real/02-review-175/after.ts"); - let review = DiffResult::from_sources("parser.ts", lhs, rhs); - assert!(super::reindented_pairs(&review).contains(&(247, 253))); - let output = review.snapshot(); - let assignment: Vec<_> = output - .lines() - .filter(|line| line.contains("renameTo = unquoteGitPath")) - .collect(); - assert_eq!(assignment.len(), 2); - assert!(assignment.iter().all(|line| line.as_bytes()[10] == b'~')); - let domain = review.domain_json(); - assert_eq!(domain["lhs_src"]["Text"], lhs); - assert_eq!(domain["rhs_src"]["Text"], rhs); - let token = domain["lhs_positions"] - .as_array() - .unwrap() - .iter() - .find(|p| p["pos"]["line"] == 247 && p["pos"]["start_col"] == 6) - .unwrap(); - assert_eq!( - token["kind"]["UnchangedToken"]["opposite_pos"][0]["line"], - 253 - ); - assert_eq!( - token["kind"]["UnchangedToken"]["opposite_pos"][0]["start_col"], - 8 - ); - assert!(domain["lhs_folds"][0]["match_kind"]["Unchanged"].is_object()); - assert!( - domain.get("layout").is_none(), - "layout is not part of the domain" - ); - } - - #[test] - fn changed_literal_is_not_treated_as_reindentation() { - let review = DiffResult::from_sources( - "a.py", - "def f():\n return ' a'\n", - "def f():\n return ' a'\n", - ); - assert!(!super::reindented_pairs(&review).contains(&(1, 1))); - } -} diff --git a/src/review/tests.rs b/src/review/tests.rs deleted file mode 100644 index be201f4fc..000000000 --- a/src/review/tests.rs +++ /dev/null @@ -1,591 +0,0 @@ -//! End-to-end annotation checks through the experimental source adapter. -use crate::lines::{SourcePosition, SourceRange}; - -fn text<'a>(source: &'a str, range: &SourceRange) -> &'a str { - let offset = |p: &SourcePosition| { - let line = p.line.as_usize(); - let prefix = source - .split_inclusive('\n') - .take(line) - .map(str::len) - .sum::(); - prefix + p.byte_column - }; - let start = offset(&range.start); - let end = offset(&range.end); - assert!(start < end, "nonempty ranges"); - &source[start..end] // Bounds and UTF-8 boundary validation. -} - -mod folds { - use super::text; - use crate::lines::SourceRange; - use crate::parse::folds::{Fold, FoldMatch}; - use crate::summary::DiffResult; - use std::fmt::Write as _; - - fn paired(fold: &Fold) -> Option<(&SourceRange, &SourceRange)> { - match &fold.match_kind { - FoldMatch::Unchanged { opposite } => Some((&fold.range, opposite)), - FoldMatch::Novel => None, - } - } - fn added(fold: &Fold) -> Option<&SourceRange> { - matches!(fold.match_kind, FoldMatch::Novel).then_some(&fold.range) - } - - #[test] - fn renamed_function_with_changed_body_uses_structural_correspondence() { - let lhs = "fn run() {\n old_work();\n}\n"; - let rhs = "fn execute() {\n old_work();\n new_work();\n}\n"; - let diff = DiffResult::from_sources("a.rs", lhs, rhs); - assert!(diff.lhs_folds.iter().any(|f| { - paired(f).is_some_and(|(l, r)| { - l.start.line.as_usize() == 0 - && l.end.line.as_usize() == 2 - && r.end.line.as_usize() == 3 - }) - })); - } - - #[test] - fn call_arguments_are_not_fold_candidates() { - let diff = - DiffResult::from_sources("a.rs", "", "fn run() {\n call(123, other(456));\n}\n"); - assert_eq!(diff.rhs_folds.len(), 1); - } - - #[test] - fn multiline_atoms_and_test_bodies_keep_fold_metadata() { - let src = "def test_read():\n value = \"\"\"first\nsecond\"\"\"\n return value\n"; - let result = DiffResult::from_sources("a.py", "", src); - assert!(result - .rhs_folds - .iter() - .any(|fold| fold.tags.iter().any(|tag| tag == "test"))); - assert!(result - .rhs_folds - .iter() - .any(|fold| fold.placeholder == "String" - && added(fold).is_some_and(|r| text(src, r) == "\"\"\"first\nsecond\"\"\""))); - } - - #[test] - fn flattened_test_body_keeps_the_existing_string_match_on_both_sides() { - let lhs = "def test_doc():\n \"\"\"some shared words before\"\"\"\n"; - let rhs = "def test_doc():\n \"\"\"some shared words after\"\"\"\n"; - let result = DiffResult::from_sources("a.py", lhs, rhs); - assert_eq!(result.lhs_folds.len(), 1); - assert_eq!(result.rhs_folds.len(), 1); - let fold = &result.lhs_folds[0]; - assert_eq!(fold.tags, ["body", "test"]); - let (left, right) = paired(fold).expect("reuse the replaced-string correspondence"); - assert_eq!(text(lhs, left), "\"\"\"some shared words before\"\"\""); - assert_eq!(text(rhs, right), "\"\"\"some shared words after\"\"\""); - } - - #[test] - fn imports_remain_individual_typed_folds() { - let source = "import os\nimport sys\n"; - for (lhs, rhs) in [ - ( - "import os\nimport sys\nx = 1\n", - "import os\nimport sys\nx = 2\n", - ), - ("", source), - (source, ""), - ] { - let diff = DiffResult::from_sources("a.py", lhs, rhs); - for (folds, own, opposite) in [(&diff.lhs_folds, lhs, rhs), (&diff.rhs_folds, rhs, lhs)] - { - if own.is_empty() { - assert!(folds.is_empty()); - continue; - } - assert_eq!(folds.len(), 2); - for (fold, expected) in folds.iter().zip(["import os", "import sys"]) { - assert_eq!(fold.tags, ["import"]); - assert_eq!(text(own, &fold.range), expected); - match &fold.match_kind { - FoldMatch::Unchanged { opposite: range } => { - assert_eq!(text(opposite, range), expected) - } - FoldMatch::Novel => assert!(opposite.is_empty()), - } - } - } - } - } - - #[test] - fn imports_outside_displayed_context_remain_available() { - let mut body = String::new(); - for i in 0..30 { - writeln!(body, " value_{i} = {i}").unwrap(); - } - let lhs = format!("import os\n\ndef run():\n{body}\n"); - let rhs = lhs.replace("value_29 = 29", "value_29 = 999"); - let diff = DiffResult::from_sources("a.py", &lhs, &rhs); - assert!(diff - .lhs_folds - .iter() - .any(|f| matches!(f.placeholder.as_str(), "Import" | "Imports"))); - } - - #[test] - fn import_folds_remain_one_sided_when_file_added_or_deleted() { - let source = "import os\n\ndef f():\n return os.getcwd()\n"; - let added_diff = DiffResult::from_sources("a.py", "", source); - assert!(added_diff.lhs_folds.is_empty()); - assert_eq!( - text(source, added(&added_diff.rhs_folds[0]).unwrap()), - "import os" - ); - let deleted_diff = DiffResult::from_sources("a.py", source, ""); - assert!(deleted_diff.rhs_folds.is_empty()); - assert_eq!( - text(source, added(&deleted_diff.lhs_folds[0]).unwrap()), - "import os" - ); - } - - #[test] - fn nested_try_catch_bodies_pair_and_keep_delimiters() { - let lhs = "function run() {\n try {\n work(1);\n } catch (error) {\n report(error);\n }\n}\n"; - let rhs = lhs.replace("work(1)", "work(2)"); - let review = DiffResult::from_sources("a.ts", lhs, &rhs); - for expected in [ - "\n try {\n work(1);\n } catch (error) {\n report(error);\n }\n", - "\n work(1);\n ", - "\n report(error);\n ", - ] { - let expected_rhs = expected.replace("work(1)", "work(2)"); - assert!( - review.lhs_folds.iter().any(|f| { - paired(f).is_some_and(|(l, r)| { - text(lhs, l) == expected && text(&rhs, r) == expected_rhs - }) - }), - "missing paired fold for {expected:?}" - ); - } - } - - #[test] - fn added_rust_test_body_is_foldable_without_hiding_signature_or_brace() { - let lhs = include_str!("../../examples/review/real/08-ripgrep-3496/before.rs"); - let rhs = include_str!("../../examples/review/real/08-ripgrep-3496/after.rs"); - let review = DiffResult::from_sources("walk.rs", lhs, rhs); - let signature = rhs - .lines() - .position(|l| l.contains("fn max_depth_does_not_load_unreachable_ignore_files()")) - .unwrap(); - assert!(review.rhs_folds.iter().any(|f| { - added(f).is_some_and(|r| { - r.start.line.as_usize() == signature - && text(rhs, r).contains("let td = tmpdir();") - && rhs.lines().nth(r.end.line.as_usize()).unwrap().trim() == "}" - }) - })); - } - - #[test] - fn python_suites_and_go_blocks_are_foldable() { - for (path, src, expected) in [ - ( - "a.py", - "def run():\n try:\n work()\n except Exception:\n recover()\n", - "work()", - ), - ( - "a.go", - "package main\nfunc run() {\n if ready {\n work()\n }\n}\n", - "\n work()\n ", - ), - ] { - let review = DiffResult::from_sources(path, "", src); - assert!( - review - .rhs_folds - .iter() - .any(|f| added(f).is_some_and(|r| text(src, r) == expected)), - "missing body fold in {path}" - ); - } - } - - #[test] - fn inline_collections_have_exact_paired_byte_ranges() { - let lhs = "const x = [\"☕\", oldValue];\n"; - let rhs = "const x = [\"☕\", newValue];\n"; - let review = DiffResult::from_sources("a.ts", lhs, rhs); - assert!(review.lhs_folds.iter().any(|f| { - paired(f).is_some_and(|(l, r)| { - text(lhs, l) == "\"☕\", oldValue" && text(rhs, r) == "\"☕\", newValue" - }) - })); - } -} - -mod syntax_tests { - use crate::summary::DiffResult; - use crate::summary::FileContent; - use std::fmt::Write as _; - #[test] - fn distant_changes_share_one_hunk_and_one_context_copy() { - let mut statements = String::new(); - for i in 0..70 { - writeln!(statements, " work({i});").unwrap(); - } - let lhs = format!("fn run() {{\n{statements}}}\n"); - let rhs = lhs - .replace("work(10)", "changed(10)") - .replace("work(35)", "changed(35)") - .replace("work(60)", "changed(60)"); - let diff = DiffResult::from_sources("a.rs", &lhs, &rhs); - let prepared = &diff; - assert_eq!(prepared.hunks.len(), 1); - let mut seen = std::collections::BTreeSet::new(); - for (lhs, rhs) in &prepared.hunks[0].lines { - let pair = (lhs.unwrap().as_usize(), rhs.unwrap().as_usize()); - assert!(seen.insert(pair), "duplicate displayed row"); - } - assert!(!seen.contains(&(24, 24)), "distant gaps stay hidden"); - assert!(seen.contains(&(0, 0))); - assert!(seen.contains(&(71, 71))); - } - - #[test] - fn new_and_deleted_functions_have_folds_but_no_extra_context() { - let source = "fn run() {\n work();\n}\n"; - for (lhs, rhs) in [("", source), (source, "")] { - let diff = DiffResult::from_sources("a.rs", lhs, rhs); - assert!(!diff.lhs_folds.is_empty() || !diff.rhs_folds.is_empty()); - let prepared = &diff; - assert!(prepared - .hunks - .iter() - .flat_map(|h| &h.lines) - .all(|(lhs, rhs)| lhs.is_none() || rhs.is_none())); - } - } - - #[test] - fn unicode_crlf_and_multiline_return_boundaries() { - let lhs = - "def café():\r\n x = 1\r\n return (\r\n x,\r\n\r\n '☕',\r\n )\r\n"; - let rhs = lhs.replace("x = 1", "x = 2"); - let result = DiffResult::from_sources("a.py", lhs, &rhs); - assert!(matches!(&result.rhs_src,FileContent::Text(s) if s==&rhs)); - assert!(result.snapshot().contains("café")); - } - - #[test] - fn python_class_has_no_synthetic_closing_context() { - let lhs = "class C:\n def changed(self):\n if False:\n return\n x = 1\n x += 1\n x += 1\n x += 1\n x += 1\n\n def unrelated(self):\n return 999\n"; - let rhs = lhs.replace("x = 1", "x = 2"); - let result = DiffResult::from_sources("a.py", lhs, &rhs); - assert!(!result.snapshot().contains("unrelated")); - assert!(!result.snapshot().contains("return 999")); - } - - #[test] - fn context_belongs_to_the_hunk_that_requested_it() { - let function = |name: &str| { - format!( - "fn {name}() {{\n{} return;\n}}\n", - " work();\n".repeat(24) - ) - }; - let lhs = format!( - "{}{}{}", - function("first"), - "\n".repeat(12), - function("second") - ); - let mut lines: Vec<_> = lhs.lines().map(str::to_owned).collect(); - let second = lines.iter().position(|l| l == "fn second() {").unwrap(); - lines[12] = " changed_first();".into(); - lines[second + 12] = " changed_second();".into(); - let rhs = lines.join("\n") + "\n"; - let review = DiffResult::from_sources("a.rs", &lhs, &rhs); - let prepared = &review; - assert_eq!(prepared.hunks.len(), 2); - for (i, hunk) in prepared.hunks.iter().enumerate() { - let selected: std::collections::BTreeSet<_> = hunk - .lines - .iter() - .filter_map(|(_, rhs)| rhs.map(|line| line.as_usize())) - .collect(); - let (own, other) = if i == 0 { (0, second) } else { (second, 0) }; - assert!(selected.contains(&own)); - assert!(!selected.contains(&other)); - } - let domain = review.domain_json(); - assert!(domain.get("context").is_none()); - assert!(domain["hunks"][0]["lines"].is_array()); - assert!(domain["hunks"][0].get("context").is_none()); - } - - #[test] - fn generator_declarations_keep_enclosing_signature_context() { - let body = (0..30) - .map(|i| format!(" yield value_{i};\n")) - .collect::(); - for extension in ["js", "jsx", "ts", "tsx"] { - for prefix in ["export function*", "export async function*"] { - let lhs = format!("{prefix} stream(\n chunks,\n) {{\n{body}}}\n"); - let rhs = lhs.replace("yield value_20;", "yield changed_value;"); - let diff = DiffResult::from_sources(&format!("stream.{extension}"), &lhs, &rhs); - let selected = super::selection_without_padding(&diff); - assert!( - (0..3).all(|line| selected.lhs.contains(&line) && selected.rhs.contains(&line)), - "missing generator signature: {extension}, {prefix}" - ); - assert!( - selected.lhs.contains(&33) && selected.rhs.contains(&33), - "missing closing brace: {extension}, {prefix}" - ); - assert!( - !selected.lhs.contains(&10) && !selected.rhs.contains(&10), - "unrelated body should stay hidden" - ); - } - } - } - - #[test] - fn consecutive_signature_context_is_selected() { - let mut body = String::new(); - for i in 0..30 { - writeln!(body, " value_{i} = {i}").unwrap(); - } - let lhs = format!("def run(\n first,\n second,\n):\n{body}"); - let rhs = lhs.replace("value_20 = 20", "value_20 = 999"); - let diff = DiffResult::from_sources("a.py", &lhs, &rhs); - let selected = super::selection_without_padding(&diff); - assert!((0..4).all(|line| selected.lhs.contains(&line) && selected.rhs.contains(&line))); - } - - #[test] - fn signature_context_does_not_expose_neighboring_statements() { - let lhs = include_str!("../../examples/review/real/02-review-175/before.ts"); - let rhs = include_str!("../../examples/review/real/02-review-175/after.ts"); - let diff = DiffResult::from_sources("a.ts", lhs, rhs); - let output = diff.snapshot(); - assert!(output.contains("function parseReviewDiffFile(")); - assert!(!output.contains("return sections.filter(")); - assert!(!output.contains("const lines = section.split(")); - assert!( - output.contains("let additions = 0;"), - "changes retain ordinary context" - ); - } - - #[test] - fn unrelated_tail_return_is_not_extra_context() { - let lhs = include_str!("../../examples/review/real/07-ripgrep-3487/before.rs"); - let rhs = include_str!("../../examples/review/real/07-ripgrep-3487/after.rs"); - let review = DiffResult::from_sources("a.rs", lhs, rhs); - let selected = super::selection_without_padding(&review); - let return_line = rhs - .lines() - .position(|line| line.contains("Ok(if matched")) - .unwrap(); - assert!( - !selected.rhs.contains(&return_line), - "the unrelated return is not syntax context" - ); - assert!(!review.snapshot().contains("Ok(if matched")); - assert!(review.snapshot().contains("fn run(")); - } - - #[test] - fn changed_entry_keeps_enclosing_return_boundaries() { - let mut entries = String::new(); - for i in 0..24 { - writeln!(entries, " '{i}': {i},").unwrap(); - } - let lhs = format!("def values():\n return {{\n{entries} }}\n"); - let rhs = lhs.replace("'12': 12", "'12': 999"); - let review = DiffResult::from_sources("a.py", &lhs, &rhs); - let context = super::selection_without_padding(&review); - assert!( - context.rhs.contains(&1), - "return opener is beyond ordinary padding" - ); - assert!( - context.rhs.contains(&26), - "returned dictionary closer is beyond padding" - ); - } -} -mod hunk_tests { - use super::text; - use crate::display::line_layout as layout; - use crate::parse::folds::FoldMatch; - use crate::summary::DiffResult; - use crate::summary::FileFormat; - #[test] - fn touching_windows_merge_but_a_hidden_row_separates_hunks() { - use crate::display::hunks::Hunk; - use line_numbers::LineNumber; - use std::fmt::Write; - let mut lhs = String::new(); - for line in 0..40 { - writeln!(lhs, "line {line}").unwrap(); - } - for (second, expected_hunks) in [(17, 1), (18, 2)] { - let rhs = lhs - .replace("line 10\n", "changed 10\n") - .replace(&format!("line {second}\n"), &format!("changed {second}\n")); - let diff = DiffResult::from_sources("a.txt", &lhs, &rhs); - // Give preparation the two separate change seeds: upstream may have - // already merged them using its different padding policy. - let raw: Vec<_> = [10, second] - .into_iter() - .map(|line| { - let line = LineNumber(line); - Hunk { - novel_lhs: [line].into_iter().collect(), - novel_rhs: [line].into_iter().collect(), - lines: vec![(Some(line), Some(line))], - } - }) - .collect(); - let hunks = crate::display::prepare::prepare( - &raw, - (&lhs, &rhs), - (&diff.lhs_positions, &diff.rhs_positions), - &Default::default(), - 3, - ); - assert_eq!(hunks.len(), expected_hunks); - let selected = layout::LineSelection::from_hunks(&hunks); - assert_eq!(selected.lhs.contains(&14), second == 17); - } - } - - #[test] - fn identical_files_keep_alignment_for_showing_hidden_source() { - let source = "fn unchanged() {\n work();\n}\n"; - let diff = DiffResult::from_sources("a.rs", source, source); - assert!(diff.hunks.is_empty()); - let viewer = diff.viewer_json(); - assert_eq!( - viewer["layout"]["rows"], - serde_json::json!([[0, 0], [1, 1], [2, 2]]) - ); - } - - #[test] - fn unsupported_language_uses_text_diff_without_syntax_annotations() { - let result = DiffResult::from_sources("a.txt", "hello old\n", "hello new\n"); - assert!(matches!(result.file_format, FileFormat::PlainText)); - assert!(result.lhs_folds.is_empty() && result.rhs_folds.is_empty()); - assert!(result.snapshot().contains("- hello old")); - assert!(result.snapshot().contains("+ hello new")); - } - - #[test] - fn real_annotations_have_valid_ranges_and_unchanged_context() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/review/real"); - for dir in std::fs::read_dir(root).unwrap() { - let dir = dir.unwrap().path(); - let p: serde_json::Value = - serde_json::from_slice(&std::fs::read(dir.join("provenance.json")).unwrap()) - .unwrap(); - let read_source = |side: &str| { - std::fs::read_to_string(dir.join(p["sources"][side]["file"].as_str().unwrap())) - .unwrap() - }; - let (lhs_src, rhs_src) = (read_source("lhs"), read_source("rhs")); - let path = p["sources"]["rhs"]["path"].as_str().unwrap(); - let result = DiffResult::from_sources(path, &lhs_src, &rhs_src); - let prepared = &result; - let baseline = layout::LineSelection::from_hunks(&prepared.hunks); - let lhs_novel = layout::novel_lines(&result.lhs_positions); - let rhs_novel = layout::novel_lines(&result.rhs_positions); - assert!(lhs_novel.is_subset(&baseline.lhs)); - assert!(rhs_novel.is_subset(&baseline.rhs)); - for (folds, own_src, opposite_src, opposite_folds) in [ - (&result.lhs_folds, &lhs_src, &rhs_src, &result.rhs_folds), - (&result.rhs_folds, &rhs_src, &lhs_src, &result.lhs_folds), - ] { - for fold in folds { - text(own_src, &fold.range); - if let FoldMatch::Unchanged { opposite } = &fold.match_kind { - text(opposite_src, opposite); - assert!(opposite_folds.iter().any(|other| { - other.range == *opposite - && matches!(&other.match_kind, FoldMatch::Unchanged { opposite: back } if *back == fold.range) - }), "matched folds must be reciprocal"); - } - } - } - let mut seen = std::collections::BTreeSet::new(); - for row in prepared.hunks.iter().flat_map(|h| &h.lines) { - assert!( - seen.insert(*row), - "duplicate selected row: {}", - dir.display() - ); - if let Some(lhs) = row.0 { - assert!(lhs.as_usize() < lhs_src.lines().count()); - } - if let Some(rhs) = row.1 { - assert!(rhs.as_usize() < rhs_src.lines().count()); - } - } - } - } - - #[test] - fn byte_limit_fallback_has_no_syntax_annotations() { - let file = crate::options::FileArgument::NamedPath("a.py".into()); - let options = crate::options::DiffOptions { - byte_limit: 1, - ..Default::default() - }; - let diff = crate::diff_file_content( - &crate::config::Params::default(), - "a.py", - None, - &file, - &file, - "x = 1\n", - "x = 2\n", - &Default::default(), - &options, - &[], - ); - assert!(matches!(diff.file_format, FileFormat::TextFallback { .. })); - assert!(diff.lhs_folds.is_empty() && diff.rhs_folds.is_empty()); - let result = diff; - assert!(result.snapshot().contains("+ x = 2")); - } -} - -fn selection_without_padding( - diff: &crate::summary::DiffResult, -) -> crate::display::line_layout::LineSelection { - let (lhs, rhs) = crate::display::line_layout::sources(diff); - let file = crate::options::FileArgument::NamedPath(diff.display_path.clone().into()); - let options = crate::options::DisplayOptions { - num_context_lines: 0, - ..Default::default() - }; - let diff = crate::diff_file_content( - &crate::config::Params::default(), - &diff.display_path, - None, - &file, - &file, - lhs, - rhs, - &options, - &crate::options::DiffOptions::default(), - &[], - ); - crate::display::line_layout::LineSelection::from_hunks(&diff.hunks) -} diff --git a/src/review/wire.rs b/src/review/wire.rs deleted file mode 100644 index ce3fc9780..000000000 --- a/src/review/wire.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Lossless JSON encoding of the domain model, not Difftastic's display JSON. -use crate::display::line_layout as layout; -use crate::lines::SourceRange; -use crate::parse::folds::{Fold, FoldMatch}; -use crate::parse::syntax::{MatchKind, MatchedPos}; -use crate::summary::DiffResult; -use crate::summary::{FileContent, FileFormat}; -use line_numbers::SingleLineSpan; -use serde_json::{json, Value}; - -fn span(position: &SingleLineSpan) -> Value { - json!({ - "line": position.line.0, - "start_col": position.start_col, - "end_col": position.end_col, - }) -} - -fn spans(positions: &[SingleLineSpan]) -> Value { - Value::Array(positions.iter().map(span).collect()) -} - -fn position(position: &MatchedPos) -> Value { - let kind = match &position.kind { - MatchKind::UnchangedToken { - highlight, - self_pos, - opposite_pos, - } => json!({ - "UnchangedToken": { - "highlight": highlight, - "self_pos": spans(self_pos), - "opposite_pos": spans(opposite_pos), - }, - }), - MatchKind::UnchangedPartOfNovelItem { - highlight, - self_pos, - opposite_pos, - } => json!({ - "UnchangedPartOfNovelItem": { - "highlight": highlight, - "self_pos": span(self_pos), - "opposite_pos": spans(opposite_pos), - }, - }), - MatchKind::Novel { highlight } => json!({"Novel": {"highlight": highlight}}), - MatchKind::NovelWord { highlight } => json!({"NovelWord": {"highlight": highlight}}), - MatchKind::Ignored { highlight } => json!({"Ignored": {"highlight": highlight}}), - }; - json!({"pos": span(&position.pos), "kind": kind}) -} - -pub(crate) fn range(range: &SourceRange) -> Value { - json!({ - "start": { - "line": range.start.line.0, - "byte_column": range.start.byte_column, - }, - "end": { - "line": range.end.line.0, - "byte_column": range.end.byte_column, - }, - }) -} - -fn fold(fold: &Fold) -> Value { - let match_kind = match &fold.match_kind { - FoldMatch::Unchanged { opposite } => json!({"Unchanged": {"opposite": range(opposite)}}), - FoldMatch::Novel => json!("Novel"), - }; - json!({ - "tags": fold.tags, - "range": range(&fold.range), - "match_kind": match_kind, - "placeholder": fold.placeholder, - "summary": fold.summary, - }) -} - -fn content(content: &FileContent) -> Value { - match content { - FileContent::Text(source) => json!({"Text": source}), - FileContent::Binary => json!("Binary"), - } -} - -fn file_format(format: &FileFormat) -> Value { - match format { - FileFormat::SupportedLanguage(language) => json!({ - "SupportedLanguage": crate::parse::guess_language::language_name(*language), - }), - FileFormat::PlainText => json!("PlainText"), - FileFormat::Binary => json!("Binary"), - FileFormat::TextFallback { reason } => json!({"TextFallback": {"reason": reason}}), - } -} - -fn hunk(hunk: &crate::display::hunks::Hunk) -> Value { - let mut novel_lhs: Vec<_> = hunk.novel_lhs.iter().map(|line| line.0).collect(); - let mut novel_rhs: Vec<_> = hunk.novel_rhs.iter().map(|line| line.0).collect(); - novel_lhs.sort_unstable(); - novel_rhs.sort_unstable(); - let lines: Vec<_> = hunk - .lines - .iter() - .map(|(lhs, rhs)| (lhs.map(|line| line.0), rhs.map(|line| line.0))) - .collect(); - json!({ - "novel_lhs": novel_lhs, "novel_rhs": novel_rhs, "lines": lines, - }) -} - -impl DiffResult { - pub(crate) fn domain_json(&self) -> Value { - let hunks: Vec<_> = self.hunks.iter().map(hunk).collect(); - let aligned_rows = match (&self.lhs_src, &self.rhs_src) { - (FileContent::Text(lhs), FileContent::Text(rhs)) => { - layout::aligned_rows((lhs, rhs), (&self.lhs_positions, &self.rhs_positions)) - } - _ => vec![], - }; - json!({ - "display_path": self.display_path, - "extra_info": self.extra_info, - "file_format": file_format(&self.file_format), - "lhs_src": content(&self.lhs_src), - "rhs_src": content(&self.rhs_src), - "lhs_positions": self.lhs_positions.iter().map(position).collect::>(), - "rhs_positions": self.rhs_positions.iter().map(position).collect::>(), - "hunks": hunks, - "aligned_rows": aligned_rows, - "has_byte_changes": self.has_byte_changes, - "has_syntactic_changes": self.has_syntactic_changes, - "lhs_folds": self.lhs_folds.iter().map(fold).collect::>(), - "rhs_folds": self.rhs_folds.iter().map(fold).collect::>(), - }) - } - - /// Adapt the existing alignment and selected rows for the fixture viewer. - #[cfg(test)] - pub(crate) fn viewer_json(&self) -> Value { - let rows = layout::aligned_rows( - layout::sources(self), - (&self.lhs_positions, &self.rhs_positions), - ); - let baseline = layout::LineSelection::from_hunks(&self.hunks); - let reindented = layout::reindented_pairs(self); - json!({ - "domain": self.domain_json(), - "layout": {"rows": rows, "baseline": (&baseline.lhs, &baseline.rhs), "reindented": reindented}, - }) - } -} diff --git a/src/stream.rs b/src/stream.rs deleted file mode 100644 index 4dd6d502e..000000000 --- a/src/stream.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Incremental stdout protocol over the shared file iterator. -use crate::git::{DiffSession, FileChange, LoadedFile, Operand, Result}; -use crate::hook::Hook; -use crate::summary::DiffResult; -use rayon::iter::{ParallelBridge, ParallelIterator}; -use serde::Serialize; -use serde_json::Value; -use std::io::{BufWriter, Write}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc::{sync_channel, SendError, SyncSender}; -use std::sync::Arc; -use std::thread; - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum Event { - Start { - version: u32, - before: Operand, - after: Operand, - total: usize, - files: Vec, - }, - File { - file: FileChange, - diff: Value, - /// The fold hook failed for this file; its folds keep their placeholders. - #[serde(skip_serializing_if = "Option::is_none")] - hook_error: Option, - }, - FileError { - file: FileChange, - message: String, - }, - Complete { - succeeded: usize, - failed: usize, - }, -} - -/// Returns whether any file failed. Files are diffed on `jobs` workers and -/// emitted as they finish, so results arrive in completion order. The queue -/// holds at most one ready event; computation can overlap output without -/// retaining the whole diff. -pub(crate) fn write( - session: DiffSession, - jobs: usize, - hook: Option>, - output: &mut impl Write, -) -> Result { - let (sender, receiver) = sync_channel(1); - let pool = rayon::ThreadPoolBuilder::new() - .num_threads(jobs) - .thread_name(|index| format!("diffr-worker-{index}")) - .build()?; - let worker = thread::spawn(move || { - // A disconnected consumer cancels production after the files in flight. - let _ = produce(session, &pool, hook.as_deref(), sender); - }); - let mut output = BufWriter::new(output); - let result: Result = (|| { - let mut failed = false; - for event in &receiver { - if let Event::Complete { failed: count, .. } = &event { - failed = *count > 0; - } - serde_json::to_writer(&mut output, &event)?; - output.write_all(b"\n")?; - output.flush()?; - } - Ok(failed) - })(); - // Wake a producer blocked on a full queue if writing failed. - drop(receiver); - let joined = worker.join(); - let failed = result?; - joined.map_err(|_| "diff computation thread panicked")?; - Ok(failed) -} - -fn produce( - session: DiffSession, - pool: &rayon::ThreadPool, - hook: Option<&Hook>, - sender: SyncSender, -) -> std::result::Result<(), SendError> { - sender.send(Event::Start { - version: 1, - before: session.comparison.before.clone(), - after: session.comparison.after.clone(), - total: session.remaining(), - files: session.file_manifest(), - })?; - let succeeded = AtomicUsize::new(0); - let failed = AtomicUsize::new(0); - let cancelled = Arc::new(AtomicBool::new(false)); - let loader = Loader { - session, - cancelled: Arc::clone(&cancelled), - }; - pool.install(|| { - loader.par_bridge().for_each(|(file, loaded)| { - let event = match loaded { - Ok(loaded) => { - succeeded.fetch_add(1, Ordering::Relaxed); - file_event(file, loaded.diff(), hook) - } - Err(error) => { - failed.fetch_add(1, Ordering::Relaxed); - Event::FileError { - file, - message: error.to_string(), - } - } - }; - if sender.send(event).is_err() { - cancelled.store(true, Ordering::Relaxed); - } - }); - }); - sender.send(Event::Complete { - succeeded: succeeded.into_inner(), - failed: failed.into_inner(), - }) -} - -/// Summaries are filled in before the event so clients never see a fold change. -fn file_event(file: FileChange, mut diff: DiffResult, hook: Option<&Hook>) -> Event { - let hook_error = hook.and_then(|hook| hook.summarize(&mut diff).err()); - Event::File { - file, - diff: diff.domain_json(), - hook_error, - } -} - -/// Reads sources serially on whichever worker pulls next; diffing then -/// proceeds on that worker while others pull further files. -struct Loader { - session: DiffSession, - cancelled: Arc, -} - -impl Iterator for Loader { - type Item = (FileChange, crate::git::Result); - fn next(&mut self) -> Option { - if self.cancelled.load(Ordering::Relaxed) { - return None; - } - self.session.load() - } -} - -/// Stream a standalone file comparison through the same file/completion events. -/// File operands identify paths rather than repository revisions. -pub(crate) fn write_file( - before: &str, - after: &str, - compute: impl FnOnce() -> DiffResult, - hook: Option<&Hook>, - output: &mut impl Write, -) -> Result<()> { - let file = FileChange { - old_path: (before != "/dev/null").then(|| before.into()), - new_path: (after != "/dev/null").then(|| after.into()), - status: if before == "/dev/null" { - crate::git::FileStatus::Added - } else if after == "/dev/null" { - crate::git::FileStatus::Deleted - } else { - crate::git::FileStatus::Modified - }, - class: None, - }; - let mut output = BufWriter::new(output); - serde_json::to_writer( - &mut output, - &serde_json::json!({ - "type": "start", "version": 1, "total": 1, "files": [&file], - "before": {"kind": "file", "path": before}, - "after": {"kind": "file", "path": after} - }), - )?; - output.write_all(b"\n")?; - output.flush()?; - serde_json::to_writer(&mut output, &file_event(file, compute(), hook))?; - output.write_all(b"\n")?; - serde_json::to_writer( - &mut output, - &Event::Complete { - succeeded: 1, - failed: 0, - }, - )?; - output.write_all(b"\n")?; - output.flush()?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn standalone_manifest_is_flushed_before_computation() { - struct Disconnected(Vec); - impl Write for Disconnected { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.extend_from_slice(bytes); - Ok(bytes.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)) - } - } - let mut output = Disconnected(vec![]); - let result = write_file( - "before.rs", - "after.rs", - || panic!("must not compute after manifest flush fails"), - None, - &mut output, - ); - assert!(result.is_err()); - let start: Value = serde_json::from_slice(&output.0).unwrap(); - assert_eq!(start["type"], "start"); - assert_eq!(start["files"][0]["new_path"], "after.rs"); - } -} diff --git a/src/summary.rs b/src/summary.rs index 25e049fc2..d749a3c54 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -17,16 +17,32 @@ pub(crate) enum FileContent { pub(crate) enum FileFormat { SupportedLanguage(guess_language::Language), PlainText, - TextFallback { reason: String }, + /// A file in a supported language diffed by line: `cause` says which + /// limit it hit, and `reason` says so in prose, with the numbers. + TextFallback { + cause: FallbackCause, + reason: String, + }, Binary, } +/// Why a file in a supported language was diffed by line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FallbackCause { + /// A side is larger than the byte limit. + ByteLimit, + /// The AST matching graph grew past the graph limit. + GraphLimit, + /// A side has more parse errors than the parse error limit. + ParseErrorLimit, +} + impl Display for FileFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::SupportedLanguage(language) => write!(f, "{}", language_name(*language)), Self::PlainText => write!(f, "Text"), - Self::TextFallback { reason } => write!(f, "Text ({})", reason), + Self::TextFallback { reason, .. } => write!(f, "Text ({})", reason), Self::Binary => write!(f, "Binary"), } } diff --git a/tests/hooks/rpc_server.py b/tests/hooks/rpc_server.py deleted file mode 100755 index 668b33032..000000000 --- a/tests/hooks/rpc_server.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""Minimal JSON-RPC 2.0 fold hook for tests, standard library only. - -Listens on 127.0.0.1:$DIFFR_HOOK_PORT. The first argument selects a behavior: - echo answer every fold with "pseudo " - first answer only fold 0 with "summary of f" - error return a JSON-RPC error for every request - slow never answer (sleeps inside the handler) - cwd answer fold 0 with the working directory and assert DIFFR_WORKSPACE - bad return a non-JSON body -""" - -import json -import os -import sys -import time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -MODE = sys.argv[1] - - -class Handler(BaseHTTPRequestHandler): - def do_POST(self): - request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) - assert request["jsonrpc"] == "2.0" and request["method"] == "summarize", request - params = request["params"] - assert params["src"] and isinstance(params["folds"], list), params - if MODE == "slow": - time.sleep(30) - if MODE == "bad": - body = b"not json" - elif MODE == "error": - body = json.dumps( - { - "jsonrpc": "2.0", - "id": request["id"], - "error": {"code": -32000, "message": "declined"}, - } - ).encode() - else: - if MODE == "echo": - texts = { - str(f["id"]): "pseudo " + f["placeholder"] for f in params["folds"] - } - elif MODE == "first": - texts = {"0": "summary of f"} - elif MODE == "cwd": - assert os.environ["DIFFR_WORKSPACE"] == sys.argv[2], os.environ[ - "DIFFR_WORKSPACE" - ] - texts = {"0": os.getcwd()} - else: - raise SystemExit(f"unknown mode {MODE}") - body = json.dumps( - {"jsonrpc": "2.0", "id": request["id"], "result": texts} - ).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass - - -if MODE == "exit": - raise SystemExit(3) -ThreadingHTTPServer( - ("127.0.0.1", int(os.environ["DIFFR_HOOK_PORT"])), Handler -).serve_forever() diff --git a/tests/review.rs b/tests/review.rs deleted file mode 100644 index 96ff4acc8..000000000 --- a/tests/review.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Each fixture runs the CLI against Git base/head commits and checks its full output. -//! Open examples/review/real//review.snap to read the expected diff. -mod review_support; - -use review_support::{Fixture, Repo}; - -#[test] -fn review_context() { - let fixture = Fixture::load("01-review-108"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn parser_return() { - let fixture = Fixture::load("02-review-175"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn new_file() { - let fixture = Fixture::load("03-review-160"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn python_method() { - let fixture = Fixture::load("04-flask-6096"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn new_method() { - let fixture = Fixture::load("05-flask-6133"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn deleted_block() { - let fixture = Fixture::load("06-requests-6965"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn rust_tail() { - let fixture = Fixture::load("07-ripgrep-3487"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn rust_test() { - let fixture = Fixture::load("08-ripgrep-3496"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn go_return() { - let fixture = Fixture::load("09-cli-11038"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn go_nested() { - let fixture = Fixture::load("10-go-git-1492"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn typed_parameter() { - let fixture = Fixture::load("11-flask-5526"); - let actual = fixture.run(); - fixture.assert_expected(&actual); -} - -#[test] -fn missing_refs_and_paths_are_errors() { - let repo = Repo::new(); - let (base, _) = repo.commit("a.py", Some(b"x = 1\n"), None); - assert!(!repo.review("invalid-ref", &base, "a.py").status.success()); - assert!(repo.review(&base, &base, "missing.py").status.success()); - assert!(!repo.review(&base, &base, "../a.py").status.success()); -} - -#[test] -fn deleted_file_and_identical_refs() { - let repo = Repo::new(); - let (base, _) = repo.commit("a.py", Some("def café():\n return 1\n".as_bytes()), None); - let (head, _) = repo.commit("a.py", None, Some(&base)); - let output = repo.review(&base, &head, "a.py"); - assert!(output.status.success()); - let text = String::from_utf8(output.stdout).unwrap(); - assert!(text.contains("- def café():")); - assert!(text.contains("- return 1")); - assert!(!text.contains(" + ")); - let output = repo.review(&base, &base, "a.py"); - assert!(output.status.success()); - assert!(output.stdout.is_empty()); -} diff --git a/tests/review_support/git.rs b/tests/review_support/git.rs deleted file mode 100644 index 2c20dde05..000000000 --- a/tests/review_support/git.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Temporary Git repositories used by the integration fixtures. -use git2::{Oid, Repository, Signature, Time}; -use std::fs; -use std::path::PathBuf; -use std::process::Command; -use std::sync::atomic::{AtomicUsize, Ordering}; - -pub(crate) struct Repo(PathBuf); -impl Repo { - pub(crate) fn new() -> Self { - static NEXT: AtomicUsize = AtomicUsize::new(0); - let path = std::env::temp_dir().join(format!( - "difft-review-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::SeqCst) - )); - fs::create_dir(&path).unwrap(); - let repo = Self(path); - Repository::init(&repo.0).unwrap(); - repo - } - pub(crate) fn commit( - &self, - path: &str, - source: Option<&[u8]>, - parent: Option<&str>, - ) -> (String, Option) { - let repo = Repository::open(&self.0).unwrap(); - let blob = source.map(|source| repo.blob(source).unwrap()); - let tree_id = match blob { - Some(blob) => file_tree(&repo, path, blob), - None => repo.treebuilder(None).unwrap().write().unwrap(), - }; - let tree = repo.find_tree(tree_id).unwrap(); - let parent = parent.map(|id| repo.find_commit(Oid::from_str(id).unwrap()).unwrap()); - let parents: Vec<_> = parent.iter().collect(); - let signature = Signature::new( - "Fixture", - "fixture@example.invalid", - &Time::new(946684800, 0), - ) - .unwrap(); - let commit = repo - .commit( - None, - &signature, - &signature, - "Pinned fixture source\n", - &tree, - &parents, - ) - .unwrap(); - (commit.to_string(), blob.map(|id| id.to_string())) - } - pub(crate) fn review(&self, base: &str, head: &str, path: &str) -> std::process::Output { - Command::new(assert_cmd::cargo_bin!("diffr")) - .args(["--repo"]) - .arg(&self.0) - .args([base, head, "--format", "snapshot", "--", path]) - .env_remove("DFT_DBG_KEEP_UNCHANGED") - .output() - .unwrap() - } -} -impl Drop for Repo { - fn drop(&mut self) { - fs::remove_dir_all(&self.0).unwrap(); - } -} - -// Build only this fixture's path, without an index or worktree checkout. -fn file_tree(repo: &Repository, path: &str, blob: Oid) -> Oid { - let mut tree = repo.treebuilder(None).unwrap(); - match path.split_once('/') { - Some((directory, remaining)) => { - let child = file_tree(repo, remaining, blob); - tree.insert(directory, child, 0o040000).unwrap(); - } - None => { - tree.insert(path, blob, 0o100644).unwrap(); - } - } - tree.write().unwrap() -} diff --git a/tests/review_support/mod.rs b/tests/review_support/mod.rs deleted file mode 100644 index c85e3c3a2..000000000 --- a/tests/review_support/mod.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Fixture loading and assertions. Test cases live in ../review.rs. -mod git; -pub(crate) use git::Repo; - -use serde::Deserialize; -use serde_json::Value; -use std::fs; -use std::path::{Path, PathBuf}; - -#[derive(Deserialize)] -struct Provenance { - sources: Sources, -} - -#[derive(Deserialize)] -struct Sources { - lhs: Source, - rhs: Source, -} - -#[derive(Deserialize)] -struct Source { - file: String, - path: Option, - git_blob_sha: Option, -} - -pub(crate) struct Fixture { - name: String, - directory: PathBuf, - repo: Repo, - base: String, - head: String, - path: String, - sources: [String; 2], -} - -impl Fixture { - /// Restore the pinned source blobs as two local commits. No network needed. - pub(crate) fn load(name: &str) -> Self { - let directory = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("examples/review/real") - .join(name); - let provenance: Provenance = - serde_json::from_slice(&fs::read(directory.join("provenance.json")).unwrap()).unwrap(); - let Sources { lhs, rhs } = provenance.sources; - let path = match (&lhs.path, &rhs.path) { - (Some(left), Some(right)) => { - assert_eq!(left, right, "Rename fixtures need separate base/head paths"); - right.clone() - } - (Some(path), None) | (None, Some(path)) => path.clone(), - (None, None) => panic!("Fixture is missing both source paths"), - }; - let before = fs::read_to_string(directory.join(&lhs.file)).unwrap(); - let after = fs::read_to_string(directory.join(&rhs.file)).unwrap(); - let repo = Repo::new(); - let base = restore_commit(&repo, &path, &lhs, &before, None); - let head = restore_commit(&repo, &path, &rhs, &after, Some(&base)); - Self { - name: name.into(), - directory, - repo, - base, - head, - path, - sources: [before, after], - } - } - - /// Invoke the actual CLI using the repository, base/head refs and file path. - pub(crate) fn run(&self) -> String { - let output = self.repo.review(&self.base, &self.head, &self.path); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).unwrap() - } - - /// Check source/context requirements, then compare the complete golden. - /// Updating a golden cannot bypass the required return/brace checks. - pub(crate) fn assert_expected(&self, actual: &str) { - self.assert_source_and_context(actual); - let golden = self.directory.join("review.snap"); - if std::env::var_os("UPDATE_REVIEW_GOLDENS").as_deref() == Some(std::ffi::OsStr::new("1")) { - fs::write(&golden, actual).unwrap(); - } - let expected = fs::read_to_string(&golden) - .expect("Missing review.snap: run UPDATE_REVIEW_GOLDENS=1 cargo test --test review"); - pretty_assertions::assert_eq!(expected, actual, "{}", self.name); - } - - fn assert_source_and_context(&self, actual: &str) { - let mut shown: [std::collections::BTreeSet; 2] = Default::default(); - for line in actual.lines() { - if line.len() < 11 { - continue; - } - let bytes = line.as_bytes(); - if !bytes[..10].iter().all(|c| c.is_ascii_digit() || *c == b' ') { - continue; - } - for (side, number) in [line[..4].trim(), line[5..9].trim()].iter().enumerate() { - if let Ok(number) = number.parse::() { - let index = number - 1; - assert!(shown[side].insert(index), "Duplicate source row: {line}"); - assert_eq!( - &line[12..], - self.sources[side].split('\n').nth(index).unwrap(), - "Wrong source text: {line}" - ); - } - } - } - let case: Value = - serde_json::from_slice(&fs::read(self.directory.join("case.json")).unwrap()).unwrap(); - for (side, key) in ["lhs", "rhs"].iter().enumerate() { - if let Some(entries) = case["keep_visible"][key].as_array() { - for entry in entries { - let start = entry["range"]["start"]["line"].as_u64().unwrap() as usize; - let end = entry["range"]["end"]["line"].as_u64().unwrap() as usize; - let exclusive = end - + usize::from(entry["range"]["end"]["byte_column"].as_u64().unwrap() > 0); - for row in start..exclusive { - assert!( - shown[side].contains(&row), - "{}: missing {key} line {}: {}", - self.name, - row + 1, - self.sources[side].split('\n').nth(row).unwrap() - ); - } - } - } - } - } -} - -fn restore_commit( - repo: &Repo, - path: &str, - spec: &Source, - text: &str, - parent: Option<&str>, -) -> String { - let contents = match &spec.path { - Some(_) => Some(text.as_bytes()), - None => { - assert!( - text.is_empty(), - "An absent file must have empty fixture content" - ); - None - } - }; - let (commit, blob) = repo.commit(path, contents, parent); - assert_eq!( - blob, spec.git_blob_sha, - "Pinned source blob changed: {}", - spec.file - ); - commit -} diff --git a/tests/streaming/check.py b/tests/streaming/check.py deleted file mode 100755 index db57aea86..000000000 --- a/tests/streaming/check.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise the CLI stream against real Git repositories and partial staging.""" - -import json -import os -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -EXE = ROOT / "target/debug/diffr" -ENV = dict( - os.environ, - GIT_CONFIG_GLOBAL="/dev/null", - GIT_CONFIG_NOSYSTEM="1", - GIT_AUTHOR_NAME="Test", - GIT_AUTHOR_EMAIL="test@example.invalid", - GIT_COMMITTER_NAME="Test", - GIT_COMMITTER_EMAIL="test@example.invalid", -) - - -def git(repo, *args): - return ( - subprocess.check_output(["git", "-C", str(repo), *args], env=ENV) - .decode() - .strip() - ) - - -def commit(repo, message): - git(repo, "add", ".") - git(repo, "commit", "-qm", message) - return git(repo, "rev-parse", "HEAD") - - -def cli(repo, *args): - return subprocess.run( - [str(EXE), "--repo", str(repo), *args], - capture_output=True, - env=ENV, - check=False, - ) - - -def stream(repo, *args, code=0): - with subprocess.Popen( - [str(EXE), "--repo", str(repo), "--format", "ndjson", *args], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=ENV, - ) as process: - first = json.loads(process.stdout.readline()) - assert first["type"] == "start" and first["version"] == 1 - assert len(first["files"]) == first["total"] - events = [first, *[json.loads(line) for line in process.stdout]] - stderr = process.stderr.read() - assert process.wait(timeout=30) == code, stderr - assert events[-1]["type"] == "complete" - succeeded = sum(e["type"] == "file" for e in events) - failed = sum(e["type"] == "file_error" for e in events) - assert events[-1] == {"type": "complete", "succeeded": succeeded, "failed": failed} - assert succeeded + failed == first["total"] - assert all("layout" not in e for e in events) - return events - - -with tempfile.TemporaryDirectory(prefix="diffr-stream-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - (repo / "a.rs").write_text("fn run() { old(); }\n") - (repo / "remove.py").write_text("print('remove')\n") - (repo / "rename.py").write_text("print('same content')\n") - base = commit(repo, "base") - (repo / "a.rs").write_text("fn run() { old(); new(); }\n") - (repo / "remove.py").unlink() - (repo / "rename.py").rename(repo / "renamed.py") - (repo / "binary.bin").write_bytes(b"a\0b") - (repo / "z.py").write_text("print('new')\n") - head = commit(repo, "head") - (repo / ".gitattributes").write_text( - "*.rs diffr-classify=source\n*.py diffr-classify=test\n*.bin diffr-classify=generated\n" - ) - (repo / "diffr.toml").write_text('[languages.rust]\nfolds = ""\n') - events = stream(repo, base, head, "--order", "test,source,generated", code=2) - assert events[0]["before"] == {"kind": "revision", "ref": base} - assert events[0]["after"] == {"kind": "revision", "ref": head} - # Results arrive in completion order; --order governs computation priority only. - assert sorted(e["file"]["class"] for e in events[1:-1]) == [ - "generated", - "source", - "test", - "test", - "test", - ] - renamed = next(e["file"] for e in events[1:-1] if e["file"]["status"] == "renamed") - assert renamed["old_path"] == "rename.py" and renamed["new_path"] == "renamed.py" - rust = next(e for e in events[1:-1] if e["file"]["new_path"] == "a.rs") - assert rust["diff"]["rhs_folds"] == [] - # An early file failure must not prevent the later successes. - events = stream(repo, base, head, "--order", "generated", code=2) - assert ( - sum(e["type"] == "file_error" for e in events) == 1 - and events[-1]["succeeded"] == 4 - ) - events = stream(repo, base, head, "--", "a.rs", "z.py") - assert sorted(e["file"]["new_path"] for e in events[1:-1]) == ["a.rs", "z.py"] - events = stream(repo, base, head, "--jobs", "1", "--", "a.rs", "z.py") - assert [e["file"]["new_path"] for e in events[1:-1]] == ["a.rs", "z.py"] - assert len(stream(repo, head, head)) == 2 - assert len(stream(repo, base, head, "--", "missing.rs")) == 2 - stream(repo, base, head, "--exit-code", "--", "a.rs", code=1) - for args in ( - ["bad-ref", head], - [base, head, "--quiet"], - ["--no-index", "a", "b"], - [base, head, "--stat"], - ): - result = cli(repo, "--format", "ndjson", *args) - assert result.returncode == 2 and not result.stdout and result.stderr - (repo / "diffr.toml").write_text("invalid toml") - assert cli(repo, "--format", "ndjson", base, head).returncode == 2 - custom = repo / "custom.toml" - custom.write_text("") - events = stream(repo, base, head, "--config", str(custom), "--", "a.rs") - assert events[1]["diff"]["rhs_folds"] - -with tempfile.TemporaryDirectory(prefix="diffr-operands-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - source = repo / "a.rs" - initial = "fn run() { initial(); }\n" - staged = "fn run() { staged(); }\n" - working = "fn run() { working(); }\n" - source.write_text(initial) - base = commit(repo, "initial") - source.write_text(staged) - git(repo, "add", "a.rs") - source.write_text(working) - for selection, left, right in ( - ([], staged, working), - (["--cached"], initial, staged), - ([base], initial, working), - ): - for reverse in (False, True): - args = [*selection, *(["-R"] if reverse else [])] - for output in ("--name-only", "--numstat"): - actual = cli(repo, *args, output) - expected = subprocess.check_output( - ["git", "-C", str(repo), "diff", *args, output], env=ENV - ) - assert actual.returncode == 0 and actual.stdout == expected - diff = stream(repo, *args)[1]["diff"] - assert diff["lhs_src"]["Text"] == (right if reverse else left) - assert diff["rhs_src"]["Text"] == (left if reverse else right) - assert cli(repo, "--quiet").returncode == 1 - assert cli(repo, base, "HEAD", "--exit-code").returncode == 0 - git(repo, "rm", "-f", "a.rs") - source.write_text(working) - assert stream(repo, base)[1]["file"]["status"] == "deleted" - assert cli(repo, base, "--name-status").stdout == b"D\ta.rs\n" - -with tempfile.TemporaryDirectory(prefix="diffr-unborn-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - (repo / "new.rs").write_text("fn new() {}\n") - git(repo, "add", ".") - events = stream(repo, "--cached") - assert events[0]["before"] == {"kind": "empty_tree"} - assert events[1]["file"]["status"] == "added" -# A configured fold hook fills summaries before each file event; its failures -# are reported per file without losing the diff. -with tempfile.TemporaryDirectory(prefix="diffr-hook-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - git(repo, "commit", "--allow-empty", "-qm", "empty") - base = git(repo, "rev-parse", "HEAD") - rpc_server = ROOT / "tests/hooks/rpc_server.py" - large = "def f():\n a()\n b()\n c()\n\ndef g():\n d()\n" - (repo / "good.py").write_text(large) - (repo / "bad.py").write_text(large) - (repo / "small.py").write_text("def h():\n e()\n") - head = commit(repo, "additions") - - def hook_config(mode, *extra): - command = [sys.executable, str(rpc_server), mode, *extra] - return f"[folds.hook]\ncommand = {json.dumps(command)}\ntags = ['body']\nmin_lines = 3\n" - - (repo / "diffr.toml").write_text(hook_config("echo")) - events = {e["file"]["new_path"]: e for e in stream(repo, base, head)[1:-1]} - good = events["good.py"] - assert "hook_error" not in good - assert [ - f["summary"] for f in good["diff"]["rhs_folds"] if f["tags"] == ["body"] - ] == ["pseudo Body", None] - assert all(f["summary"] is None for f in events["small.py"]["diff"]["rhs_folds"]) - (repo / "diffr.toml").write_text(hook_config("error")) - events = {e["file"]["new_path"]: e for e in stream(repo, base, head)[1:-1]} - assert events["bad.py"]["hook_error"] == "fold hook reported: declined" - assert all(f["summary"] is None for f in events["good.py"]["diff"]["rhs_folds"]) - (repo / "diffr.toml").write_text("[folds.hook]\ncommand = ['./missing-hook']\n") - assert cli(repo, "--format", "ndjson", base, head).returncode == 2 - (repo / "diffr.toml").write_text(hook_config("exit")) - assert cli(repo, "--format", "ndjson", base, head).returncode == 2 - # Relative hook paths resolve against the config file, not the repository. - with tempfile.TemporaryDirectory(prefix="diffr-hook-config-") as elsewhere: - shutil.copy(rpc_server, Path(elsewhere) / "hook.py") - (Path(elsewhere) / "hook.toml").write_text( - f"[folds.hook]\ncommand = [{json.dumps(sys.executable)}, 'hook.py', 'cwd', {json.dumps(str(repo.resolve()) + os.sep)}]\ntags = ['body']\n" - ) - events = stream( - repo, - base, - head, - "--config", - str(Path(elsewhere) / "hook.toml"), - "--", - "good.py", - ) - assert events[1]["diff"]["rhs_folds"][0]["summary"] == str( - Path(elsewhere).resolve() - ) - -# Closing the pipe while a multi-file producer is active must not leave it -# blocked forever on a full queue. Unix CLI output retains normal SIGPIPE behavior. -if os.name == "posix": - with tempfile.TemporaryDirectory(prefix="diffr-cancel-") as temp: - repo = Path(temp) - git(repo, "init", "-q") - git(repo, "commit", "--allow-empty", "-qm", "empty") - base = git(repo, "rev-parse", "HEAD") - for index in range(8): - (repo / f"{index}.txt").write_text("some new text\n" * 4096) - head = commit(repo, "large additions") - with subprocess.Popen( - [str(EXE), "--repo", str(repo), base, head, "--format", "ndjson"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - env=ENV, - ) as process: - assert json.loads(process.stdout.readline())["type"] == "start" - process.stdout.close() - assert process.wait(timeout=30) != 0 -print("CLI streaming, cancellation and Git operand checks passed") From e973e956308e5b78de4f35bc4aa98e19b4da40be Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 02/20] Keep folds when a diff falls back to lines When the structural matcher gives up on a parsed file (graph limit or parse-error limit), the engine still collects each side's folds from the parse before taking the line diff, so a file diffed by line has the same regions as one diffed structurally. The parse-error fallback numbers both sides' syntax together, since a fold's identity is its node's syntax id. A fold is paired only by the matcher, and the matcher did not run, so these folds are novel: `folds::unmatched` collects every fold of one side with no opposite, and the projection gives each its own fold state. What is new inside such a fold is still the leaves' answer. Written with AI assistance (Claude Code). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 Agent-Session: 01a0a925-9df2-7c02-9334-147bb9c9f780 --- docs/streaming.md | 7 ++- src/engine.rs | 41 ++++++++++++++ src/parse/folds.rs | 11 ++++ src/protocol/mod.rs | 1 + src/protocol/project.rs | 122 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 2 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index 4c5fad0cf..c5fc871fb 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -124,8 +124,11 @@ deleted file has `lhs` only. `stats.fallback` is present when the AST match did not run: `unsupported_language`, `too_large`, `too_complex`, `parse_error`. Its `message` is the engine's own account of why, such as the size a file reached and the limit it exceeded. A -fallback diff is aligned by a line diff, its `changed` spans are word-level, -and it has leaves alone. +fallback diff is aligned by a line diff and its `changed` spans are word-level, +but the parse still stands: folds are present whenever the language parsed +(`too_complex`, `parse_error`). A line diff has no matcher, so its folds pair +with nothing. Only `unsupported_language` and `too_large` produce leaves +alone. ### Regions diff --git a/src/engine.rs b/src/engine.rs index ac96eac42..effec73f4 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -268,6 +268,14 @@ pub(crate) fn diff_file_content( } if exceeded_graph_limit { + // The parse still stands, so its folds do, + // each unpaired: nothing matched the nodes + // they belong to. + 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); ( @@ -357,6 +365,39 @@ 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 conflict = |side| { + move |conflict| QueryConflict { + path: display_path.to_owned(), + side, + conflict, + } + }; + let (lhs, _) = tsp::to_syntax( + &lhs_tree, + lhs_src, + &arena, + lang_config, + diff_options.ignore_comments, + ) + .map_err(conflict(Side::Left))?; + let (rhs, _) = tsp::to_syntax( + &rhs_tree, + rhs_src, + &arena, + lang_config, + diff_options.ignore_comments, + ) + .map_err(conflict(Side::Right))?; + // Folds are identified by syntax id, which only + // exists once both sides are numbered. + syntax::init_all_info(&lhs, &rhs); + folds::unmatched(&lhs, &mut lhs_folds); + folds::unmatched(&rhs, &mut rhs_folds); + annotations = + display::syntax_context::SyntaxAnnotations::collect((&lhs, &rhs)); + let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); (file_format, lhs_positions, rhs_positions) diff --git a/src/parse/folds.rs b/src/parse/folds.rs index 0a0d9eb0b..8b2e9518d 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -275,6 +275,17 @@ pub(crate) fn split_lines(spans: impl IntoIterator) -> BT .collect() } +/// Every fold in one parsed side, each unpaired: the parse's folds survive +/// when the matcher does not run, with nothing to pair them with. +pub(crate) fn unmatched(nodes: &[&Syntax<'_>], folds: &mut Vec) { + for node in nodes { + folds.extend(project(node, None)); + if let Syntax::List { children, .. } = node { + unmatched(children, folds); + } + } +} + /// The node the matcher paired `node` with, when the pairing is mutual. /// /// Every matcher step records a pair on both nodes, but the nested slider diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 9c50020ad..b80d4be21 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -234,6 +234,7 @@ pub struct SourcePos { /// Line counts for one file. `fallback` is present exactly when the AST /// match did not run and the alignment is a line diff, carrying why: /// `too_complex`, `too_large`, `unsupported_language`, `parse_error`. +/// Folds are still present on a fallback whenever the language parsed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Stats { /// Lines with any byte change. diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 15535def7..64f4bbf0d 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -879,6 +879,128 @@ mod tests { } } + #[test] + fn a_parse_error_fallback_numbers_its_folds() { + // Both sides hold a stray `)`, so the parse-error limit of zero sends + // the file to a line diff; the folds still come from the parse. + let lhs = format!("{RUST_LHS})\n"); + let rhs = format!("{RUST_RHS})\n"); + let diff = project_with( + "a.rs", + &lhs, + &rhs, + DiffOptions { + parse_error_limit: 0, + ..DiffOptions::default() + }, + ); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.fallback.as_ref().unwrap().code, "parse_error"); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + let (lhs_folds, rhs_folds) = (fold_ids(lhs), fold_ids(rhs)); + let lhs_ids: BTreeSet = lhs_folds.values().copied().collect(); + let rhs_ids: BTreeSet = rhs_folds.values().copied().collect(); + assert_eq!( + lhs_ids.len(), + lhs_folds.len(), + "lhs folds have distinct ids" + ); + assert_eq!( + rhs_ids.len(), + rhs_folds.len(), + "rhs folds have distinct ids" + ); + let (lhs_states, rhs_states) = (fold_states(lhs), fold_states(rhs)); + // Nothing matched the nodes these folds belong to, so no fold pairs. + let lhs_state_ids: BTreeSet = lhs_states.values().copied().collect(); + assert!( + !rhs_states + .values() + .any(|state| lhs_state_ids.contains(state)), + "a fallback's folds are unpaired" + ); + } + + #[test] + fn folds_pair_only_where_the_matcher_paired_their_nodes() { + for options in [ + DiffOptions::default(), + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ] { + let structural = options.graph_limit != 1; + let diff = project_with("a.rs", RUST_LHS, RUST_RHS, options); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.fallback.is_none(), structural); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + let (lhs_folds, rhs_folds) = (fold_states(lhs), fold_states(rhs)); + assert_eq!(lhs_folds.len(), 2); + assert_eq!(rhs_folds.len(), 3); + let shared: BTreeSet = lhs_folds + .values() + .filter(|state| rhs_folds.values().any(|other| other == *state)) + .copied() + .collect(); + if structural { + // `f` changed its signature and stays paired through the + // matcher; `keep` is untouched. `added` is rhs-only. + assert_eq!( + lhs_folds[&1], rhs_folds[&1], + "f pairs across a changed header" + ); + assert_eq!(lhs_folds[&7], rhs_folds[&7], "keep pairs"); + assert_eq!(shared.len(), 2); + } else { + // The matcher never ran, so every fold is on its own. + assert!(shared.is_empty(), "a fallback's folds are unpaired"); + } + assert!( + !lhs_folds.values().any(|id| *id == rhs_folds[&13]), + "added is rhs-only" + ); + } + } + + #[test] + fn the_fallback_keeps_folds() { + let diff = project_with( + "a.rs", + RUST_LHS, + RUST_RHS, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.fallback.as_ref().unwrap().code, "too_complex"); + let (_, rhs) = sources(&diff); + let rhs = rhs.unwrap(); + let bodies: Vec<_> = all(&rhs.regions) + .into_iter() + .filter(|r| r.tags.iter().any(|tag| tag == "body")) + .map(|r| r.range.start.line) + .collect(); + // Nothing is collapsed, so the untouched `keep` is a fold like the + // changed `f` and the new `added`. + assert_eq!(bodies, vec![1, 7, 13]); + assert!(leaves(&rhs.regions) + .iter() + .all(|leaf| !leaf.visibility.collapsed && leaf.tags.is_empty())); + } + #[test] fn leaves_tile_both_sides_and_paired_leaves_share_ids() { let lhs = "import os\n\ndef f():\n x = 1\n return x\n"; From a87f098df9442a8f386dea5aa5cc001462858e86 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 03/20] Layer configuration and add `diffr config` Configuration comes from exactly three sources: the global file (`$XDG_CONFIG_HOME/diffr/config.toml`, or `--config PATH` in its place, which must exist), command-line flags, and git attributes. The file is plain TOML read with serde defaults: every omitted key keeps its default, and an unknown key or mistyped value is an error naming the file and the key's dotted path. There is no repository config file and no environment layer. A schemars JSON Schema describes every setting with a title and a settings group. `diffr config schema`, `show` and `set` print it, print the resolved configuration, or edit one key of the global file while keeping the rest as written (src/config/store.rs). `set` reads the value as the type the schema gives the key, and nothing else. The `[diff]` table holds the engine limits, with the `--byte-limit`, `--graph-limit` and `--parse-error-limit` flags on top, and fallback reasons point at those keys. `[theme]` names the terminal frontend's theme. docs/config.md describes the file and the commands. Written with AI assistance (Claude Code). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 Agent-Session: 01a0a925-9df2-7c02-9334-147bb9c9f780 --- Cargo.lock | 372 +++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 7 +- README.md | 3 +- diffr.1 | 2 +- diffr.1.md | 2 +- docs/cli.md | 4 + docs/config.md | 67 ++++++++ docs/streaming.md | 8 +- src/cli.rs | 113 +++++++++---- src/config.rs | 236 +++++++++++++++++++++++++-- src/config/README.md | 2 +- src/config/store.rs | 299 ++++++++++++++++++++++++++++++++++ src/engine.rs | 15 +- tests/cli.rs | 2 +- 14 files changed, 1057 insertions(+), 75 deletions(-) create mode 100644 docs/config.md create mode 100644 src/config/store.rs diff --git a/Cargo.lock b/Cargo.lock index 81e4c1bad..6912d5c6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,7 @@ dependencies = [ "bumpalo", "cc", "clap", + "dirs", "encoding_rs", "git2", "glob", @@ -234,16 +235,20 @@ dependencies = [ "rayon", "regex", "rustc-hash", + "schemars", "serde", "serde_json", + "serde_path_to_error", "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", @@ -310,6 +315,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" @@ -327,6 +353,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[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" @@ -371,6 +403,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -413,6 +451,28 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "git2" version = "0.20.4" @@ -692,6 +752,15 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + [[package]] name = "libz-sys" version = "1.1.29" @@ -716,6 +785,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" @@ -758,6 +833,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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" @@ -870,6 +957,12 @@ 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" @@ -896,6 +989,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", +] + +[[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" @@ -940,10 +1064,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "ryu" version = "1.0.17" @@ -959,6 +1096,31 @@ dependencies = [ "winapi-util", ] +[[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 = "serde" version = "1.0.228" @@ -989,6 +1151,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" @@ -1003,6 +1176,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1096,6 +1280,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" @@ -1111,7 +1308,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", ] @@ -1121,6 +1318,26 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tikv-jemalloc-sys" version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" @@ -1168,8 +1385,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -1181,6 +1398,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -1190,10 +1416,45 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", - "winnow", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", ] +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tree-sitter" version = "0.26.10" @@ -1878,6 +2139,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "winapi-util" version = "0.1.9" @@ -1893,13 +2160,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1908,7 +2184,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1920,34 +2196,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1960,24 +2269,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1993,6 +2326,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 931b8e337..0606ff2b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,7 +134,11 @@ tree-sitter-xml = "0.7.0" tree-sitter-yaml = "0.7.0" tree-sitter-zig = "1.1.2" ts-parser-perl = "1.2.0" -toml = { version = "0.8", default-features = false, features = ["parse"] } +toml = { version = "0.8", default-features = false, features = ["display", "parse"] } +serde_path_to_error = "0.1.20" +schemars = { version = "1.2.2", features = ["preserve_order"] } +dirs = "5" +toml_edit = "0.25.15" [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 0d2015f3a..582983f30 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ diffr main...HEAD -- src/ # merge-base comparison diffr main HEAD --format ndjson ``` -See the [CLI reference](docs/cli.md) and the [streaming API](docs/streaming.md). +See the [CLI reference](docs/cli.md), [streaming API](docs/streaming.md) +including [configuration](docs/config.md). The executable is `diffr`; the Cargo package remains `difftastic`. The upstream installation commands below install upstream Difftastic, not this fork. diff --git a/diffr.1 b/diffr.1 index 66915d7e9..d16b876bc 100644 --- a/diffr.1 +++ b/diffr.1 @@ -38,7 +38,7 @@ Terminal rendering, or the event stream described under STREAMING. Ordinary context padding (default 3). .TP .B --config FILE -Read explicit TOML instead of repository diffr.toml. +Read this TOML file in place of the global configuration file; it must exist. .TP .B --help List supported options. Unsupported Git flags are rejected. diff --git a/diffr.1.md b/diffr.1.md index 03a0fc12d..286e3d7b0 100644 --- a/diffr.1.md +++ b/diffr.1.md @@ -43,7 +43,7 @@ compare their trees. A...B compares merge-base(A,B) to B; A..B compares A to B. : Select ordinary context padding (default 3). **--config** FILE -: Read explicit TOML configuration instead of repository diffr.toml. +: Read this TOML file in place of the global configuration file (`~/.config/diffr/config.toml`); it must exist. **--help** : List the supported options. Unsupported Git flags are rejected. diff --git a/docs/cli.md b/docs/cli.md index 866a29561..c30581076 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -33,6 +33,10 @@ files at once (default 16) and emitting each as it finishes. `-U N` selects ordinary context padding. Matching limits, `--ignore-comments`, color, width and inline/split display remain configurable; see `--help`. +Configuration comes from the global file, command-line flags and git +attributes; `--config PATH` replaces the global file. `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. The old file/external-diff/debug parser is available under `debug` for diagnostic diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 000000000..a16e5c5ac --- /dev/null +++ b/docs/config.md @@ -0,0 +1,67 @@ +# Configuration + +diffr's configuration comes from exactly three places: + +1. The global file, `$XDG_CONFIG_HOME/diffr/config.toml` (usually + `~/.config/diffr/config.toml`). `--config PATH` replaces it and must exist; + a missing global file means every default. +2. Command-line flags, for one run. `--byte-limit`, `--graph-limit` and + `--parse-error-limit` override `[diff]`, and `-U` sets context padding. +3. Git attributes, for facts about files in a repository (`.gitattributes` + and git's user-wide attributes file). + +There is no repository configuration file and no environment-variable layer. +Keys the file omits keep their defaults. An unknown key or a mistyped value is +an error that names the file and the key's dotted path, for example +`config.toml: diff.typo: unknown field`. + +## Commands + +```sh +diffr config schema # JSON Schema: title, group, description and default per key +diffr config show [--json] # the resolved configuration +diffr config set diff.graph_limit 5000000 +diffr config set theme.name default-light +``` + +`set` writes one key into the global file (or the `--config` file), keeping +everything else in it as written. The value is read as the type the schema +gives the key, and only that type: a string key takes the text as it is +(`diffr config set theme.name 1234` writes `"1234"`), an integer key a TOML +integer (`12`), a number key a TOML number (`1.5`), a boolean key `true` or +`false`, an array key a TOML array (`'["a", "b"]'`), and an enum key one of +its choices. Anything else, an unknown key, or a value the configuration +rejects (such as a negative limit) is an error naming the key and what it +expected, for example `diff.graph_limit: expected an integer, got "abc"`; +diffr exits 2 and the file is not touched. Frontends drive their settings pages +through these three commands; the schema is the only contract. Each setting +in the schema carries a `title` and an `x-group` that settings screens show +in place of the dotted key. + +## Keys + +```toml +[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 = '''...''' +``` + +`languages` is not in the schema, so the settings screen does not show it; +`config set` still accepts its keys. + +When a file exceeds a `[diff]` limit it falls back to a line diff: the file's +`stats` carries a `fallback` with code `too_large`, `too_complex` or +`parse_error` and a message naming the key to raise. `--byte-limit`, +`--graph-limit` and `--parse-error-limit` override the file for one run. 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. diff --git a/docs/streaming.md b/docs/streaming.md index c5fc871fb..6d63a8ce7 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -16,9 +16,11 @@ 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 reads the global configuration file (or `--config PATH`), +applies command-line flags, and compiles the result 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: diff --git a/src/cli.rs b/src/cli.rs index 95e121f17..832669ba6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,5 @@ //! Git-style CLI input; rendering and NDJSON remain adapters over the same engine. -use crate::config::Config; +use crate::config::{self, Config}; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; use crate::options::{DiffOptions, DisplayMode, DisplayOptions}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; @@ -33,7 +33,7 @@ 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("jobs") .long("jobs") @@ -69,8 +69,28 @@ 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 or edit diffr's configuration") + .subcommand_required(true) + .subcommand(Command::new("schema").about("Print the configuration's JSON Schema")) + .subcommand( + Command::new("show") + .about("Print the resolved configuration") + .arg(flag("json")), + ) + .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"), @@ -109,25 +129,11 @@ pub(crate) fn run() -> Result { }, ..DisplayOptions::default() }; - let mut diff_options = DiffOptions { - ignore_comments: args.get_flag("ignore-comments"), - ..DiffOptions::default() - }; - if let Some(limit) = args.get_one::("byte-limit") { - diff_options.byte_limit = *limit; - } - if let Some(limit) = args.get_one::("graph-limit") { - diff_options.graph_limit = *limit; - } - if let Some(limit) = args.get_one::("parse-error-limit") { - diff_options.parse_error_limit = *limit; - } if args.get_flag("no-index") { return no_index( &args, items.into_iter().chain(explicit_paths).collect(), &display, - &diff_options, ); } if args.get_flag("null") && !args.get_flag("name-only") && !args.get_flag("name-status") { @@ -162,9 +168,8 @@ pub(crate) fn run() -> Result { } changed } else { - let params = Arc::new( - Config::load(workspace, args.get_one::("config").map(Path::new))?.compile()?, - ); + let params = Arc::new(load_config(&args)?.compile()?); + let diff_options = diff_options(&args, ¶ms); let mut session = DiffSession::open(workspace, comparison, params, &files)?; session.diff_options = diff_options; let changed = session.remaining() > 0; @@ -391,12 +396,7 @@ fn print_metadata(diff: &git2::Diff<'_>, args: &ArgMatches, width: usize) -> Res Ok(()) } -fn no_index( - args: &ArgMatches, - paths: Vec, - display: &DisplayOptions, - options: &DiffOptions, -) -> Result { +fn no_index(args: &ArgMatches, paths: Vec, display: &DisplayOptions) -> Result { if paths.len() != 2 { return Err("--no-index requires two file paths".into()); } @@ -423,11 +423,8 @@ 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 config = load_config(args)?.compile()?; + let options = &diff_options(args, &config); let lhs = crate::options::FileArgument::from_path_argument(&paths[0]); let rhs = crate::options::FileArgument::from_path_argument(&paths[1]); let compute = || { @@ -465,6 +462,62 @@ fn no_index( } } +/// The engine limits: the configured `[diff]` table, then the command-line +/// flags. +fn diff_options(args: &ArgMatches, params: &config::Params) -> DiffOptions { + 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; + } + options +} + +/// The global file, or the `--config` file in its place. +fn load_config(args: &ArgMatches) -> Result { + Ok(Config::load( + args.get_one::("config_file").map(Path::new), + )?) +} + +/// `diffr config schema`, `show` or `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)?; + if show.get_flag("json") { + serde_json::to_writer_pretty(&mut stdout, &config::store::show(&config))?; + stdout.write_all(b"\n")?; + } else { + stdout.write_all(toml::to_string_pretty(&config)?.as_bytes())?; + } + } + Some(("set", set)) => { + let path = match args.get_one::("config_file") { + Some(path) => PathBuf::from(path), + None => config::global_path()?, + }; + config::store::set( + &path, + set.get_one::("key").unwrap(), + set.get_one::("value").unwrap(), + )?; + } + _ => unreachable!("clap requires a config command"), + } + Ok(0) +} + /// Explicit machine/text modes and redirected output must never enter the alternate screen. fn opens_tui(explicit_format: bool, metadata_or_quiet: bool, terminal: bool) -> bool { terminal && !explicit_format && !metadata_or_quiet diff --git a/src/config.rs b/src/config.rs index 526bde592..bf263ac3f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,21 +1,102 @@ -//! Deserialize user settings, resolve defaults, and compile once before diffing. +//! diffr's configuration comes from three places: the global file +//! (`$XDG_CONFIG_HOME/diffr/config.toml`, or `--config PATH` in its place), +//! command-line flags, and git attributes. This module owns the file: every +//! key it omits keeps its serde default, and an unknown key or mistyped +//! value is an error naming the key's dotted path. Flags and attributes are +//! applied by their callers. Every field carries a doc comment, which becomes +//! its description in `diffr config schema`, and every setting a `title` and +//! an `x-group` that settings screens show in place of the dotted key. pub(crate) mod query; +pub(crate) mod store; use crate::hash::DftHashMap; +use crate::options::DiffOptions; use crate::parse::{guess_language::Language, tree_sitter_parser}; use query::AnnotationQuery; -use serde::Deserialize; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use std::path::Path; +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, + /// 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 matching +/// command-line flags override these for one run. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct DiffConfig { + /// Files larger than this many bytes on either side get a line diff. + #[schemars(title = "Largest file to diff structurally (bytes)", extend("x-group" = "Diff limits"))] + pub(crate) byte_limit: usize, + /// The largest AST matching graph diffr will explore for one file. + /// A large change to a large file can exceed it; raising it costs time + /// and memory on those files only. + #[schemars(title = "Largest matching graph", extend("x-group" = "Diff limits"))] + pub(crate) graph_limit: usize, + /// Files with more tree-sitter parse errors than this get a line diff. + #[schemars(title = "Parse errors allowed", extend("x-group" = "Diff limits"))] + pub(crate) parse_error_limit: usize, +} + +impl Default for DiffConfig { + fn default() -> Self { + Self { + byte_limit: crate::options::DEFAULT_BYTE_LIMIT, + graph_limit: crate::options::DEFAULT_GRAPH_LIMIT, + parse_error_limit: crate::options::DEFAULT_PARSE_ERROR_LIMIT, + } + } +} + +impl DiffConfig { + /// The engine options for these limits. + pub(crate) fn options(&self, ignore_comments: bool) -> DiffOptions { + DiffOptions { + byte_limit: self.byte_limit, + graph_limit: self.graph_limit, + 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 ThemeConfig { + /// A bundled theme name. + #[schemars(title = "Theme", extend("x-group" = "Appearance"))] + pub(crate) name: String, + /// A Helix-style theme file that replaces the bundled theme. + #[schemars(title = "Theme file", extend("x-group" = "Appearance"))] + pub(crate) path: Option, +} + +impl Default for ThemeConfig { + fn default() -> Self { + Self { + name: "default-dark".to_owned(), + path: None, + } + } +} + +#[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. @@ -34,6 +115,7 @@ impl std::error::Error for ConfigError {} pub(crate) struct Params { languages: DftHashMap>>, + pub(crate) diff: DiffConfig, } pub(crate) struct LanguageParams { @@ -59,22 +141,54 @@ impl LanguageParams { } } +/// 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")) +} + 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) => Self::from_toml(&source), + /// Read the global file, or `explicit` in its place. A missing global + /// file is the defaults; an explicit file must exist. + pub(crate) fn load(explicit: Option<&Path>) -> Result { + let path = match explicit { + Some(path) => path.to_path_buf(), + None => global_path()?, + }; + let source = match std::fs::read_to_string(&path) { + Ok(source) => source, Err(error) if explicit.is_none() && error.kind() == std::io::ErrorKind::NotFound => { - Ok(Self::default()) + return Ok(Self::default()); } - Err(error) => Err(ConfigError(format!("{}: {error}", path.display()))), - } + Err(error) => return Err(ConfigError(format!("{}: {error}", path.display()))), + }; + Self::from_toml(&source) + .map_err(|error| ConfigError(format!("{}: {error}", path.display()))) } + /// Parse one file's text. Errors lead with the dotted path of the key + /// they concern, such as `diff.typo`. pub(crate) fn from_toml(source: &str) -> Result { - toml::from_str(source).map_err(|error| ConfigError(error.to_string())) + serde_path_to_error::deserialize(toml::Deserializer::new(source)).map_err(|error| { + let path = error.path().to_string(); + let message = error.inner().to_string(); + ConfigError(match path.as_str() { + "." => message, + _ => format!("{path}: {message}"), + }) + }) + } + + /// 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 { @@ -111,7 +225,10 @@ impl Config { })), ); } - Ok(Params { languages }) + Ok(Params { + languages, + diff: self.diff, + }) } } @@ -154,6 +271,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); + 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 = ''") @@ -444,3 +582,69 @@ mod tag_tests { )); } } + +#[cfg(test)] +mod load_tests { + use super::*; + + #[test] + fn the_file_overrides_defaults_key_by_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "[diff]\ngraph_limit = 5\nbyte_limit = 6\n[theme]\nname = 'mine'\n", + ) + .unwrap(); + let config = Config::load(Some(&path)).unwrap(); + assert_eq!(config.diff.graph_limit, 5); + assert_eq!(config.diff.byte_limit, 6); + assert_eq!( + config.diff.parse_error_limit, + crate::options::DEFAULT_PARSE_ERROR_LIMIT + ); + assert_eq!(config.theme.name, "mine"); + assert_eq!(config.theme.path, None); + } + + #[test] + fn unknown_keys_name_their_path_and_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[diff]\ngraph_limit = 5\ntypo = 1\n").unwrap(); + let error = Config::load(Some(&path)).unwrap_err().to_string(); + assert!( + error.starts_with(&format!("{}: diff.typo: ", path.display())), + "{error}" + ); + std::fs::write(&path, "[diff]\ngraph_limit = 'many'\n").unwrap(); + let error = Config::load(Some(&path)).unwrap_err().to_string(); + assert!(error.contains("diff.graph_limit: "), "{error}"); + } + + #[test] + fn a_missing_explicit_file_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + assert!(Config::load(Some(&dir.path().join("absent.toml"))).is_err()); + } + + #[test] + fn schema_describes_every_setting_with_its_default() { + let schema = Config::schema(); + let diff = &schema["properties"]["diff"]; + let diff = match diff.get("$ref") { + Some(reference) => { + let name = reference.as_str().unwrap().rsplit('/').next().unwrap(); + &schema["$defs"][name] + } + None => diff, + }; + let graph_limit = &diff["properties"]["graph_limit"]; + assert_eq!(graph_limit["default"], crate::options::DEFAULT_GRAPH_LIMIT); + assert!(graph_limit["description"] + .as_str() + .unwrap() + .contains("matching graph")); + assert!(schema["properties"].get("languages").is_none()); + } +} diff --git a/src/config/README.md b/src/config/README.md index 8c479fa63..1f8dabecb 100644 --- a/src/config/README.md +++ b/src/config/README.md @@ -3,7 +3,7 @@ The caller parses TOML with `Config::from_toml`, then calls `compile()` once. The resulting `Params` owns separately compiled fold and context queries and is borrowed by each diff. Each classifier runs its own traversal. Combining them is a separate optimization. -`Config::load` reads repository-root `diffr.toml` or an explicit replacement file. +`Config::load` reads the global file (`$XDG_CONFIG_HOME/diffr/config.toml`) or an explicit replacement file. File selection and ordering belong to the caller, not this configuration. ```rust diff --git a/src/config/store.rs b/src/config/store.rs new file mode 100644 index 000000000..96b5321b5 --- /dev/null +++ b/src/config/store.rs @@ -0,0 +1,299 @@ +//! `diffr config`: read the schema and resolved values, and write one key to +//! the global file. +use super::{Config, ConfigError, LanguageConfig}; +use serde_json::Value; +use std::path::Path; + +/// The resolved configuration as JSON, with the same nesting as the TOML. +pub(crate) fn show(config: &Config) -> serde_json::Value { + serde_json::to_value(config).expect("config serializes") +} + +/// Write `key = value` into the global file, keeping everything else in it +/// as written. `value` is read as the type the schema gives the key (see +/// [`typed_value`]), then the whole file is validated, before anything +/// touches the disk: unknown keys, text that is not the key's type, and +/// values the configuration rejects 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 typed = typed_value(key, &setting_schema(key)?, value)?; + 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())))?; + assign(&mut document, key, &typed)?; + Config::from_toml(&document.to_string())?; + 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()))) +} + +/// The JSON Schema of the setting at the dotted `key`. `languages` is left +/// out of `diffr config schema`, so its keys are looked up in +/// [`LanguageConfig`]'s own schema. +fn setting_schema(key: &str) -> Result { + let unknown = || ConfigError(format!("{key}: unknown key")); + let segments: Vec<&str> = key.split('.').collect(); + let (root, path) = match segments.as_slice() { + ["languages", _language, field] => ( + serde_json::to_value(schemars::schema_for!(LanguageConfig)).expect("schema serializes"), + vec![*field], + ), + ["languages", ..] => return Err(unknown()), + _ => (Config::schema(), segments), + }; + let resolve = |node: &Value| -> Value { + match node.get("$ref").and_then(Value::as_str) { + Some(reference) => { + let name = reference.rsplit('/').next().expect("split yields a piece"); + root["$defs"][name].clone() + } + None => node.clone(), + } + }; + let mut node = root.clone(); + for segment in path { + node = resolve( + resolve(&node) + .get("properties") + .and_then(|properties| properties.get(segment)) + .ok_or_else(unknown)?, + ); + } + if node.get("properties").is_some() { + return Err(ConfigError(format!( + "{key} is a table; set one of its keys" + ))); + } + Ok(node) +} + +/// `text` read as the type `schema` gives the setting `key`, and nothing +/// else: a string setting takes the text as it is, an integer, number or +/// boolean setting takes only that TOML literal, an array setting a TOML +/// array, and an enum setting one of its choices. +fn typed_value(key: &str, schema: &Value, text: &str) -> Result { + let mistyped = + |expected: &str| ConfigError(format!("{key}: expected {expected}, got {text:?}")); + if let Some(choices) = schema.get("enum").and_then(Value::as_array) { + if !choices.iter().any(|choice| choice.as_str() == Some(text)) { + let choices: Vec = choices.iter().map(Value::to_string).collect(); + return Err(mistyped(&format!("one of {}", choices.join(", ")))); + } + return Ok(toml::Value::String(text.to_owned())); + } + // An optional setting is its type or null; `set` writes the type. + let kinds: Vec<&str> = match schema.get("type") { + Some(Value::String(kind)) => vec![kind.as_str()], + Some(Value::Array(kinds)) => kinds + .iter() + .filter_map(Value::as_str) + .filter(|kind| *kind != "null") + .collect(), + _ => Vec::new(), + }; + let [kind] = kinds.as_slice() else { + return Err(ConfigError(format!( + "{key}: the schema gives it no single type, so config set cannot write it" + ))); + }; + Ok(match *kind { + "string" => toml::Value::String(text.to_owned()), + "integer" => toml::Value::Integer(text.parse().map_err(|_| mistyped("an integer"))?), + "number" => toml::Value::Float(text.parse().map_err(|_| mistyped("a number"))?), + "boolean" => match text { + "true" => toml::Value::Boolean(true), + "false" => toml::Value::Boolean(false), + _ => return Err(mistyped("true or false")), + }, + "array" => { + let mut table = + toml::from_str::(&format!("value = {text}")).map_err(|error| { + ConfigError(format!( + "{key}: expected a TOML array such as [\"a\", \"b\"], got {text:?}: {}", + error.message() + )) + })?; + match table + .remove("value") + .expect("the table has the one key parsed") + { + value @ toml::Value::Array(_) => value, + _ => return Err(mistyped("a TOML array such as [\"a\", \"b\"]")), + } + } + other => { + return Err(ConfigError(format!( + "{key}: config set cannot write a value of type {other}" + ))); + } + }) +} + +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::*; + use serde_json::json; + + #[test] + fn set_writes_typed_values_and_keeps_the_rest() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("config.toml"); + set(&path, "diff.graph_limit", "20").unwrap(); + // `theme.name` is a string, so digits are its text. + set(&path, "theme.name", "1234").unwrap(); + set(&path, "theme.path", "themes/mine.toml").unwrap(); + set(&path, "languages.rust.folds", "").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("graph_limit = 20"), "{text}"); + assert!(text.contains("name = \"1234\""), "{text}"); + assert!(text.contains("path = \"themes/mine.toml\""), "{text}"); + let config = Config::from_toml(&text).unwrap(); + assert_eq!(config.diff.graph_limit, 20); + assert_eq!(config.theme.name, "1234"); + assert_eq!(config.languages["rust"].folds.as_deref(), Some("")); + } + + #[test] + fn set_rejects_unknown_keys_and_wrong_types_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "# keep me\n[diff]\ngraph_limit = 4\n").unwrap(); + let error = |key: &str, value: &str| set(&path, key, value).unwrap_err().to_string(); + assert_eq!(error("diff.typo", "1"), "diff.typo: unknown key"); + assert_eq!( + error("diff.graph_limit", "abc"), + "diff.graph_limit: expected an integer, got \"abc\"" + ); + assert_eq!( + error("diff.graph_limit", "\"20\""), + "diff.graph_limit: expected an integer, got \"\\\"20\\\"\"" + ); + assert!(error("diff.graph_limit", "-1").starts_with("diff.graph_limit: ")); + assert_eq!(error("diff", "1"), "diff is a table; set one of its keys"); + assert_eq!(error("languages.rust", "1"), "languages.rust: unknown key"); + assert_eq!(error("", "1"), "invalid key \"\""); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "# keep me\n[diff]\ngraph_limit = 4\n" + ); + } + + #[test] + fn values_are_read_as_the_setting_type_only() { + let value = |schema: Value, text: &str| typed_value("k", &schema, text); + let error = |schema: Value, text: &str| value(schema, text).unwrap_err().to_string(); + assert_eq!( + value(json!({"type": "string"}), "12").unwrap(), + toml::Value::String("12".into()) + ); + assert_eq!( + value(json!({"type": ["string", "null"]}), "true").unwrap(), + toml::Value::String("true".into()) + ); + assert_eq!( + value(json!({"type": "integer"}), "12").unwrap(), + toml::Value::Integer(12) + ); + assert_eq!( + error(json!({"type": "integer"}), "1.5"), + "k: expected an integer, got \"1.5\"" + ); + assert_eq!( + value(json!({"type": "number"}), "1.5").unwrap(), + toml::Value::Float(1.5) + ); + assert_eq!( + value(json!({"type": "boolean"}), "false").unwrap(), + toml::Value::Boolean(false) + ); + assert_eq!( + error(json!({"type": "boolean"}), "yes"), + "k: expected true or false, got \"yes\"" + ); + assert_eq!( + value(json!({"type": "array"}), "[\"a\", \"b\"]").unwrap(), + toml::Value::Array(vec!["a".into(), "b".into()]) + ); + assert_eq!( + error(json!({"type": "array"}), "12"), + "k: expected a TOML array such as [\"a\", \"b\"], got \"12\"" + ); + assert!(error(json!({"type": "array"}), "a, b") + .starts_with("k: expected a TOML array such as [\"a\", \"b\"], got \"a, b\": ")); + assert_eq!( + value(json!({"enum": ["gemini"]}), "gemini").unwrap(), + toml::Value::String("gemini".into()) + ); + assert_eq!( + error(json!({"enum": ["gemini"]}), "openai"), + "k: expected one of \"gemini\", got \"openai\"" + ); + assert_eq!( + error(json!({"type": ["integer", "string"]}), "1"), + "k: the schema gives it no single type, so config set cannot write it" + ); + assert_eq!( + error(json!({"type": "object"}), "{}"), + "k: config set cannot write a value of type object" + ); + } +} diff --git a/src/engine.rs b/src/engine.rs index effec73f4..313573d5e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -281,7 +281,10 @@ pub(crate) fn diff_file_content( ( FileFormat::TextFallback { cause: FallbackCause::GraphLimit, - 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, @@ -347,11 +350,12 @@ pub(crate) fn diff_file_content( let file_format = FileFormat::TextFallback { cause: FallbackCause::ParseErrorLimit, reason: format!( - "{} {} parse error{}, exceeded DFT_PARSE_ERROR_LIMIT{}", + "{} {} parse error{}{}, exceeded diff.parse_error_limit ({}); raise it in diffr config", error_count, language_name(language), if error_count == 1 { "" } else { "s" }, - location + location, + diff_options.parse_error_limit ), }; @@ -409,8 +413,9 @@ pub(crate) fn diff_file_content( let file_format = FileFormat::TextFallback { cause: FallbackCause::ByteLimit, reason: format!( - "{} exceeded DFT_BYTE_LIMIT", - format_size(num_bytes, format_options) + "{} exceeded diff.byte_limit ({}); raise it in diffr config", + format_size(num_bytes, format_options), + diff_options.byte_limit ), }; diff --git a/tests/cli.rs b/tests/cli.rs index ba9d3fca2..f722c4917 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -156,7 +156,7 @@ fn yaml_parse_errors() { cmd.arg("sample_files/cli_tests/bad_yaml_1.yml") .arg("sample_files/cli_tests/bad_yaml_2.yml"); - let predicate_fn = predicate::str::contains("exceeded DFT_PARSE_ERROR_LIMIT"); + let predicate_fn = predicate::str::contains("exceeded diff.parse_error_limit (0)"); cmd.assert().stdout(predicate_fn); } From 5108820faa4f96c190955ee42ead472a358cadbd Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 04/20] Tag files from Linguist rules and git attributes Every manifest entry carries `tags`, sorted and deduplicated and omitted when empty. Bundled rules have the lowest precedence: a port of GitHub Linguist's generated.rb (names and paths, header markers in a file's first lines, minified JavaScript and CSS by average line length), Linguist's vendor.yml and documentation.yml path patterns for `vendored` and `docs` (vendored with Linguist's MIT license), and diffr's own test path rules. Git attributes then decide, with git's own precedence between info/attributes, .gitattributes and the user-wide attributes file. `linguist-generated`, `linguist-vendored` and `linguist-documentation` add their tag when set and remove it when unset or `false`, whatever the bundled rules matched. `diffr-tags=a,b` adds tags; a malformed value is a setup error naming the path. `diffr-classify` is gone, and `--order` ranks by tags. Content rules read at most the first 8 KiB of a file, during discovery and only when nothing has already decided `generated`. A failed read becomes that file's `read_failed` error record. A file tagged `generated` is diffed by line without parsing, and its `stats.fallback` has code `generated`. Written with AI assistance (Claude Code). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 Agent-Session: 01a0a925-9df2-7c02-9334-147bb9c9f780 --- .typos.toml | 1 + Cargo.lock | 20 + Cargo.toml | 1 + docs/config.md | 40 +- docs/streaming.md | 47 +- src/cli.rs | 2 +- src/engine.rs | 21 + src/git.rs | 145 ++++-- src/main.rs | 1 + src/options.rs | 4 + src/protocol/mod.rs | 22 +- src/protocol/project.rs | 1 + src/summary.rs | 6 +- src/tags/generated.rs | 692 ++++++++++++++++++++++++++++ src/tags/linguist/LICENSE | 22 + src/tags/linguist/documentation.yml | 37 ++ src/tags/linguist/vendor.yml | 399 ++++++++++++++++ src/tags/mod.rs | 415 +++++++++++++++++ tests/tags.rs | 190 ++++++++ 19 files changed, 2009 insertions(+), 57 deletions(-) create mode 100644 src/tags/generated.rs create mode 100644 src/tags/linguist/LICENSE create mode 100644 src/tags/linguist/documentation.yml create mode 100644 src/tags/linguist/vendor.yml create mode 100644 src/tags/mod.rs create mode 100644 tests/tags.rs diff --git a/.typos.toml b/.typos.toml index 011989477..e61e7321e 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,6 +1,7 @@ [files] extend-exclude = [ "vendored_parsers/", "sample_files/", + "src/tags/linguist/", "homepage/home_static/demo.cast", "homepage/home_static/*.min.js", "homepage/home_img", "demo_files/", diff --git a/Cargo.lock b/Cargo.lock index 6912d5c6c..dc1a957e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "serde_yaml_ng", "smallvec", "streaming-iterator", "strsim", @@ -1196,6 +1197,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2084,6 +2098,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index 0606ff2b9..4a994e2c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,6 +139,7 @@ serde_path_to_error = "0.1.20" schemars = { version = "1.2.2", features = ["preserve_order"] } dirs = "5" toml_edit = "0.25.15" +serde_yaml_ng = "0.10.0" [target.'cfg(not(any(windows, target_os = "illumos", target_os = "freebsd")))'.dependencies] diff --git a/docs/config.md b/docs/config.md index a16e5c5ac..5c9756080 100644 --- a/docs/config.md +++ b/docs/config.md @@ -8,7 +8,7 @@ diffr's configuration comes from exactly three places: 2. Command-line flags, for one run. `--byte-limit`, `--graph-limit` and `--parse-error-limit` override `[diff]`, and `-U` sets context padding. 3. Git attributes, for facts about files in a repository (`.gitattributes` - and git's user-wide attributes file). + and git's user-wide attributes file); see [File tags](#file-tags). There is no repository configuration file and no environment-variable layer. Keys the file omits keep their defaults. An unknown key or a mistyped value is @@ -65,3 +65,41 @@ When a file exceeds a `[diff]` limit it falls back to a line diff: the file's 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 tags + +Every file in a repository comparison carries `tags` in the stream manifest: +what the file is, such as `generated`, `vendored`, `docs` or `test`. There is +no configuration key for them. In order of precedence, lowest first: + +1. Bundled rules. `generated`, `vendored` and `docs` follow GitHub Linguist: + `generated` ports `lib/linguist/generated.rb` (lockfiles and other names + and paths, headers such as `// Code generated ... DO NOT EDIT.` in a + file's first lines, and minified JavaScript and CSS by average line + length), and `vendored` and `docs` match the path against Linguist's + `vendor.yml` and `documentation.yml`, bundled under `src/tags/linguist/` + with Linguist's MIT license. `test` comes from diffr's own path rules: + `tests/`, `test/`, `__tests__/`, `spec/`, `*_test.go`, `*.test.*`, + `*.spec.*`, `test_*.py`, `*_test.py`, `conftest.py`, `tests.rs`, `test.rs`, + `*_test.rs`, `*_tests.rs`. A file carries every tag that matches. +2. Git attributes, with git's own precedence between `$GIT_DIR/info/attributes`, + `.gitattributes` files and the user-wide file (`core.attributesFile`, + default `~/.config/git/attributes`). Attributes always beat the bundled + rules. `linguist-generated`, `linguist-vendored` and + `linguist-documentation` add `generated`, `vendored` and `docs` when set, + and remove them when unset or `false`, whatever the rules said. + `diffr-tags=a,b` adds tags: lowercase letters, digits, `-` and `_`, + separated by commas. A malformed value is an error naming the path, and + diffr exits 2 before the stream starts. + +```gitattributes +Cargo.lock linguist-generated=false +web/schema.json linguist-generated diffr-tags=schema +fixtures/** diffr-tags=fixture +``` + +Header and minified checks read only the first 8 KiB of a file's after side +(its before side when deleted), and only when no name, path or attribute +has already decided `generated`. A file tagged `generated` always gets a line +diff without parsing, and its `stats.fallback` has code `generated`. +Comparisons with `--no-index` are outside any repository and carry no tags. diff --git a/docs/streaming.md b/docs/streaming.md index 6d63a8ce7..c1bd37f85 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -2,7 +2,7 @@ ```sh diffr main HEAD --format ndjson -diffr --cached --format ndjson --order source,test -- src/ +diffr --cached --format ndjson --order test,docs -- src/ diffr --no-index --format ndjson -- before.rs after.rs ``` @@ -22,19 +22,15 @@ applies command-line flags, and compiles the result once; see retain defaults; query strings replace whole values, and empty queries disable that feature. -File classes come from the current workspace's Git attributes: +File tags come from bundled GitHub Linguist rules and diffr's test path rules, +then git attributes (`linguist-generated`, `linguist-vendored`, +`linguist-documentation`, `diffr-tags`); [config.md](config.md#file-tags) has +the precedence. Renames are tagged by new path; deletions by old path. -```gitattributes -* diffr-classify=source -tests/** diffr-classify=test -docs/** diffr-classify=docs -**/*.lock diffr-classify=generated -``` - -`--order source,test,docs` prioritizes those classes. 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. +`--order test,docs` puts files carrying those tags first, ranked by the +earliest listed tag a file carries. It can also be repeated. Files with none of +the listed tags follow, with path order breaking ties. Without `--order`, use +path order. Paths after `--` accept libgit2 directory prefixes and wildcard patterns, not Git magic pathspecs. Only changed files are selected. An unmatched path yields an empty @@ -78,12 +74,15 @@ as results are computed, not as an atomic snapshot. ```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 + "status": "modified", // added | deleted | modified | renamed | copied | type_changed + "tags": ["generated", "vendored"]} // sorted; absent when none ``` `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 +record can be matched back to the manifest. Tags are known before `start`: +reading the start of a file for the header checks happens during discovery, +and if that read fails the file's record carries a `read_failed` error. 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 @@ -101,7 +100,8 @@ One shape everywhere: `{"code": "", "message": ""}`. one syntax node with different fold ranges; the message names the line and both patterns), or `internal` for a failure diffr did not classify. - Setup failures (bad revision, unreadable config, a query that does not - compile) write to stderr and exit 2 before any record. + compile, a malformed `diffr-tags` attribute) 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. @@ -124,13 +124,14 @@ deleted file has `lhs` only. `stats.textual` counts lines with any byte change. `stats.fallback` is present when the AST match did not run: `unsupported_language`, -`too_large`, `too_complex`, `parse_error`. Its `message` is the engine's own -account of why, such as the size a file reached and the limit it exceeded. A -fallback diff is aligned by a line diff and its `changed` spans are word-level, -but the parse still stands: folds are present whenever the language parsed -(`too_complex`, `parse_error`). A line diff has no matcher, so its folds pair -with nothing. Only `unsupported_language` and `too_large` produce leaves -alone. +`too_large`, `too_complex`, `parse_error`, or `generated` for a file tagged +`generated`, which is always diffed by line without parsing. Its `message` is +the engine's own account of why, such as the size a file reached and the limit +it exceeded. A fallback diff is aligned by a line diff and its `changed` spans +are word-level, but the parse still stands: folds are present whenever the +language parsed (`too_complex`, `parse_error`). A line diff has no matcher, so +its folds pair with nothing. Only `unsupported_language`, `too_large` and +`generated` produce leaves alone. ### Regions diff --git a/src/cli.rs b/src/cli.rs index 832669ba6..9d7c59334 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,7 +42,7 @@ pub(crate) fn run() -> Result { .default_value("16") .help("Concurrent file diffs for --format ndjson; results are emitted as each finishes"), ) - .arg(Arg::new("order").long("order").value_delimiter(',').action(ArgAction::Append).help("File class priority from diffr-classify attributes")) + .arg(Arg::new("order").long("order").value_delimiter(',').action(ArgAction::Append).help("File tag priority: files carrying an earlier listed tag come first")) .arg(flag("cached").visible_alias("staged")) .arg(flag("merge-base")) .arg(flag("no-index")) diff --git a/src/engine.rs b/src/engine.rs index 313573d5e..af7b193a6 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -50,6 +50,10 @@ impl fmt::Display for QueryConflict { impl std::error::Error for QueryConflict {} +/// The fallback reason for a file tagged `generated`, which is always diffed +/// by line. +pub(crate) const GENERATED_FALLBACK: &str = "generated file, diffed by line"; + impl DiffResult { #[cfg(test)] pub(crate) fn from_sources(path: &str, lhs: &str, rhs: &str) -> Self { @@ -186,6 +190,23 @@ pub(crate) fn diff_file_content( let mut lhs_folds = Vec::new(); let mut rhs_folds = Vec::new(); let (file_format, lhs_positions, rhs_positions) = match lang_config { + _ if diff_options.generated => { + let file_format = FileFormat::TextFallback { + cause: FallbackCause::Generated, + reason: GENERATED_FALLBACK.to_owned(), + }; + if diff_options.check_only { + return Ok(check_only_text( + &file_format, + display_path, + extra_info, + lhs_src, + rhs_src, + )); + } + let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); + (file_format, lhs_positions, rhs_positions) + } None => { let file_format = FileFormat::PlainText; if diff_options.check_only { diff --git a/src/git.rs b/src/git.rs index 0ab9098b9..5a394a112 100644 --- a/src/git.rs +++ b/src/git.rs @@ -3,11 +3,13 @@ use crate::config::Params; use crate::pairing::Pairing; use crate::protocol; use crate::summary::DiffResult; +use crate::tags::{self, Attributes, Prefix, PREFIX_BYTES}; use anyhow::Context as _; -use git2::{AttrCheckFlags, AttrValue, Delta, Diff, DiffFindOptions, DiffOptions, Oid, Repository}; +use git2::{Delta, Diff, DiffFindOptions, DiffOptions, Oid, Repository}; use serde::Deserialize; use std::{ fmt, + io::Read as _, path::{Path, PathBuf}, sync::Arc, }; @@ -132,7 +134,8 @@ pub(crate) struct FileChange { pub(crate) old_path: Option, pub(crate) new_path: Option, pub(crate) status: FileStatus, - pub(crate) class: Option, + /// Sorted and deduplicated; see [`crate::tags`]. + pub(crate) tags: Vec, /// Git's delta sides. pub(crate) sides: Pairing, } @@ -173,7 +176,9 @@ impl FileChange { old_path, new_path, status, - class: None, + // Paths outside a repository have no attributes, and Linguist's + // rules are written for repository-relative paths. + tags: Vec::new(), sides, } } @@ -190,6 +195,7 @@ impl FileChange { // Both sides exist; the file record carries the unmerged error. FileStatus::Conflicted => protocol::FileStatus::Modified, }, + tags: self.tags.clone(), } } } @@ -296,6 +302,41 @@ impl Source { }) } + /// The start of the source for the content rules, or `None` when they + /// cannot apply: not a regular file, binary, or not UTF-8. Loading the + /// whole source reports those. + fn prefix(&self, repo: &Repository) -> anyhow::Result, bool)>> { + let (bytes, complete) = match self { + Self::Absent => return Ok(None), + Self::Blob { mode, .. } | Self::WorkingFile { mode, .. } + if !matches!(mode, git2::FileMode::Blob | git2::FileMode::BlobExecutable) => + { + return Ok(None); + } + Self::Blob { id, .. } => { + let blob = repo.find_blob(*id).context(FileError::ReadFailed)?; + let content = blob.content(); + let end = content.len().min(PREFIX_BYTES); + (content[..end].to_vec(), content.len() <= PREFIX_BYTES) + } + Self::WorkingFile { path, .. } => { + let mut bytes = Vec::with_capacity(PREFIX_BYTES + 1); + std::fs::File::open(path) + .context(FileError::ReadFailed)? + .take(PREFIX_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .context(FileError::ReadFailed)?; + let complete = bytes.len() <= PREFIX_BYTES; + bytes.truncate(PREFIX_BYTES); + (bytes, complete) + } + }; + if bytes.contains(&0) { + return Ok(None); + } + Ok(Some((bytes, complete))) + } + fn read(&self, repo: &Repository) -> anyhow::Result { let mode = match self { Self::Absent => return Ok(String::new()), @@ -324,6 +365,23 @@ struct PendingFile { file: FileChange, before: Source, after: Source, + /// Reading the start of the file for its tags failed; loading reports it + /// as this file's error. + prefix_error: Option, +} + +/// Whether Linguist's content rules call these bytes generated. A prefix cut +/// inside a UTF-8 sequence drops the partial character; bytes that are not +/// UTF-8 otherwise are not text, and no content rule applies. +fn generated_by_content(path: &str, bytes: &[u8], complete: bool) -> bool { + let text = match std::str::from_utf8(bytes) { + Ok(text) => text, + Err(error) if !complete && error.error_len().is_none() => { + std::str::from_utf8(&bytes[..error.valid_up_to()]).expect("valid up to here") + } + Err(_) => return false, + }; + tags::generated_by_content(path, &Prefix { text, complete }) } pub(crate) struct DiffSession { @@ -407,37 +465,58 @@ impl DiffSession { old_path, new_path, status, - class: None, + tags: Vec::new(), sides, }; - 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, - }; + let before = Source::from_delta( + delta.old_file(), + &comparison.before, + repo.workdir(), + file.old_path.is_none(), + )?; + let after = Source::from_delta( + delta.new_file(), + &comparison.after, + repo.workdir(), + file.new_path.is_none(), + )?; + let path = file.path(); + let attributes = Attributes::lookup(&repo, path)?; + let mut bundled = tags::from_path(path); + let mut prefix_error = None; + // Content is read only when it could change the answer. + if attributes.generated.is_none() + && !bundled.contains(tags::GENERATED) + && tags::needs_content(path) + { + let side = match &file.new_path { + Some(_) => &after, + None => &before, + }; + match side.prefix(&repo) { + Ok(Some((bytes, complete))) => { + if generated_by_content(path, &bytes, complete) { + bundled.insert(tags::GENERATED); + } + } + Ok(None) => {} + Err(error) => prefix_error = Some(error), + } + } + file.tags = attributes.resolve(bundled); pending.push(PendingFile { - before: Source::from_delta( - delta.old_file(), - &comparison.before, - repo.workdir(), - file.old_path.is_none(), - )?, - after: Source::from_delta( - delta.new_file(), - &comparison.after, - repo.workdir(), - file.new_path.is_none(), - )?, + before, + after, + prefix_error, file, }); } + // A file ranks by the earliest `--order` tag it carries. let rank = |file: &FileChange| { - file.class - .as_ref() - .and_then(|class| files.order.iter().position(|item| item == class)) + files + .order + .iter() + .position(|tag| file.tags.contains(tag)) .unwrap_or(files.order.len()) }; pending.sort_by(|a, b| { @@ -478,13 +557,17 @@ impl LoadedFile { &self, display: &crate::options::DisplayOptions, ) -> anyhow::Result { + let options = crate::options::DiffOptions { + generated: self.file.tags.iter().any(|tag| tag == tags::GENERATED), + ..self.diff_options.clone() + }; Ok(DiffResult::from_sources_with_options( self.file.path(), &self.before, &self.after, &self.params, display, - &self.diff_options, + &options, )?) } } @@ -492,8 +575,12 @@ impl LoadedFile { impl DiffSession { /// Read the next file's sources without diffing them. pub(crate) fn load(&mut self) -> Option<(FileChange, anyhow::Result)> { - let pending = self.files.next()?; + let mut pending = self.files.next()?; + let prefix_error = pending.prefix_error.take(); let result = (|| { + if let Some(error) = prefix_error { + return Err(error); + } if matches!(pending.file.status, FileStatus::Conflicted) { return Err(FileError::Unmerged.into()); } diff --git a/src/main.rs b/src/main.rs index c2d327391..0fff8a006 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,7 @@ mod pairing; mod parse; pub(crate) mod protocol; mod summary; +mod tags; mod version; mod words; diff --git a/src/options.rs b/src/options.rs index 4e39a370d..25cd83eb8 100644 --- a/src/options.rs +++ b/src/options.rs @@ -71,6 +71,8 @@ pub(crate) struct DiffOptions { pub(crate) check_only: bool, pub(crate) ignore_comments: bool, pub(crate) strip_cr: bool, + /// The file is tagged `generated`: diff it by line without parsing. + pub(crate) generated: bool, } impl Default for DiffOptions { @@ -82,6 +84,7 @@ impl Default for DiffOptions { check_only: false, ignore_comments: false, strip_cr: false, + generated: false, } } } @@ -874,6 +877,7 @@ pub(crate) fn parse_args() -> Mode { check_only, ignore_comments, strip_cr, + generated: false, }; let args = matches diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index b80d4be21..3bfcf37a7 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -91,6 +91,11 @@ pub struct FileChange { /// `LeftOnly` is a deletion, `RightOnly` an addition. pub file: Pairing, pub status: FileStatus, + /// What the file is, such as `generated`, `vendored`, `docs` or `test`, + /// from bundled Linguist rules and git attributes. Sorted and + /// deduplicated. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, } /// libgit2's delta status. @@ -233,7 +238,8 @@ pub struct SourcePos { /// Line counts for one file. `fallback` is present exactly when the AST /// match did not run and the alignment is a line diff, carrying why: -/// `too_complex`, `too_large`, `unsupported_language`, `parse_error`. +/// `too_complex`, `too_large`, `unsupported_language`, `parse_error`, +/// `generated`. /// Folds are still present on a fallback whenever the language parsed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Stats { @@ -388,6 +394,7 @@ mod tests { }, }, status: FileStatus::Added, + tags: vec!["generated".to_owned()], }], }, example_file(), @@ -454,5 +461,18 @@ mod tests { ] { assert!(!line.contains(absent), "{absent} appeared in {line}"); } + let untagged = FileChange { + file: Pairing::RightOnly { + rhs: FileRef { + path: "a.rs".to_owned(), + oid: "0e1f2a".to_owned(), + mode: "100644".to_owned(), + }, + }, + status: FileStatus::Added, + tags: Vec::new(), + }; + let line = serde_json::to_string(&untagged).unwrap(); + assert!(!line.contains("tags"), "{line}"); } } diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 64f4bbf0d..8be0130fc 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -114,6 +114,7 @@ fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { /// is the engine's own prose, with the numbers. fn fallback_code(cause: FallbackCause) -> &'static str { match cause { + FallbackCause::Generated => "generated", FallbackCause::ByteLimit => "too_large", FallbackCause::GraphLimit => "too_complex", FallbackCause::ParseErrorLimit => "parse_error", diff --git a/src/summary.rs b/src/summary.rs index d749a3c54..165f0e7ef 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -17,8 +17,8 @@ pub(crate) enum FileContent { pub(crate) enum FileFormat { SupportedLanguage(guess_language::Language), PlainText, - /// A file in a supported language diffed by line: `cause` says which - /// limit it hit, and `reason` says so in prose, with the numbers. + /// A file in a supported language diffed by line: `cause` says why, and + /// `reason` says so in prose, with the numbers. TextFallback { cause: FallbackCause, reason: String, @@ -35,6 +35,8 @@ pub(crate) enum FallbackCause { GraphLimit, /// A side has more parse errors than the parse error limit. ParseErrorLimit, + /// The file is tagged `generated`, and so is never parsed. + Generated, } impl Display for FileFormat { diff --git a/src/tags/generated.rs b/src/tags/generated.rs new file mode 100644 index 000000000..43185bdbd --- /dev/null +++ b/src/tags/generated.rs @@ -0,0 +1,692 @@ +//! A port of GitHub Linguist's `lib/linguist/generated.rb` (MIT; see +//! `linguist/LICENSE`), taken from github-linguist/linguist at commit +//! 16cac170f73c97eb4e4329d66fcb341ef25ef4f0. +//! +//! Linguist asks one question of a file's name and whole content. diffr splits +//! it in two so content is read only when it could matter: [`by_path`] holds +//! every rule that looks at the path alone, [`needs_content`] says whether any +//! content rule's name or extension guard admits the path, and [`by_content`] +//! runs those rules over the file's first [`super::PREFIX_BYTES`]. +//! +//! Every rule in `generated?` is ported. Where a rule reads the end of the file +//! (`has_source_map?`, `generated_net_docfile?`, `vcr_cassette?` and the +//! module-closure branch of `compiled_coffeescript?`) it runs only when the +//! prefix is the whole file. `minified_files?` averages line length over the +//! prefix, and `generated_postscript?` searches the prefix for its markers. +//! Rules that index a line Linguist would find missing (Ruby's `nil`) answer +//! false. +use regex::Regex; +use std::sync::LazyLock; + +/// The start of a file, and whether it is all of it. +pub(crate) struct Prefix<'a> { + pub(crate) text: &'a str, + pub(crate) complete: bool, +} + +impl Prefix<'_> { + /// Ruby's `data.split("\n", -1)`: an empty file has no lines, and a + /// trailing newline leaves an empty last line. + fn lines(&self) -> Vec<&str> { + if self.text.is_empty() { + Vec::new() + } else { + self.text.split('\n').collect() + } + } +} + +/// Ruby's `File.extname`: the last dot-suffix of the base name, ignoring +/// leading dots, so `.profile` has none. +fn extname(path: &str) -> &str { + let name = path.rsplit('/').next().unwrap_or(path); + let trimmed = name.trim_start_matches('.'); + match trimmed.rfind('.') { + Some(index) => &trimmed[index..], + None => "", + } +} + +fn regex(pattern: &str) -> Regex { + Regex::new(pattern).expect("Linguist pattern compiles") +} + +macro_rules! patterns { + ($($name:ident = $pattern:expr;)*) => { + $(static $name: LazyLock = LazyLock::new(|| regex($pattern));)* + }; +} + +patterns! { + INTELLIJ = r"(?:^|/)\.idea/"; + COCOAPODS = r"(^Pods|/Pods)/"; + CARTHAGE_BUILD = r"(^|/)Carthage/Build/"; + NET_DESIGNER = r"(?i)\.designer\.(cs|vb)$"; + SPECFLOW_FEATURE = r"(?i)\.feature\.cs$"; + // Linguist's `(?!-)[-0-9A-Za-z]+(?"; + DOXYGEN = r"(?i)"; + HTML_META = r"(?i)]+)>"; + // Linguist's lookbehind `(?<=^|\s)` becomes a consumed boundary; values + // never contain the whitespace it consumes. + HTML_META_ATTRIBUTE = r#"(?i)(?:^|\s)(name|content|value)\s*=\s*("[^"]+"|'[^']+'|[^\s"']+)"#; + HTML_GENERATOR = r"(?i)^(org\s+mode|j?latex2html|groff|makeinfo|texi2html|ronn)\b"; + SORBET_TYPED = r"^# typed:"; + SORBET_TAPIOCA = r"^# Please (run|instead update this file by running) `bin/tapioca"; +} + +/// Linguist's name rules: the path alone decides. +pub(crate) fn by_path(path: &str) -> bool { + let ext = extname(path); + [".nib", ".xcworkspacedata", ".xcuserstate"].contains(&ext) + || INTELLIJ.is_match(path) + || COCOAPODS.is_match(path) + || CARTHAGE_BUILD.is_match(path) + || path.contains("__generated__/") + || NET_DESIGNER.is_match(path) + || SPECFLOW_FEATURE.is_match(path) + || path.contains("composer.lock") + || path.contains("Cargo.lock") + || path.contains("Cargo.toml.orig") + || path.contains("deno.lock") + || FLAKE_LOCK.is_match(path) + || BAZEL_LOCK.is_match(path) + || path.contains("node_modules/") + || GO_VENDOR.is_match(path) + || GO_LOCK.is_match(path) + || path.contains("Package.resolved") + || path.contains("poetry.lock") + || path.contains("pdm.lock") + || path.contains("uv.lock") + || path.contains("pixi.lock") + || ESY_LOCK.is_match(path) + || path.contains("npm-shrinkwrap.json") + || path.contains("package-lock.json") + || path.contains("pnpm-lock.yaml") + || BUN_LOCK.is_match(path) + || TERRAFORM_LOCK.is_match(path) + || YARN_PNP.is_match(path) + || path.contains("Godeps/") + || ZEPHIR.is_match(path) + || HTMLCOV.is_match(path) + || (ext.eq_ignore_ascii_case(".map") && SOURCE_MAP_NAME.is_match(path)) + || path.contains("Pipfile.lock") + || GRADLE_WRAPPER.is_match(path) + || MAVEN_WRAPPER.is_match(path) + || MISE_LOCK.is_match(path) + || SECRETS_BASELINE.is_match(path) + || JULIA_MANIFEST.is_match(path) + || PASCAL_TLB.is_match(path) + || SQLX_QUERY.is_match(path) +} + +/// One content rule: the guard Linguist checks before reading data, and the +/// check itself. +struct ContentRule { + guard: fn(&str, &str) -> bool, + check: fn(&Prefix<'_>, &[&str]) -> bool, +} + +const PROTOBUF_EXTENSIONS: &[&str] = &[".py", ".java", ".h", ".cc", ".cpp", ".m", ".rb", ".php"]; +const APACHE_THRIFT_EXTENSIONS: &[&str] = &[ + ".rb", ".py", ".go", ".js", ".m", ".java", ".h", ".cc", ".cpp", ".php", +]; +const HAXE_EXTENSIONS: &[&str] = &[".js", ".py", ".lua", ".cpp", ".h", ".java", ".cs", ".php"]; + +fn maybe_minified(ext: &str) -> bool { + ext.eq_ignore_ascii_case(".js") || ext.eq_ignore_ascii_case(".css") +} + +fn first<'a>(lines: &'a [&'a str], count: usize) -> &'a [&'a str] { + &lines[..lines.len().min(count)] +} + +fn line_contains(lines: &[&str], index: usize, needle: &str) -> bool { + lines.get(index).is_some_and(|line| line.contains(needle)) +} + +fn second_to_last<'a>(lines: &[&'a str]) -> Option<&'a str> { + lines.len().checked_sub(2).map(|index| lines[index]) +} + +const CONTENT_RULES: &[ContentRule] = &[ + // minified_files? + ContentRule { + guard: |_, ext| maybe_minified(ext), + check: |_, lines| { + !lines.is_empty() + && lines.iter().map(|line| line.chars().count()).sum::() / lines.len() > 110 + }, + }, + // has_source_map? + ContentRule { + guard: |_, ext| maybe_minified(ext), + check: |prefix, lines| { + prefix.complete + && lines[lines.len().saturating_sub(2)..] + .iter() + .any(|line| SOURCE_MAP_REFERENCE.is_match(line)) + }, + }, + // source_map?, by content + ContentRule { + guard: |_, ext| ext.eq_ignore_ascii_case(".map"), + check: |_, lines| { + lines + .first() + .is_some_and(|line| SOURCE_MAP_V2.is_match(line) || SOURCE_MAP_V1.is_match(line)) + }, + }, + // compiled_coffeescript? + ContentRule { + guard: |_, ext| ext == ".js", + check: |prefix, lines| { + if lines + .first() + .is_some_and(|line| COFFEE_HEADER.is_match(line)) + { + return true; + } + if !(prefix.complete + && lines.first() == Some(&"(function() {") + && second_to_last(lines) == Some("}).call(this);") + && lines.last() == Some(&"")) + { + return false; + } + let score: usize = lines + .iter() + .filter(|line| line.contains("var ")) + .map(|line| { + COFFEE_TEMPS.find_iter(line).count() + + 3 * COFFEE_HELPERS.find_iter(line).count() + }) + .sum(); + score >= 3 + }, + }, + // generated_parser? + ContentRule { + guard: |_, ext| ext == ".js", + check: |_, lines| PEG_JS.is_match(&first(lines, 5).concat()), + }, + // generated_net_docfile? + ContentRule { + guard: |_, ext| ext.eq_ignore_ascii_case(".xml"), + check: |prefix, lines| { + prefix.complete + && lines.len() > 3 + && lines[1].contains("") + && lines[2].contains("") + && second_to_last(lines).is_some_and(|line| line.contains("")) + }, + }, + // generated_postscript? + ContentRule { + guard: |_, ext| [".ps", ".eps", ".pfa"].contains(&ext), + check: |prefix, lines| { + if POSTSCRIPT_FONT.is_match(prefix.text) { + return true; + } + let Some(creator) = first(lines, 10) + .iter() + .find(|line| POSTSCRIPT_CREATOR.is_match(line)) + else { + return false; + }; + if POSTSCRIPT_GENERATOR.is_match(creator) || POSTSCRIPT_GENERATOR_2.is_match(creator) { + return true; + } + creator.contains("EAGLE") + && first(lines, 5) + .iter() + .any(|line| POSTSCRIPT_EAGLE_TITLE.is_match(line)) + }, + }, + // generated_go? + ContentRule { + guard: |_, ext| ext == ".go", + check: |_, lines| { + lines.len() > 1 && first(lines, 40).iter().any(|line| GO_HEADER.is_match(line)) + }, + }, + // generated_protocol_buffer_from_go? + ContentRule { + guard: |_, ext| ext == ".proto", + check: |_, lines| { + lines.len() > 1 + && first(lines, 20) + .iter() + .any(|line| line.contains("This file was autogenerated by go-to-protobuf")) + }, + }, + // generated_protocol_buffer? + ContentRule { + guard: |_, ext| PROTOBUF_EXTENSIONS.contains(&ext), + check: |_, lines| { + lines.len() > 1 + && first(lines, 3).iter().any(|line| { + line.contains("Generated by the protocol buffer compiler. DO NOT EDIT!") + }) + }, + }, + // generated_javascript_protocol_buffer? + ContentRule { + guard: |_, ext| ext == ".js", + check: |_, lines| lines.len() > 6 && lines[5].contains("GENERATED CODE -- DO NOT EDIT!"), + }, + // generated_typescript_protocol_buffer? + ContentRule { + guard: |_, ext| ext == ".ts", + check: |_, lines| { + lines.len() > 4 + && lines[0].contains("Code generated by protoc-gen-ts_proto. DO NOT EDIT.") + }, + }, + // generated_twirp_ruby? + ContentRule { + guard: |_, ext| ext == ".rb", + check: |_, lines| { + lines.len() > 1 + && first(lines, 3).iter().any(|line| { + line.contains("Code generated by protoc-gen-twirp_ruby") + && line.contains("DO NOT EDIT.") + }) + }, + }, + // generated_apache_thrift? + ContentRule { + guard: |_, ext| APACHE_THRIFT_EXTENSIONS.contains(&ext), + check: |_, lines| { + first(lines, 6) + .iter() + .any(|line| line.contains("Autogenerated by Thrift Compiler")) + }, + }, + // generated_jni_header? + ContentRule { + guard: |_, ext| ext == ".h", + check: |_, lines| { + lines.len() > 2 + && lines[0].contains("/* DO NOT EDIT THIS FILE - it is machine generated */") + && lines[1].contains("#include ") + }, + }, + // vcr_cassette? + ContentRule { + guard: |_, ext| ext == ".yml", + check: |prefix, lines| { + prefix.complete + && lines.len() > 2 + && second_to_last(lines).is_some_and(|line| line.contains("recorded_with: VCR")) + }, + }, + // generated_antlr? + ContentRule { + guard: |_, ext| ext == ".g", + check: |_, lines| lines.len() > 2 && lines[1].contains("generated by Xtest"), + }, + // compiled_cython_file? + ContentRule { + guard: |_, ext| [".c", ".cpp"].contains(&ext), + check: |_, lines| lines.len() > 1 && lines[0].contains("Generated by Cython"), + }, + // generated_module? + ContentRule { + guard: |_, ext| ext == ".mod", + check: |_, lines| { + lines.len() > 1 + && (lines[0].contains("PCBNEW-LibModule-V") + || lines[0].contains("GFORTRAN module version '")) + }, + }, + // generated_unity3d_meta? + ContentRule { + guard: |_, ext| ext == ".meta", + check: |_, lines| lines.len() > 1 && lines[0].contains("fileFormatVersion: "), + }, + // generated_racc? + ContentRule { + guard: |_, ext| ext == ".rb", + check: |_, lines| { + lines.len() > 2 + && lines[2].starts_with("# This file is automatically generated by Racc") + }, + }, + // generated_jflex? + ContentRule { + guard: |_, ext| ext == ".java", + check: |_, lines| { + lines.len() > 1 && lines[0].starts_with("/* The following code was generated by JFlex ") + }, + }, + // generated_grammarkit? + ContentRule { + guard: |_, ext| ext == ".java", + check: |_, lines| { + lines.len() > 1 + && lines[0] + .starts_with("// This is a generated file. Not intended for manual editing.") + }, + }, + // generated_roxygen2? + ContentRule { + guard: |_, ext| ext == ".Rd", + check: |_, lines| { + lines.len() > 1 && lines[0].contains("% Generated by roxygen2: do not edit by hand") + }, + }, + // generated_html? + ContentRule { + guard: |_, ext| { + [".html", ".htm", ".xhtml"] + .iter() + .any(|html| ext.eq_ignore_ascii_case(html)) + }, + check: |_, lines| generated_html(lines), + }, + // generated_jison? + ContentRule { + guard: |_, ext| ext == ".js", + check: |_, lines| { + lines.len() > 1 + && (lines[0].starts_with("/* parser generated by jison ") + || lines[0].starts_with("/* generated by jison-lex ")) + }, + }, + // generated_grpc_cpp? + ContentRule { + guard: |_, ext| [".cpp", ".hpp", ".h", ".cc"].contains(&ext), + check: |_, lines| lines.len() > 1 && lines[0].starts_with("// Generated by the gRPC"), + }, + // generated_dart? + ContentRule { + guard: |_, ext| ext == ".dart", + check: |_, lines| { + lines.len() > 1 + && first(lines, 3) + .iter() + .any(|line| DART_HEADER.is_match(&line.to_lowercase())) + }, + }, + // generated_perl_ppport_header? + ContentRule { + guard: |path, _| PPPORT_NAME.is_match(path), + check: |_, lines| { + lines.len() > 10 && lines[8].contains("Automatically created by Devel::PPPort") + }, + }, + // generated_gamemakerstudio? + ContentRule { + guard: |_, ext| [".yy", ".yyp"].contains(&ext), + check: |_, lines| { + lines.len() > 3 + && (GAMEMAKER_JSON.is_match(&first(lines, 3).concat()) + || GAMEMAKER_LEGACY.is_match(lines[0])) + }, + }, + // generated_gimp? + ContentRule { + guard: |_, ext| [".c", ".h"].contains(&ext), + check: |_, lines| { + lines + .first() + .is_some_and(|line| GIMP_SOURCE.is_match(line) || GIMP_HEADER.is_match(line)) + }, + }, + // generated_visualstudio6? + ContentRule { + guard: |_, ext| ext.eq_ignore_ascii_case(".dsp"), + check: |_, lines| { + first(lines, 3) + .iter() + .any(|line| line.contains("# Microsoft Developer Studio Generated Build File")) + }, + }, + // generated_haxe? + ContentRule { + guard: |_, ext| HAXE_EXTENSIONS.contains(&ext), + check: |_, lines| { + first(lines, 3) + .iter() + .any(|line| line.contains("Generated by Haxe")) + }, + }, + // generated_jooq? + ContentRule { + guard: |_, ext| ext.eq_ignore_ascii_case(".java"), + check: |_, lines| { + first(lines, 2) + .iter() + .any(|line| line.contains("This file is generated by jOOQ.")) + }, + }, + // generated_sorbet_rbi? + ContentRule { + guard: |_, ext| ext.eq_ignore_ascii_case(".rbi"), + check: |_, lines| { + lines.len() >= 5 + && SORBET_TYPED.is_match(lines[0]) + && lines[2].contains("DO NOT EDIT MANUALLY") + && SORBET_TAPIOCA.is_match(lines[4]) + }, + }, + // generated_mysql_view_definition_format? + ContentRule { + guard: |_, ext| ext.eq_ignore_ascii_case(".frm"), + check: |_, lines| line_contains(lines, 0, "TYPE=VIEW"), + }, +]; + +/// Pkgdown, mandoc and Doxygen markers, or a `` naming +/// a documentation tool, near the top of an HTML file. +fn generated_html(lines: &[&str]) -> bool { + if lines.len() <= 1 { + return false; + } + if first(lines, 2).iter().any(|line| PKGDOWN.is_match(line)) { + return true; + } + if lines.len() > 2 && lines[2].starts_with("\n"), + ("img.c", "/* GIMP RGB C-Source image dump (img.c) */\n\n"), + ("rbi/foo.rbi", "# typed: true\n\n# DO NOT EDIT MANUALLY\n# This is an autogenerated file\n# Please run `bin/tapioca dsl Foo`.\n"), + ("view.frm", "TYPE=VIEW\nquery=select 1\n"), + ("fig.eps", "%!PS-Adobe-3.0 EPSF-3.0\n%%Creator: cairo 1.16.0\n"), + ]; + for (path, text) in cases { + assert!(needs_content(path), "{path}"); + assert!(by_content(path, &whole(text)), "{path}"); + } + assert!(!by_content( + "api/service.go", + &whole("package api\n\nfunc main() {}\n") + )); + assert!(!by_content( + "page.html", + &whole("\n\n\n") + )); + assert!(!needs_content("src/main.rs")); + assert!(!needs_content("README.md")); + } + + #[test] + fn minified_by_average_line_length() { + let long = format!("var a={};\n", "x".repeat(300)); + assert!(by_content("app.js", &whole(&long))); + assert!(by_content("site.CSS", &whole(&long))); + let short = "function a() {\n return 1;\n}\n"; + assert!(!by_content("app.js", &whole(short))); + // Only JavaScript and CSS are measured. + assert!(!by_content("data.json", &whole(&long))); + } + + #[test] + fn tail_rules_need_the_whole_file() { + let mapped = "var a = 1;\n//# sourceMappingURL=app.js.map\n"; + assert!(by_content("app.js", &whole(mapped))); + let truncated = Prefix { + text: mapped, + complete: false, + }; + assert!(!by_content("app.js", &truncated)); + let cassette = "---\nhttp_interactions: []\nrecorded_with: VCR 6.0.0\n"; + assert!(by_content("fixtures/c.yml", &whole(cassette))); + } +} diff --git a/src/tags/linguist/LICENSE b/src/tags/linguist/LICENSE new file mode 100644 index 000000000..acc8e6f25 --- /dev/null +++ b/src/tags/linguist/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2017 GitHub, Inc. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/tags/linguist/documentation.yml b/src/tags/linguist/documentation.yml new file mode 100644 index 000000000..e4d0a95df --- /dev/null +++ b/src/tags/linguist/documentation.yml @@ -0,0 +1,37 @@ +# Documentation files and directories are excluded from language +# statistics. +# +# Lines in this file are Regexps that are matched against the file +# pathname. +# +# Run `script/check-regex-compatibility lib/linguist/documentation.yml` +# after changing these patterns. +# +# Please add additional test coverage to +# `test/test_blob.rb#test_documentation` if you make any changes. + +## Documentation directories ## + +- ^[Dd]ocs?/ +- (^|/)[Dd]ocumentation/ +- (^|/)[Gg]roovydoc/ +- (^|/)[Jj]avadoc/ +- ^[Mm]an/ +- ^[Ee]xamples/ +- ^[Dd]emos?/ +- (^|/)inst/doc/ + +## Documentation files ## + +- (^|/)CITATION(\.cff|(S)?(\.(bib|md))?)$ +- (^|/)CHANGE(S|LOG)?(\.|$) +- (^|/)CONTRIBUTING(\.|$) +- (^|/)COPYING(\.|$) +- (^|/)INSTALL(\.|$) +- (^|/)LICEN[CS]E(\.|$) +- (^|/)[Ll]icen[cs]e(\.|$) +- (^|/)README(\.|$) +- (^|/)[Rr]eadme(\.|$) + +# Samples folders +- ^[Ss]amples?/ diff --git a/src/tags/linguist/vendor.yml b/src/tags/linguist/vendor.yml new file mode 100644 index 000000000..9e854a4c3 --- /dev/null +++ b/src/tags/linguist/vendor.yml @@ -0,0 +1,399 @@ +# Vendored files and directories are excluded from language +# statistics. +# +# Lines in this file are Regexps that are matched against the file +# pathname. +# +# Run `script/check-regex-compatibility lib/linguist/vendor.yml` +# after changing these patterns. +# +# Please add additional test coverage to +# `test/test_file_blob.rb#test_vendored` if you make any changes. + +## Vendor Conventions ## + +# Caches +- (^|/)cache/ + +# Dependencies +- ^[Dd]ependencies/ + +# Distributions +- (^|/)dist/ + +# C deps +- ^deps/ +- (^|/)configure$ +- (^|/)config\.guess$ +- (^|/)config\.sub$ + +# stuff autogenerated by autoconf - still C deps +- (^|/)aclocal\.m4 +- (^|/)libtool\.m4 +- (^|/)ltoptions\.m4 +- (^|/)ltsugar\.m4 +- (^|/)ltversion\.m4 +- (^|/)lt~obsolete\.m4 + +# .NET Core Install Scripts +- (^|/)dotnet-install\.(ps1|sh)$ + +# Linters +- (^|/)cpplint\.py + +# Node dependencies +- (^|/)node_modules/ + +# Yarn 2 +- (^|/)\.yarn/releases/ +- (^|/)\.yarn/plugins/ +- (^|/)\.yarn/sdks/ +- (^|/)\.yarn/versions/ +- (^|/)\.yarn/unplugged/ + +# esy.sh dependencies +- (^|/)_esy$ + +# Bower Components +- (^|/)bower_components/ + +# Erlang bundles +- ^rebar$ +- (^|/)erlang\.mk + +# Go dependencies +- (^|/)Godeps/_workspace/ + +# Go fixtures +- (^|/)testdata/ + +# GNU indent profiles +- (^|/)\.indent\.pro + +# Minified JavaScript and CSS +- (\.|-)min\.(js|css)$ + +# Stylesheets imported from packages +- ([^\s]*)import\.(css|less|scss|styl)$ + +# Bootstrap css and js +- (^|/)bootstrap([^/.]*)(\..*)?\.(js|css|less|scss|styl)$ +- (^|/)custom\.bootstrap([^\s]*)(js|css|less|scss|styl)$ + +# Font Awesome +- (^|/)font-?awesome\.(css|less|scss|styl)$ +- (^|/)font-?awesome/.*\.(css|less|scss|styl)$ + +# Foundation css +- (^|/)foundation\.(css|less|scss|styl)$ + +# Normalize.css +- (^|/)normalize\.(css|less|scss|styl)$ + +# Skeleton.css +- (^|/)skeleton\.(css|less|scss|styl)$ + +# Bourbon css +- (^|/)[Bb]ourbon/.*\.(css|less|scss|styl)$ + +# Animate.css +- (^|/)animate\.(css|less|scss|styl)$ + +# Materialize.css +- (^|/)materialize\.(css|less|scss|styl|js)$ + +# Select2 +- (^|/)select2/.*\.(css|scss|js)$ + +# Bulma css +- (^|/)bulma\.(css|sass|scss)$ + +# Vendored dependencies +- (3rd|[Tt]hird)[-_]?[Pp]arty/ +- (^|/)vendors?/ +- (^|/)[Ee]xtern(als?)?/ +- (^|/)[Vv]+endor/ + +# Debian packaging +- ^debian/ + +# Haxelib projects often contain a neko bytecode file named run.n +- (^|/)run\.n$ + +# Bootstrap Datepicker +- (^|/)bootstrap-datepicker/ + +## Commonly Bundled JavaScript frameworks ## + +# jQuery +- (^|/)jquery([^.]*)\.js$ +- (^|/)jquery\-\d\.\d+(\.\d+)?\.js$ + +# jQuery UI +- (^|/)jquery\-ui(\-\d\.\d+(\.\d+)?)?(\.\w+)?\.(js|css)$ +- (^|/)jquery\.(ui|effects)\.([^.]*)\.(js|css)$ + +# jQuery Gantt +- (^|/)jquery\.fn\.gantt\.js + +# jQuery fancyBox +- (^|/)jquery\.fancybox\.(js|css) + +# Fuel UX +- (^|/)fuelux\.js + +# jQuery File Upload +- (^|/)jquery\.fileupload(-\w+)?\.js$ + +# jQuery dataTables +- (^|/)jquery\.dataTables\.js + +# bootboxjs +- (^|/)bootbox\.js + +# pdf-worker +- (^|/)pdf\.worker\.js + +# Slick +- (^|/)slick\.\w+.js$ + +# Leaflet plugins +- (^|/)Leaflet\.Coordinates-\d+\.\d+\.\d+\.src\.js$ +- (^|/)leaflet\.draw-src\.js +- (^|/)leaflet\.draw\.css +- (^|/)Control\.FullScreen\.css +- (^|/)Control\.FullScreen\.js +- (^|/)leaflet\.spin\.js +- (^|/)wicket-leaflet\.js + +# Sublime Text workspace files +- (^|/)\.sublime-project +- (^|/)\.sublime-workspace + +# VS Code workspace files +- (^|/)\.vscode/ + +# Prototype +- (^|/)prototype(.*)\.js$ +- (^|/)effects\.js$ +- (^|/)controls\.js$ +- (^|/)dragdrop\.js$ + +# Typescript definition files +- (.*?)\.d\.ts$ + +# MooTools +- (^|/)mootools([^.]*)\d+\.\d+.\d+([^.]*)\.js$ + +# Dojo +- (^|/)dojo\.js$ + +# MochiKit +- (^|/)MochiKit\.js$ + +# YUI +- (^|/)yahoo-([^.]*)\.js$ +- (^|/)yui([^.]*)\.js$ + +# WYS editors +- (^|/)ckeditor\.js$ +- (^|/)tiny_mce([^.]*)\.js$ +- (^|/)tiny_mce/(langs|plugins|themes|utils) + +# Ace Editor +- (^|/)ace-builds/ + +# Fontello CSS files +- (^|/)fontello(.*?)\.css$ + +# MathJax +- (^|/)MathJax/ + +# Chart.js +- (^|/)Chart\.js$ + +# CodeMirror +- (^|/)[Cc]ode[Mm]irror/(\d+\.\d+/)?(lib|mode|theme|addon|keymap|demo) + +# SyntaxHighlighter - http://alexgorbatchev.com/ +- (^|/)shBrush([^.]*)\.js$ +- (^|/)shCore\.js$ +- (^|/)shLegacy\.js$ + +# AngularJS +- (^|/)angular([^.]*)\.js$ + +# D3.js +- (^|\/)d3(\.v\d+)?([^.]*)\.js$ + +# React +- (^|/)react(-[^.]*)?\.js$ + +# flow-typed +- (^|/)flow-typed/.*\.js$ + +# Modernizr +- (^|/)modernizr\-\d\.\d+(\.\d+)?\.js$ +- (^|/)modernizr\.custom\.\d+\.js$ + +# Knockout +- (^|/)knockout-(\d+\.){3}(debug\.)?js$ + +## Python ## + +# Sphinx +- (^|/)docs?/_?(build|themes?|templates?|static)/ + +# django +- (^|/)admin_media/ +- (^|/)env/ + +# Fabric +- (^|/)fabfile\.py$ + +# WAF +- (^|/)waf$ + +# .osx +- (^|/)\.osx$ + +## Obj-C ## + +# Xcode +### these can be part of a directory name + +- \.xctemplate/ +- \.imageset/ + +# Carthage +- (^|/)Carthage/ + +# Sparkle +- (^|/)Sparkle/ + +# Crashlytics +- (^|/)Crashlytics\.framework/ + +# Fabric +- (^|/)Fabric\.framework/ + +# BuddyBuild +- (^|/)BuddyBuildSDK\.framework/ + +# Realm +- (^|/)Realm\.framework + +# RealmSwift +- (^|/)RealmSwift\.framework + +# git config files +- (^|/)\.gitattributes$ +- (^|/)\.gitignore$ +- (^|/)\.gitmodules$ + +## Groovy ## + +# Gradle +- (^|/)gradlew$ +- (^|/)gradlew\.bat$ +- (^|/)gradle/wrapper/ + +## Java ## + +# Maven +- (^|/)mvnw$ +- (^|/)mvnw\.cmd$ +- (^|/)\.mvn/wrapper/ + +## .NET ## + +# Visual Studio IntelliSense +- -vsdoc\.js$ +- \.intellisense\.js$ + +# jQuery validation plugin (MS bundles this with asp.net mvc) +- (^|/)jquery([^.]*)\.validate(\.unobtrusive)?\.js$ +- (^|/)jquery([^.]*)\.unobtrusive\-ajax\.js$ + +# Microsoft Ajax +- (^|/)[Mm]icrosoft([Mm]vc)?([Aa]jax|[Vv]alidation)(\.debug)?\.js$ + +# NuGet +- (^|/)[Pp]ackages\/.+\.\d+\/ + +# ExtJS +- (^|/)extjs/.*?\.js$ +- (^|/)extjs/.*?\.xml$ +- (^|/)extjs/.*?\.txt$ +- (^|/)extjs/.*?\.html$ +- (^|/)extjs/.*?\.properties$ +- (^|/)extjs/\.sencha/ +- (^|/)extjs/docs/ +- (^|/)extjs/builds/ +- (^|/)extjs/cmd/ +- (^|/)extjs/examples/ +- (^|/)extjs/locale/ +- (^|/)extjs/packages/ +- (^|/)extjs/plugins/ +- (^|/)extjs/resources/ +- (^|/)extjs/src/ +- (^|/)extjs/welcome/ + +# Html5shiv +- (^|/)html5shiv\.js$ + +# Test fixtures +- (^|/)[Tt]ests?/fixtures/ +- (^|/)[Ss]pecs?/fixtures/ + +# PhoneGap/Cordova +- (^|/)cordova([^.]*)\.js$ +- (^|/)cordova\-\d\.\d(\.\d)?\.js$ + +# Foundation js +- (^|/)foundation(\..*)?\.js$ + +# Vagrant +- (^|/)Vagrantfile$ + +# .DS_Stores +- (^|/)\.[Dd][Ss]_[Ss]tore$ + +# R packages +- (^|/)inst/extdata/ + +# Octicons +- (^|/)octicons\.css +- (^|/)sprockets-octicons\.scss + +# Typesafe Activator +- (^|/)activator$ +- (^|/)activator\.bat$ + +# ProGuard +- (^|/)proguard\.pro$ +- (^|/)proguard-rules\.pro$ + +# PuPHPet +- (^|/)puphpet/ + +# Android Google APIs +- (^|/)\.google_apis/ + +# Jenkins Pipeline +- (^|/)Jenkinsfile$ + +# Gitpod +- (^|/)\.gitpod\.Dockerfile$ + +# GitHub.com +- (^|/)\.github/ + +# obsidian.md settings +- (^|/)\.obsidian/ + +# teamcity CI configuration +- (^|/)\.teamcity/ + +# XVBA dependencies +- (^|/)xvba_modules/ diff --git a/src/tags/mod.rs b/src/tags/mod.rs new file mode 100644 index 000000000..0764fda9c --- /dev/null +++ b/src/tags/mod.rs @@ -0,0 +1,415 @@ +//! File tags: `generated`, `vendored`, `docs`, `test`, and whatever a +//! repository adds. +//! +//! Bundled rules come first and have the lowest precedence: GitHub +//! Linguist's `generated.rb` (ported in [`generated`]), its `vendor.yml` and +//! `documentation.yml` path patterns (vendored under `linguist/` with +//! Linguist's MIT license), and diffr's own test path rules. Git attributes +//! then decide outright: `linguist-generated`, `linguist-vendored` and +//! `linguist-documentation` add or remove their tag whatever the bundled rules +//! said, and `diffr-tags=a,b` adds tags. + +mod generated; + +pub(crate) use generated::Prefix; + +use git2::{AttrCheckFlags, AttrValue, Repository}; +use regex::Regex; +use std::collections::BTreeSet; +use std::fmt; +use std::path::Path; +use std::sync::LazyLock; + +pub(crate) const GENERATED: &str = "generated"; +pub(crate) const VENDORED: &str = "vendored"; +pub(crate) const DOCS: &str = "docs"; +pub(crate) const TEST: &str = "test"; + +/// How much of a file the content rules read. +pub(crate) const PREFIX_BYTES: usize = 8 * 1024; + +/// Linguist builds one regex from a YAML list of patterns joined with `|` +/// and matches it anywhere in the repository-relative path. +fn linguist_patterns(yaml: &str) -> Regex { + let patterns: Vec = serde_yaml_ng::from_str(yaml).expect("Linguist YAML parses"); + Regex::new(&patterns.join("|")).expect("Linguist patterns compile") +} + +static VENDOR: LazyLock = + LazyLock::new(|| linguist_patterns(include_str!("linguist/vendor.yml"))); +static DOCUMENTATION: LazyLock = + LazyLock::new(|| linguist_patterns(include_str!("linguist/documentation.yml"))); + +/// Directory names, anywhere in the path, that hold tests. +const TEST_DIRS: &[&str] = &["tests", "test", "__tests__", "spec"]; + +fn is_test(path: &str) -> bool { + let mut parts = path.split('/').filter(|part| !part.is_empty()); + let name = parts.next_back().unwrap_or_default(); + parts.any(|dir| TEST_DIRS.contains(&dir)) + || name == "conftest.py" + || name == "tests.rs" + || name == "test.rs" + || name.ends_with("_test.go") + || name.ends_with("_test.py") + || name.ends_with("_test.rs") + || name.ends_with("_tests.rs") + || (name.starts_with("test_") && name.ends_with(".py")) + || name.contains(".test.") + || name.contains(".spec.") +} + +/// Every bundled rule that looks at the repository-relative path alone. +pub(crate) fn from_path(path: &str) -> BTreeSet<&'static str> { + let mut tags = BTreeSet::new(); + if generated::by_path(path) { + tags.insert(GENERATED); + } + if VENDOR.is_match(path) { + tags.insert(VENDORED); + } + if DOCUMENTATION.is_match(path) { + tags.insert(DOCS); + } + if is_test(path) { + tags.insert(TEST); + } + tags +} + +/// Whether a Linguist content rule could apply to this path, so the start of +/// the file is worth reading. +pub(crate) fn needs_content(path: &str) -> bool { + generated::needs_content(path) +} + +/// Linguist's content rules over the start of the file. +pub(crate) fn generated_by_content(path: &str, prefix: &Prefix<'_>) -> bool { + generated::by_content(path, prefix) +} + +/// A `diffr-tags` attribute that is not a comma-separated list of tags. +#[derive(Debug)] +pub(crate) struct TagError { + path: String, + message: String, +} + +impl fmt::Display for TagError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.path, self.message) + } +} + +impl std::error::Error for TagError {} + +/// What git attributes say about one file. `None` leaves the bundled rules +/// in charge of that tag. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Attributes { + pub(crate) generated: Option, + pub(crate) vendored: Option, + pub(crate) docs: Option, + pub(crate) added: Vec, +} + +impl Attributes { + /// Look up the file's attributes with git's precedence: the repository's + /// `$GIT_DIR/info/attributes`, then `.gitattributes` files (deeper first, + /// working tree then index), then the user-wide file + /// (`core.attributesFile`, default `$XDG_CONFIG_HOME/git/attributes`), + /// then the system file. + pub(crate) fn lookup( + repo: &Repository, + path: &str, + ) -> Result> { + // libgit2 marks set and unset attributes by pointer identity, so + // each value is classified before it is copied anywhere. + let attr = |name: &str| -> Result, git2::Error> { + Ok(AttrValue::from_bytes(repo.get_attr_bytes( + Path::new(path), + name, + AttrCheckFlags::FILE_THEN_INDEX, + )?)) + }; + Ok(Self::from_values( + path, + attr("linguist-generated")?, + attr("linguist-vendored")?, + attr("linguist-documentation")?, + attr("diffr-tags")?, + )?) + } + + fn from_values( + path: &str, + generated: AttrValue<'_>, + vendored: AttrValue<'_>, + docs: AttrValue<'_>, + tags: AttrValue<'_>, + ) -> Result { + let error = |message: String| TagError { + path: path.to_owned(), + message, + }; + let added = match tags { + AttrValue::Unspecified | AttrValue::False => Vec::new(), + AttrValue::True => { + return Err(error( + "diffr-tags needs a value, such as diffr-tags=fixture,schema".to_owned(), + )) + } + AttrValue::Bytes(_) => { + return Err(error("diffr-tags is not valid UTF-8".to_owned())); + } + AttrValue::String(value) => parse_tags(value).map_err(error)?, + }; + Ok(Self { + generated: linguist_flag(generated), + vendored: linguist_flag(vendored), + docs: linguist_flag(docs), + added, + }) + } + + /// Apply these attributes over the bundled tags, sorted and deduplicated. + pub(crate) fn resolve(&self, bundled: BTreeSet<&'static str>) -> Vec { + let mut tags: BTreeSet = bundled.into_iter().map(str::to_owned).collect(); + for (tag, flag) in [ + (GENERATED, self.generated), + (VENDORED, self.vendored), + (DOCS, self.docs), + ] { + match flag { + Some(true) => { + tags.insert(tag.to_owned()); + } + Some(false) => { + tags.remove(tag); + } + None => {} + } + } + tags.extend(self.added.iter().cloned()); + tags.into_iter().collect() + } +} + +/// Linguist's reading of a boolean attribute: unspecified has no opinion, +/// unset or the string `false` is false, and anything else is true. +fn linguist_flag(value: AttrValue<'_>) -> Option { + match value { + AttrValue::Unspecified => None, + AttrValue::False => Some(false), + AttrValue::String("false") => Some(false), + AttrValue::True | AttrValue::String(_) | AttrValue::Bytes(_) => Some(true), + } +} + +/// `a,b`: each tag is lowercase ASCII letters, digits, `-` and `_`, starting +/// with a letter or digit. +fn parse_tags(value: &str) -> Result, String> { + value + .split(',') + .map(|tag| { + let valid = tag + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase() || first.is_ascii_digit()) + && tag + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'); + if valid { + Ok(tag.to_owned()) + } else { + Err(format!( + "diffr-tags={value}: {tag:?} is not a tag; use lowercase letters, digits, '-' and '_', separated by commas" + )) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use git2::Repository; + use std::fs; + + fn tags(path: &str) -> Vec<&'static str> { + from_path(path).into_iter().collect() + } + + #[test] + fn vendored_paths_follow_vendor_yml() { + for path in [ + "node_modules/left-pad/index.js", + "vendor/github.com/pkg/errors/errors.go", + "third_party/zlib/inflate.c", + "dist/app.js", + "static/jquery-3.7.1.min.js", + "deps/uv/src/uv.c", + "lib/bootstrap.css", + "configure", + "web/cache/data.json", + ] { + assert!(tags(path).contains(&VENDORED), "{path}"); + } + for path in ["src/main.rs", "src/vendors.rs", "distribution/a.py"] { + assert!(!tags(path).contains(&VENDORED), "{path}"); + } + } + + #[test] + fn docs_paths_follow_documentation_yml() { + for path in [ + "docs/cli.md", + "Doc/index.rst", + "README.md", + "sub/readme.txt", + "CHANGELOG.md", + "LICENSE", + "examples/review/viewer/README.md", + "CITATION.cff", + ] { + assert!(tags(path).contains(&DOCS), "{path}"); + } + for path in ["src/docs/mod.rs", "src/readme_parser.rs", "notes.md"] { + assert!(!tags(path).contains(&DOCS), "{path}"); + } + } + + #[test] + fn test_paths() { + 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!(tags(path), vec![TEST], "{path}"); + } + for path in [ + "src/main.rs", + "testing/helpers.rs", + "src/testament.py", + "latest.txt", + ] { + assert!(!tags(path).contains(&TEST), "{path}"); + } + } + + #[test] + fn a_file_carries_every_tag_that_matches() { + assert_eq!(tags("tests/snapshots/Cargo.lock"), vec![GENERATED, TEST]); + assert_eq!( + tags("node_modules/x/package-lock.json"), + vec![GENERATED, VENDORED] + ); + } + + fn resolve( + path: &str, + generated: AttrValue<'_>, + tags: AttrValue<'_>, + ) -> Result, TagError> { + Ok(Attributes::from_values( + path, + generated, + AttrValue::Unspecified, + AttrValue::Unspecified, + tags, + )? + .resolve(from_path(path))) + } + + #[test] + fn attributes_beat_bundled_rules() { + let none = AttrValue::Unspecified; + assert_eq!(resolve("Cargo.lock", none, none).unwrap(), vec![GENERATED]); + assert!(resolve("Cargo.lock", AttrValue::False, none) + .unwrap() + .is_empty()); + assert!(resolve("Cargo.lock", AttrValue::String("false"), none) + .unwrap() + .is_empty()); + assert_eq!( + resolve("src/schema.rs", AttrValue::True, none).unwrap(), + vec![GENERATED] + ); + assert_eq!( + resolve("src/schema.rs", AttrValue::String("true"), none).unwrap(), + vec![GENERATED] + ); + let docs = + Attributes::from_values("README.md", none, AttrValue::True, AttrValue::False, none) + .unwrap() + .resolve(from_path("README.md")); + assert_eq!(docs, vec![VENDORED]); + } + + #[test] + fn diffr_tags_add_tags_and_reject_bad_syntax() { + let none = AttrValue::Unspecified; + assert_eq!( + resolve( + "tests/a.json", + none, + AttrValue::String("schema,fixture_2,schema") + ) + .unwrap(), + vec!["fixture_2", "schema", TEST] + ); + assert!(resolve("a.json", none, AttrValue::False) + .unwrap() + .is_empty()); + for bad in [ + AttrValue::True, + AttrValue::String("a,,b"), + AttrValue::String("Schema"), + AttrValue::String("-a"), + ] { + let error = resolve("web/a.json", none, bad).unwrap_err().to_string(); + assert!(error.starts_with("web/a.json: diffr-tags"), "{error}"); + } + } + + /// A repository whose user-wide attributes file is `core.attributesFile`. + #[test] + fn the_user_wide_attributes_file_is_honoured_below_the_repository() { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path().join("repo")).unwrap(); + let user = dir.path().join("attributes"); + fs::write( + &user, + "*.json linguist-generated diffr-tags=user\nCargo.lock -linguist-generated\n", + ) + .unwrap(); + repo.config() + .unwrap() + .set_str("core.attributesFile", user.to_str().unwrap()) + .unwrap(); + fs::write( + dir.path().join("repo/.gitattributes"), + "local.json diffr-tags=repo -linguist-generated\n", + ) + .unwrap(); + let lookup = |path: &str| { + Attributes::lookup(&repo, path) + .unwrap() + .resolve(from_path(path)) + }; + assert_eq!(lookup("web/schema.json"), vec![GENERATED, "user"]); + assert_eq!(lookup("sub/Cargo.lock"), Vec::::new()); + assert_eq!(lookup("Cargo.toml"), Vec::::new()); + assert_eq!(lookup("local.json"), vec!["repo"]); + } +} diff --git a/tests/tags.rs b/tests/tags.rs new file mode 100644 index 000000000..a573ce1c4 --- /dev/null +++ b/tests/tags.rs @@ -0,0 +1,190 @@ +//! File tags on the stream manifest, from bundled rules and git attributes. +use git2::{IndexAddOption, Repository, Signature, Time}; +use serde_json::Value; +use std::fs; +use std::process::{Command, Output}; +use tempfile::TempDir; + +/// A repository with a working tree, plus a home directory holding git's +/// user-wide attributes file. +struct Fixture { + dir: TempDir, + repo: Repository, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path().join("repo")).unwrap(); + fs::create_dir_all(dir.path().join("home/.config/git")).unwrap(); + Self { dir, repo } + } + + fn write(&self, path: &str, text: &str) { + let path = self.dir.path().join("repo").join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + + fn user_attributes(&self, text: &str) { + fs::write(self.dir.path().join("home/.config/git/attributes"), text).unwrap(); + } + + fn commit(&self) -> String { + let mut index = self.repo.index().unwrap(); + index.add_all(["*"], IndexAddOption::DEFAULT, None).unwrap(); + index.write().unwrap(); + let tree = self.repo.find_tree(index.write_tree().unwrap()).unwrap(); + let signature = Signature::new( + "Fixture", + "fixture@example.invalid", + &Time::new(946684800, 0), + ) + .unwrap(); + let parent = self + .repo + .head() + .ok() + .map(|head| head.peel_to_commit().unwrap()); + let parents: Vec<_> = parent.iter().collect(); + self.repo + .commit( + Some("HEAD"), + &signature, + &signature, + "fixture\n", + &tree, + &parents, + ) + .unwrap() + .to_string() + } + + fn run(&self, base: &str, head: &str) -> Output { + let home = self.dir.path().join("home"); + Command::new(assert_cmd::cargo_bin!("diffr")) + .arg("--repo") + .arg(self.dir.path().join("repo")) + .args([base, head, "--format", "ndjson"]) + .env("HOME", &home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env_remove("GIT_DIR") + .output() + .unwrap() + } +} + +fn records(output: &Output) -> Vec { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout.clone()) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +fn path_of(file: &Value) -> &str { + file.get("rhs").or_else(|| file.get("lhs")).unwrap()["path"] + .as_str() + .unwrap() +} + +fn tags_of<'a>(records: &'a [Value], path: &str) -> Vec<&'a str> { + let entry = records[0]["files"] + .as_array() + .unwrap() + .iter() + .find(|entry| path_of(&entry["file"]) == path) + .unwrap_or_else(|| panic!("{path} is not in the manifest")); + match entry.get("tags") { + Some(tags) => tags + .as_array() + .unwrap() + .iter() + .map(|tag| tag.as_str().unwrap()) + .collect(), + None => Vec::new(), + } +} + +fn fallback_of<'a>(records: &'a [Value], path: &str) -> Option<&'a str> { + let record = records + .iter() + .find(|record| record["type"] == "file" && path_of(&record["file"]) == path) + .unwrap_or_else(|| panic!("{path} has no file record")); + record["diff"]["stats"] + .get("fallback") + .map(|fallback| fallback["code"].as_str().unwrap()) +} + +#[test] +fn manifest_tags_follow_rules_then_attributes() { + let fixture = Fixture::new(); + let lock = "[[package]]\nname = \"a\"\nversion = \"1.0.0\"\n"; + let go = "package api\n\nfunc A() int {\n\treturn 1\n}\n"; + fixture.write("Cargo.lock", lock); + fixture.write("locks/Cargo.lock", lock); + fixture.write("api/service.go", go); + fixture.write("src/lib.rs", "fn a() -> u32 {\n 1\n}\n"); + fixture.write("tests/check.py", "def test_a():\n assert 1\n"); + fixture.write("web/schema.json", "{\"a\": 1}\n"); + fixture.write("user.txt", "one\n"); + fixture.write("README.md", "# a\n"); + fixture.write( + ".gitattributes", + "locks/Cargo.lock linguist-generated=false\nweb/schema.json diffr-tags=schema,api\n", + ); + fixture.user_attributes("user.txt diffr-tags=from-user\n"); + let base = fixture.commit(); + + let lock = lock.replace("1.0.0", "1.1.0"); + fixture.write("Cargo.lock", &lock); + fixture.write("locks/Cargo.lock", &lock); + fixture.write( + "api/service.go", + &format!( + "// Code generated by protoc-gen-go. DO NOT EDIT.\n{}", + go.replace('1', "2") + ), + ); + fixture.write("src/lib.rs", "fn a() -> u32 {\n 2\n}\n"); + fixture.write("tests/check.py", "def test_a():\n assert 2\n"); + fixture.write("web/schema.json", "{\"a\": 2}\n"); + fixture.write("user.txt", "two\n"); + fixture.write("README.md", "# b\n"); + let head = fixture.commit(); + + let records = records(&fixture.run(&base, &head)); + assert_eq!(tags_of(&records, "Cargo.lock"), vec!["generated"]); + assert_eq!(tags_of(&records, "locks/Cargo.lock"), Vec::<&str>::new()); + assert_eq!(tags_of(&records, "api/service.go"), vec!["generated"]); + assert_eq!(tags_of(&records, "src/lib.rs"), Vec::<&str>::new()); + assert_eq!(tags_of(&records, "tests/check.py"), vec!["test"]); + assert_eq!(tags_of(&records, "web/schema.json"), vec!["api", "schema"]); + assert_eq!(tags_of(&records, "user.txt"), vec!["from-user"]); + assert_eq!(tags_of(&records, "README.md"), vec!["docs"]); + + assert_eq!(fallback_of(&records, "Cargo.lock"), Some("generated")); + assert_eq!(fallback_of(&records, "api/service.go"), Some("generated")); + assert_ne!(fallback_of(&records, "locks/Cargo.lock"), Some("generated")); + assert_eq!(fallback_of(&records, "src/lib.rs"), None); +} + +#[test] +fn a_malformed_diffr_tags_attribute_names_the_path() { + let fixture = Fixture::new(); + fixture.write("bad.txt", "one\n"); + fixture.write(".gitattributes", "bad.txt diffr-tags=Not,ok\n"); + let base = fixture.commit(); + fixture.write("bad.txt", "two\n"); + let head = fixture.commit(); + let output = fixture.run(&base, &head); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("bad.txt: diffr-tags=Not,ok"), "{stderr}"); +} From f7c06cf57887fbddfd86d2a6a3564cacf25a1dd5 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 05/20] Define the plugin contract, and shape each file with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins decide how each diffed file is shown. The contract is one WIT world, `wit/plugin.wit`: a plugin is a resource with a static `new` that takes its validated options, and two calls on a file — `classify`, which adds tags before the `start` header is written, and `mutate`, which reads the region trees and returns moves. It imports three host functions, `read_head`, `git` and `log`. `crates/diffr-plugin-sdk` is that contract as Rust. `types` holds the contract's records; the one `Plugin` trait every plugin implements mirrors the world's exports, taking exactly those records, with an associated `Options` type diffr deserializes into; `export!` turns an implementation into a component's exports on wasm32 and expands to nothing elsewhere. The host functions are one API, calling the imports in a component and, natively, whatever host diffr installs for the call. `tree` rebuilds the records as trees and back and holds the helpers for reading them; `apply` carries moves out, and `Draft` carries a plugin's own moves out on a copy as it makes them, so a plugin predicts exactly the ids diffr will assign. `src/plugin/` is the host, and knows nothing about any one plugin. `config` reads each enabled entry's folder — its `plugin.toml` (name, title, options schema, query files) — embedded for a bundled plugin, on disk for one an entry's `path` names. `Pipeline::from_config` makes each plugin's one instance for the run from its options; a plugin that cannot be made is a setup error naming it, printed with its whole chain. Every call then gets the contract's records, built once per call from the file's manifest entry and its current trees, and the SDK's applier carries the moves out, so the next plugin sees the result. `plugins/context` is the first plugin, and teaches the contract: its queries tag the constructs a change can sit in `context:scope`, and the plugin keeps each enclosing scope's first and last line, plus the lines within its `lines` option of a change, collapsing every other unchanged stretch. It reads only regions, their tags and the text. This replaces the enclosing-context machinery it was carved out of: the `@context` captures, `parse/context.rs`, `Source.contexts` and the projection's context plumbing are gone, and so is the `[languages]` fold and context query configuration — a plugin's queries are its own files, and every tag a query sets is written `:`, naming a plugin in `plugins.order`. Two query files that capture one node with different fold ranges are a conflict, and that file is not diffed. No fold takes a label from its tags any more: a label is the plugin's that collapsed the fold. Written with AI assistance (Claude Code). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 --- Cargo.lock | 623 ++++++++++++++++- Cargo.toml | 17 + README.md | 2 +- crates/diffr-plugin-sdk/Cargo.toml | 19 + crates/diffr-plugin-sdk/src/apply.rs | 884 +++++++++++++++++++++++++ crates/diffr-plugin-sdk/src/draft.rs | 226 +++++++ crates/diffr-plugin-sdk/src/guest.rs | 111 ++++ crates/diffr-plugin-sdk/src/host.rs | 112 ++++ crates/diffr-plugin-sdk/src/lib.rs | 102 +++ crates/diffr-plugin-sdk/src/tree.rs | 443 +++++++++++++ crates/diffr-plugin-sdk/src/types.rs | 144 ++++ docs/cli.md | 3 +- docs/config.md | 144 +++- docs/streaming.md | 191 +++++- plugins/context/Cargo.toml | 12 + plugins/context/plugin.toml | 26 + plugins/context/queries/go.scm | 16 + plugins/context/queries/javascript.scm | 23 + plugins/context/queries/python.scm | 14 + plugins/context/queries/rust.scm | 27 + plugins/context/src/lib.rs | 427 ++++++++++++ src/cli.rs | 67 +- src/config.rs | 453 ++++++++----- src/config/README.md | 98 +-- src/config/defaults.toml | 65 -- src/config/query.rs | 100 ++- src/config/store.rs | 63 +- src/display/line_layout.rs | 6 - src/display/mod.rs | 1 - src/display/prepare.rs | 16 - src/display/syntax_context.rs | 150 ----- src/engine.rs | 29 +- src/git.rs | 66 +- src/main.rs | 7 +- src/pairing.rs | 16 + src/parse/context.rs | 72 -- src/parse/fold_tests.rs | 43 +- src/parse/folds.rs | 154 +++-- src/parse/mod.rs | 1 - src/parse/query.rs | 8 +- src/parse/syntax.rs | 3 - src/parse/tree_sitter_parser.rs | 11 +- src/plugin/builtin.rs | 97 +++ src/plugin/config.rs | 646 ++++++++++++++++++ src/plugin/host.rs | 113 ++++ src/plugin/mod.rs | 366 ++++++++++ src/plugin/native.rs | 66 ++ src/plugin/queries.rs | 273 ++++++++ src/plugin/tests/context.rs | 566 ++++++++++++++++ src/plugin/tests/mod.rs | 315 +++++++++ src/protocol/mod.rs | 68 +- src/protocol/project.rs | 34 +- src/protocol/stream.rs | 411 ++++++++++-- src/tags/mod.rs | 20 +- wit/plugin.wit | 215 ++++++ 55 files changed, 7332 insertions(+), 853 deletions(-) create mode 100644 crates/diffr-plugin-sdk/Cargo.toml create mode 100644 crates/diffr-plugin-sdk/src/apply.rs create mode 100644 crates/diffr-plugin-sdk/src/draft.rs create mode 100644 crates/diffr-plugin-sdk/src/guest.rs create mode 100644 crates/diffr-plugin-sdk/src/host.rs create mode 100644 crates/diffr-plugin-sdk/src/lib.rs create mode 100644 crates/diffr-plugin-sdk/src/tree.rs create mode 100644 crates/diffr-plugin-sdk/src/types.rs create mode 100644 plugins/context/Cargo.toml create mode 100644 plugins/context/plugin.toml create mode 100644 plugins/context/queries/go.scm create mode 100644 plugins/context/queries/javascript.scm create mode 100644 plugins/context/queries/python.scm create mode 100644 plugins/context/queries/rust.scm create mode 100644 plugins/context/src/lib.rs delete mode 100644 src/display/syntax_context.rs delete mode 100644 src/parse/context.rs create mode 100644 src/plugin/builtin.rs create mode 100644 src/plugin/config.rs create mode 100644 src/plugin/host.rs create mode 100644 src/plugin/mod.rs create mode 100644 src/plugin/native.rs create mode 100644 src/plugin/queries.rs create mode 100644 src/plugin/tests/context.rs create mode 100644 src/plugin/tests/mod.rs create mode 100644 wit/plugin.wit diff --git a/Cargo.lock b/Cargo.lock index dc1a957e4..512772620 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -94,12 +108,33 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.9.1" @@ -117,6 +152,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "cc" version = "1.2.54" @@ -194,6 +235,12 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "diff" version = "0.1.13" @@ -206,6 +253,24 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "diffr-plugin-context" +version = "0.1.0" +dependencies = [ + "diffr-plugin-sdk", + "serde", +] + +[[package]] +name = "diffr-plugin-sdk" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "wit-bindgen 0.60.0", +] + [[package]] name = "difftastic" version = "0.71.0" @@ -215,6 +280,8 @@ dependencies = [ "bumpalo", "cc", "clap", + "diffr-plugin-context", + "diffr-plugin-sdk", "dirs", "encoding_rs", "git2", @@ -223,6 +290,7 @@ dependencies = [ "humansize", "ignore", "imara-diff", + "jsonschema", "lazy_static", "libc", "line-numbers", @@ -366,6 +434,15 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -404,6 +481,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -431,6 +519,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -452,6 +551,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -463,6 +572,20 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -471,7 +594,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -634,6 +757,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -689,6 +818,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -723,12 +854,81 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.189" @@ -798,18 +998,44 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "nom" version = "8.0.0" @@ -825,6 +1051,75 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -846,12 +1141,41 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -940,6 +1264,16 @@ dependencies = [ "log", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -958,6 +1292,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -990,6 +1330,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1021,6 +1370,23 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "referencing" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.2" @@ -1082,6 +1448,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" @@ -1122,6 +1494,18 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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" @@ -2086,11 +2470,17 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" @@ -2128,6 +2518,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2140,6 +2540,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.0" @@ -2165,6 +2571,94 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01df5f3b4ca7881e843f3bc0fb8a3905d79c68692250dcb8e33e698705ccdb6" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", +] + [[package]] name = "winapi-util" version = "0.1.9" @@ -2355,6 +2849,103 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a301904d6657d6364c758d869e5389d05d393b16d5b65db60b4f03cbe71bb80d" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48521bb96e56cbb9e031ad306c80dc20e89e69e49ada02781ee06f60fbcb545f" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df3fe9b9a0066f82fd0c2f07f79d0ffc0f85c53fa5f998516d8722712479042" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.106", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d689c4bc9d6af067c651cfba444766343c9a4bdef15fad1e2efab95c0c277af0" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.106", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e65bb94c369b3c4741ce3d1d2704b1fec93db7c540df0e521a097e7ceeb5be" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1655131e4f7d3f0cb141f6eca71315ca40eff0f3d4de7cff0a82bacedd8c89b4" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.4" @@ -2390,6 +2981,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2443,3 +3054,9 @@ dependencies = [ "quote", "syn 3.0.5", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 4a994e2c6..511c958dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ rust-version = "1.85.0" include = [ "/build.rs", "/src/", + "/plugins/", "/vendored_parsers/highlights/*.scm", "/vendored_parsers/*-src/**/*.c", "/vendored_parsers/*-src/**/*.h", @@ -140,6 +141,11 @@ schemars = { version = "1.2.2", features = ["preserve_order"] } dirs = "5" toml_edit = "0.25.15" serde_yaml_ng = "0.10.0" +jsonschema = { version = "0.56.0", default-features = false } +# The plugin contract, and the bundled plugins: each a crate the native +# registry calls, which also builds as a WASM component. +diffr-plugin-sdk = { path = "crates/diffr-plugin-sdk" } +diffr-plugin-context = { path = "plugins/context" } [target.'cfg(not(any(windows, target_os = "illumos", target_os = "freebsd")))'.dependencies] @@ -159,6 +165,17 @@ cc = "1.2.48" rayon = "1.7.0" version_check = "0.9.4" +[workspace] +members = [ + "crates/diffr-plugin-sdk", + "plugins/context", +] +default-members = [ + ".", + "crates/diffr-plugin-sdk", + "plugins/context", +] + [profile.release] lto = "thin" diff --git a/README.md b/README.md index 582983f30..f2b4e466b 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 [configuration](docs/config.md). +including [configuration](docs/config.md) and [plugins](docs/streaming.md#plugins). The executable is `diffr`; the Cargo package remains `difftastic`. The upstream installation commands below install upstream Difftastic, not this fork. diff --git a/crates/diffr-plugin-sdk/Cargo.toml b/crates/diffr-plugin-sdk/Cargo.toml new file mode 100644 index 000000000..c5c935f4e --- /dev/null +++ b/crates/diffr-plugin-sdk/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "diffr-plugin-sdk" +description = "The diffr plugin contract in Rust: one trait for native and WASM plugins." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +anyhow = "1.0" +serde = "1.0" + +# The guest bindings exist only in a component. +[target.'cfg(target_arch = "wasm32")'.dependencies] +# The newest wit-bindgen whose rust-version (1.85) matches diffr's. +wit-bindgen = "0.60.0" +# The resource's `new` deserializes the options string. +serde_json = "1.0" diff --git a/crates/diffr-plugin-sdk/src/apply.rs b/crates/diffr-plugin-sdk/src/apply.rs new file mode 100644 index 000000000..e8ee7e0b2 --- /dev/null +++ b/crates/diffr-plugin-sdk/src/apply.rs @@ -0,0 +1,884 @@ +//! Carry out moves, in order. diffr carries every plugin's moves out with +//! this applier, and [`crate::Draft`] runs the same code on a copy, so a +//! plugin predicts exactly the trees and ids its moves leave. +//! +//! A move names a region by `id`, on the one side that holds it, or the file +//! by [`ROOT`]. An unknown `id` is an error. +//! +//! - `Cut { region, at }` splits a leaf into the lines before `at`, relative +//! to its first line, and the lines from `at` on; `at` must fall strictly +//! inside it. The leaf on the other side with its `alignment_id`, which +//! has the same length, is cut at the same offset. The first piece on each +//! side keeps its leaf's ids. The second piece takes a fresh `id` on each +//! side and a fresh `alignment_id` shared by the two, and its +//! `fold_state_id` is the `id` of the lhs piece, or of its only piece. +//! Pieces keep the leaf's tags and visibility and the `changed` spans on +//! their lines. A fold, or the file, cannot be cut. +//! - `JoinFolds { regions }` needs two or more region ids. Each side wraps the +//! ones it holds, which must be two or more consecutive siblings under one +//! parent, in a new fold spanning them, with a fresh `id`, no tags, and an +//! open, unlabelled visibility. When both sides wrap, the two new folds +//! share the lhs fold's `fold_state_id`, its `id`, so a plugin joins a run +//! and the run matched with it on the other side in one move by listing +//! both runs' ids. A side holding exactly one of the ids is an error, and +//! so is an id no side holds. +//! - `LinkFoldState { regions }` needs two or more region ids. Every region +//! in any of their fold states, on either side, takes the first region's +//! `fold_state_id` and whether it starts collapsed. +//! - `SetCollapsed { region, collapsed }` sets whether every region sharing +//! the region's `fold_state_id` starts collapsed, on both sides: they open +//! and close together. On [`ROOT`] it sets whether the file starts hidden. +//! - `SetLabel { region, label }` sets the label of that region alone, or of +//! the file on [`ROOT`]; `None` clears it. +//! - `SetTags { region, tags }` replaces that region's tags. The file's tags +//! are its manifest entry's, fixed before any diff runs, so [`ROOT`] is an +//! error. +//! +//! Fresh `id`s start above the largest `id` in the file (and above +//! [`ROOT`]) when a plugin's moves begin, and fresh `alignment_id`s above the +//! largest leaf `alignment_id`; each is handed out in the order the moves +//! need them, lhs before rhs. +use crate::tree::{walk, walk_mut, Node, Pairing, Region, Source}; +use crate::types::{Move, Position, Range, Visibility, ROOT}; +use anyhow::{bail, ensure}; +use std::collections::BTreeSet; + +/// Carries out one plugin's moves, handing out fresh ids as they need them. +pub struct Applier { + fresh: Fresh, +} + +impl Applier { + /// An applier for moves on `sides` as they are now. + pub fn new(sides: &Pairing) -> Self { + Self { + fresh: Fresh::of(sides), + } + } + + /// Carry out one move on `sides` and `file`, the file's own visibility. + pub fn apply( + &mut self, + next: Move, + sides: &mut Pairing, + file: &mut Visibility, + ) -> anyhow::Result<()> { + match next { + Move::Cut { region, at } => cut(sides, region, at, &mut self.fresh), + Move::JoinFolds { regions } => join(sides, ®ions, &mut self.fresh), + Move::LinkFoldState { regions } => link(sides, ®ions), + Move::SetCollapsed { + region: ROOT, + collapsed, + } => { + file.collapsed = collapsed; + Ok(()) + } + Move::SetCollapsed { region, collapsed } => { + let state = region_of(sides, region)?.fold_state_id; + for tree in trees(sides) { + walk_mut(tree, &mut |region| { + if region.fold_state_id == state { + region.visibility.collapsed = collapsed; + } + }); + } + Ok(()) + } + Move::SetLabel { + region: ROOT, + label, + } => { + file.label = label.unwrap_or_default(); + Ok(()) + } + Move::SetLabel { region, label } => { + region_mut(sides, region)?.visibility.label = label.unwrap_or_default(); + Ok(()) + } + Move::SetTags { region: ROOT, .. } => { + bail!("the file's tags are its manifest entry's; only a region's tags can be set") + } + Move::SetTags { region, tags } => { + region_mut(sides, region)?.tags = tags; + Ok(()) + } + } + } +} + +/// Carry out `moves` in order. +pub fn apply( + moves: Vec, + sides: &mut Pairing, + file: &mut Visibility, +) -> anyhow::Result<()> { + let mut applier = Applier::new(sides); + for next in moves { + applier.apply(next, sides, file)?; + } + Ok(()) +} + +fn trees(sides: &mut Pairing) -> Vec<&mut Vec> { + match sides { + Pairing::Both { lhs, rhs } => vec![&mut lhs.regions, &mut rhs.regions], + Pairing::LeftOnly { lhs } => vec![&mut lhs.regions], + Pairing::RightOnly { rhs } => vec![&mut rhs.regions], + } +} + +pub fn trees_ref(sides: &Pairing) -> Vec<&[Region]> { + match sides { + Pairing::Both { lhs, rhs } => vec![&lhs.regions, &rhs.regions], + Pairing::LeftOnly { lhs } => vec![&lhs.regions], + Pairing::RightOnly { rhs } => vec![&rhs.regions], + } +} + +/// The next unused `id` and leaf `alignment_id` in the file: what a plugin's +/// moves hand out, in order. A plugin that does not use [`crate::Draft`] +/// predicts the ids its cuts and joins create with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fresh { + pub id: u32, + pub alignment: u32, +} + +impl Fresh { + /// One above every `id`, and [`ROOT`], and one above every leaf + /// `alignment_id`, in the file. + pub fn of(sides: &Pairing) -> Self { + let mut fresh = Self { + id: ROOT + 1, + alignment: 0, + }; + for tree in trees_ref(sides) { + walk(tree, &mut |region| { + fresh.id = fresh.id.max(region.id + 1); + if let Some(alignment_id) = region.alignment_id() { + fresh.alignment = fresh.alignment.max(alignment_id + 1); + } + }); + } + fresh + } + + /// Take the next `id`. + pub fn id(&mut self) -> u32 { + let id = self.id; + self.id += 1; + id + } + + /// Take the next `alignment_id`. + pub fn alignment(&mut self) -> u32 { + let alignment = self.alignment; + self.alignment += 1; + alignment + } +} + +/// Child indices from the root down to the first region `is` accepts. +fn path_where(regions: &[Region], is: &impl Fn(&Region) -> bool) -> Option> { + for (index, region) in regions.iter().enumerate() { + if is(region) { + return Some(vec![index]); + } + if let Node::Fold { children } = ®ion.node { + if let Some(mut path) = path_where(children, is) { + path.insert(0, index); + return Some(path); + } + } + } + None +} + +/// Child indices from the root down to the region with this `id`. +fn path_of(regions: &[Region], id: u32) -> Option> { + path_where(regions, &|region| region.id == id) +} + +/// The region at a path. +fn at<'a>(regions: &'a [Region], path: &[usize]) -> &'a Region { + let (&index, rest) = path.split_first().expect("a path is never empty"); + match (rest.is_empty(), ®ions[index].node) { + (true, _) => ®ions[index], + (false, Node::Fold { children }) => at(children, rest), + (false, Node::Leaf { .. }) => unreachable!("a path descends through folds"), + } +} + +/// The sibling list the last index of a path points into. +fn siblings<'a>(regions: &'a mut Vec, parent: &[usize]) -> &'a mut Vec { + match parent.split_first() { + None => regions, + Some((&index, rest)) => match &mut regions[index].node { + Node::Fold { children } => siblings(children, rest), + Node::Leaf { .. } => unreachable!("a path descends through folds"), + }, + } +} + +/// The region with this `id`, on whichever side holds it. +fn region_of(sides: &Pairing, id: u32) -> anyhow::Result<&Region> { + trees_ref(sides) + .into_iter() + .find_map(|tree| find(tree, id)) + .ok_or_else(|| anyhow::anyhow!("no region {id}")) +} + +fn region_mut(sides: &mut Pairing, id: u32) -> anyhow::Result<&mut Region> { + trees(sides) + .into_iter() + .find_map(|tree| find_mut(tree, id)) + .ok_or_else(|| anyhow::anyhow!("no region {id}")) +} + +pub fn find(regions: &[Region], id: u32) -> Option<&Region> { + regions.iter().find_map(|region| { + if region.id == id { + return Some(region); + } + match ®ion.node { + Node::Fold { children } => find(children, id), + Node::Leaf { .. } => None, + } + }) +} + +fn find_mut(regions: &mut [Region], id: u32) -> Option<&mut Region> { + for region in regions { + if region.id == id { + return Some(region); + } + if let Node::Fold { children } = &mut region.node { + if let Some(found) = find_mut(children, id) { + return Some(found); + } + } + } + None +} + +fn cut(sides: &mut Pairing, id: u32, offset: u32, fresh: &mut Fresh) -> anyhow::Result<()> { + ensure!(id != ROOT, "the file cannot be cut; only a leaf can"); + let Some((side, path)) = trees(sides) + .into_iter() + .enumerate() + .find_map(|(side, tree)| Some((side, path_of(tree, id)?))) + else { + bail!("no region {id}"); + }; + let (alignment, len) = { + let leaf = at(trees(sides).swap_remove(side), &path); + let Some(alignment) = leaf.alignment_id() else { + bail!("region {id} is a fold; only a leaf can be cut"); + }; + (alignment, leaf.range.lines().len() as u32) + }; + ensure!( + 0 < offset && offset < len, + "line {offset} is not inside region {id}, which has {len} lines" + ); + let piece_alignment = fresh.alignment(); + // The second pieces' fold state: the id of the first side's. + let mut state = None; + for (tree_side, tree) in trees(sides).into_iter().enumerate() { + let path = if tree_side == side { + path.clone() + } else { + match path_where(tree, &|region| region.alignment_id() == Some(alignment)) { + Some(path) => path, + None => continue, + } + }; + let (index, parent) = path.split_last().expect("a path is never empty"); + let list = siblings(tree, parent); + ensure!( + list[*index].range.lines().len() as u32 == len, + "region {id} has a different length on each side" + ); + let piece_id = fresh.id(); + let piece_state = *state.get_or_insert(piece_id); + let leaf = list.remove(*index); + let pieces = split(leaf, offset, piece_id, piece_alignment, piece_state); + list.splice(*index..*index, pieces); + } + Ok(()) +} + +/// A leaf split at relative line `offset`. The second piece takes `id`, +/// `alignment_id` and `fold_state_id`. +fn split(leaf: Region, offset: u32, id: u32, alignment_id: u32, fold_state_id: u32) -> [Region; 2] { + let Node::Leaf { changed, .. } = &leaf.node else { + unreachable!("only leaves are cut"); + }; + let boundary = Position { + line: leaf.range.start.line + offset, + column: 0, + }; + let piece = |range: Range, id: u32, alignment_id: u32, fold_state_id: u32| { + let lines = range.lines(); + Region { + id, + fold_state_id, + range, + tags: leaf.tags.clone(), + visibility: leaf.visibility.clone(), + node: Node::Leaf { + alignment_id, + changed: changed + .iter() + .copied() + .filter(|span| lines.contains(&span.line)) + .collect(), + }, + } + }; + let head = piece( + Range { + start: leaf.range.start, + end: boundary, + }, + leaf.id, + leaf.alignment_id().expect("a leaf"), + leaf.fold_state_id, + ); + let tail = piece( + Range { + start: boundary, + end: leaf.range.end, + }, + id, + alignment_id, + fold_state_id, + ); + [head, tail] +} + +/// Two or more distinct region ids, none of them the file. +fn check_regions(ids: &[u32], what: &str) -> anyhow::Result<()> { + ensure!(ids.len() >= 2, "{what} needs at least two regions"); + ensure!(!ids.contains(&ROOT), "{what} cannot include the file"); + ensure!( + ids.iter().collect::>().len() == ids.len(), + "{what} lists a region twice: {ids:?}" + ); + Ok(()) +} + +fn link(sides: &mut Pairing, ids: &[u32]) -> anyhow::Result<()> { + check_regions(ids, "a link")?; + let first = region_of(sides, ids[0])?; + let (state, collapsed) = (first.fold_state_id, first.visibility.collapsed); + let states = ids + .iter() + .map(|id| Ok(region_of(sides, *id)?.fold_state_id)) + .collect::>>()?; + for tree in trees(sides) { + walk_mut(tree, &mut |region| { + if states.contains(®ion.fold_state_id) { + region.fold_state_id = state; + region.visibility.collapsed = collapsed; + } + }); + } + Ok(()) +} + +fn join(sides: &mut Pairing, ids: &[u32], fresh: &mut Fresh) -> anyhow::Result<()> { + check_regions(ids, "a join")?; + for id in ids { + region_of(sides, *id)?; + } + let mut state = None; + for tree in trees(sides) { + let mut paths: Vec> = ids.iter().filter_map(|id| path_of(tree, *id)).collect(); + if paths.is_empty() { + continue; + } + ensure!( + paths.len() >= 2, + "a side holds only one of the joined regions {ids:?}" + ); + // Child-index paths sort in document order. + paths.sort(); + let parent = &paths[0][..paths[0].len() - 1]; + let first = paths[0][paths[0].len() - 1]; + let adjacent = paths.iter().enumerate().all(|(offset, path)| { + path.len() == paths[0].len() + && &path[..path.len() - 1] == parent + && path[path.len() - 1] == first + offset + }); + ensure!( + adjacent, + "the joined regions {ids:?} are not consecutive siblings" + ); + let parent = parent.to_vec(); + let list = siblings(tree, &parent); + let children: Vec = list.drain(first..first + paths.len()).collect(); + let range = Range { + start: children[0].range.start, + end: children[children.len() - 1].range.end, + }; + let id = fresh.id(); + let fold_state_id = *state.get_or_insert(id); + list.insert( + first, + Region { + id, + fold_state_id, + range, + tags: Vec::new(), + visibility: Visibility::default(), + node: Node::Fold { children }, + }, + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Span; + + fn range(start: u32, end: u32) -> Range { + Range { + start: Position { + line: start, + column: 0, + }, + end: Position { + line: end, + column: 0, + }, + } + } + + /// A leaf whose `fold_state_id` is its `id`. + fn leaf(id: u32, alignment: u32, start: u32, end: u32, changed: &[u32]) -> Region { + Region { + id, + fold_state_id: id, + range: range(start, end), + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { + alignment_id: alignment, + changed: changed + .iter() + .map(|&line| Span { + line, + start_column: 0, + end_column: 1, + }) + .collect(), + }, + } + } + + /// A fold whose `fold_state_id` is its `id`. + fn fold(id: u32, collapsed: bool, children: Vec) -> Region { + Region { + id, + fold_state_id: id, + range: Range { + start: children[0].range.start, + end: children[children.len() - 1].range.end, + }, + tags: vec![], + visibility: Visibility { + collapsed, + label: String::new(), + }, + node: Node::Fold { children }, + } + } + + /// The region sharing another's fold state: the second of a pair. + fn in_state(mut region: Region, state: u32) -> Region { + region.fold_state_id = state; + region + } + + fn source(regions: Vec) -> Source { + Source { + text: String::new(), + regions, + } + } + + fn both(lhs: Vec, rhs: Vec) -> Pairing { + Pairing::Both { + lhs: source(lhs), + rhs: source(rhs), + } + } + + fn run(moves: Vec, sides: &mut Pairing) -> anyhow::Result { + let mut visibility = Visibility::default(); + apply(moves, sides, &mut visibility)?; + Ok(visibility) + } + + type Shape = (u32, Option, u32, u32, u32, bool, String); + + /// `(id, alignment_id, fold_state_id, start, end, collapsed, label)` in + /// preorder. + fn shape(regions: &[Region]) -> Vec { + let mut out = Vec::new(); + walk(regions, &mut |region| { + out.push(( + region.id, + region.alignment_id(), + region.fold_state_id, + region.range.start.line, + region.range.end.line, + region.visibility.collapsed, + region.visibility.label.clone(), + )) + }); + out + } + + fn sides_of(sides: &Pairing) -> (&Source, &Source) { + let Pairing::Both { lhs, rhs } = sides else { + panic!("both sides"); + }; + (lhs, rhs) + } + + #[test] + fn cutting_a_paired_leaf_cuts_both_sides_with_fresh_ids_and_a_shared_alignment() { + // Leaves 2 (lhs) and 3 (rhs) are paired: alignment 1, fold state 2. + // Cutting either side cuts both the same way. + for target in [2, 3] { + let mut sides = both( + vec![leaf(1, 0, 0, 2, &[]), leaf(2, 1, 2, 8, &[3, 6])], + vec![ + in_state(leaf(3, 1, 0, 6, &[1, 4]), 2), + leaf(4, 2, 6, 7, &[]), + ], + ); + let moves = vec![ + Move::Cut { + region: target, + at: 2, + }, + Move::Cut { region: 5, at: 2 }, + ]; + run(moves, &mut sides).unwrap(); + let (lhs, rhs) = sides_of(&sides); + let open = String::new; + assert_eq!( + shape(&lhs.regions), + [ + (1, Some(0), 1, 0, 2, false, open()), + (2, Some(1), 2, 2, 4, false, open()), + (5, Some(3), 5, 4, 6, false, open()), + (7, Some(4), 7, 6, 8, false, open()), + ], + "target {target}" + ); + assert_eq!( + shape(&rhs.regions), + [ + (3, Some(1), 2, 0, 2, false, open()), + (6, Some(3), 5, 2, 4, false, open()), + (8, Some(4), 7, 4, 6, false, open()), + (4, Some(2), 4, 6, 7, false, open()), + ], + "target {target}" + ); + let Node::Leaf { changed, .. } = &lhs.regions[3].node else { + panic!("a leaf"); + }; + assert_eq!( + changed.iter().map(|span| span.line).collect::>(), + [6] + ); + } + } + + #[test] + fn cutting_a_one_sided_leaf_takes_fresh_ids() { + let mut sides = both( + vec![leaf(1, 0, 0, 1, &[]), leaf(2, 1, 1, 6, &[])], + vec![in_state(leaf(3, 0, 0, 1, &[]), 1)], + ); + run(vec![Move::Cut { region: 2, at: 1 }], &mut sides).unwrap(); + let (lhs, rhs) = sides_of(&sides); + assert_eq!( + shape(&lhs.regions), + [ + (1, Some(0), 1, 0, 1, false, String::new()), + (2, Some(1), 2, 1, 2, false, String::new()), + (4, Some(2), 4, 2, 6, false, String::new()), + ] + ); + assert_eq!(rhs.regions.len(), 1); + } + + #[test] + fn set_collapsed_reaches_every_region_in_the_fold_state_and_labels_reach_one() { + // Folds 2 (lhs) and 4 (rhs) are matched. + let mut sides = both( + vec![ + leaf(1, 0, 0, 2, &[]), + fold(2, false, vec![leaf(3, 1, 2, 5, &[])]), + ], + vec![in_state(fold(4, false, vec![leaf(5, 2, 0, 4, &[])]), 2)], + ); + let moves = vec![ + Move::LinkFoldState { + regions: vec![2, 1], + }, + Move::SetCollapsed { + region: 4, + collapsed: true, + }, + Move::SetLabel { + region: 2, + label: Some("summary".to_owned()), + }, + ]; + run(moves, &mut sides).unwrap(); + let (lhs, rhs) = sides_of(&sides); + assert_eq!( + shape(&lhs.regions), + [ + (1, Some(0), 2, 0, 2, true, String::new()), + (2, None, 2, 2, 5, true, "summary".to_owned()), + (3, Some(1), 3, 2, 5, false, String::new()), + ] + ); + assert_eq!( + shape(&rhs.regions)[0], + (4, None, 2, 0, 4, true, String::new()) + ); + run( + vec![ + Move::SetCollapsed { + region: 1, + collapsed: false, + }, + Move::SetLabel { + region: 2, + label: None, + }, + ], + &mut sides, + ) + .unwrap(); + let (lhs, rhs) = sides_of(&sides); + assert!(lhs + .regions + .iter() + .all(|region| region.visibility == Visibility::default())); + assert!(!rhs.regions[0].visibility.collapsed); + } + + #[test] + fn a_link_takes_the_first_regions_fold_state_and_collapsed_state() { + // Folds 1 (lhs) and 5 (rhs) are a matched pair, as are 3 (lhs) and + // 8 (rhs). + let tree = || { + both( + vec![ + fold(1, false, vec![leaf(2, 0, 0, 3, &[])]), + fold(3, true, vec![leaf(4, 1, 3, 6, &[])]), + ], + vec![ + in_state(fold(8, true, vec![leaf(7, 2, 0, 3, &[])]), 3), + in_state(fold(5, false, vec![leaf(6, 3, 3, 6, &[])]), 1), + ], + ) + }; + let mut sides = tree(); + run( + vec![Move::LinkFoldState { + regions: vec![7, 5], + }], + &mut sides, + ) + .unwrap(); + let (lhs, rhs) = sides_of(&sides); + assert_eq!(lhs.regions[0].fold_state_id, 7, "the pair stays together"); + assert_eq!(rhs.regions[1].fold_state_id, 7); + assert_eq!(rhs.regions[0].fold_state_id, 3); + + let mut sides = tree(); + run( + vec![Move::LinkFoldState { + regions: vec![8, 1], + }], + &mut sides, + ) + .unwrap(); + let (lhs, rhs) = sides_of(&sides); + for region in [&lhs.regions[0], &lhs.regions[1], &rhs.regions[1]] { + assert_eq!( + (region.fold_state_id, region.visibility.collapsed), + (3, true) + ); + } + } + + #[test] + fn a_join_listing_both_sides_runs_wraps_each_with_one_fold_state() { + // Folds 1 and 3 on the lhs are matched with 5 and 6 on the rhs; the + // one-line leaves 2 (lhs) and 7 (rhs) between them are paired. + let mut sides = both( + vec![ + fold(1, true, vec![leaf(10, 0, 0, 3, &[])]), + leaf(2, 1, 3, 4, &[]), + fold(3, true, vec![leaf(11, 2, 4, 7, &[])]), + ], + vec![ + leaf(4, 3, 0, 1, &[]), + in_state(fold(5, true, vec![leaf(12, 4, 1, 4, &[])]), 1), + in_state(leaf(7, 1, 4, 5, &[]), 2), + in_state(fold(6, true, vec![leaf(13, 5, 5, 8, &[])]), 3), + ], + ); + let moves = vec![Move::JoinFolds { + regions: vec![1, 2, 3, 5, 7, 6], + }]; + run(moves, &mut sides).unwrap(); + let (lhs, rhs) = sides_of(&sides); + assert_eq!(lhs.regions.len(), 1); + assert_eq!( + shape(&lhs.regions)[0], + (14, None, 14, 0, 7, false, String::new()) + ); + assert_eq!(rhs.regions.len(), 2); + assert_eq!( + shape(&rhs.regions[1..])[0], + (15, None, 14, 1, 8, false, String::new()) + ); + } + + #[test] + fn a_join_wraps_consecutive_siblings_on_each_side_that_holds_them() { + let mut sides = both( + vec![ + leaf(1, 0, 0, 1, &[]), + fold(2, false, vec![leaf(3, 1, 1, 4, &[])]), + leaf(4, 2, 4, 5, &[]), + fold(5, true, vec![leaf(6, 3, 5, 8, &[])]), + ], + vec![in_state(leaf(7, 0, 0, 1, &[]), 1)], + ); + let moves = vec![Move::JoinFolds { + regions: vec![2, 4, 5], + }]; + run(moves, &mut sides).unwrap(); + let (lhs, rhs) = sides_of(&sides); + assert_eq!(lhs.regions.len(), 2); + assert_eq!( + shape(&lhs.regions[1..])[0], + (8, None, 8, 1, 8, false, String::new()) + ); + assert_eq!(rhs.regions.len(), 1); + } + + #[test] + fn moves_that_cannot_be_carried_out_are_errors() { + let tree = || { + both( + vec![ + leaf(1, 0, 0, 1, &[]), + fold(2, false, vec![leaf(3, 1, 1, 4, &[])]), + leaf(4, 2, 4, 5, &[]), + ], + vec![ + in_state(leaf(5, 0, 0, 1, &[]), 1), + in_state(leaf(6, 2, 1, 2, &[]), 4), + ], + ) + }; + let error = |next: Move| run(vec![next], &mut tree()).unwrap_err().to_string(); + assert!(error(Move::SetCollapsed { + region: 9, + collapsed: true + }) + .contains("no region 9")); + assert!(error(Move::Cut { region: 3, at: 3 }).contains("not inside region 3")); + assert!(error(Move::Cut { region: 3, at: 0 }).contains("not inside region 3")); + assert!(error(Move::Cut { region: 2, at: 1 }).contains("is a fold")); + assert!(error(Move::Cut { + region: ROOT, + at: 1 + }) + .contains("the file cannot be cut")); + assert!(error(Move::JoinFolds { + regions: vec![1, 4] + }) + .contains("not consecutive")); + assert!(error(Move::JoinFolds { + regions: vec![1, 2, 99] + }) + .contains("no region 99")); + assert!(error(Move::JoinFolds { + regions: vec![ROOT, 1] + }) + .contains("cannot include the file")); + assert!(error(Move::JoinFolds { + regions: vec![1, 2, 5] + }) + .contains("holds only one")); + assert!(error(Move::LinkFoldState { regions: vec![1] }).contains("at least two")); + assert!(error(Move::LinkFoldState { + regions: vec![1, 1] + }) + .contains("twice")); + assert!(error(Move::SetTags { + region: ROOT, + tags: vec![] + }) + .contains("manifest")); + } + + #[test] + fn the_root_is_the_file() { + let mut sides = both( + vec![leaf(1, 0, 0, 1, &[])], + vec![in_state(leaf(2, 0, 0, 1, &[]), 1)], + ); + let visibility = run( + vec![ + Move::SetLabel { + region: ROOT, + label: Some("first".to_owned()), + }, + Move::SetCollapsed { + region: ROOT, + collapsed: true, + }, + Move::SetLabel { + region: ROOT, + label: Some("Generated file".to_owned()), + }, + Move::SetTags { + region: 2, + tags: vec!["mine:tag".to_owned()], + }, + ], + &mut sides, + ) + .unwrap(); + assert_eq!( + visibility, + Visibility { + collapsed: true, + label: "Generated file".to_owned() + } + ); + let (lhs, rhs) = sides_of(&sides); + assert_eq!(lhs.regions[0].visibility, Visibility::default()); + assert_eq!(rhs.regions[0].tags, ["mine:tag"]); + } +} diff --git a/crates/diffr-plugin-sdk/src/draft.rs b/crates/diffr-plugin-sdk/src/draft.rs new file mode 100644 index 000000000..77f4b25fd --- /dev/null +++ b/crates/diffr-plugin-sdk/src/draft.rs @@ -0,0 +1,226 @@ +//! A plugin's moves, carried out on a copy of the region trees as the plugin +//! makes them. The copy lets a plugin name what its own moves create (the +//! piece a cut leaves, the fold a join adds) with the ids the applier will +//! give them, since both hand out ids the same way. +//! +//! The helpers here are the moves the bundled plugins make together: +//! collapsing a region under a label, linking regions, cutting lines out of +//! a leaf, and grouping siblings under a labelled fold. +use crate::apply::{find, trees_ref, Applier}; +use crate::tree::{siblings_of, walk, Node, Pairing, Region, Source}; +use crate::types::{Move, Visibility}; +use anyhow::{anyhow, ensure}; + +pub struct Draft { + /// The trees as the moves so far leave them. Only regions are copied. + sides: Pairing, + file: Visibility, + applier: Applier, + moves: Vec, +} + +impl Draft { + pub fn new(sides: &Pairing) -> Self { + let sides = sides.clone().map(|source| Source { + text: String::new(), + regions: source.regions, + }); + Self { + applier: Applier::new(&sides), + sides, + file: Visibility::default(), + moves: Vec::new(), + } + } + + /// Carry out `next` on the copy and keep it. A move the applier refuses + /// is an error, as it would be in the pipeline. + pub fn push(&mut self, next: Move) -> anyhow::Result<()> { + self.applier + .apply(next.clone(), &mut self.sides, &mut self.file)?; + self.moves.push(next); + Ok(()) + } + + pub fn into_moves(self) -> Vec { + self.moves + } + + fn region(&self, id: u32) -> anyhow::Result<&Region> { + trees_ref(&self.sides) + .into_iter() + .find_map(|tree| find(tree, id)) + .ok_or_else(|| anyhow!("no region {id}")) + } + + fn regions(&self, visit: &mut impl FnMut(&Region)) { + for tree in trees_ref(&self.sides) { + walk(tree, visit); + } + } + + /// The leaf on the other side whose rows pair with the leaf `id`. + pub fn paired_leaf(&self, id: u32) -> anyhow::Result> { + let alignment = self.region(id)?.alignment_id(); + let mut paired = None; + self.regions(&mut |region| { + if region.id != id && alignment.is_some() && region.alignment_id() == alignment { + paired = Some(region.id); + } + }); + Ok(paired) + } + + /// Start `id` collapsed behind `label`, and with it every region in its + /// fold state. The leaf paired with a leaf `id` takes the label too. Any + /// other region in the fold state that was open loses its label, so it + /// reads as part of `id`'s row rather than as its own placeholder. + pub fn collapse(&mut self, id: u32, label: String) -> anyhow::Result<()> { + let target = self.region(id)?; + let (state, alignment) = (target.fold_state_id, target.alignment_id()); + let (mut named, mut cleared) = (vec![id], Vec::new()); + self.regions(&mut |region| { + if region.fold_state_id != state || region.id == id { + return; + } + if alignment.is_some() && region.alignment_id() == alignment { + named.push(region.id); + } else if !region.visibility.collapsed && !region.visibility.label.is_empty() { + cleared.push(region.id); + } + }); + self.push(Move::SetCollapsed { + region: id, + collapsed: true, + })?; + for region in named { + self.push(Move::SetLabel { + region, + label: Some(label.clone()), + })?; + } + for region in cleared { + self.push(Move::SetLabel { + region, + label: None, + })?; + } + Ok(()) + } + + /// Open and close `ids` together, collapsed if any region in their fold + /// states starts collapsed. A region that was open and so starts + /// collapsed loses its label, as in [`Draft::collapse`]. + pub fn link(&mut self, ids: &[u32]) -> anyhow::Result<()> { + let states = ids + .iter() + .map(|id| Ok(self.region(*id)?.fold_state_id)) + .collect::>>()?; + let first_collapsed = self.region(ids[0])?.visibility.collapsed; + let (mut collapsed, mut cleared) = (false, Vec::new()); + self.regions(&mut |region| { + if !states.contains(®ion.fold_state_id) { + return; + } + collapsed |= region.visibility.collapsed; + if !region.visibility.collapsed && !region.visibility.label.is_empty() { + cleared.push(region.id); + } + }); + self.push(Move::LinkFoldState { + regions: ids.to_vec(), + })?; + if !collapsed { + return Ok(()); + } + if !first_collapsed { + self.push(Move::SetCollapsed { + region: ids[0], + collapsed: true, + })?; + } + for region in cleared { + self.push(Move::SetLabel { + region, + label: None, + })?; + } + Ok(()) + } + + /// Cut the lines `start..end`, relative to the leaf `id`'s first line + /// and half-open, out of it (and so out of the leaf paired with it), + /// returning the `id` of the piece on `id`'s side that holds them. The + /// leaf keeps its `id` for the lines before `start`. + pub fn cut_lines(&mut self, id: u32, start: u32, end: u32) -> anyhow::Result { + let leaf = self.region(id)?; + ensure!( + leaf.alignment_id().is_some(), + "region {id} is a fold; only a leaf can be cut to lines" + ); + let len = leaf.range.lines().len() as u32; + ensure!( + start < end && end <= len, + "lines {start}..{end} are outside region {id}, which has {len} lines" + ); + let mut piece = id; + if start > 0 { + self.push(Move::Cut { + region: id, + at: start, + })?; + piece = self.next_sibling(id)?; + } + if end < len { + self.push(Move::Cut { + region: piece, + at: end - start, + })?; + } + Ok(piece) + } + + /// The `id` of the region just after `id` among its siblings. + fn next_sibling(&self, id: u32) -> anyhow::Result { + trees_ref(&self.sides) + .into_iter() + .find_map(|tree| { + let siblings = siblings_of(tree, id)?; + let index = siblings.iter().position(|region| region.id == id)?; + siblings.get(index + 1).map(|region| region.id) + }) + .ok_or_else(|| anyhow!("region {id} has no next sibling")) + } + + /// Wrap `ids`, consecutive siblings on each side that holds them, in a + /// new fold on each such side, starting collapsed behind `label`. + pub fn group(&mut self, ids: Vec, label: String) -> anyhow::Result<()> { + self.push(Move::JoinFolds { + regions: ids.clone(), + })?; + let mut folds = Vec::new(); + for tree in trees_ref(&self.sides) { + let mut parent = None; + walk(tree, &mut |region| { + if let Node::Fold { children } = ®ion.node { + if children.iter().any(|child| ids.contains(&child.id)) { + parent = Some(region.id); + } + } + }); + folds.extend(parent); + } + let first = *folds.first().expect("a join adds a fold on some side"); + self.push(Move::SetCollapsed { + region: first, + collapsed: true, + })?; + for region in folds { + self.push(Move::SetLabel { + region, + label: Some(label.clone()), + })?; + } + Ok(()) + } +} diff --git a/crates/diffr-plugin-sdk/src/guest.rs b/crates/diffr-plugin-sdk/src/guest.rs new file mode 100644 index 000000000..1ab4a8edd --- /dev/null +++ b/crates/diffr-plugin-sdk/src/guest.rs @@ -0,0 +1,111 @@ +//! The component side of [`crate::export!`]: the `plugin` resource over a +//! [`Plugin`], with the generated guest bindings' records converted to the +//! contract's Rust records and back around each call. +use crate::bindings::diffr::plugin::types as wit; +use crate::bindings::exports::diffr::plugin::guest; +use crate::types; +use crate::Plugin; + +/// One instance of the plugin `P`, the component's `plugin` resource. +pub struct Instance

(P); + +impl guest::GuestPlugin for Instance

{ + fn new(options: String) -> Result { + let options: P::Options = + serde_json::from_str(&options).map_err(|error| format!("invalid options: {error}"))?; + let plugin = P::new(options).map_err(|error| format!("{error:#}"))?; + Ok(guest::Plugin::new(Instance(plugin))) + } + + fn classify(&self, file: wit::FileEntry) -> Result, String> { + self.0 + .classify(&file_entry(file)) + .map_err(|error| format!("{error:#}")) + } + + fn mutate( + &self, + file: wit::FileEntry, + lhs: Option, + rhs: Option, + ) -> Result, String> { + let lhs = lhs.map(source); + let rhs = rhs.map(source); + self.0 + .mutate(&file_entry(file), lhs.as_ref(), rhs.as_ref()) + .map(|moves| moves.into_iter().map(lower).collect()) + .map_err(|error| format!("{error:#}")) + } +} + +fn file_entry(file: wit::FileEntry) -> types::FileEntry { + types::FileEntry { + path: file.path, + old_path: file.old_path, + status: match file.status { + wit::FileStatus::Added => types::FileStatus::Added, + wit::FileStatus::Deleted => types::FileStatus::Deleted, + wit::FileStatus::Modified => types::FileStatus::Modified, + wit::FileStatus::Renamed => types::FileStatus::Renamed, + wit::FileStatus::Copied => types::FileStatus::Copied, + wit::FileStatus::TypeChanged => types::FileStatus::TypeChanged, + }, + tags: file.tags, + } +} + +fn source(side: wit::Source) -> types::Source { + let position = |position: wit::Position| types::Position { + line: position.line, + column: position.column, + }; + types::Source { + text: side.text, + regions: side + .regions + .into_iter() + .map(|region| types::Region { + id: region.id, + parent: region.parent, + fold_state_id: region.fold_state_id, + range: types::Range { + start: position(region.range.start), + end: position(region.range.end), + }, + tags: region.tags, + visibility: types::Visibility { + collapsed: region.visibility.collapsed, + label: region.visibility.label, + }, + kind: match region.kind { + wit::Kind::Leaf(leaf) => types::Kind::Leaf(types::Leaf { + alignment_id: leaf.alignment_id, + changed: leaf + .changed + .into_iter() + .map(|span| types::Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), + }), + wit::Kind::Fold => types::Kind::Fold, + }, + }) + .collect(), + } +} + +fn lower(next: types::Move) -> wit::Move { + match next { + types::Move::Cut { region, at } => wit::Move::Cut(wit::Cut { region, at }), + types::Move::JoinFolds { regions } => wit::Move::JoinFolds(regions), + types::Move::LinkFoldState { regions } => wit::Move::LinkFoldState(regions), + types::Move::SetCollapsed { region, collapsed } => { + wit::Move::SetCollapsed((region, collapsed)) + } + types::Move::SetLabel { region, label } => wit::Move::SetLabel((region, label)), + types::Move::SetTags { region, tags } => wit::Move::SetTags((region, tags)), + } +} diff --git a/crates/diffr-plugin-sdk/src/host.rs b/crates/diffr-plugin-sdk/src/host.rs new file mode 100644 index 000000000..ecc4ed0fb --- /dev/null +++ b/crates/diffr-plugin-sdk/src/host.rs @@ -0,0 +1,112 @@ +//! What diffr gives every plugin: `read_head`, `git` and `log`, the `host` +//! interface of `wit/plugin.wit`. A plugin calls these functions the same +//! way wherever it runs. In a component they call the imports. Natively, +//! diffr runs each call of a plugin inside [`scope`], with its own +//! implementation of [`Host`], and the functions call that. +use crate::types::Side; + +/// Up to `max_bytes` from the start of one side of the file: during +/// `classify` the blob (or working-tree file), none for a side the file does +/// not have or one that is not a regular file; during `mutate` the side's +/// text as diffed; during `new` none. +pub fn read_head(side: Side, max_bytes: u32) -> Option> { + imp::read_head(side, max_bytes) +} + +/// Run `git` with `args` in the repository's working directory: its stdout +/// when it exits successfully, its stderr otherwise. +pub fn git(args: &[String]) -> Result { + imp::git(args) +} + +/// Write a line to diffr's stderr, prefixed with the plugin's name. +pub fn log(message: &str) { + imp::log(message) +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use crate::bindings::diffr::plugin::{host, types}; + use crate::types::Side; + + pub(super) fn read_head(side: Side, max_bytes: u32) -> Option> { + let side = match side { + Side::Lhs => types::Side::Lhs, + Side::Rhs => types::Side::Rhs, + }; + host::read_head(side, max_bytes) + } + + pub(super) fn git(args: &[String]) -> Result { + host::git(args) + } + + pub(super) fn log(message: &str) { + host::log(message) + } +} + +/// diffr's implementation of the host functions for one call of a native +/// plugin. A failure of the host itself (git cannot be started, a blob +/// cannot be read) is diffr's to report: it fails the call once the plugin +/// returns, as a trap fails a component's call. +#[cfg(not(target_arch = "wasm32"))] +pub trait Host { + fn read_head(&self, side: Side, max_bytes: u32) -> Option>; + fn git(&self, args: &[String]) -> Result; + fn log(&self, message: &str); +} + +/// Run `call`, one call of a native plugin, with `host` behind the host +/// functions on this thread. +#[cfg(not(target_arch = "wasm32"))] +pub fn scope(host: std::rc::Rc, call: impl FnOnce() -> R) -> R { + imp::scope(host, call) +} + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use super::Host; + use crate::types::Side; + use std::cell::RefCell; + use std::rc::Rc; + + thread_local! { + static CURRENT: RefCell>> = const { RefCell::new(None) }; + } + + /// Restores the host of the enclosing scope, if any, when a scope ends. + struct Restore(Option>); + + impl Drop for Restore { + fn drop(&mut self) { + CURRENT.with(|current| *current.borrow_mut() = self.0.take()); + } + } + + pub(super) fn scope(host: Rc, call: impl FnOnce() -> R) -> R { + let _restore = Restore(CURRENT.with(|current| current.borrow_mut().replace(host))); + call() + } + + fn current() -> Rc { + CURRENT.with(|current| { + current + .borrow() + .clone() + .expect("a host function is called only while diffr runs a plugin") + }) + } + + pub(super) fn read_head(side: Side, max_bytes: u32) -> Option> { + current().read_head(side, max_bytes) + } + + pub(super) fn git(args: &[String]) -> Result { + current().git(args) + } + + pub(super) fn log(message: &str) { + current().log(message) + } +} diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs new file mode 100644 index 000000000..7f2f23355 --- /dev/null +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -0,0 +1,102 @@ +//! The diffr plugin contract in Rust. `wit/plugin.wit` in the diffr +//! repository is the contract; this crate is its one Rust form, shared by +//! every plugin whether diffr compiles it in or runs it as a WASM component. +//! +//! - [`types`] holds the contract's records: the file entry, each side's +//! flat preorder region list with parent ids and text, and the moves. +//! - [`Plugin`] is the one trait every plugin implements: `new`, which makes +//! it from its options, then `classify` and `mutate`, taking and returning +//! exactly those records. +//! - [`host`] holds what diffr gives every plugin: `read_head`, `git` and +//! `log`. +//! - [`export!`] makes a plugin the `plugin` resource a component exports +//! when the crate is built for `wasm32-wasip2`, and expands to nothing +//! otherwise, where diffr's native registry calls the trait directly. The same source +//! builds both ways. +//! +//! Plugins reason about regions with the same code: +//! +//! - [`tree`] rebuilds a side's list as a tree ([`tree::sides`]) and holds +//! the helpers for reading trees ([`walk`], [`OtherSide`], [`one_sided`], +//! and the rest). +//! - [`apply`] carries moves out. diffr carries every plugin's moves out with +//! it, so [`Draft`], which carries a plugin's moves out on a copy as it +//! makes them, and [`apply::Fresh`], which predicts fresh ids, give a +//! plugin exactly the ids diffr will. +pub mod apply; +pub mod draft; +pub mod host; +pub mod tree; +pub mod types; + +pub use anyhow; +pub use draft::Draft; +use serde::de::DeserializeOwned; +pub use tree::{ + before_and_after_ids, has_tag, is_fold, line_count, one_sided, path_to, + siblings_of, sides_with_other_ids, walk, walk_mut, Node, OtherSide, Pairing, Region, Source, +}; +pub use types::{FileEntry, FileStatus, Move, Position, Range, Side, Span, Visibility, ROOT}; + +/// A diffr plugin: the `plugin` resource of `wit/plugin.wit`. diffr makes one +/// with [`Plugin::new`] when it builds its pipeline, before any file, and +/// calls that one instance for every file of the run. +pub trait Plugin: Sized { + /// The plugin's options, deserialized from its entry in + /// `[plugins.]`: a JSON object, validated against the options + /// schema in `plugin.toml` and filled with its defaults. + type Options: DeserializeOwned; + + /// Make the plugin from its options. An error, like options that do not + /// deserialize, is a setup error naming the plugin. + fn new(options: Self::Options) -> anyhow::Result; + + /// Tags to add to the file's manifest entry before it is diffed. A + /// plugin that does not classify returns none. + fn classify(&self, file: &FileEntry) -> anyhow::Result>; + + /// The moves that shape how the diffed file starts out. `lhs` and `rhs` + /// are the sides the file has. + fn mutate( + &self, + file: &FileEntry, + lhs: Option<&types::Source>, + rhs: Option<&types::Source>, + ) -> anyhow::Result>; +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod bindings { + wit_bindgen::generate!({ + path: "../../wit", + world: "plugin", + pub_export_macro: true, + default_bindings_module: "diffr_plugin_sdk::bindings", + }); +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod guest; + +/// Export a [`Plugin`] as the component's `plugin` resource when the crate +/// is built for `wasm32`: the resource's `new` deserializes the options +/// string into [`Plugin::Options`] and calls [`Plugin::new`], and its +/// `classify` and `mutate` call the instance. Built for anything else it +/// expands to nothing: diffr calls the trait itself. +#[macro_export] +macro_rules! export { + ($plugin:ty) => { + #[cfg(target_arch = "wasm32")] + const _: () = { + struct DiffrPluginExport; + + impl $crate::bindings::exports::diffr::plugin::guest::Guest for DiffrPluginExport { + type Plugin = $crate::guest::Instance<$plugin>; + } + + $crate::bindings::export!(DiffrPluginExport with_types_in $crate::bindings); + }; + }; +} diff --git a/crates/diffr-plugin-sdk/src/tree.rs b/crates/diffr-plugin-sdk/src/tree.rs new file mode 100644 index 000000000..aa95b78f2 --- /dev/null +++ b/crates/diffr-plugin-sdk/src/tree.rs @@ -0,0 +1,443 @@ +//! Region trees: each side's flat preorder list rebuilt as a tree, the +//! records rebuilt from a tree, and the helpers plugins read trees with. +use crate::types::{self, Range, Span, Visibility, ROOT}; +use std::collections::BTreeSet; + +/// Which sides a thing exists on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pairing { + Both { lhs: T, rhs: T }, + LeftOnly { lhs: T }, + RightOnly { rhs: T }, +} + +impl Pairing { + /// The same sides, each value transformed by `f`. + pub 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, + } + } + + /// Every side that exists, lhs first. + pub fn sides(&self) -> Vec<&T> { + match self { + Self::Both { lhs, rhs } => vec![lhs, rhs], + Self::LeftOnly { lhs } => vec![lhs], + Self::RightOnly { rhs } => vec![rhs], + } + } + + /// Every side that exists, lhs first. + pub fn sides_mut(&mut self) -> Vec<&mut T> { + match self { + Self::Both { lhs, rhs } => vec![lhs, rhs], + Self::LeftOnly { lhs } => vec![lhs], + Self::RightOnly { rhs } => vec![rhs], + } + } +} + +/// One region of a side's tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Region { + pub id: u32, + pub fold_state_id: u32, + pub range: Range, + pub tags: Vec, + pub visibility: Visibility, + pub node: Node, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Node { + Leaf { + alignment_id: u32, + changed: Vec, + }, + Fold { + children: Vec, + }, +} + +impl Region { + /// A leaf's row alignment; a fold has none. + pub fn alignment_id(&self) -> Option { + match self.node { + Node::Leaf { alignment_id, .. } => Some(alignment_id), + Node::Fold { .. } => None, + } + } +} + +/// One side of the diffed file as a tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Source { + pub text: String, + /// The largest regions, in order. + pub regions: Vec, +} + +impl Source { + /// Rebuild a side's tree from its preorder list with parent ids. + pub fn from_record(side: &types::Source) -> anyhow::Result { + fn children( + regions: &mut std::iter::Peekable>, + parent: u32, + ) -> Vec { + let mut out = Vec::new(); + while let Some(region) = regions.next_if(|region| region.parent == parent) { + let node = match ®ion.kind { + types::Kind::Leaf(leaf) => Node::Leaf { + alignment_id: leaf.alignment_id, + changed: leaf.changed.clone(), + }, + types::Kind::Fold => Node::Fold { + children: children(regions, region.id), + }, + }; + out.push(Region { + id: region.id, + fold_state_id: region.fold_state_id, + range: region.range, + tags: region.tags.clone(), + visibility: region.visibility.clone(), + node, + }); + } + out + } + let mut regions = side.regions.iter().peekable(); + let tree = children(&mut regions, ROOT); + if let Some(stray) = regions.next() { + anyhow::bail!( + "region {} names parent {}, which does not precede it", + stray.id, + stray.parent + ); + } + Ok(Self { + text: side.text.clone(), + regions: tree, + }) + } + + /// The side as a record: the tree flattened in preorder with parent ids. + pub fn to_record(&self) -> types::Source { + fn flatten(regions: &[Region], parent: u32, out: &mut Vec) { + for region in regions { + let kind = match ®ion.node { + Node::Leaf { + alignment_id, + changed, + } => types::Kind::Leaf(types::Leaf { + alignment_id: *alignment_id, + changed: changed.clone(), + }), + Node::Fold { .. } => types::Kind::Fold, + }; + out.push(types::Region { + id: region.id, + parent, + fold_state_id: region.fold_state_id, + range: region.range, + tags: region.tags.clone(), + visibility: region.visibility.clone(), + kind, + }); + if let Node::Fold { children } = ®ion.node { + flatten(children, region.id, out); + } + } + } + let mut regions = Vec::new(); + flatten(&self.regions, ROOT, &mut regions); + types::Source { + text: self.text.clone(), + regions, + } + } +} + +/// The sides `mutate` receives, as trees. diffr passes at least one side. +pub fn sides( + lhs: Option<&types::Source>, + rhs: Option<&types::Source>, +) -> anyhow::Result> { + Ok( + match ( + lhs.map(Source::from_record).transpose()?, + rhs.map(Source::from_record).transpose()?, + ) { + (Some(lhs), Some(rhs)) => Pairing::Both { lhs, rhs }, + (Some(lhs), None) => Pairing::LeftOnly { lhs }, + (None, Some(rhs)) => Pairing::RightOnly { rhs }, + (None, None) => anyhow::bail!("diffr passed a file with no sides"), + }, + ) +} + +// ── region helpers ──────────────────────────────────────────────────────── + +pub 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); + } + } +} + +pub 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); + } + } +} + +/// What the other side holds, to tell one side's paired regions from +/// one-sided ones. A leaf is paired when the other side holds its +/// `alignment_id`: its rows line up with a leaf there. A fold is paired when +/// a region on the other side shares its `fold_state_id`: the fold the +/// matcher paired it with, or a region linked to that fold. Only the other +/// side counts, since a link merges fold states within a side as well. +#[derive(Default)] +pub struct OtherSide { + leaf_ids: BTreeSet, + fold_state_ids: BTreeSet, +} + +impl OtherSide { + /// What the other side, whose regions are `other`, holds. + pub fn of(other: &[Region]) -> Self { + let mut side = Self::default(); + walk(other, &mut |region| { + if let Some(alignment_id) = region.alignment_id() { + side.leaf_ids.insert(alignment_id); + } + side.fold_state_ids.insert(region.fold_state_id); + }); + side + } + + /// Whether `region`, on the side opposite this one, is paired. + pub fn pairs(&self, region: &Region) -> bool { + match region.node { + Node::Leaf { alignment_id, .. } => self.leaf_ids.contains(&alignment_id), + Node::Fold { .. } => self.fold_state_ids.contains(®ion.fold_state_id), + } + } +} + +/// True when no leaf under `region`, itself included, is paired with the +/// other side. +/// +/// Newness is the leaves' answer. A fold covers its body alone — the +/// header line that opens it belongs to the leaf before it — so the leaves +/// inside a body fold are the body, and a body grown in place, whose lines +/// still align, is a rewrite rather than a removal. A fold state, on the +/// other hand, says only whether the matcher paired the two nodes, and a +/// file diffed by line pairs no folds at all while its lines still align. +/// So a fold is new, or deleted, exactly when nothing inside it lines up +/// with the other side. +pub fn one_sided(region: &Region, other: &OtherSide) -> bool { + let mut paired = false; + walk(std::slice::from_ref(region), &mut |inner| { + paired |= matches!(inner.node, Node::Leaf { .. }) && other.pairs(inner); + }); + !paired +} + +/// The lines a region spans: for a fold, exactly the lines collapsing it +/// hides. A body fold spans its body alone, starting the line after the +/// `{` or `:` that opens it. +pub fn line_count(region: &Region) -> usize { + region.range.lines().len() +} + +pub fn is_fold(region: &Region) -> bool { + matches!(region.node, Node::Fold { .. }) +} + +pub fn has_tag(region: &Region, tag: &str) -> bool { + region.tags.iter().any(|own| own == tag) +} + +/// Child indices from the root down to the region with this `id`. +pub fn path_to(regions: &[Region], id: u32) -> Option> { + for (index, region) in regions.iter().enumerate() { + if region.id == id { + return Some(vec![index]); + } + if let Node::Fold { children } = ®ion.node { + if let Some(mut path) = path_to(children, id) { + path.insert(0, index); + return Some(path); + } + } + } + None +} + +/// The sibling list holding the region with this `id`. +pub fn siblings_of(regions: &[Region], id: u32) -> Option<&[Region]> { + if regions.iter().any(|region| region.id == id) { + return Some(regions); + } + regions.iter().find_map(|region| match ®ion.node { + Node::Fold { children } => siblings_of(children, id), + Node::Leaf { .. } => None, + }) +} + +/// The before side, and what the after side holds. +pub fn before_and_after_ids(sides: &Pairing) -> Option<(&Source, OtherSide)> { + match sides { + Pairing::Both { lhs, rhs } => Some((lhs, OtherSide::of(&rhs.regions))), + Pairing::LeftOnly { lhs } => Some((lhs, OtherSide::default())), + Pairing::RightOnly { .. } => None, + } +} + +/// Each side that exists, with what the other side holds. +pub fn sides_with_other_ids(sides: &Pairing) -> Vec<(&Source, OtherSide)> { + match sides { + Pairing::Both { lhs, rhs } => vec![ + (lhs, OtherSide::of(&rhs.regions)), + (rhs, OtherSide::of(&lhs.regions)), + ], + Pairing::LeftOnly { lhs } => vec![(lhs, OtherSide::default())], + Pairing::RightOnly { rhs } => vec![(rhs, OtherSide::default())], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Position; + + /// A one-line leaf, or a fold holding one, with `id`. A leaf's + /// `alignment_id` is `alignment`; the fold's child takes ids of its own. + fn region(id: u32, alignment: u32, fold_state_id: u32, line: u32, fold: bool) -> Region { + let range = Range { + start: Position { line, column: 0 }, + end: Position { + line: line + 1, + column: 0, + }, + }; + let leaf = Region { + id, + fold_state_id, + range, + tags: vec![], + visibility: Visibility::default(), + node: Node::Leaf { + alignment_id: alignment, + changed: vec![], + }, + }; + if !fold { + return leaf; + } + Region { + node: Node::Fold { + children: vec![Region { + id: id + 100, + fold_state_id: id + 100, + node: Node::Leaf { + alignment_id: alignment + 100, + changed: vec![], + }, + ..leaf.clone() + }], + }, + ..leaf + } + } + + #[test] + fn leaves_pair_by_alignment_id_and_folds_by_fold_state_on_the_other_side() { + // lhs: a paired leaf (1), a matched fold (2), and an unmatched fold + // (3) linked on this side with a leaf (4). rhs: the leaf paired with + // 1, which shares its alignment id (5), and the fold matched with 2, + // which shares its fold state (6). + let lhs = vec![ + region(1, 1, 1, 0, false), + region(2, 2, 2, 1, true), + region(3, 3, 3, 2, true), + region(4, 4, 3, 3, false), + ]; + let rhs = vec![region(5, 1, 1, 0, false), region(6, 6, 2, 1, true)]; + let from_lhs = OtherSide::of(&rhs); + let from_rhs = OtherSide::of(&lhs); + assert!(from_lhs.pairs(&lhs[0])); + assert!(from_lhs.pairs(&lhs[1]), "the matched fold is paired"); + assert!( + !from_lhs.pairs(&lhs[2]), + "a fold state shared only on its own side does not pair" + ); + assert!( + !from_lhs.pairs(&lhs[3]), + "a leaf pairs by alignment id alone" + ); + assert!(from_rhs.pairs(&rhs[0])); + assert!(from_rhs.pairs(&rhs[1])); + } + + #[test] + fn newness_comes_from_the_leaves_not_the_fold_state() { + // A fold matched across sides (both in fold state 2) whose leaves + // align with nothing there: nothing inside it survived, so it is + // one-sided, whatever the matcher made of the two nodes. + let moved = region(2, 2, 2, 1, true); + let elsewhere = region(6, 6, 2, 1, true); + let other = OtherSide::of(std::slice::from_ref(&elsewhere)); + assert!(other.pairs(&moved), "the fold itself is matched"); + assert!(one_sided(&moved, &other)); + // The same fold, with a leaf inside it that does align: a rewrite. + let kept = region(6, 2, 9, 1, true); + let other = OtherSide::of(std::slice::from_ref(&kept)); + assert!(!other.pairs(&moved), "the folds are not matched"); + assert!(!one_sided(&moved, &other)); + } + + #[test] + fn a_tree_survives_the_round_trip_through_its_record() { + let source = Source { + text: "a\nb\nc\n".to_owned(), + regions: vec![region(1, 1, 1, 0, false), region(2, 2, 2, 1, true)], + }; + let record = source.to_record(); + assert_eq!( + record + .regions + .iter() + .map(|region| (region.id, region.parent)) + .collect::>(), + [(1, ROOT), (2, ROOT), (102, 2)] + ); + assert_eq!(Source::from_record(&record).unwrap(), source); + } +} diff --git a/crates/diffr-plugin-sdk/src/types.rs b/crates/diffr-plugin-sdk/src/types.rs new file mode 100644 index 000000000..d6ed96b03 --- /dev/null +++ b/crates/diffr-plugin-sdk/src/types.rs @@ -0,0 +1,144 @@ +//! The records of `wit/plugin.wit`'s `types` interface, as plain Rust. A +//! plugin receives and returns exactly these, natively and in a component; +//! in a component the export macro converts them to and from the generated +//! guest bindings. + +/// The before (`Lhs`) or after (`Rhs`) side of a comparison. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Lhs, + Rhs, +} + +/// Git's delta status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileStatus { + Added, + Deleted, + Modified, + Renamed, + Copied, + TypeChanged, +} + +/// One changed file, as its manifest entry describes it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileEntry { + /// The path on the after side, or on the before side when the file was + /// deleted. + pub path: String, + /// The path on the before side, when the file has one. + pub old_path: Option, + pub status: FileStatus, + /// Sorted and deduplicated. During `classify`, the tags so far. + pub tags: Vec, +} + +/// 0-based line, and 0-based byte column in the UTF-8 text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Position { + pub line: u32, + pub column: u32, +} + +/// Half-open. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Range { + pub start: Position, + pub end: Position, +} + +impl Range { + /// The lines this range touches, half-open. A range ending at column + /// zero does not touch its end line. + pub fn lines(&self) -> std::ops::Range { + let end = if self.end.column == 0 { + self.end.line + } else { + self.end.line + 1 + }; + self.start.line..end + } +} + +/// Bytes painted as changed on one line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span { + pub line: u32, + pub start_column: u32, + pub end_column: u32, +} + +/// How a region starts out: collapsed or open, and the label shown while it +/// is collapsed (empty for none). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Visibility { + pub collapsed: bool, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Leaf { + /// Row alignment: the leaf on the other side with the same value lines + /// up with this one. + pub alignment_id: u32, + pub changed: Vec, +} + +/// A leaf tiles the file; a fold's range is the hull of its children. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Kind { + Leaf(Leaf), + Fold, +} + +/// One region of a side's tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Region { + /// Unique across both sides of the file, never [`ROOT`]. + pub id: u32, + /// The fold that holds this region, or [`ROOT`] for a top-level region. + pub parent: u32, + /// Regions sharing it open and close together, on either side. + pub fold_state_id: u32, + pub range: Range, + /// The tags the fold queries set, `:`. + pub tags: Vec, + pub visibility: Visibility, + pub kind: Kind, +} + +/// One side of a diffed file. `regions` lists the tree in preorder: a fold +/// comes before its children, and siblings keep their order. A binary file's +/// sides have empty text and no regions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Source { + pub text: String, + pub regions: Vec, +} + +/// The id that names the file itself rather than a region. +pub const ROOT: u32 = 0; + +/// What a plugin asks for. A region is named by its id, or the file by +/// [`ROOT`] where a move allows it; `wit/plugin.wit` documents each move and +/// how fresh ids are handed out, and [`crate::apply`] carries them out. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Move { + /// Split a leaf, and the leaf paired with it, at a line offset relative + /// to its first line. + Cut { region: u32, at: u32 }, + /// Wrap two or more consecutive siblings on each side that holds them in + /// a new open, unlabelled fold without tags. + JoinFolds { regions: Vec }, + /// Every region in the listed regions' fold states takes the first + /// region's fold state id and collapsed state. + LinkFoldState { regions: Vec }, + /// Collapse or open every region sharing the region's fold state, or + /// hide or show the file. + SetCollapsed { region: u32, collapsed: bool }, + /// Set or clear the label of one region, or of the file. + SetLabel { region: u32, label: Option }, + /// Replace one region's tags. + SetTags { region: u32, tags: Vec }, +} diff --git a/docs/cli.md b/docs/cli.md index c30581076..c735fb3c0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -30,7 +30,8 @@ files, implies change exit status, and does not yet support metadata options. Structural output uses the existing terminal renderer. `--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. -`-U N` selects ordinary context padding. Matching limits, `--ignore-comments`, +`-U N` sets the unchanged lines kept around each change, overriding +`plugins.context.lines` (default 3). Matching limits, `--ignore-comments`, color, width and inline/split display remain configurable; see `--help`. Configuration comes from the global file, command-line flags and git diff --git a/docs/config.md b/docs/config.md index 5c9756080..6fd78218b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -6,7 +6,8 @@ diffr's configuration comes from exactly three places: `~/.config/diffr/config.toml`). `--config PATH` replaces it and must exist; a missing global file means every default. 2. Command-line flags, for one run. `--byte-limit`, `--graph-limit` and - `--parse-error-limit` override `[diff]`, and `-U` sets context padding. + `--parse-error-limit` override `[diff]`, and `-U` overrides + `plugins.context.lines`. 3. Git attributes, for facts about files in a repository (`.gitattributes` and git's user-wide attributes file); see [File tags](#file-tags). @@ -18,10 +19,11 @@ an error that names the file and the key's dotted path, for example ## Commands ```sh -diffr config schema # JSON Schema: title, group, description and default per key +diffr config schema # JSON Schema: title, group, description and default per key, plugins included diffr config show [--json] # the resolved configuration diffr config set diff.graph_limit 5000000 diffr config set theme.name default-light +diffr config set plugins.context.lines 5 ``` `set` writes one key into the global file (or the `--config` file), keeping @@ -36,7 +38,7 @@ expected, for example `diff.graph_limit: expected an integer, got "abc"`; diffr exits 2 and the file is not touched. Frontends drive their settings pages through these three commands; the schema is the only contract. Each setting in the schema carries a `title` and an `x-group` that settings screens show -in place of the dotted key. +in place of the dotted key; keys marked `"x-settings": false` are not settings. ## Keys @@ -50,14 +52,10 @@ parse_error_limit = 0 # more tree-sitter parse errors than this: line diff 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 = '''...''' +[plugins] # see "Plugins" below +order = ["context"] ``` -`languages` is not in the schema, so the settings screen does not show it; -`config set` still accepts its keys. - When a file exceeds a `[diff]` limit it falls back to a line diff: the file's `stats` carries a `fallback` with code `too_large`, `too_complex` or `parse_error` and a message naming the key to raise. `--byte-limit`, @@ -66,6 +64,134 @@ 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. +## Plugins + +After a file is diffed and its regions are built, plugins decide how it starts +out on screen: which files are hidden, which regions start collapsed and with +what label, which regions open and close together, and which collapsed +regions are grouped. They run in `plugins.order`, each on the region trees the +one before it left. [streaming.md](streaming.md#plugins) describes what they +produce on the wire. + +```toml +[plugins] +order = ["context"] + +[plugins.context] # unchanged lines far from any change collapse +enabled = true +lines = 3 # kept on either side of a change; -U overrides it +``` + +Every bundled plugin has an entry pre-filled with the values above; a file +writes only what it changes. `order` must name every entry exactly once: a +name without an entry, an entry left out, or a name listed twice is an error +when the file loads. In an entry, `enabled` belongs to diffr: set +`enabled = false` to turn a plugin off. Every other key is one of the +plugin's options, checked against the options its `plugin.toml` declares: an +unknown option or a value of the wrong type is an error naming the key, such +as `plugins.context: lines: "many" is not of type "integer"`. + +Every plugin that is on is made (its `new` runs) when diffr starts, before +any output, and one that cannot be made stops diffr with an error naming it. + +An entry that names no bundled plugin is an error. + +### Plugin folders + +Every bundled plugin is a folder under `plugins/`, laid out the way any plugin +is: + +```text +plugins/context/ + plugin.toml # name, title, options, query files + queries/rust.scm # one query file per language + Cargo.toml, src/lib.rs # the plugin's code, a crate using the plugin SDK +``` + +diffr embeds a bundled plugin's `plugin.toml` and query files and compiles +its code in. + +`plugin.toml` is static: + +```toml +name = "context" # the entry name, and the prefix of its tags +title = "Context" # the group settings screens list its settings under +description = "Collapse unchanged stretches far from any change, …" + +[enabled] # how settings screens show the switch +title = "Collapse unchanged lines" +description = "…" +default = true # whether the plugin is on unless an entry says otherwise + +[options.lines] # each option is a JSON Schema, written in TOML +type = "integer" +minimum = 0 +title = "Context lines" +description = "Unchanged lines kept visible on either side of a change. …" +default = 3 + +[queries] # per language key, relative to the folder +rust = "queries/rust.scm" +typescript = "queries/javascript.scm" +``` + +Every option needs a `title`; one with a `default` is pre-filled in +`[plugins.]`, and one without starts unset. Options are listed in the +settings schema in the order `plugin.toml` declares them. + +### Queries + +Tree-sitter queries decide which folds exist and which constructs enclose a +change; plugins decide how they are shown. Each plugin that reads syntax +(today `context`) lists one query file per language +key (`rust`, `python`, `go`, `javascript`, `javascriptjsx`, `typescript`, +`typescripttsx`, or any other lowercase language name) under `[queries]` in +its `plugin.toml`. Queries are not configured in `[plugins]`. + +- A relative path is inside the plugin's folder. +- `builtin:/` names a file of a bundled plugin, embedded in + diffr from `plugins//`. +- An absolute path is a file. + +For each language, diffr concatenates the query files of every enabled +plugin, in `order`, into one query and runs it during the diff. A file whose +first line is `; inherits: shared.scm` (several paths separated by commas; +relative to the importing file, or `builtin:` paths) includes those files +first. Each file is included at most once per language, however many plugins +import it, and an import cycle is an error. No bundled plugin owns fold +queries yet: each language's query starts with the bundled fold rules in +`src/config/defaults.toml`, whose tags carry no plugin prefix. + +`src/config/README.md` describes the capture conventions (`@fold`, +`@fold.open`, `@fold.close` and `#set! tag`). Every `@fold` capture in one match +forms one fold, so a quantified run such as `(comment)+ @fold` folds as one +region spanning the run. Tags name a plugin and are written in the query: +`(#set! tag ":function")`. A tag must have the form +`:` with a plugin from `plugins.order`, or the configuration +does not compile; a plugin reads only the tags its own queries set. A +pattern may set no tag at all, to make a fold exist without meaning anything +to a plugin. + +Patterns from different files that capture the same syntax node merge into +one fold with the union of their tags when they agree on its range. When they +capture it with different ranges, that file is not diffed: its record carries +an error with code `query_conflict` naming both files, for example +`src/lib.rs:42: /path/to/a.scm and /path/to/b.scm capture the same +function_item with different fold ranges`. Other files proceed. + +A query file that does not compile (a syntax error, an unknown node, an +unsupported capture or directive, a malformed tag) is an error naming the +file, and diffr exits 2 before the stream starts. + +### Settings screen + +`diffr config schema` builds the `plugins` part of the schema from each +plugin's `plugin.toml`. Every scalar setting under `plugins` has a `title` +and an `x-group`: the group is the plugin's `title` (`Context`), and each +`enabled` is titled after what that plugin does. `plugins.order` and every +list option are marked `"x-settings": false`: the schema describes them, a settings screen does not edit them, and +they are set in the file or with `diffr config set`. + ## File tags Every file in a repository comparison carries `tags` in the stream manifest: diff --git a/docs/streaming.md b/docs/streaming.md index c1bd37f85..772185be2 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -12,15 +12,16 @@ arguments are the same as the [ordinary CLI](cli.md). There is no HTTP server. 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`. +from the internal diff is `src/protocol/project.rs`, the plugin host that +shapes it is `src/plugin/`, and the bundled plugins are under `plugins/`. ## Configuration and ordering Each invocation reads the global configuration file (or `--config PATH`), applies command-line flags, and compiles the result once; see [config.md](config.md). Omitted keys -retain defaults; query strings replace whole values, and empty queries disable -that feature. +retain defaults. The fold query for each language is assembled from the query +files of the enabled plugins; see [plugins](#plugins). File tags come from bundled GitHub Linguist rules and diffr's test path rules, then git attributes (`linguist-generated`, `linguist-vendored`, @@ -60,8 +61,8 @@ lines; pipe reads can split records or contain several. | Record | Fields | Consumer action | | --- | --- | --- | | `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` | Mark complete. | +| `file` | `file`, optional `visibility`, 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 @@ -82,13 +83,20 @@ as results are computed, not as an atomic snapshot. path pair is the file's identity; each `file` record repeats it verbatim so the record can be matched back to the manifest. Tags are known before `start`: reading the start of a file for the header checks happens during discovery, -and if that read fails the file's record carries a `read_failed` error. Working-tree sides carry git's +and if that read fails the file's record carries a `read_failed` error. Whether +a file starts hidden is not known yet: the plugins decide that after diffing, and +the `file` record carries it. Working-tree sides carry git's all-zero oid. A `--no-index` comparison has empty `oid` and `mode`. +A `file` record's `visibility` is how the file starts out, set by the plugins: +`{"collapsed": true, "label": "Generated file · hidden by default"}` for a +hidden file, absent for an open one. A file whose record is an `error` has no +`visibility`. + 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. EOF without -`complete` means the output was cut off. +completion, `succeeded + failed` equals the manifest length unless `aborted` is +present. EOF without `complete` means the output was cut off. ### Errors @@ -96,15 +104,23 @@ 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`, `query_conflict` (two fold query patterns capture - one syntax node with different fold ranges; the message names the line and - both patterns), or `internal` for a failure diffr did not classify. -- Setup failures (bad revision, unreadable config, a query that does not - compile, a malformed `diffr-tags` attribute) write to stderr + `unmerged`, `read_failed`, `query_conflict` (two query files capture one + syntax node with different fold ranges; the message names the line and both + files), or `internal` for a failure diffr did not classify. +- On `complete`, `aborted` reports a run-level failure in a plugin: + `mutation_failed` when a plugin fails or asks for a move that cannot be + carried out. The message names the plugin. diffr + stops pulling files, lets the ones in flight finish, and exits 2. Every + `file` record already written stays valid; the file that failed has no + record. +- Setup failures (bad revision, unreadable config, a query file that does not + compile, a malformed `diffr-tags` attribute, a plugin option that does not + match its schema, a plugin that cannot be made, or a `classify` that fails + or returns a malformed tag) 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. +any file failed or the run aborted. ## The diff @@ -112,7 +128,8 @@ any file failed. {"type": "text", "lhs": {"text": "…", "regions": [...]}, "rhs": {"text": "…", "regions": [...]}, - "stats": {"textual": {"added": 4, "removed": 1}}} // + "fallback": {code, message} on a line diff + "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 @@ -121,7 +138,14 @@ deleted file has `lhs` only. `text` is the complete source. -`stats.textual` counts lines with any byte change. +`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 plugins +run, so configuration changes 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`, or `generated` for a file tagged @@ -137,10 +161,12 @@ its folds pair with nothing. Only `unsupported_language`, `too_large` and Each side carries a tree of regions. A region is a line range on that side with identities that never stand in for one another. `id` names the region: it is -unique within the file, across both sides. +unique within the file, across both sides, and it is what plugins address. `fold_state_id` says what the region opens and closes with: regions sharing it open and close together, on the same side or across sides. Paired leaves and -matched folds share it across sides. Only a leaf has an +matched folds share it across sides, and a plugin that links regions gives +several regions one +`fold_state_id` while each keeps its own `id`. Only a leaf has an `alignment_id`, and it is row alignment: the same value on the other side marks the leaf whose rows line up with this one, line for line. Consumers key the row zip by leaf `alignment_id`, collapse state by `fold_state_id`, and anything @@ -149,8 +175,8 @@ about the region itself by `id`. ```jsonc {"id": 7, "fold_state_id": 7, "kind": "fold", "start": {"line": 18, "column": 0}, "end": {"line": 53, "column": 0}, - "tags": ["body"], - "visibility": {"collapsed": false, "label": "Body"}, + "tags": ["context:scope"], + "visibility": {"collapsed": false, "label": ""}, "children": [ {"id": 8, "fold_state_id": 8, "kind": "leaf", "alignment_id": 5, "start": {"line": 18, "column": 0}, "end": {"line": 30, "column": 0}}, {"id": 9, "fold_state_id": 9, "kind": "leaf", "alignment_id": 6, "start": {"line": 30, "column": 0}, "end": {"line": 31, "column": 0}, @@ -193,11 +219,43 @@ syntactic region that spans a single line is not a region: it hides nothing. `tags` name what a region is. A leaf carries none. A fold carries -the tags the fold queries set on it (`body`, `import`, `test`). Tags describe -syntax; how a region starts out is `visibility`, and frontends need no -knowledge of tags to render it. `visibility` is `collapsed` and the `label` to -show while collapsed: a placeholder named after the fold's first tag. Nothing -starts collapsed yet. Absent means open. +the tags the fold queries set on it, each written `:` after the +plugin whose query set it (`context:scope`); a fold only some query made +exist without meaning carries none. Tags describe syntax, and are the only +syntax a plugin sees; how a region starts out is `visibility`, +and frontends need no knowledge of tags to render it. `visibility` is +`collapsed` and the `label` to show while collapsed: the count for a +collapsed stretch of unchanged lines, say. +A label is the plugin's that collapsed the fold, so a fold no plugin +collapsed has none, and the empty label shows the source. Absent means open. + +**Context** comes from the `context` plugin; the projection itself hides +nothing. A line stays open when it is within `plugins.context.lines` +(`-U`, default 3) of a changed line, when it is paired with such a line, or +when it opens or closes a scope, such as the function a change +sits in, that contains a change. Scopes are folds the plugin's own queries +tag `context:scope`, on the construct's own node: a scope runs from the line +its signature starts on to the line that closes it, so keeping its first and +last line always shows where it opens and where it ends, however many lines +the signature takes. The body fold inside it covers the body alone and starts +a line later, so the two never share a range. Every other stretch of unchanged paired lines that +is at least three lines long collapses with a label such as +`"142 unchanged lines"`; shorter stretches stay open because a row would save +nothing. A file with no change collapses whole however short. + +Region edges cut a stretch. The part of it among one list of siblings +collapses when it is at least three lines long or is the whole stretch, so a +sliver at a fold edge stays open. A part inside one leaf is that leaf's lines, +cut out. A part that spans several siblings, folds among them, collapses under +one new fold with no tags, labelled with the part's line count. A fold collapses only when it +lies wholly inside the stretch, and so does every region on the other side in +its fold state, so a fold whose matched counterpart holds changes stays open. +Both folds of a matched pair take the label. The group is made only when the +two sides' siblings match one for one (leaves sharing an `alignment_id`, or +folds sharing a fold state), and then on both sides, the two group folds +sharing a fold state; +otherwise the regions collapse one by one. A stretch that crosses the end of a fold collapses on +each side of that edge. Ids are per file. The projection numbers the regions as it builds them, in lhs preorder then rhs preorder, from two counters: `id` starts at 1 and @@ -206,8 +264,9 @@ the next `alignment_id` unless its counterpart on the other side was numbered first, whose `alignment_id` it takes instead. A region's `fold_state_id` is its own `id`, except that a paired leaf or matched fold numbered second takes its counterpart's `fold_state_id`. -No region has `id` 0: it names the file itself. Ids mean nothing across files -or runs. +Plugins take `id`s and `alignment_id`s above every one already in the file. +No region has `id` 0: plugins use it to name the file itself. Ids mean nothing +across files or runs. ## Computation and output @@ -226,3 +285,81 @@ on Unix. 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. + +## Plugins + +Each file goes through one pipeline before its record is written: + +1. Git lists the file and its tags ([config.md](config.md#file-tags)), and + each enabled plugin's `classify` adds tags of its own, before the + `start` header is written. +2. The diff runs. Its fold query for the file's language is assembled from + the query files of every enabled plugin ([config.md](config.md#queries)). +3. The projection builds the region trees above and pairs them. Nothing + starts collapsed yet. +4. The enabled plugins run in `plugins.order` on the finished trees, starting + with `context`. +5. `stats.visible` is recounted, and the record is written. + +Queries decide which regions exist; plugins decide how they start out. No +plugin matches one side to the other: pairing is the projection's alone. A +plugin counts a leaf as paired when the other side holds its `alignment_id`, +and a fold as paired when a region on the other side shares its +`fold_state_id`. Only the other side counts, since a link shares a fold state +within one side too. Whether a fold is new, deleted or unpaired is a +different question, and the leaves answer it: a fold is new when no line +inside it pairs with the other side, whatever the matcher made of the two +nodes, and however the file was diffed. + +A plugin's `mutate` step reads the file (its paths, status and tags) and both +sides, as the records of `wit/plugin.wit`, and returns moves. It never edits a +tree itself; one applier, the plugin SDK's, carries the moves out, in order, +and the next plugin sees the result. A move names a +region by its `id`, which is on one side only, or the file by `0`, the root +every region hangs from: + +- **Cut** a leaf at a line offset, relative to its first line and strictly + inside it. The leaf paired with it (the one on the other side with its + `alignment_id`) is cut at the same offset. The first piece on each side + keeps its leaf's ids; the second takes a fresh `id` of its own and a fresh + `alignment_id` shared with its counterpart, so the rows still pair, and the + lhs piece's `id` as its `fold_state_id`. A fold, or the file, cannot be + cut. +- **Join folds**: wrap consecutive sibling regions in a new fold, open, + unlabelled and without tags. Each side wraps the listed regions it holds, + so one move can list a run and the run matched with it on the other side; + the two new folds take their own `id`s and share one `fold_state_id`. +- **Link fold state**: every region in the listed regions' fold states, on + both sides, takes the first region's `fold_state_id` and whether it starts + collapsed, so they open and close together. +- **Set collapsed** on a region: every region sharing its `fold_state_id` + starts collapsed, or open. On the file, whether the file starts hidden: the + record's `visibility`. +- **Set label** on one region, or on the file: the label shown while it is + collapsed. +- **Set tags** on one region, replacing them. The file's `tags` are its + manifest entry's, known before any diff runs, so the file's cannot be set. + +Collapsing a region behind a label is a set collapsed and a set label (after +a cut, for some lines of a leaf); grouping is a join, a set collapsed and a +set label on each new fold; hiding a file is a set collapsed and a set label +on the file. When the bundled plugins collapse a region, the regions that +start collapsed with it and were open lose their labels, and the +leaf paired with a collapsed leaf takes its label. + +New `id`s are larger than every `id` already in the file, and new +`alignment_id`s larger than every leaf's. A move that cannot be carried out +(an unknown region, an offset outside a leaf, a cut on a fold, a join of +regions that are not consecutive siblings, or of a run one side holds only +one region of) is an error: the run aborts with `mutation_failed`. + +The bundled plugins, in their default order, each a folder under `plugins/`: + +| Plugin | What starts out differently | +| --- | --- | +| `context` | Unchanged lines far from any change and outside its enclosing headers collapse: `"142 unchanged lines"`. | + +A binary diff has no regions, so only moves on the file can change how it +starts out. Streaming is the only output mode that runs +plugins. + diff --git a/plugins/context/Cargo.toml b/plugins/context/Cargo.toml new file mode 100644 index 000000000..ba7eac74a --- /dev/null +++ b/plugins/context/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "diffr-plugin-context" +description = "The context plugin: collapse unchanged stretches far from any change." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +diffr-plugin-sdk = { path = "../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/context/plugin.toml b/plugins/context/plugin.toml new file mode 100644 index 000000000..743f44859 --- /dev/null +++ b/plugins/context/plugin.toml @@ -0,0 +1,26 @@ +name = "context" +title = "Context" +description = "Collapse unchanged stretches far from any change, keeping `lines` around every change and the headers of the syntax that encloses it." + +[enabled] +title = "Collapse unchanged lines" +description = "Collapse unchanged lines that are neither near a change nor the header of the function, type or block a change sits in." + +[options.lines] +type = "integer" +minimum = 0 +maximum = 4294967295 +title = "Context lines" +description = "Unchanged lines kept visible on either side of a change. `-U` overrides it for one run." +default = 3 + +# Folds tagged `context:scope`: the scopes whose first and last line stay +# visible around a change. +[queries] +rust = "queries/rust.scm" +python = "queries/python.scm" +go = "queries/go.scm" +javascript = "queries/javascript.scm" +javascriptjsx = "queries/javascript.scm" +typescript = "queries/javascript.scm" +typescripttsx = "queries/javascript.scm" diff --git a/plugins/context/queries/go.scm b/plugins/context/queries/go.scm new file mode 100644 index 000000000..f815512e1 --- /dev/null +++ b/plugins/context/queries/go.scm @@ -0,0 +1,16 @@ +; The scopes whose first and last line stay visible above and below a change +; inside them. A scope is the whole construct, not its body: its first line is +; the line its signature starts on, however many lines that signature runs to, +; and its last is the line that closes it. The body fold the bundled rules +; give the construct covers only the body, so it nests inside the scope and +; starts a line later. +((function_declaration) @fold + (#set! tag "context:scope")) +((method_declaration) @fold + (#set! tag "context:scope")) +((for_statement) @fold + (#set! tag "context:scope")) +((expression_switch_statement) @fold + (#set! tag "context:scope")) +((return_statement) @fold + (#set! tag "context:scope")) diff --git a/plugins/context/queries/javascript.scm b/plugins/context/queries/javascript.scm new file mode 100644 index 000000000..379497a74 --- /dev/null +++ b/plugins/context/queries/javascript.scm @@ -0,0 +1,23 @@ +; The scopes whose first and last line stay visible above and below a change +; inside them. A scope is the whole construct, not its body: its first line is +; the line its signature starts on, however many lines that signature runs to, +; and its last is the line that closes it. The body fold the bundled rules +; give the construct covers only the body, so it nests inside the scope and +; starts a line later. +((function_declaration) @fold + (#set! tag "context:scope")) +((generator_function_declaration) @fold + (#set! tag "context:scope")) +((method_definition) @fold + (#set! tag "context:scope")) +; An arrow function is a scope whether its body is a block or an expression. +((arrow_function) @fold + (#set! tag "context:scope")) +((class_declaration) @fold + (#set! tag "context:scope")) +((for_statement) @fold + (#set! tag "context:scope")) +((switch_statement) @fold + (#set! tag "context:scope")) +((return_statement) @fold + (#set! tag "context:scope")) diff --git a/plugins/context/queries/python.scm b/plugins/context/queries/python.scm new file mode 100644 index 000000000..4386bbf85 --- /dev/null +++ b/plugins/context/queries/python.scm @@ -0,0 +1,14 @@ +; The scopes whose first and last line stay visible above and below a change +; inside them. A scope is the whole construct, not its body: its first line is +; the line its signature starts on, however many lines that signature runs to, +; and its last is the line that closes it. The body fold the bundled rules +; give the construct covers only the body, so it nests inside the scope and +; starts a line later. +((function_definition) @fold + (#set! tag "context:scope")) +((class_definition) @fold + (#set! tag "context:scope")) +((for_statement) @fold + (#set! tag "context:scope")) +((return_statement) @fold + (#set! tag "context:scope")) diff --git a/plugins/context/queries/rust.scm b/plugins/context/queries/rust.scm new file mode 100644 index 000000000..9d149c46b --- /dev/null +++ b/plugins/context/queries/rust.scm @@ -0,0 +1,27 @@ +; The scopes whose first and last line stay visible above and below a change +; inside them. A scope is the whole construct, not its body: its first line is +; the line its signature starts on, however many lines that signature runs to, +; and its last is the line that closes it. The body fold the bundled rules +; give the construct covers only the body, so it nests inside the scope and +; starts a line later. +((function_item) @fold + (#set! tag "context:scope")) +((impl_item) @fold + (#set! tag "context:scope")) +((mod_item) @fold + (#set! tag "context:scope")) +((struct_item) @fold + (#set! tag "context:scope")) +((for_expression) @fold + (#set! tag "context:scope")) +((loop_expression) @fold + (#set! tag "context:scope")) +((match_expression) @fold + (#set! tag "context:scope")) +((return_expression) @fold + (#set! tag "context:scope")) +; A function's tail expression. Blocks, arrays and strings are left out: the +; bundled rules fold them with a range of its own. +((function_item body: (block (_expression) @fold .)) + (#not-match? @fold "^(\\{|\\[|b?r?#*\")") + (#set! tag "context:scope")) diff --git a/plugins/context/src/lib.rs b/plugins/context/src/lib.rs new file mode 100644 index 000000000..c23ac0636 --- /dev/null +++ b/plugins/context/src/lib.rs @@ -0,0 +1,427 @@ +//! Collapse unchanged stretches far from any change. +//! +//! A line stays visible when it is within `lines` of a changed line on its +//! side, when it is paired with such a line, or when it opens or closes a +//! scope that holds a change on its side. Scopes are the constructs this +//! plugin's queries tag `context:scope` (see [`scope_rows`]), each covering +//! its signature line through the line that closes it; a file the +//! diff did not parse has none. Every other stretch of unchanged +//! paired lines that is at least [`MIN_GAP`] lines long collapses, labelled +//! with its line count; a file with no change collapses whole, however +//! short. +//! +//! A stretch is cut at region edges. The part in one list of siblings +//! collapses when it is at least [`MIN_GAP`] lines long or is the whole +//! stretch; a shorter sliver stays open. When a part spans several +//! siblings, each collapses and a group wraps them in one row on each side, +//! provided the two sides' siblings match one for one: leaves sharing an +//! `alignment_id`, or folds sharing a fold state. A fold in it collapses only when every +//! region on the other side in its fold state lies wholly inside the +//! stretch there, so no fold is hidden on one side while it holds changed +//! lines on the other, such as a matched function that moved elsewhere. +//! Both folds of a matched pair are labelled with their line count. +use diffr_plugin_sdk::types::Source as SourceRecord; +use diffr_plugin_sdk::{ + anyhow, export, has_tag, is_fold, tree, walk, Draft, FileEntry, Move, Node, Pairing, Plugin, + Region, Source, +}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; + +/// Collapsing fewer lines than this saves nothing worth a fold row. +const MIN_GAP: u32 = 3; + +/// A fold the queries mark as a scope: a function, a type, a block. +const SCOPE: &str = "context:scope"; + +pub struct Context { + options: Options, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options { + /// Unchanged lines kept on either side of a change. + lines: u32, +} + +/// The first and last line of every scope on `source` that holds a changed +/// line. +/// +/// A scope region is a whole construct: its first line is the line its +/// signature or header starts on and its last is the line that closes it, +/// since the construct's own node is what the queries tag. Keeping both +/// always shows where a scope opens and where it ends, whatever sits inside. +fn scope_rows(source: &Source, changed: &BTreeSet) -> BTreeSet { + let mut rows = BTreeSet::new(); + walk(&source.regions, &mut |region| { + if !is_fold(region) || !has_tag(region, SCOPE) { + return; + } + let span = region.range.lines(); + if changed.range(span.clone()).next().is_none() { + return; + } + rows.insert(span.start); + rows.insert(span.end - 1); + }); + rows +} + +/// One leaf, as far as context cares. +struct Leaf { + id: u32, + alignment: u32, + start: u32, + end: u32, + /// Whether a leaf on the other side has the same `alignment_id`. + paired: bool, + /// Whether this side paints any byte of it as changed. + spans: bool, +} + +fn leaves(regions: &[Region], other: &BTreeSet) -> Vec { + let mut out = Vec::new(); + walk(regions, &mut |region| { + if let Node::Leaf { + alignment_id, + changed, + } = ®ion.node + { + out.push(Leaf { + id: region.id, + alignment: *alignment_id, + start: region.range.start.line, + end: region.range.end.line, + paired: other.contains(alignment_id), + spans: !changed.is_empty(), + }); + } + }); + out +} + +fn leaf_alignments(regions: &[Region]) -> BTreeSet { + let mut alignments = BTreeSet::new(); + walk(regions, &mut |region| { + alignments.extend(region.alignment_id()); + }); + alignments +} + +fn unchanged_label(count: u32) -> String { + match count { + 1 => "1 unchanged line".to_owned(), + count => format!("{count} unchanged lines"), + } +} + +/// A region a stretch covers in one list of siblings: whole, or the lines +/// `start..end` of a leaf, relative to the leaf's first line. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Member { + /// `state` is a fold's `fold_state_id`, and `None` for a leaf; + /// `alignment` is a leaf's `alignment_id`, and `None` for a fold. + Whole { + id: u32, + lines: u32, + state: Option, + alignment: Option, + }, + Part { + id: u32, + alignment: u32, + start: u32, + end: u32, + }, +} + +impl Member { + fn lines(&self) -> u32 { + match self { + Self::Whole { lines, .. } => *lines, + Self::Part { start, end, .. } => end - start, + } + } + + /// What must match across sides: the leaf's alignment and which of its + /// lines, or the fold state of a fold. Every region has its own id on + /// each side. + fn key(&self) -> (bool, u32, u32) { + match self { + Self::Whole { + state: Some(state), .. + } => (true, *state, 0), + Self::Whole { + alignment: Some(alignment), + .. + } => (false, *alignment, 0), + Self::Whole { .. } => unreachable!("a region is a fold or a leaf"), + Self::Part { + alignment, start, .. + } => (false, *alignment, *start + 1), + } + } +} + +/// The parts of the lines `start..end` in each list of siblings, in +/// document order. +fn segments(regions: &[Region], start: u32, end: u32, out: &mut Vec>) { + let mut current = Vec::new(); + for region in regions { + let (first, last) = (region.range.start.line, region.range.end.line); + if last <= start || first >= end { + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + } + continue; + } + if start <= first && last <= end { + current.push(Member::Whole { + id: region.id, + lines: last - first, + state: is_fold(region).then_some(region.fold_state_id), + alignment: region.alignment_id(), + }); + continue; + } + match ®ion.node { + Node::Leaf { alignment_id, .. } => current.push(Member::Part { + id: region.id, + alignment: *alignment_id, + start: start.max(first) - first, + end: end.min(last) - first, + }), + Node::Fold { children } => { + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + } + segments(children, start, end, out); + } + } + } + if !current.is_empty() { + out.push(current); + } +} + +impl Plugin for Context { + type Options = Options; + + fn new(options: Options) -> anyhow::Result { + Ok(Self { options }) + } + + fn classify(&self, _file: &FileEntry) -> anyhow::Result> { + Ok(Vec::new()) + } + + fn mutate( + &self, + _file: &FileEntry, + lhs: Option<&SourceRecord>, + rhs: Option<&SourceRecord>, + ) -> anyhow::Result> { + let options = &self.options; + let sides = tree::sides(lhs, rhs)?; + let Pairing::Both { lhs, rhs } = &sides else { + // A one-sided file is all changed lines. + return Ok(Vec::new()); + }; + let lhs_leaves = leaves(&lhs.regions, &leaf_alignments(&rhs.regions)); + let rhs_leaves = leaves(&rhs.regions, &leaf_alignments(&lhs.regions)); + let rhs_by_alignment: BTreeMap = rhs_leaves + .iter() + .map(|leaf| (leaf.alignment, leaf)) + .collect(); + + // Changed lines on each side, and the lines of rows that change. + let mut novel = [BTreeSet::new(), BTreeSet::new()]; + let mut seeds = [BTreeSet::new(), BTreeSet::new()]; + // (lhs start, rhs start, length) of every unchanged paired leaf. + let mut unchanged: Vec<(u32, u32, u32)> = Vec::new(); + for (side, own) in [(0, &lhs_leaves), (1, &rhs_leaves)] { + for leaf in own.iter() { + if !leaf.paired || leaf.spans { + novel[side].extend(leaf.start..leaf.end); + seeds[side].extend(leaf.start..leaf.end); + } + } + } + for leaf in &lhs_leaves { + let Some(partner) = rhs_by_alignment + .get(&leaf.alignment) + .filter(|_| leaf.paired) + else { + continue; + }; + if leaf.spans || partner.spans { + seeds[0].extend(leaf.start..leaf.end); + seeds[1].extend(partner.start..partner.end); + } else { + unchanged.push((leaf.start, partner.start, leaf.end - leaf.start)); + } + } + let changed = !seeds[0].is_empty() || !seeds[1].is_empty(); + + let counts = [lhs, rhs].map(|source| source.text.split_terminator('\n').count() as u32); + let mut shown = [BTreeSet::new(), BTreeSet::new()]; + for side in 0..2 { + for &line in &seeds[side] { + shown[side].extend( + line.saturating_sub(options.lines) + ..(line + options.lines + 1).min(counts[side]), + ); + } + } + if changed { + for (side, source) in [lhs, rhs].into_iter().enumerate() { + // Context adds unchanged rows only; changed rows are shown anyway. + shown[side].extend(scope_rows(source, &novel[side])); + } + } + + // Hidden rows of unchanged paired leaves, as (lhs line, rhs line). + let mut hidden: Vec<(u32, u32)> = Vec::new(); + for &(lhs_start, rhs_start, len) in &unchanged { + for offset in 0..len { + let (l, r) = (lhs_start + offset, rhs_start + offset); + if !changed || (!shown[0].contains(&l) && !shown[1].contains(&r)) { + hidden.push((l, r)); + } + } + } + hidden.sort_unstable(); + let mut stretches: Vec<((u32, u32), (u32, u32))> = Vec::new(); + for (l, r) in hidden { + match stretches.last_mut() { + Some(((_, lhs_end), (_, rhs_end))) if *lhs_end == l && *rhs_end == r => { + *lhs_end += 1; + *rhs_end += 1; + } + _ => stretches.push(((l, l + 1), (r, r + 1))), + } + } + + let mut lhs_states: BTreeMap = BTreeMap::new(); + walk(&lhs.regions, &mut |region| { + lhs_states.insert(region.id, region.fold_state_id); + }); + // The rhs regions in each fold state, by id. + let mut rhs_by_state: BTreeMap> = BTreeMap::new(); + walk(&rhs.regions, &mut |region| { + rhs_by_state + .entry(region.fold_state_id) + .or_default() + .push(region.id); + }); + // The rhs leaf paired with each lhs leaf, by id. + let rhs_leaf_ids: BTreeMap = lhs_leaves + .iter() + .filter_map(|leaf| Some((leaf.id, rhs_by_alignment.get(&leaf.alignment)?.id))) + .collect(); + // Stretches are shaped in reverse document order, so a cut leaf's id + // still names the piece that starts where the leaf did. + let mut draft = Draft::new(&sides); + for ((lhs_start, lhs_end), (rhs_start, rhs_end)) in stretches.into_iter().rev() { + let total = lhs_end - lhs_start; + if total < MIN_GAP && changed { + continue; + } + let mut lhs_parts = Vec::new(); + segments(&lhs.regions, lhs_start, lhs_end, &mut lhs_parts); + let mut rhs_parts = Vec::new(); + segments(&rhs.regions, rhs_start, rhs_end, &mut rhs_parts); + let key = |parts: &[Vec]| -> Vec> { + parts + .iter() + .map(|part| part.iter().map(Member::key).collect()) + .collect() + }; + let same_shape = key(&lhs_parts) == key(&rhs_parts); + // A fold may collapse when everything its collapse reaches on the + // rhs hides only these unchanged lines. + let rhs_whole: BTreeSet = rhs_parts + .iter() + .flatten() + .filter_map(|member| match member { + Member::Whole { id, .. } => Some(*id), + Member::Part { .. } => None, + }) + .collect(); + let hides_only_this = |id: u32| { + rhs_by_state + .get(&lhs_states[&id]) + .is_none_or(|ids| ids.iter().all(|id| rhs_whole.contains(id))) + }; + // Line counts of the rhs folds wholly in the stretch, so each + // side's fold takes its own label. + let rhs_fold_lines: BTreeMap = rhs_parts + .iter() + .flatten() + .filter_map(|member| match member { + Member::Whole { + id, + lines, + state: Some(_), + .. + } => Some((*id, *lines)), + _ => None, + }) + .collect(); + for (index, part) in lhs_parts.iter().enumerate().rev() { + let lines: u32 = part.iter().map(Member::lines).sum(); + if lines < MIN_GAP && lines != total { + continue; + } + let mut members = Vec::new(); + // The rhs leaves and pieces the lhs members pair with. + let mut rhs_members = Vec::new(); + for member in part.iter().rev() { + match member { + Member::Whole { + id, state: Some(_), .. + } if !hides_only_this(*id) => {} + Member::Whole { + id, lines, state, .. + } => { + draft.collapse(*id, unchanged_label(*lines))?; + members.push(*id); + let Some(state) = state else { + rhs_members.extend(rhs_leaf_ids.get(id)); + continue; + }; + for rhs_id in rhs_by_state.get(state).into_iter().flatten() { + if let Some(rhs_lines) = rhs_fold_lines.get(rhs_id) { + draft.collapse(*rhs_id, unchanged_label(*rhs_lines))?; + } + } + } + Member::Part { id, start, end, .. } => { + let piece = draft.cut_lines(*id, *start, *end)?; + draft.collapse(piece, unchanged_label(end - start))?; + members.push(piece); + rhs_members.extend(draft.paired_leaf(piece)?); + } + } + } + // A fold left open breaks the run, so nothing is grouped. + if same_shape && members.len() == part.len() && members.len() >= 2 { + members.reverse(); + // The rhs leaves and pieces, then the rhs folds. + members.extend(rhs_members); + members.extend(rhs_parts[index].iter().filter_map(|member| match member { + Member::Whole { + id, state: Some(_), .. + } => Some(*id), + _ => None, + })); + draft.group(members, unchanged_label(lines))?; + } + } + } + Ok(draft.into_moves()) + } +} + +export!(Context); diff --git a/src/cli.rs b/src/cli.rs index 9d7c59334..74f8660a7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,7 @@ use crate::config::{self, Config}; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; use crate::options::{DiffOptions, DisplayMode, DisplayOptions}; +use crate::plugin::Pipeline; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use git2::{DiffStatsFormat, Repository}; use std::{ @@ -58,7 +59,7 @@ pub(crate) fn run() -> Result { .arg(flag("null").short('z')) .arg(flag("no-renames")) .arg(flag("find-renames").short('M').conflicts_with("no-renames")) - .arg(Arg::new("unified").short('U').long("unified").default_value("3").value_parser(clap::value_parser!(u32))) + .arg(Arg::new("unified").short('U').long("unified").value_parser(clap::value_parser!(u32)).help("Unchanged lines kept around each change; defaults to plugins.context.lines")) .arg(Arg::new("format").long("format").value_parser(["text", "ndjson"]).default_value("text")) .arg(Arg::new("display").long("display").value_parser(["inline", "side-by-side", "side-by-side-show-both"]).default_value("side-by-side")) .arg(Arg::new("color").long("color").num_args(0..=1).require_equals(true).default_missing_value("always").default_value("auto").value_parser(["auto", "always", "never"])) @@ -109,7 +110,8 @@ pub(crate) fn run() -> Result { .cloned() .collect(); let display = DisplayOptions { - num_context_lines: *args.get_one::("unified").unwrap(), + // Set once the configuration is read: `-U`, or the context plugin's lines. + num_context_lines: 0, terminal_width: args .get_one::("width") .copied() @@ -168,9 +170,21 @@ pub(crate) fn run() -> Result { } changed } else { - let params = Arc::new(load_config(&args)?.compile()?); + let mut config = load_config(&args)?; + let display = DisplayOptions { + num_context_lines: context_lines(&args, &mut config), + ..display + }; + let params = Arc::new(config.compile()?); let diff_options = diff_options(&args, ¶ms); - let mut session = DiffSession::open(workspace, comparison, params, &files)?; + let pipeline = if streaming { + // The whole chain: `plugins.` and why the plugin cannot be made. + Pipeline::from_config(¶ms.plugins, workspace).map_err(|error| format!("{error:#}"))? + } else { + Pipeline::default() + }; + let mut session = + DiffSession::open(workspace, comparison, Arc::clone(¶ms), &files, &pipeline)?; session.diff_options = diff_options; let changed = session.remaining() > 0; if streaming { @@ -178,8 +192,13 @@ pub(crate) fn run() -> Result { if jobs == 0 { return Err("--jobs must be at least 1".into()); } - let failed = crate::protocol::stream::write(session, jobs, &mut io::stdout().lock())?; - return Ok(if failed { + let ended = crate::protocol::stream::write( + session, + jobs, + Arc::new(pipeline), + &mut io::stdout().lock(), + )?; + return Ok(if ended.failed || ended.aborted { 2 } else { i32::from(changed && args.get_flag("exit-code")) @@ -423,7 +442,12 @@ fn no_index(args: &ArgMatches, paths: Vec, display: &DisplayOptions) - if args.get_flag("quiet") { return Ok(i32::from(changed)); } - let config = load_config(args)?.compile()?; + let mut config = load_config(args)?; + let display = &DisplayOptions { + num_context_lines: context_lines(args, &mut config), + ..display.clone() + }; + let config = config.compile()?; let options = &diff_options(args, &config); let lhs = crate::options::FileArgument::from_path_argument(&paths[0]); let rhs = crate::options::FileArgument::from_path_argument(&paths[1]); @@ -444,20 +468,23 @@ fn no_index(args: &ArgMatches, paths: Vec, display: &DisplayOptions) - ) }; if args.get_one::("format").map(String::as_str) == Some("ndjson") { - let failed = crate::protocol::stream::write_file( + let pipeline = Pipeline::from_config(&config.plugins, &std::env::current_dir()?) + .map_err(|error| format!("{error:#}"))?; + 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, + &pipeline, &mut io::stdout().lock(), )?; - Ok(if failed { + Ok(if ended.failed || ended.aborted { 2 } else { i32::from(changed && args.get_flag("exit-code")) }) } else { - crate::print_diff_result(display, &crate::diff_or_die(compute(), display.use_color)); + crate::print_diff_result(display, &compute()?); Ok(i32::from(changed)) } } @@ -478,6 +505,26 @@ fn diff_options(args: &ArgMatches, params: &config::Params) -> DiffOptions { options } +/// Unchanged lines kept around each change: `-U`, which also overrides the +/// context plugin's `lines` for this run, or that setting. +fn context_lines(args: &ArgMatches, config: &mut Config) -> u32 { + let lines = config + .plugins + .entries + .get_mut("context") + .expect("the context plugin always has an entry") + .options + .get_mut("lines") + .expect("plugins.context.lines has a default"); + if let Some(unified) = args.get_one::("unified") { + *lines = serde_json::Value::from(*unified); + } + lines + .as_u64() + .and_then(|lines| u32::try_from(lines).ok()) + .expect("plugins.context.lines is validated as an integer in u32's range") +} + /// The global file, or the `--config` file in its place. fn load_config(args: &ArgMatches) -> Result { Ok(Config::load( diff --git a/src/config.rs b/src/config.rs index bf263ac3f..d9333b996 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,14 +4,28 @@ //! key it omits keeps its serde default, and an unknown key or mistyped //! value is an error naming the key's dotted path. Flags and attributes are //! applied by their callers. Every field carries a doc comment, which becomes -//! its description in `diffr config schema`, and every setting a `title` and -//! an `x-group` that settings screens show in place of the dotted key. +//! its description in `diffr config schema`, and every scalar setting a +//! `title` and an `x-group` that settings screens show in place of the +//! dotted key. Lists are in the schema marked `"x-settings": false`: a +//! settings screen edits scalars and leaves those to the file and `diffr +//! config set`. The `plugins` part of the schema comes from each plugin's +//! `plugin.toml`, with lists and multi-line values marked the same way. +//! +//! `[plugins]` configures the plugins that shape regions after diffing (see +//! [`crate::plugin`]). Compiling assembles, per language, one query from the +//! query files of every enabled plugin (see [`crate::plugin::queries`]): its +//! `@fold` captures decide which folds exist, and its tags what they are. +//! Every tag a query sets must be written `:`. 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 query::AnnotationQuery; +use crate::plugin::config::PluginsConfig; +#[cfg(test)] +use crate::plugin::config::Queries; +use crate::plugin::queries; +use query::{AnnotationQuery, QuerySource}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -22,11 +36,11 @@ use strum::IntoEnumIterator; #[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. + /// The plugins that decide what starts collapsed, hidden, linked or + /// grouped, and the fold queries they own. Its schema comes from each + /// plugin's `plugin.toml`; see [`PluginsConfig::schema`]. #[schemars(skip)] - pub(crate) languages: BTreeMap, + pub(crate) plugins: PluginsConfig, /// Colors for the terminal frontend. pub(crate) theme: ThemeConfig, /// Limits on the structural comparison itself. @@ -96,16 +110,8 @@ impl Default for ThemeConfig { } } -#[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. - pub(crate) folds: Option, - pub(crate) context: Option, -} - #[derive(Debug)] -pub(crate) struct ConfigError(String); +pub(crate) struct ConfigError(pub(crate) String); impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) @@ -115,13 +121,14 @@ impl std::error::Error for ConfigError {} pub(crate) struct Params { languages: DftHashMap>>, + pub(crate) plugins: PluginsConfig, pub(crate) diff: DiffConfig, } pub(crate) struct LanguageParams { pub(crate) parser: &'static tree_sitter_parser::TreeSitterConfig, - pub(crate) folds: AnnotationQuery, - pub(crate) context: AnnotationQuery, + /// The fold and context queries of every enabled plugin, concatenated. + pub(crate) query: AnnotationQuery, sub_languages: OnceLock< Vec<( &'static tree_sitter_parser::TreeSitterSubLanguage, @@ -153,6 +160,11 @@ pub(crate) fn global_path() -> Result { Ok(dir.join("diffr").join("config.toml")) } +/// The directory a configuration file's relative paths resolve against. +pub(crate) fn directory_of(file: &Path) -> &Path { + file.parent().unwrap_or(Path::new("")) +} + impl Config { /// Read the global file, or `explicit` in its place. A missing global /// file is the defaults; an explicit file must exist. @@ -168,70 +180,158 @@ impl Config { } Err(error) => return Err(ConfigError(format!("{}: {error}", path.display()))), }; - Self::from_toml(&source) + Self::from_toml_in(&source, directory_of(&path)) .map_err(|error| ConfigError(format!("{}: {error}", path.display()))) } - /// Parse one file's text. Errors lead with the dotted path of the key - /// they concern, such as `diff.typo`. + /// Parse text that is not a file's: a plugin folder's `path` is relative + /// to the current directory. + #[cfg(test)] pub(crate) fn from_toml(source: &str) -> Result { - serde_path_to_error::deserialize(toml::Deserializer::new(source)).map_err(|error| { - let path = error.path().to_string(); - let message = error.inner().to_string(); - ConfigError(match path.as_str() { - "." => message, - _ => format!("{path}: {message}"), - }) - }) + Self::from_toml_in(source, Path::new("")) + } + + /// Parse the text of a file in `directory`. Errors lead with the dotted + /// path of the key they concern, such as `diff.typo`. + pub(crate) fn from_toml_in(source: &str, directory: &Path) -> Result { + let mut config: Self = serde_path_to_error::deserialize(toml::Deserializer::new(source)) + .map_err(|error| { + let path = error.path().to_string(); + let message = error.inner().to_string(); + ConfigError(match path.as_str() { + "." => message, + _ => format!("{path}: {message}"), + }) + })?; + config.plugins.resolve(directory)?; + Ok(config) } /// The JSON Schema of the configuration, with a description and default - /// on every setting. + /// on every setting. `plugins` comes first, built from each bundled + /// plugin's `plugin.toml`. pub(crate) fn schema() -> serde_json::Value { - serde_json::to_value(schemars::schema_for!(Config)).expect("schema serializes") + let mut schema = + serde_json::to_value(schemars::schema_for!(Config)).expect("schema serializes"); + let rest = std::mem::take( + schema["properties"] + .as_object_mut() + .expect("the schema has properties"), + ); + let properties = schema["properties"] + .as_object_mut() + .expect("the schema has properties"); + properties.insert("plugins".to_owned(), PluginsConfig::schema()); + properties.extend(rest); + schema } + /// Compile with the query files of every enabled plugin, after the + /// bundled fold rules no plugin owns yet. pub(crate) fn compile(self) -> Result { - let defaults = Self::from_toml(include_str!("config/defaults.toml"))?; - let mut resolved = defaults.languages; - for (name, overrides) in self.languages { - let target = resolved.entry(name).or_default(); - if overrides.folds.is_some() { - target.folds = overrides.folds; - } - if overrides.context.is_some() { - target.context = overrides.context; - } + let queries = self.plugins.enabled_queries(); + let mut assembled = queries::assemble(&queries)?; + for (name, bundled) in bundled_folds() { + assembled.entry(name).or_default().insert(0, bundled); } + self.compile_assembled(assembled) + } + + /// Compile with `queries`, the enabled plugins' query files in + /// `plugins.order`. + #[cfg(test)] + pub(crate) fn compile_queries( + self, + queries: Vec<(String, Queries)>, + ) -> Result { + let assembled = queries::assemble(&queries)?; + self.compile_assembled(assembled) + } + + fn compile_assembled( + self, + assembled: BTreeMap>, + ) -> Result { let mut languages: DftHashMap<_, _> = Language::iter() .map(|language| (language, OnceLock::new())) .collect(); - for (name, config) in resolved { + for (name, sources) in assembled { let language = Language::iter() .find(|language| format!("{language:?}").to_lowercase() == name) .ok_or_else(|| ConfigError(format!("unknown language: {name}")))?; let parser = tree_sitter_parser::from_language(language); - let compile = |feature, source: Option| { - AnnotationQuery::compile(&parser.language, source.as_deref().unwrap_or_default()) - .map_err(|error| ConfigError(format!("languages.{name}.{feature}: {error}"))) - }; + let query = AnnotationQuery::compile(&parser.language, &sources)?; + check_tags(&query, &self.plugins.order)?; languages.insert( language, OnceLock::from(Arc::new(LanguageParams { parser, - folds: compile("folds", config.folds)?, - context: compile("context", config.context)?, + query, sub_languages: OnceLock::new(), })), ); } Ok(Params { languages, + plugins: self.plugins, diff: self.diff, }) } } +/// The name of the bundled fold queries: config/defaults.toml. +const BUNDLED_FOLDS: &str = "config/defaults.toml"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BundledFolds { + languages: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BundledLanguage { + folds: String, +} + +/// Each language's bundled fold query. No bundled plugin owns fold queries +/// yet, so they stay in config/defaults.toml and come before the plugins' +/// query files. +fn bundled_folds() -> impl Iterator { + let bundled: BundledFolds = toml::from_str(include_str!("config/defaults.toml")) + .expect("the bundled fold queries parse"); + bundled.languages.into_iter().map(|(name, language)| { + let source = QuerySource { + name: format!("{BUNDLED_FOLDS}: languages.{name}.folds"), + text: language.folds, + }; + (name, source) + }) +} + +/// Every tag a fold query sets is `:`, naming a plugin in +/// `plugins.order`: a plugin reads only the tags its own queries set. The +/// bundled fold queries' tags predate plugins and carry no prefix. +fn check_tags(query: &AnnotationQuery, order: &[String]) -> Result<(), ConfigError> { + for pattern in &query.patterns { + if query.sources[pattern.source].starts_with(BUNDLED_FOLDS) { + continue; + } + for tag in &pattern.tags { + let owned = tag.split_once(':').is_some_and(|(plugin, name)| { + !name.is_empty() && order.iter().any(|own| own == plugin) + }); + if !owned { + return Err(ConfigError(format!( + "{}: tag {tag:?} must be written : with a plugin from plugins.order", + query.sources[pattern.source] + ))); + } + } + } + Ok(()) +} + impl Params { pub(crate) fn language(&self, language: Language) -> &Arc { let config = self.languages[&language].get_or_init(|| { @@ -240,9 +340,7 @@ impl Params { let parser = tree_sitter_parser::from_language(language); Arc::new(LanguageParams { parser, - folds: AnnotationQuery::compile(&parser.language, "").expect("empty fold query"), - context: AnnotationQuery::compile(&parser.language, "") - .expect("empty context query"), + query: AnnotationQuery::compile(&parser.language, &[]).expect("an empty query"), sub_languages: OnceLock::new(), }) }); @@ -292,22 +390,6 @@ mod tests { ); } - #[test] - fn configuration_is_independent_and_omission_keeps_other_defaults() { - let custom = Config::from_toml("[languages.rust]\nfolds = ''") - .unwrap() - .compile() - .unwrap(); - let defaults = Params::default(); - let source = "fn f() { work(); }"; - let custom_result = DiffResult::from_sources_with_params("a.rs", "", source, &custom); - let default_result = DiffResult::from_sources_with_params("a.rs", "", source, &defaults); - assert!(custom_result.rhs_folds.is_empty()); - assert!(!default_result.rhs_folds.is_empty()); - let python = DiffResult::from_sources_with_params("a.py", "", "import os\n", &custom); - assert!(!python.rhs_folds.is_empty()); - } - #[test] fn language_without_annotation_rules_keeps_structural_diffing() { let params = Params::default(); @@ -328,18 +410,20 @@ mod tests { #[test] fn shared_grammars_keep_language_configuration_independent() { - let params = Config::from_toml( - r#" - [languages.javascript] - folds = '''((statement_block) @fold (#set! tag "plain-js"))''' - [languages.javascriptjsx] - folds = '''((statement_block) @fold (#set! tag "jsx"))''' - "#, - ) - .unwrap() - .compile() - .unwrap(); - for (path, tag) in [("file.js", "plain-js"), ("file.jsx", "jsx")] { + let params = with_queries(&[ + ( + "javascript", + r#"((statement_block) @fold (#set! tag "context:plain-js"))"#, + ), + ( + "javascriptjsx", + r#"((statement_block) @fold (#set! tag "context:jsx"))"#, + ), + ]); + for (path, tag) in [ + ("file.js", "context:plain-js"), + ("file.jsx", "context:jsx"), + ] { let result = DiffResult::from_sources_with_params( path, "", @@ -353,12 +437,10 @@ mod tests { #[test] fn embedded_languages_use_the_configured_queries() { - let params = Config::from_toml( - "[languages.javascript]\nfolds = '((statement_block) @fold (#set! tag embedded))'", - ) - .unwrap() - .compile() - .unwrap(); + let params = with_queries(&[( + "javascript", + r#"((statement_block) @fold (#set! tag "context:embedded"))"#, + )]); let result = DiffResult::from_sources_with_params( "page.html", "", @@ -366,49 +448,93 @@ mod tests { ¶ms, ); assert_eq!(result.rhs_folds.len(), 1); - assert_eq!(result.rhs_folds[0].tags, ["embedded"]); + assert_eq!(result.rhs_folds[0].tags, ["context:embedded"]); } #[test] fn rejects_unknown_settings_languages_and_invalid_queries() { assert!(Config::from_toml("typo = true").is_err()); - for input in [ - "[languages.unknown]", - "[languages.rust]\nfolds = '(not_a_rust_node) @fold.body'", - "[languages.rust]\nfolds = '(block) @typo'", + assert!(Config::from_toml("[languages.rust]\ncontext = '(block) @context'").is_err()); + let error = Config::default() + .compile_queries(vec![( + "context".to_owned(), + Queries::from([( + "klingon".to_owned(), + "builtin:context/queries/rust.scm".to_owned(), + )]), + )]) + .err() + .expect("an unknown language") + .to_string(); + assert!(error.contains("unknown language: klingon"), "{error}"); + for (query, message) in [ + ("(not_a_rust_node) @fold", "NodeType error"), + ("(block) @typo", "unsupported capture @typo"), + ( + r#"((block) @fold (#set! tag "body"))"#, + "must be written :", + ), + ( + r#"((block) @fold (#set! tag "nobody:body"))"#, + "must be written :", + ), ] { - assert!( - Config::from_toml(input).unwrap().compile().is_err(), - "{input}" - ); + let error = try_with_queries(&[("rust", query)]) + .err() + .expect(query) + .to_string(); + assert!(error.contains("context.scm"), "{error}"); + assert!(error.contains(message), "{error}"); } } } +/// Params whose only fold query per listed language is the given text, +/// written to a file the context plugin owns; no other plugin +/// contributes queries. +#[cfg(test)] +pub(crate) fn try_with_queries(queries: &[(&str, &str)]) -> Result { + let dir = tempfile::tempdir().expect("a temporary directory"); + let mut files = Queries::new(); + for (language, query) in queries { + let path = dir.path().join(format!("{language}-context.scm")); + std::fs::write(&path, query).expect("a query file"); + files.insert((*language).to_owned(), path.display().to_string()); + } + Config::default().compile_queries(vec![("context".to_owned(), files)]) +} + +#[cfg(test)] +fn with_queries(queries: &[(&str, &str)]) -> Params { + try_with_queries(queries).expect("the test queries compile") +} + +/// Params with the bundled fold rules alone: the bodies and collections the +/// engine's tests are written against, without the scopes `context` lays +/// over them. +#[cfg(test)] +pub(crate) fn body_params() -> Params { + Config::from_toml("[plugins.context]\nenabled = false\n") + .expect("a valid configuration") + .compile() + .expect("the bundled queries compile") +} + #[cfg(test)] mod query_tests { use super::*; use crate::summary::DiffResult; - fn configured(folds: &str, context: &str) -> Params { - Config::from_toml(&format!( - "[languages.rust]\nfolds = '''{folds}'''\ncontext = '''{context}'''" - )) - .unwrap() - .compile() - .unwrap() - } - #[test] fn arbitrary_tags_and_delimiter_captures_reach_the_domain() { - let params = configured( - r#"((block "{" @fold.open "}" @fold.close) @fold (#set! tag "user.validation"))"#, - "", - ); + let params = with_queries(&[( + "rust", + r#"((block "{" @fold.open "}" @fold.close) @fold (#set! tag "context:user.validation"))"#, + )]); let source = "fn f() { println!(\"☕\"); }"; let diff = DiffResult::from_sources_with_params("a.rs", "", source, ¶ms); let fold = &diff.rhs_folds[0]; - assert_eq!(fold.tags, ["user.validation"]); + assert_eq!(fold.tags, ["context:user.validation"]); assert_eq!( &source[fold.range.start.byte_column..fold.range.end.byte_column], " println!(\"☕\"); " @@ -424,13 +550,11 @@ mod query_tests { "((block) @fold (#set! typo value))", "((block) @fold (#set! tag))", ] { - let config = - Config::from_toml(&format!("[languages.rust]\nfolds = '''{query}'''")).unwrap(); - let error = match config.compile() { + let error = match try_with_queries(&[("rust", query)]) { Ok(_) => panic!("accepted {query}"), - Err(error) => error, + Err(error) => error.to_string(), }; - assert!(error.to_string().contains("languages.rust.folds:")); + assert!(error.contains("rust-context.scm: "), "{error}"); } } @@ -458,7 +582,7 @@ mod query_tests { #[test] fn unicode_string_fold_uses_the_complete_node_range() { - let params = configured("(string_literal) @fold", ""); + let params = with_queries(&[("rust", "(string_literal) @fold")]); let source = "fn f() { let x = \"☕\"; }"; let diff = DiffResult::from_sources_with_params("a.rs", "", source, ¶ms); assert_eq!(diff.rhs_folds.len(), 1); @@ -468,25 +592,6 @@ mod query_tests { "\"☕\"" ); } - - #[test] - fn neovim_header_end_and_final_have_distinct_endpoints() { - use crate::parse::{context, guess_language::Language, tree_sitter_parser as parser}; - let src = "fn f(\n x: i32,\n) {\n work(x);\n}\n"; - let grammar = parser::from_language(Language::Rust); - let tree = parser::to_tree(src, grammar); - for (query, last_header) in [ - ("(function_item body: (block) @context.end) @context", 2), - ("(function_item body: (block) @context.final) @context", 4), - ("(function_item) @context", 0), - ] { - let params = configured("", query); - let contexts = - context::classify(&tree, src, Some(¶ms.language(Language::Rust).context)); - let context = contexts.values().next().unwrap().first().unwrap(); - assert_eq!(context.header, 0..=last_header); - } - } } #[cfg(test)] @@ -497,29 +602,27 @@ mod tag_tests { #[test] fn repeated_rules_accumulate_sorted_tags_without_duplicate_folds() { - let query = r#" - ((block) @fold (#set! tag "user.check")) - ((block) @fold (#set! tag "body")) - ((block) @fold (#set! tag "user.check")) - "#; - let params = Config::from_toml(&format!("[languages.rust]\nfolds = '''{query}'''")) - .unwrap() - .compile() - .unwrap(); + let params = with_queries(&[( + "rust", + r#" + ((block) @fold (#set! tag "context:user.check")) + ((block) @fold (#set! tag "context:body")) + ((block) @fold (#set! tag "context:user.check")) + "#, + )]); let result = DiffResult::from_sources_with_params("a.rs", "", "fn f() { work(); }", ¶ms); assert_eq!(result.rhs_folds.len(), 1); - assert_eq!(result.rhs_folds[0].tags, ["body", "user.check"]); + assert_eq!( + result.rhs_folds[0].tags, + ["context:body", "context:user.check"] + ); } #[test] fn an_opening_capture_alone_folds_to_the_end_of_the_fold_node() { - let query = - r#"((function_definition ":" @fold.open body: (block) @fold) (#set! tag "body"))"#; - let params = Config::from_toml(&format!("[languages.python]\nfolds = '''{query}'''")) - .unwrap() - .compile() - .unwrap(); + let query = r#"((function_definition ":" @fold.open body: (block) @fold) (#set! tag "context:body"))"#; + let params = with_queries(&[("python", query)]); let rhs = "def f(a):\n x = a\n return x\n"; let result = DiffResult::from_sources_with_params("a.py", "", rhs, ¶ms); assert_eq!(result.rhs_folds.len(), 1); @@ -533,17 +636,33 @@ mod tag_tests { } #[test] - fn a_node_captured_with_two_ranges_is_a_query_conflict_naming_both_patterns() { - let whole = "((block) @fold (#set! tag \"whole\"))"; - let interior = "((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"inside\"))"; - for query in [ - format!("{whole}\n{interior}"), - format!("{interior}\n{whole}"), + fn a_node_captured_with_two_ranges_is_a_query_conflict_naming_both_files() { + let whole = "((block) @fold (#set! tag \"context:whole\"))"; + let interior = + "((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"context:inside\"))"; + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("queries/rust")).unwrap(); + let whole_file = dir.path().join("queries/rust/whole.scm"); + let interior_file = dir.path().join("queries/rust/interior.scm"); + std::fs::write(&whole_file, whole).unwrap(); + std::fs::write(&interior_file, interior).unwrap(); + let queries = |plugin: &str, path: &std::path::Path| { + ( + plugin.to_owned(), + Queries::from([("rust".to_owned(), path.display().to_string())]), + ) + }; + for order in [ + [ + queries("context", &whole_file), + queries("context", &interior_file), + ], + [ + queries("context", &interior_file), + queries("context", &whole_file), + ], ] { - let params = Config::from_toml(&format!("[languages.rust]\nfolds = '''{query}'''")) - .unwrap() - .compile() - .unwrap(); + let params = Config::default().compile_queries(order.to_vec()).unwrap(); let conflict = DiffResult::try_from_sources_with_params( "src/lib.rs", "", @@ -551,10 +670,14 @@ mod tag_tests { ¶ms, ) .expect_err("a conflict"); - assert_eq!( - conflict.to_string(), - "src/lib.rs:1: fold query patterns 0 and 1 capture the same block \ - with different fold ranges" + let message = conflict.to_string(); + assert!(message.starts_with("src/lib.rs:1: "), "{message}"); + assert!( + message.contains("queries/rust/interior.scm and ") + && message.contains( + "queries/rust/whole.scm capture the same block with different fold ranges" + ), + "{message}" ); // Other files diff as usual. assert!( @@ -564,8 +687,8 @@ mod tag_tests { } #[test] - fn test_bodies_keep_both_tags_and_remain_paired() { - let params = Params::default(); + fn test_bodies_keep_every_owner_tag_and_remain_paired() { + let params = body_params(); let result = DiffResult::from_sources_with_params( "a.rs", "#[test]\nfn example() { old(); }", diff --git a/src/config/README.md b/src/config/README.md index 1f8dabecb..f25977cff 100644 --- a/src/config/README.md +++ b/src/config/README.md @@ -1,8 +1,8 @@ # Syntax annotation configuration The caller parses TOML with `Config::from_toml`, then calls `compile()` once. -The resulting `Params` owns separately compiled fold and context queries and is borrowed by each diff. -Each classifier runs its own traversal. Combining them is a separate optimization. +The resulting `Params` owns one compiled query per language, holding the enabled plugins' fold +patterns, and is borrowed by each diff. `Config::load` reads the global file (`$XDG_CONFIG_HOME/diffr/config.toml`) or an explicit replacement file. File selection and ordering belong to the caller, not this configuration. @@ -11,25 +11,31 @@ let params = Config::from_toml(toml_source)?.compile()?; let result = DiffResult::from_sources_with_params(path, before, after, ¶ms); ``` -Queries live inline in TOML: +Fold queries are files owned by plugins. Each plugin's `plugin.toml` names +one file per language under `[queries]`, and `compile()` concatenates the +files of every enabled plugin into one query per language, remembering which +file each pattern came from (see `src/plugin/queries.rs` and +[docs/config.md](../../docs/config.md#queries)): ```toml -[languages.rust] -folds = ''' -((block "{" @fold.open "}" @fold.close) @fold - (#set! tag "body")) -''' -context = ''' -(function_item body: (block - "{" @context.final - "}" @context.last)) @context -''' +# plugins/mine/plugin.toml +[queries] +rust = "queries/rust.scm" +typescript = "queries/javascript.scm" ``` -A supplied feature replaces its bundled query. Omitted features retain defaults; -an empty string disables the feature. Language keys are lowercase built-in language -names, such as `rust`, `python`, `go`, `javascript`, and `typescript`. -See `defaults.toml` for the complete bundled rules. +```scheme +; inherits: builtin:shared/queries/rust.scm +((function_item) @fold + (#set! tag "context:scope")) +``` + +The `context` plugin's files are fold queries like any other: they tag +the folds of the scopes whose first and last line stay visible around a +change (see [Scopes](#scopes)). + +Language keys are lowercase built-in language names, such as `rust`, +`python`, `go`, `javascript`, and `typescript`. ## Folds and tags @@ -49,13 +55,19 @@ from its header's `:`. A `@fold.close` without `@fold.open` selects no fold. These fold-boundary captures are our convention. There are no arbitrary byte or line offsets, and no `#offset!` or `#make-range!` directives. -`#set! tag "name"` supplies application metadata. Tag names are arbitrary strings; -dots have no special meaning. Multiple rules selecting the same node and range -accumulate sorted, unique tags. A node whose rules select different ranges is a -query conflict: the file is not diffed, and its record carries a -`query_conflict` error naming both patterns, so the result never depends on -query order. Tags do not control context selection or initial UI collapse -state. +Every `@fold` capture in one match forms one fold, attached to the first +captured node: a quantified run such as `((comment)+ @fold . (function_item))` +folds as one region over the hull of the run. Delimiter captures apply only to +a match with a single `@fold`. + +`#set! tag ":"` supplies metadata for one plugin. The prefix +must name a plugin in `plugins.order`; `compile()` rejects any other tag. Dots +in the name have no special meaning. Rules selecting the same node and range +accumulate sorted, unique tags, whichever files they came from. A node whose +rules select different ranges is a query conflict: the file is not diffed, and +its record carries a `query_conflict` error naming both query files, so the +result never depends on query order. Tags do not decide what starts +collapsed; plugins do, reading only regions, their tags and the text. A node has at most one fold, so two folds align exactly when the matcher paired their nodes. When a node that has a fold is flattened into its only @@ -63,26 +75,29 @@ child during conversion (a Python `block` holding one statement, say), the fold moves onto the child; a child that has a fold of its own keeps it, and the wrapper node stays so that each fold still has a node. -## Context - -These capture meanings follow `nvim-treesitter-context`: - -- `@context`: the enclosing construct; its first line is the default header. -- `@context.start`: the start of the header. -- `@context.end`: the exclusive end of the header, at this capture's start. -- `@context.final`: an alternative header end, at this capture's end. - -`@context.last` is our extension: also retain the capture's final occupied line, -for example a closing brace. This does not retain all the lines between the header -and closing line. The diff engine selects relevant enclosing context for changed -hunks. It does not implement Neovim's sticky-header UI. +## Scopes + +The `context` plugin reads scopes from its own tags. A fold tagged +`context:scope` is a construct whose first and last line stay visible when a +change falls inside it. The tag goes on the construct's own node, so the +scope runs from the line its signature starts on to the line that closes it, +however many lines the signature takes. The body fold the shared query gives +the same construct covers the body alone and starts a line later, so the two +are different regions: one fold per node still holds, and the scope nests +around the body. + +```scheme +; inherits: builtin:shared/queries/rust.scm +((function_item) @fold + (#set! tag "context:scope")) +``` ## Supported query features Tree-sitter supplies query syntax and text predicates such as `#eq?` and `#match?`. This module implements `#set!` with the `tag` property. It does not implement arbitrary Neovim directives or Lua callbacks. Helper captures must begin with `_`. Unsupported captures, properties, directives, -invalid TOML, and invalid query syntax fail during `compile()`. +invalid TOML, and invalid query syntax fail during `compile()`, naming the query file. ## Resolved language configuration @@ -92,11 +107,12 @@ There are two inputs to language configuration: detects the language; [`TreeSitterConfig` / `build_config`](../parse/tree_sitter_parser.rs) supplies its built-in Tree-sitter grammar and parsing/highlighting rules. - **What syntax to expose in a diff:** [`Config`](../config.rs) supplies - queries identifying foldable AST regions, their tags, and useful extra context. - Bundled rules live in [`defaults.toml`](defaults.toml). + the enabled plugins' queries, which identify foldable AST regions and + their tags. The bundled queries live + in [`plugins//queries/`](../../plugins/). [`Params::language`](../config.rs) resolves both into one `LanguageParams`: -the parser configuration plus fold and context queries compiled against its grammar. +the parser configuration plus the assembled query compiled against its grammar. Parsing receives only this entry, including resolved embedded-language entries. It does not also receive the server-wide `Params`. Shared entries reuse the compiled queries when a language appears both directly and inside another language. diff --git a/src/config/defaults.toml b/src/config/defaults.toml index 65b4bd59a..f47f90126 100644 --- a/src/config/defaults.toml +++ b/src/config/defaults.toml @@ -28,16 +28,6 @@ folds = ''' (#match? @_attribute "test") (#set! tag "test")) ''' -context = ''' -[ - (function_item body: (block "{" @context.final "}" @context.last)) - (impl_item body: (declaration_list "{" @context.final "}" @context.last)) - (mod_item body: (declaration_list "{" @context.final "}" @context.last)) - (struct_item body: (field_declaration_list "{" @context.final "}" @context.last)) -] @context -[(for_expression) (loop_expression) (match_expression) (return_expression)] @context @context.last -(function_item body: (block (_expression) @context @context.last .)) -''' [languages.python] folds = ''' @@ -77,10 +67,6 @@ folds = ''' (function_definition name: (identifier) @_name ":" @fold.open body: (block) @fold (#match? @_name "^test_") (#set! tag "test")) ''' -context = ''' -[(function_definition ":" @context.final) (class_definition ":" @context.final)] @context -[(for_statement) (return_statement)] @context @context.last -''' [languages.go] folds = ''' @@ -103,13 +89,6 @@ folds = ''' (function_declaration name: (identifier) @_name body: (block "{" @fold.open "}" @fold.close) @fold (#match? @_name "^Test") (#set! tag "test")) ''' -context = ''' -[ - (function_declaration body: (block "{" @context.final "}" @context.last)) - (method_declaration body: (block "{" @context.final "}" @context.last)) -] @context -[(for_statement) (expression_switch_statement) (return_statement)] @context @context.last -''' [languages.javascript] folds = ''' @@ -134,17 +113,6 @@ folds = ''' ] @fold (#set! tag "string")) ''' -context = ''' -[ - (function_declaration body: (statement_block "{" @context.final "}" @context.last)) - (generator_function_declaration body: (statement_block "{" @context.final "}" @context.last)) - (method_definition body: (statement_block "{" @context.final "}" @context.last)) - (arrow_function body: (statement_block "{" @context.final "}" @context.last)) - (class_declaration body: (class_body "{" @context.final "}" @context.last)) -] @context -[(for_statement) (switch_statement) (return_statement)] @context @context.last -(arrow_function body: (_) @context.end) @context -''' [languages.javascriptjsx] folds = ''' @@ -169,17 +137,6 @@ folds = ''' ] @fold (#set! tag "string")) ''' -context = ''' -[ - (function_declaration body: (statement_block "{" @context.final "}" @context.last)) - (generator_function_declaration body: (statement_block "{" @context.final "}" @context.last)) - (method_definition body: (statement_block "{" @context.final "}" @context.last)) - (arrow_function body: (statement_block "{" @context.final "}" @context.last)) - (class_declaration body: (class_body "{" @context.final "}" @context.last)) -] @context -[(for_statement) (switch_statement) (return_statement)] @context @context.last -(arrow_function body: (_) @context.end) @context -''' [languages.typescript] folds = ''' @@ -204,17 +161,6 @@ folds = ''' ] @fold (#set! tag "string")) ''' -context = ''' -[ - (function_declaration body: (statement_block "{" @context.final "}" @context.last)) - (generator_function_declaration body: (statement_block "{" @context.final "}" @context.last)) - (method_definition body: (statement_block "{" @context.final "}" @context.last)) - (arrow_function body: (statement_block "{" @context.final "}" @context.last)) - (class_declaration body: (class_body "{" @context.final "}" @context.last)) -] @context -[(for_statement) (switch_statement) (return_statement)] @context @context.last -(arrow_function body: (_) @context.end) @context -''' [languages.typescripttsx] folds = ''' @@ -239,15 +185,4 @@ folds = ''' ] @fold (#set! tag "string")) ''' -context = ''' -[ - (function_declaration body: (statement_block "{" @context.final "}" @context.last)) - (generator_function_declaration body: (statement_block "{" @context.final "}" @context.last)) - (method_definition body: (statement_block "{" @context.final "}" @context.last)) - (arrow_function body: (statement_block "{" @context.final "}" @context.last)) - (class_declaration body: (class_body "{" @context.final "}" @context.last)) -] @context -[(for_statement) (switch_statement) (return_statement)] @context @context.last -(arrow_function body: (_) @context.end) @context -''' diff --git a/src/config/query.rs b/src/config/query.rs index 85fed6952..d76658099 100644 --- a/src/config/query.rs +++ b/src/config/query.rs @@ -1,61 +1,97 @@ //! Compile the supported Tree-sitter capture and directive conventions. +//! +//! A query is compiled from one or more named sources, concatenated in +//! order. Every pattern remembers which source it came from, so errors and +//! conflicts can name the file that wrote it. use super::ConfigError; use tree_sitter::Query; pub(crate) struct AnnotationQuery { pub(crate) query: Query, pub(crate) patterns: Vec, + /// The name of every source, indexed by `Pattern::source`. + pub(crate) sources: Vec, } -#[derive(Default)] pub(crate) struct Pattern { pub(crate) tags: Vec, + /// Index into `AnnotationQuery::sources`. + pub(crate) source: usize, +} + +/// One piece of query text and the name errors call it by: a query file's +/// path, or a configuration key. +pub(crate) struct QuerySource { + pub(crate) name: String, + pub(crate) text: String, } impl AnnotationQuery { pub(crate) fn compile( grammar: &tree_sitter::Language, - source: &str, + sources: &[QuerySource], ) -> Result { - let query = Query::new(grammar, source).map_err(|error| ConfigError(error.to_string()))?; + let mut text = String::new(); + let mut starts = Vec::with_capacity(sources.len()); + for source in sources { + starts.push(text.len()); + text.push_str(&source.text); + text.push('\n'); + } + // The source a byte of the concatenated text belongs to. + let owner = |offset: usize| starts.partition_point(|&start| start <= offset) - 1; + let query = Query::new(grammar, &text).map_err(|error| { + if sources.is_empty() { + return ConfigError(error.to_string()); + } + let index = owner(error.offset.min(text.len().saturating_sub(1))); + let line = text[starts[index]..error.offset].matches('\n').count() + 1; + ConfigError(format!( + "{}:{line}: {:?} error: {}", + sources[index].name, error.kind, error.message + )) + })?; + let named = |pattern: usize, message: String| { + ConfigError(format!( + "{}: {message}", + sources[owner(query.start_byte_for_pattern(pattern))].name + )) + }; for name in query.capture_names() { - if name.starts_with('_') - || matches!( - *name, - "fold" - | "fold.open" - | "fold.close" - | "context" - | "context.start" - | "context.end" - | "context.final" - | "context.last" - ) - { + if name.starts_with('_') || matches!(*name, "fold" | "fold.open" | "fold.close") { continue; } + let source = sources + .iter() + .find(|source| source.text.contains(&format!("@{name}"))) + .expect("a capture name appears in the text that declared it"); return Err(ConfigError(format!( - "unsupported capture @{name}; use an underscore prefix for helper captures" + "{}: unsupported capture @{name}; use an underscore prefix for helper captures", + source.name ))); } let mut patterns = Vec::new(); for index in 0..query.pattern_count() { - let mut pattern = Pattern::default(); + let mut pattern = Pattern { + tags: Vec::new(), + source: owner(query.start_byte_for_pattern(index)), + }; if !query.property_predicates(index).is_empty() { - return Err(ConfigError("#is? and #is-not? are not supported".into())); + return Err(named(index, "#is? and #is-not? are not supported".into())); } for property in query.property_settings(index) { if property.key.as_ref() != "tag" { - return Err(ConfigError(format!( - "unsupported #set! property {}", - property.key - ))); + return Err(named( + index, + format!("unsupported #set! property {}", property.key), + )); } if property .capture_id .is_some_and(|id| query.capture_names()[id] != "fold") { - return Err(ConfigError( + return Err(named( + index, "tag metadata must target @fold or its pattern".into(), )); } @@ -63,17 +99,21 @@ impl AnnotationQuery { .value .as_deref() .filter(|tag| !tag.is_empty()) - .ok_or_else(|| ConfigError("#set! tag requires a nonempty string".into()))?; + .ok_or_else(|| named(index, "#set! tag requires a nonempty string".into()))?; pattern.tags.push(tag.to_owned()); } if let Some(predicate) = query.general_predicates(index).first() { - return Err(ConfigError(format!( - "unsupported directive #{}", - predicate.operator - ))); + return Err(named( + index, + format!("unsupported directive #{}", predicate.operator), + )); } patterns.push(pattern); } - Ok(Self { query, patterns }) + Ok(Self { + query, + patterns, + sources: sources.iter().map(|source| source.name.clone()).collect(), + }) } } diff --git a/src/config/store.rs b/src/config/store.rs index 96b5321b5..2985aa621 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -1,6 +1,6 @@ //! `diffr config`: read the schema and resolved values, and write one key to //! the global file. -use super::{Config, ConfigError, LanguageConfig}; +use super::{directory_of, Config, ConfigError}; use serde_json::Value; use std::path::Path; @@ -28,7 +28,7 @@ pub(crate) fn set(path: &Path, key: &str, value: &str) -> Result<(), ConfigError .parse() .map_err(|error| ConfigError(format!("{}: {error}", path.display())))?; assign(&mut document, key, &typed)?; - Config::from_toml(&document.to_string())?; + Config::from_toml_in(&document.to_string(), directory_of(path))?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|error| ConfigError(format!("{}: {error}", parent.display())))?; @@ -37,20 +37,11 @@ pub(crate) fn set(path: &Path, key: &str, value: &str) -> Result<(), ConfigError .map_err(|error| ConfigError(format!("{}: {error}", path.display()))) } -/// The JSON Schema of the setting at the dotted `key`. `languages` is left -/// out of `diffr config schema`, so its keys are looked up in -/// [`LanguageConfig`]'s own schema. +/// The JSON Schema of the setting at the dotted `key`, as `diffr config +/// schema` describes it. fn setting_schema(key: &str) -> Result { let unknown = || ConfigError(format!("{key}: unknown key")); - let segments: Vec<&str> = key.split('.').collect(); - let (root, path) = match segments.as_slice() { - ["languages", _language, field] => ( - serde_json::to_value(schemars::schema_for!(LanguageConfig)).expect("schema serializes"), - vec![*field], - ), - ["languages", ..] => return Err(unknown()), - _ => (Config::schema(), segments), - }; + let root = Config::schema(); let resolve = |node: &Value| -> Value { match node.get("$ref").and_then(Value::as_str) { Some(reference) => { @@ -61,7 +52,7 @@ fn setting_schema(key: &str) -> Result { } }; let mut node = root.clone(); - for segment in path { + for segment in key.split('.') { node = resolve( resolve(&node) .get("properties") @@ -201,7 +192,6 @@ mod tests { // `theme.name` is a string, so digits are its text. set(&path, "theme.name", "1234").unwrap(); set(&path, "theme.path", "themes/mine.toml").unwrap(); - set(&path, "languages.rust.folds", "").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); assert!(text.contains("graph_limit = 20"), "{text}"); assert!(text.contains("name = \"1234\""), "{text}"); @@ -209,7 +199,45 @@ mod tests { let config = Config::from_toml(&text).unwrap(); assert_eq!(config.diff.graph_limit, 20); assert_eq!(config.theme.name, "1234"); - assert_eq!(config.languages["rust"].folds.as_deref(), Some("")); + } + + #[test] + fn plugin_keys_are_written_under_their_quoted_names() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + set(&path, "plugins.context.lines", "30").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let config = Config::from_toml(&text).unwrap(); + assert_eq!(config.plugins.entries["context"].options["lines"], 30); + assert!(set(&path, "plugins.context.typo", "1").is_err()); + assert!(set(&path, "plugins.context.lines", "-1").is_err()); + assert!(set(&path, "plugins.order", "[\"mine\"]").is_err()); + let error = |key: &str, value: &str| set(&path, key, value).unwrap_err().to_string(); + assert_eq!( + error("plugins.context.enabled", "yes"), + "plugins.context.enabled: expected true or false, got \"yes\"" + ); + assert!(error("plugins.order", "context") + .starts_with("plugins.order: expected a TOML array such as [\"a\", \"b\"]")); + assert_eq!( + error("plugins.context.lines", "many"), + "plugins.context.lines: expected an integer, got \"many\"" + ); + assert_eq!( + error("plugins.mine.enabled", "true"), + "plugins.mine.enabled: unknown key" + ); + set(&path, "plugins.context.enabled", "false").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + Config::from_toml(&text).unwrap().plugins.entries["context"].enabled, + Some(false) + ); + let shown = show(&config); + assert_eq!( + shown["plugins"]["context"], + serde_json::json!({"enabled": true, "lines": 30}) + ); } #[test] @@ -229,7 +257,6 @@ mod tests { ); assert!(error("diff.graph_limit", "-1").starts_with("diff.graph_limit: ")); assert_eq!(error("diff", "1"), "diff is a table; set one of its keys"); - assert_eq!(error("languages.rust", "1"), "languages.rust: unknown key"); assert_eq!(error("", "1"), "invalid key \"\""); assert_eq!( std::fs::read_to_string(&path).unwrap(), diff --git a/src/display/line_layout.rs b/src/display/line_layout.rs index 8db2ac07a..78e5511a7 100644 --- a/src/display/line_layout.rs +++ b/src/display/line_layout.rs @@ -4,12 +4,6 @@ use crate::pairing::Pairing; use crate::parse::syntax::MatchedPos; use std::collections::BTreeSet; -#[derive(Default)] -pub(crate) struct LineSelection { - pub(crate) lhs: BTreeSet, - pub(crate) rhs: BTreeSet, -} - pub(crate) type Row = (Option, Option); pub(crate) fn novel_lines(positions: &[MatchedPos]) -> BTreeSet { diff --git a/src/display/mod.rs b/src/display/mod.rs index 3a5a1b85b..6f4b7ec55 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -6,4 +6,3 @@ pub(crate) mod line_layout; pub(crate) mod prepare; pub(crate) mod side_by_side; pub(crate) mod style; -pub(crate) mod syntax_context; diff --git a/src/display/prepare.rs b/src/display/prepare.rs index f2631d5b4..433e992ac 100644 --- a/src/display/prepare.rs +++ b/src/display/prepare.rs @@ -1,7 +1,6 @@ //! Complete the selected rows before publishing a DiffResult. use super::hunks::Hunk; use super::line_layout; -use super::syntax_context::SyntaxAnnotations; use crate::parse::syntax::MatchedPos; use line_numbers::LineNumber; use std::collections::{BTreeMap, BTreeSet}; @@ -10,7 +9,6 @@ pub(crate) fn prepare( hunks: &[Hunk], sources: (&str, &str), positions: (&[MatchedPos], &[MatchedPos]), - annotations: &SyntaxAnnotations, padding: usize, ) -> Vec { let rows = line_layout::aligned_rows(sources, positions); @@ -40,20 +38,6 @@ pub(crate) fn prepare( for index in seeds { selected.extend(index.saturating_sub(padding)..(index + padding + 1).min(rows.len())); } - let context = annotations.context_for_changes(&hunk.novel_lhs, &hunk.novel_rhs); - let context_indexes = context - .lhs - .iter() - .filter_map(|line| lhs_index.get(line)) - .chain(context.rhs.iter().filter_map(|line| rhs_index.get(line))); - for &index in context_indexes { - let (Some(lhs), Some(rhs)) = rows[index] else { - continue; - }; - if !lhs_novel.contains(&lhs) && !rhs_novel.contains(&rhs) { - selected.insert(index); - } - } selections.push(selected); } diff --git a/src/display/syntax_context.rs b/src/display/syntax_context.rs deleted file mode 100644 index 5b768338b..000000000 --- a/src/display/syntax_context.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Select enclosing syntax context for one change hunk. -use std::collections::BTreeSet; - -use super::line_layout::LineSelection; -use crate::parse::syntax::Syntax; - -/// Syntax candidates are discovered once, before hunks are constructed. -#[derive(Clone, Debug, Default)] -pub(crate) struct SyntaxAnnotations { - lhs_candidates: Vec, - rhs_candidates: Vec, -} - -#[derive(Clone, Debug)] -pub(crate) struct ContextCandidate { - pub(crate) contains: std::ops::RangeInclusive, - pub(crate) rows: BTreeSet, -} - -impl SyntaxAnnotations { - pub(crate) fn collect((lhs, rhs): (&[&Syntax<'_>], &[&Syntax<'_>])) -> Self { - Self { - lhs_candidates: candidates(lhs), - rhs_candidates: candidates(rhs), - } - } - - pub(crate) fn context_for_changes( - &self, - lhs: &crate::hash::DftHashSet, - rhs: &crate::hash::DftHashSet, - ) -> LineSelection { - LineSelection { - lhs: select_candidates(&self.lhs_candidates, lhs), - rhs: select_candidates(&self.rhs_candidates, rhs), - } - } -} - -fn candidates(roots: &[&Syntax<'_>]) -> Vec { - let mut contexts = Vec::new(); - let mut occupied = BTreeSet::new(); - let mut pending = roots.to_vec(); - while let Some(node) = pending.pop() { - contexts.extend(node.info().context.borrow().iter().cloned()); - match node { - Syntax::Atom { position, .. } => { - occupied.extend(position.iter().map(|span| span.line.as_usize())) - } - Syntax::List { - open_position, - close_position, - children, - .. - } => { - occupied.extend( - open_position - .iter() - .chain(close_position) - .filter(|span| span.start_col < span.end_col) - .map(|span| span.line.as_usize()), - ); - pending.extend(children); - } - } - } - contexts - .into_iter() - .map(|context| { - let mut rows: BTreeSet<_> = occupied.range(context.header).copied().collect(); - rows.extend(context.closing); - ContextCandidate { - contains: context.contains, - rows, - } - }) - .collect() -} - -fn select_candidates( - candidates: &[ContextCandidate], - changed: &crate::hash::DftHashSet, -) -> BTreeSet { - let changed: BTreeSet<_> = changed.iter().map(|line| line.as_usize()).collect(); - candidates - .iter() - .filter(|candidate| changed.range(candidate.contains.clone()).next().is_some()) - .flat_map(|candidate| candidate.rows.iter().copied()) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Params; - use crate::parse::{guess_language::Language, tree_sitter_parser as parser}; - use typed_arena::Arena; - - #[test] - fn context_survives_without_source_or_tree() { - let arena = Arena::new(); - let syntax = { - let source = String::from( - "def run(\n value,\n):\n return {\n 'key': value,\n }\n", - ); - parser::parse( - &arena, - &source, - Params::default().language(Language::Python), - false, - ) - .unwrap() - }; - let selected = select_candidates( - &candidates(&syntax), - &std::iter::once(line_numbers::LineNumber::from(4)).collect(), - ); - assert_eq!(selected, BTreeSet::from([0, 1, 2, 3, 5])); - } - - #[test] - fn atomic_tail_retains_context_after_wrapper_flattening() { - let arena = Arena::new(); - let syntax = parser::parse( - &arena, - "fn run() -> i32 {\n answer\n}\n", - Params::default().language(Language::Rust), - false, - ) - .unwrap(); - let mut pending = syntax; - let mut found_tail = false; - while let Some(node) = pending.pop() { - match node { - Syntax::Atom { content, .. } if content == "answer" => { - assert!(node - .info() - .context - .borrow() - .iter() - .any(|context| context.contains == (1..=1))); - found_tail = true; - } - Syntax::List { children, .. } => pending.extend(children), - _ => {} - } - } - assert!(found_tail); - } -} diff --git a/src/engine.rs b/src/engine.rs index af7b193a6..904c77a4d 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,4 +1,6 @@ //! Shared source-to-domain diff computation, independent of CLI and transport. +#[cfg(test)] +use crate::config::body_params; use crate::config::Params; use crate::constants::Side; use crate::diff::changes::ChangeMap; @@ -20,6 +22,10 @@ use humansize::{format_size, FormatSizeOptions, BINARY}; use std::{env, fmt, path::Path}; use typed_arena::Arena; +/// The fallback reason for a file tagged `generated`, which is always diffed +/// by line. +pub(crate) const GENERATED_FALLBACK: &str = "generated file, diffed by line"; + /// A file whose fold query captured one syntax node with two different /// ranges. The file is not diffed; the stream reports it as a /// `query_conflict` error. @@ -33,10 +39,10 @@ pub(crate) struct QueryConflict { impl fmt::Display for QueryConflict { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let (first, second) = self.conflict.patterns; + let (first, second) = &self.conflict.sources; write!( f, - "{}:{}{}: fold query patterns {first} and {second} capture the same {} with different fold ranges", + "{}:{}{}: {first} and {second} capture the same {} with different fold ranges", self.path, self.conflict.line + 1, match self.side { @@ -50,14 +56,10 @@ impl fmt::Display for QueryConflict { impl std::error::Error for QueryConflict {} -/// The fallback reason for a file tagged `generated`, which is always diffed -/// by line. -pub(crate) const GENERATED_FALLBACK: &str = "generated file, diffed by line"; - impl DiffResult { #[cfg(test)] pub(crate) fn from_sources(path: &str, lhs: &str, rhs: &str) -> Self { - Self::from_sources_with_params(path, lhs, rhs, &Params::default()) + Self::from_sources_with_params(path, lhs, rhs, &body_params()) } /// A diff with the given parameters, for tests whose queries cannot @@ -154,7 +156,6 @@ pub(crate) fn diff_file_content( diff_options: &DiffOptions, overrides: &[(LanguageOverride, Vec)], ) -> Result { - let mut annotations = display::syntax_context::SyntaxAnnotations::default(); let guess_src = match rhs_path { FileArgument::DevNull => &lhs_src, _ => &rhs_src, @@ -256,7 +257,7 @@ pub(crate) fn diff_file_content( hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], - has_byte_changes, + has_byte_changes, has_syntactic_changes, }); } @@ -294,9 +295,6 @@ pub(crate) fn diff_file_content( // they belong to. 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); ( @@ -329,10 +327,6 @@ pub(crate) fn diff_file_content( rhs_positions.extend(rhs_comments); } - annotations = display::syntax_context::SyntaxAnnotations::collect( - (&lhs, &rhs), - ); - ( FileFormat::SupportedLanguage(language), lhs_positions, @@ -420,8 +414,6 @@ pub(crate) fn diff_file_content( syntax::init_all_info(&lhs, &rhs); folds::unmatched(&lhs, &mut lhs_folds); folds::unmatched(&rhs, &mut rhs_folds); - annotations = - display::syntax_context::SyntaxAnnotations::collect((&lhs, &rhs)); let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); @@ -486,7 +478,6 @@ pub(crate) fn diff_file_content( &hunks, (lhs_src, rhs_src), (&lhs_positions, &rhs_positions), - &annotations, display_options.num_context_lines as usize, ); diff --git a/src/git.rs b/src/git.rs index 5a394a112..dda3bdbb0 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,6 +1,8 @@ //! Git comparison selection and lazy source loading used by the CLI and its stdout stream. use crate::config::Params; +use crate::constants::Side; use crate::pairing::Pairing; +use crate::plugin::{Head, Pipeline}; use crate::protocol; use crate::summary::DiffResult; use crate::tags::{self, Attributes, Prefix, PREFIX_BYTES}; @@ -11,7 +13,7 @@ use std::{ fmt, io::Read as _, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, }; pub(crate) type Result = std::result::Result>; @@ -272,6 +274,7 @@ impl Default for FileParams { } /// Blob IDs pin revision/index content without retaining every file's source text. +#[derive(Clone)] enum Source { Absent, Blob { id: Oid, mode: git2::FileMode }, @@ -306,35 +309,42 @@ impl Source { /// cannot apply: not a regular file, binary, or not UTF-8. Loading the /// whole source reports those. fn prefix(&self, repo: &Repository) -> anyhow::Result, bool)>> { - let (bytes, complete) = match self { - Self::Absent => return Ok(None), + let Some(mut bytes) = self.head(repo, PREFIX_BYTES + 1)? else { + return Ok(None); + }; + let complete = bytes.len() <= PREFIX_BYTES; + bytes.truncate(PREFIX_BYTES); + if bytes.contains(&0) { + return Ok(None); + } + Ok(Some((bytes, complete))) + } + + /// Up to `max` bytes from the start of the source, or `None` when it is + /// absent or not a regular file. + fn head(&self, repo: &Repository, max: usize) -> anyhow::Result>> { + match self { + Self::Absent => Ok(None), Self::Blob { mode, .. } | Self::WorkingFile { mode, .. } if !matches!(mode, git2::FileMode::Blob | git2::FileMode::BlobExecutable) => { - return Ok(None); + Ok(None) } Self::Blob { id, .. } => { let blob = repo.find_blob(*id).context(FileError::ReadFailed)?; let content = blob.content(); - let end = content.len().min(PREFIX_BYTES); - (content[..end].to_vec(), content.len() <= PREFIX_BYTES) + Ok(Some(content[..content.len().min(max)].to_vec())) } Self::WorkingFile { path, .. } => { - let mut bytes = Vec::with_capacity(PREFIX_BYTES + 1); + let mut bytes = Vec::with_capacity(max.min(PREFIX_BYTES + 1)); std::fs::File::open(path) .context(FileError::ReadFailed)? - .take(PREFIX_BYTES as u64 + 1) + .take(max as u64) .read_to_end(&mut bytes) .context(FileError::ReadFailed)?; - let complete = bytes.len() <= PREFIX_BYTES; - bytes.truncate(PREFIX_BYTES); - (bytes, complete) + Ok(Some(bytes)) } - }; - if bytes.contains(&0) { - return Ok(None); } - Ok(Some((bytes, complete))) } fn read(&self, repo: &Repository) -> anyhow::Result { @@ -405,13 +415,20 @@ impl DiffSession { self.files.len() } + /// List the comparison's files and their tags: the bundled rules, git + /// attributes, then each plugin's `classify` in `pipeline`, whose + /// failure fails the session. pub(crate) fn open( workspace: &Path, comparison: Comparison, params: Arc, files: &FileParams, + pipeline: &Pipeline, ) -> Result { let repo = Repository::open(workspace)?; + // A plugin's store owns what it reads sources with, so classifying + // plugins share a handle of their own. + let shared = Arc::new(Mutex::new(Repository::open(workspace)?)); let comparison = comparison.resolve(&repo)?; let pending = { let diff = comparison.diff(&repo, files)?; @@ -504,6 +521,10 @@ impl DiffSession { } } file.tags = attributes.resolve(bundled); + let head = head_of(&shared, &before, &after); + file.tags = pipeline + .classify(&file.manifest_entry(), &head) + .map_err(|error| format!("{error:#}"))?; pending.push(PendingFile { before, after, @@ -536,6 +557,21 @@ impl DiffSession { } } +/// What a classifying plugin reads of a file: the start of either side. +fn head_of(repo: &Arc>, before: &Source, after: &Source) -> Head { + let (repo, before, after) = (Arc::clone(repo), before.clone(), after.clone()); + Arc::new(move |side, max| { + let repo = repo + .lock() + .expect("no reader panics holding the repository"); + match side { + Side::Left => &before, + Side::Right => &after, + } + .head(&repo, max) + }) +} + /// Sources read on the session thread; diffing needs no repository access. pub(crate) struct LoadedFile { pub(crate) file: FileChange, diff --git a/src/main.rs b/src/main.rs index 0fff8a006..e008eb491 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,7 @@ mod lines; mod options; mod pairing; mod parse; +mod plugin; pub(crate) mod protocol; mod summary; mod tags; @@ -679,10 +680,10 @@ fn conflict_or_die(result: Result) -> T { Ok(value) => value, Err(conflict) => { eprintln!( - "line {}: fold query patterns {} and {} capture the same {} with different fold ranges", + "line {}: {} and {} capture the same {} with different fold ranges", conflict.line + 1, - conflict.patterns.0, - conflict.patterns.1, + conflict.sources.0, + conflict.sources.1, conflict.kind ); std::process::exit(EXIT_BAD_ARGUMENTS); diff --git a/src/pairing.rs b/src/pairing.rs index 701a5588e..d588c38ee 100644 --- a/src/pairing.rs +++ b/src/pairing.rs @@ -26,6 +26,22 @@ impl Pairing { Self::RightOnly { rhs } => Pairing::RightOnly { rhs: f(rhs) }, } } + + /// The before side, when there is one. + pub(crate) fn lhs(&self) -> Option<&T> { + match self { + Self::Both { lhs, .. } | Self::LeftOnly { lhs } => Some(lhs), + Self::RightOnly { .. } => None, + } + } + + /// The after side, or the before side when there is no after side. + pub(crate) fn rhs_or_lhs(&self) -> &T { + match self { + Self::Both { rhs, .. } | Self::RightOnly { rhs } => rhs, + Self::LeftOnly { lhs } => lhs, + } + } } #[derive(Serialize, Deserialize)] diff --git a/src/parse/context.rs b/src/parse/context.rs deleted file mode 100644 index 7edff732b..000000000 --- a/src/parse/context.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Semantic context boundaries captured before Tree-sitter wrappers are flattened. -use super::query::{last_line, node_range}; -use crate::config::query::AnnotationQuery; -use crate::hash::DftHashMap; -use crate::lines::SourcePosition; -use streaming_iterator::StreamingIterator as _; -use tree_sitter::{QueryCursor, Tree}; - -/// Whole-line context, independent of the eventual hunk or display layout. -#[derive(Clone, Debug)] -pub(crate) struct ContextMetadata { - pub(crate) contains: std::ops::RangeInclusive, - pub(crate) header: std::ops::RangeInclusive, - pub(crate) closing: Option, -} - -/// Interpret configurable context captures in their own query traversal. -pub(crate) fn classify( - tree: &Tree, - src: &str, - compiled: Option<&AnnotationQuery>, -) -> DftHashMap> { - let mut contexts = DftHashMap::<_, Vec<_>>::default(); - let Some(compiled) = compiled else { - return contexts; - }; - let query = &compiled.query; - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(query, tree.root_node(), src.as_bytes()); - while let Some(matched) = matches.next() { - let capture = |name| { - matched - .captures - .iter() - .rev() - .find(|capture| query.capture_names()[capture.index as usize] == name) - }; - let Some(owner) = capture("context") else { - continue; - }; - let scope = node_range(owner.node); - let start = - capture("context.start").map_or(scope.start, |capture| node_range(capture.node).start); - let end = if let Some(capture) = capture("context.end") { - node_range(capture.node).start - } else if let Some(capture) = capture("context.final") { - node_range(capture.node).end - } else { - SourcePosition { - line: (scope.start.line.0 + 1).into(), - byte_column: 0, - } - }; - if (start.line, start.byte_column) >= (end.line, end.byte_column) { - continue; - } - let last = capture("context.last").map(|capture| last_line(&node_range(capture.node))); - contexts - .entry(owner.node.id()) - .or_default() - .push(ContextMetadata { - contains: scope.start.line.as_usize()..=last_line(&scope), - header: start.line.as_usize() - ..=end - .line - .as_usize() - .saturating_sub(usize::from(end.byte_column == 0)), - closing: last, - }); - } - contexts -} diff --git a/src/parse/fold_tests.rs b/src/parse/fold_tests.rs index 479cb3e9c..12690f960 100644 --- a/src/parse/fold_tests.rs +++ b/src/parse/fold_tests.rs @@ -83,9 +83,8 @@ mod folds { assert!(result .rhs_folds .iter() - .any(|fold| fold.placeholder == "String" - && added(fold, &result.lhs_folds) - .is_some_and(|r| text(src, r) == "\"\"\"first\nsecond\"\"\""))); + .any(|fold| added(fold, &result.lhs_folds) + .is_some_and(|r| text(src, r) == "\"\"\"first\nsecond\"\"\""))); } #[test] @@ -156,7 +155,7 @@ mod folds { assert!(diff .lhs_folds .iter() - .any(|f| matches!(f.placeholder.as_str(), "Import" | "Imports"))); + .any(|f| text(&lhs, &f.range) == "import os")); } #[test] @@ -247,6 +246,37 @@ mod folds { } } + #[test] + fn two_queries_folding_the_same_lines_make_one_fold() { + // A function whose body is one `match`: the block the shared query + // folds and the `match` the context query folds cover the same + // lines, so they are one fold. It is the `match`'s, the innermost + // node whose extent that region is, and it carries both tags. + let src = + "fn f(x: u32) -> u32 {\n match x {\n 1 => 2,\n _ => 3,\n }\n}\n"; + let params = crate::config::Config::from_toml("") + .expect("a valid configuration") + .compile() + .expect("the bundled queries compile"); + let review = DiffResult::from_sources_with_params("a.rs", "", src, ¶ms); + let lines: Vec<&str> = src.split_terminator('\n').collect(); + let same_lines: Vec<&Fold> = review + .rhs_folds + .iter() + .filter(|fold| crate::parse::folds::line_span(fold, &lines) == (1, 5)) + .collect(); + assert_eq!(same_lines.len(), 1, "{same_lines:#?}"); + assert_eq!( + text(src, &same_lines[0].range), + "match x {\n 1 => 2,\n _ => 3,\n }" + ); + assert!( + same_lines[0].tags.iter().any(|tag| tag == "context:scope"), + "{:?}", + same_lines[0].tags + ); + } + #[test] fn inline_collections_have_exact_paired_byte_ranges() { let lhs = "const x = [\"☕\", oldValue];\n"; @@ -320,7 +350,10 @@ mod results { fn identical_files_keep_alignment_for_showing_hidden_source() { let source = "fn unchanged() {\n work();\n}\n"; let diff = DiffResult::from_sources("a.rs", source, source); - let rows = aligned_rows((source, source), (&diff.lhs_positions, &diff.rhs_positions)); + let rows = aligned_rows( + (source, source), + (&diff.lhs_positions, &diff.rhs_positions), + ); assert_eq!( rows, [(Some(0), Some(0)), (Some(1), Some(1)), (Some(2), Some(2))] diff --git a/src/parse/folds.rs b/src/parse/folds.rs index 8b2e9518d..c181873a8 100644 --- a/src/parse/folds.rs +++ b/src/parse/folds.rs @@ -27,7 +27,9 @@ pub(crate) struct Fold { /// wire, where the projection numbers regions itself. pub(crate) syntax_id: SyntaxId, pub(crate) match_kind: FoldMatch, - /// Text shown in place of the source. + /// Text shown in place of the source. Always empty here: only a plugin + /// that collapses a fold gives it a label, and a fold no plugin + /// collapsed has none. pub(crate) placeholder: String, } @@ -50,13 +52,16 @@ pub(crate) struct Conflict { pub(crate) line: usize, /// The node's tree-sitter kind, such as `function_item`. pub(crate) kind: String, - /// The two patterns, by index in the language's fold query, sorted. The - /// query is one text here, so an index is all a pattern can be named by. - pub(crate) patterns: (usize, usize), + /// The two query sources, sorted. + pub(crate) sources: (String, String), } /// Interpret configurable fold captures in their own query traversal. /// +/// Every `@fold` capture in one match forms one fold attached to the first +/// captured node: a single node's range, the text between `@fold.open` and +/// `@fold.close`, the text from `@fold.open` to the end of the node, or the +/// hull of a quantified run such as `(comment)+ @fold`. /// Captures of the same node merge their tags when their ranges agree; when /// they disagree the result is a [`Conflict`]. pub(crate) fn classify( @@ -73,61 +78,86 @@ pub(crate) fn classify( let mut matches = cursor.matches(query, tree.root_node(), src.as_bytes()); while let Some(matched) = matches.next() { let pattern = &compiled.patterns[matched.pattern_index]; - for fold in matched - .captures - .iter() - .filter(|capture| query.capture_names()[capture.index as usize] == "fold") - { - let capture = |name| { - matched - .captures - .iter() - .rev() - .find(|capture| query.capture_names()[capture.index as usize] == name) - }; - let region = match (capture("fold.open"), capture("fold.close")) { - (Some(open), Some(close)) - if open.node.start_byte() >= fold.node.start_byte() - && close.node.end_byte() <= fold.node.end_byte() - && open.node.end_byte() <= close.node.start_byte() => - { - SourceRange { - start: node_range(open.node).end, - end: node_range(close.node).start, - } + let named = |name: &'static str| { + matched + .captures + .iter() + .filter(move |capture| query.capture_names()[capture.index as usize] == name) + }; + let folds: Vec<_> = named("fold").collect(); + let Some(first) = folds.iter().min_by_key(|capture| capture.node.start_byte()) else { + continue; + }; + let region = match (named("fold.open").last(), named("fold.close").last()) { + (Some(open), Some(close)) + if folds.len() == 1 + && open.node.start_byte() >= first.node.start_byte() + && close.node.end_byte() <= first.node.end_byte() + && open.node.end_byte() <= close.node.start_byte() => + { + SourceRange { + start: node_range(open.node).end, + end: node_range(close.node).start, } - // No closing delimiter: the fold runs to the end of its - // node, from an opening token that may precede the node, - // such as the `:` ahead of a Python block. - (Some(open), None) if open.node.end_byte() <= fold.node.end_byte() => SourceRange { + } + // No closing delimiter: the fold runs to the end of its node, + // from an opening token that may precede the node, such as the + // `:` ahead of a Python block. + (Some(open), None) + if folds.len() == 1 && open.node.end_byte() <= first.node.end_byte() => + { + SourceRange { start: node_range(open.node).end, - end: node_range(fold.node).end, - }, - (None, None) => node_range(fold.node), - _ => continue, - }; - if region.start == region.end { - continue; + end: node_range(first.node).end, + } } - let (metadata, first_pattern) = kinds.entry(fold.node.id()).or_insert(( - FoldMetadata { - tags: Vec::new(), - range_override: Some(region), - }, - matched.pattern_index, - )); - if metadata.range_override != Some(region) { - let mut patterns = [*first_pattern, matched.pattern_index]; - patterns.sort(); - return Err(Conflict { - line: fold.node.start_position().row, - kind: fold.node.kind().to_owned(), - patterns: (patterns[0], patterns[1]), - }); + (None, None) => { + let last = folds + .iter() + .max_by_key(|capture| capture.node.end_byte()) + .expect("a fold capture"); + SourceRange { + start: node_range(first.node).start, + end: node_range(last.node).end, + } + } + _ => continue, + }; + if region.start == region.end { + continue; + } + match kinds.entry(first.node.id()) { + Entry::Vacant(entry) => { + let mut tags = pattern.tags.clone(); + tags.sort(); + tags.dedup(); + entry.insert(( + FoldMetadata { + tags, + range_override: Some(region), + }, + pattern.source, + )); + } + Entry::Occupied(mut entry) => { + let (metadata, source) = entry.get_mut(); + if metadata.range_override != Some(region) { + let mut sources = [ + compiled.sources[*source].clone(), + compiled.sources[pattern.source].clone(), + ]; + sources.sort(); + let [first_source, second_source] = sources; + return Err(Conflict { + line: first.node.start_position().row, + kind: first.node.kind().to_owned(), + sources: (first_source, second_source), + }); + } + metadata.tags.extend(pattern.tags.iter().cloned()); + metadata.tags.sort(); + metadata.tags.dedup(); } - metadata.tags.extend(pattern.tags.iter().cloned()); - metadata.tags.sort(); - metadata.tags.dedup(); } } Ok(kinds @@ -342,17 +372,9 @@ pub(crate) fn project(node: &Syntax<'_>, partner: Option<&Syntax<'_>>) -> Option }, None => FoldMatch::Novel, }, - placeholder: metadata - .tags - .first() - .map(|tag| { - let mut chars = tag.chars(); - chars - .next() - .map(|first| first.to_uppercase().collect::() + chars.as_str()) - .unwrap_or_default() - }) - .unwrap_or_else(|| "…".into()), + // A label is a plugin's to give: the parse knows what a fold covers, + // not what to say in its place. + placeholder: String::new(), }) } diff --git a/src/parse/mod.rs b/src/parse/mod.rs index d4072fcf7..d698599c9 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod context; #[cfg(test)] mod fold_tests; pub(crate) mod folds; diff --git a/src/parse/query.rs b/src/parse/query.rs index 50bc188f7..fd85d4ef7 100644 --- a/src/parse/query.rs +++ b/src/parse/query.rs @@ -1,13 +1,7 @@ -//! Source-coordinate helpers shared by the independent annotation queries. +//! Source-coordinate helpers for the annotation queries. use crate::lines::{SourcePosition, SourceRange}; use tree_sitter::Node; -pub(super) fn last_line(range: &SourceRange) -> usize { - range.end.line.as_usize().saturating_sub(usize::from( - range.end.byte_column == 0 && range.end.line > range.start.line, - )) -} - pub(super) fn node_range(node: Node<'_>) -> SourceRange { let position = |point: tree_sitter::Point| SourcePosition { line: (point.row as u32).into(), diff --git a/src/parse/syntax.rs b/src/parse/syntax.rs index 7b2bc2067..ec73dfc44 100644 --- a/src/parse/syntax.rs +++ b/src/parse/syntax.rs @@ -79,8 +79,6 @@ pub(crate) struct SyntaxInfo<'a> { /// The fold this node owns, if a query gave it one. A node has at most /// one, so two folds align exactly when the matcher paired their nodes. pub(crate) fold: RefCell>, - /// Multiple enclosing contexts can survive on one flattened node. - pub(crate) context: RefCell>, /// The previous node with the same parent as this one. previous_sibling: Cell>>, /// The next node with the same parent as this one. @@ -111,7 +109,6 @@ impl<'a> SyntaxInfo<'a> { pub(crate) fn new() -> Self { Self { fold: RefCell::new(None), - context: RefCell::new(Vec::new()), previous_sibling: Cell::new(None), next_sibling: Cell::new(None), prev: Cell::new(None), diff --git a/src/parse/tree_sitter_parser.rs b/src/parse/tree_sitter_parser.rs index b2b053315..151060a75 100644 --- a/src/parse/tree_sitter_parser.rs +++ b/src/parse/tree_sitter_parser.rs @@ -1462,8 +1462,7 @@ fn tree_highlights( } Ok(HighlightedNodeIds { - fold_kinds: folds::classify(tree, src, Some(&config.folds))?, - contexts: super::context::classify(tree, src, Some(&config.context)), + fold_kinds: folds::classify(tree, src, Some(&config.query))?, comment_ids, keyword_ids, string_ids, @@ -1727,7 +1726,6 @@ fn find_delim_positions( #[derive(Debug)] pub(crate) struct HighlightedNodeIds { fold_kinds: DftHashMap, - contexts: DftHashMap>, keyword_ids: DftHashSet, comment_ids: DftHashSet, string_ids: DftHashSet, @@ -1849,13 +1847,6 @@ fn syntax_from_cursor<'a>( } else { atom_from_cursor(arena, src, nl_pos, cursor, highlights, ignore_comments) }?; - if let Some(contexts) = highlights.contexts.get(&node.id()) { - syntax - .info() - .context - .borrow_mut() - .extend(contexts.iter().cloned()); - } Some(syntax) } diff --git a/src/plugin/builtin.rs b/src/plugin/builtin.rs new file mode 100644 index 000000000..29029e24b --- /dev/null +++ b/src/plugin/builtin.rs @@ -0,0 +1,97 @@ +//! The bundled plugins' folders, embedded. Each is a folder under +//! `plugins/` shaped like any plugin's: a `plugin.toml` (name, title, options +//! schema, query files), its query files, and its code, a crate that uses the +//! plugin SDK. The code is compiled in and reached through the native +//! registry ([`super::native`]); `plugin.toml` and the query files are +//! embedded here, so `builtin:/` names `plugins//`. +use super::config::Manifest; +use std::sync::OnceLock; + +/// The bundled plugins, in their default order. +pub(crate) const NAMES: [&str; 1] = ["context"]; + +macro_rules! embed { + ($($path:literal),* $(,)?) => { + &[$(($path, include_str!(concat!("../../plugins/", $path)))),*] + }; +} + +/// Every embedded file, by its path under `plugins/`. +const FILES: &[(&str, &str)] = embed![ + "context/plugin.toml", + "context/queries/go.scm", + "context/queries/javascript.scm", + "context/queries/python.scm", + "context/queries/rust.scm", +]; + +/// An embedded file by its normalized path under `plugins/`. +pub(crate) fn file(path: &str) -> Option<&'static str> { + FILES + .iter() + .find(|(name, _)| *name == path) + .map(|(_, text)| *text) +} + +/// Every bundled plugin's `plugin.toml`, in [`NAMES`] order. +pub(crate) fn manifests() -> &'static [Manifest] { + static MANIFESTS: OnceLock> = OnceLock::new(); + MANIFESTS.get_or_init(|| { + NAMES + .iter() + .map(|name| { + let path = format!("{name}/plugin.toml"); + let text = file(&path).unwrap_or_else(|| panic!("{path} is embedded")); + Manifest::parse(text).unwrap_or_else(|error| panic!("plugins/{path}: {error}")) + }) + .collect() + }) +} + +pub(crate) fn manifest(name: &str) -> Option<&'static Manifest> { + manifests().iter().find(|manifest| manifest.name == name) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::native::REGISTRY; + use std::collections::BTreeSet; + use std::path::Path; + + #[test] + fn every_plugin_folder_is_embedded_and_described() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins"); + let mut on_disk = BTreeSet::new(); + let mut folders = BTreeSet::new(); + for folder in std::fs::read_dir(&root).unwrap() { + let folder = folder.unwrap().path(); + let name = folder.file_name().unwrap().to_str().unwrap().to_owned(); + if folder.join("plugin.toml").exists() { + on_disk.insert(format!("{name}/plugin.toml")); + folders.insert(name.clone()); + } + let queries = folder.join("queries"); + if queries.exists() { + for query in std::fs::read_dir(queries).unwrap() { + let query = query.unwrap().path(); + let file = query.file_name().unwrap().to_str().unwrap(); + on_disk.insert(format!("{name}/queries/{file}")); + } + } + } + let embedded: BTreeSet = FILES.iter().map(|(path, _)| (*path).to_owned()).collect(); + assert_eq!(embedded, on_disk); + assert_eq!( + folders, + NAMES.iter().map(|name| (*name).to_owned()).collect() + ); + for name in NAMES { + assert_eq!(manifest(name).unwrap().name, name); + assert!( + REGISTRY.iter().any(|(native, _)| *native == name), + "{name} has native code" + ); + } + } +} diff --git a/src/plugin/config.rs b/src/plugin/config.rs new file mode 100644 index 000000000..e6facfc0d --- /dev/null +++ b/src/plugin/config.rs @@ -0,0 +1,646 @@ +//! `[plugins]`: the order plugins run in, and one entry per plugin with its +//! switch and options. Also `plugin.toml`, the static description every +//! plugin folder carries: the plugin's name, title, options schema and query +//! files. +//! +//! Every bundled plugin has an entry, pre-filled with the defaults its +//! `plugin.toml` declares; a file only writes the keys it changes. `order` +//! must name every entry exactly once. In an entry, `enabled` and `path` are +//! diffr's; every other key is one of the plugin's options, validated +//! against the schema in its `plugin.toml`. +//! +//! Every entry has a plugin folder: `plugin.toml`, its query files, and for +//! a component `plugin.wasm`. A bundled plugin's folder is embedded in diffr +//! ([`builtin`]); an entry with `path` names a folder on disk, relative to +//! the configuration file's directory, whose `plugin.toml` names the entry. +//! A bundled plugin's entry may set `path` too, and that folder then runs in +//! its place. Both kinds of folder are read the same way, and +//! [`super::Pipeline`] loads what they hold the same way. +use super::builtin; +use crate::config::ConfigError; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +/// Query files per language key. +pub(crate) type Queries = BTreeMap; + +/// The key in a plugin entry that turns the plugin on and off. +pub(crate) const ENABLED: &str = "enabled"; + +/// The key in a plugin entry that points at a plugin folder on disk. +pub(crate) const PATH: &str = "path"; + +/// The keys in a plugin entry that diffr owns: a plugin's options may not +/// use them. +pub(crate) const RESERVED: [&str; 2] = [ENABLED, PATH]; + +/// A plugin folder's description. +pub(crate) const MANIFEST_FILE: &str = "plugin.toml"; + +/// A plugin's `plugin.toml`. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Manifest { + /// The plugin's entry name in `[plugins]`, and the prefix of every tag + /// its queries set: `:`. + pub(crate) name: String, + /// The human name settings screens group the plugin's settings under. + pub(crate) title: String, + #[serde(default)] + pub(crate) description: String, + /// How settings screens show the `enabled` switch, and whether the + /// plugin is on by default. Without it the switch is titled "Run + /// " and the plugin is on. + #[serde(default)] + pub(crate) enabled: Option<Switch>, + /// Each option's JSON Schema, in the order settings screens list them. + /// Every option has a `title`; one with a `default` is pre-filled. + #[serde(default)] + pub(crate) options: Map<String, Value>, + /// Query files per language key, relative to the plugin's folder. + #[serde(default)] + pub(crate) queries: Queries, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Switch { + pub(crate) title: String, + #[serde(default)] + pub(crate) description: String, + /// Whether an entry that does not set `enabled` runs the plugin. + #[serde(default = "on")] + pub(crate) default: bool, +} + +fn on() -> bool { + true +} + +impl Manifest { + /// Parse and check a `plugin.toml`. + pub(crate) fn parse(text: &str) -> Result<Self, String> { + let manifest: Self = toml::from_str(text).map_err(|error| error.to_string())?; + manifest.check()?; + Ok(manifest) + } + + /// The manifest has a name and a title, every option has a title and + /// is not a key diffr owns, the options schema is valid, and every + /// default satisfies it. + fn check(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("the plugin has no name".to_owned()); + } + if self.title.trim().is_empty() { + return Err(format!("{}: the plugin has no title", self.name)); + } + if let Some(reserved) = RESERVED.iter().find(|key| self.options.contains_key(**key)) { + return Err(format!( + "{}: the options declare {reserved:?}, which diffr owns", + self.name + )); + } + if let Some((key, _)) = self.options.iter().find(|(_, option)| { + option + .get("title") + .and_then(Value::as_str) + .is_none_or(|title| title.trim().is_empty()) + }) { + return Err(format!( + "{}: option {key:?} has no title for settings screens", + self.name + )); + } + self.validate(&self.defaults()) + .map_err(|error| format!("{}: the defaults: {error}", self.name)) + } + + /// The JSON Schema of the plugin's options as an object: unknown keys + /// are errors. + fn options_schema(&self) -> Value { + json!({ + "type": "object", + "properties": self.options, + "additionalProperties": false, + }) + } + + /// Check `options` against the options schema. The message leads with + /// the dotted path of the key it concerns, when there is one. + pub(crate) fn validate(&self, options: &Map<String, Value>) -> Result<(), String> { + let validator = jsonschema::validator_for(&self.options_schema()) + .map_err(|error| format!("the options schema is invalid: {error}"))?; + let instance = Value::Object(options.clone()); + let errors: Vec<String> = validator + .iter_errors(&instance) + .map(|error| match error.instance_path().as_str() { + "" => error.to_string(), + path => format!( + "{}: {error}", + path.trim_start_matches('/').replace('/', ".") + ), + }) + .collect(); + match errors.is_empty() { + true => Ok(()), + false => Err(errors.join("; ")), + } + } + + /// Whether an entry that does not set `enabled` runs the plugin. + pub(crate) fn enabled_by_default(&self) -> bool { + self.enabled.as_ref().is_none_or(|switch| switch.default) + } + + /// Every option that declares a default, with it. + pub(crate) fn defaults(&self) -> Map<String, Value> { + self.options + .iter() + .filter_map(|(key, option)| { + option + .get("default") + .map(|default| (key.clone(), default.clone())) + }) + .collect() + } + + /// The entry this plugin adds to `diffr config schema` under `plugins`. + /// Each option keeps its own schema, with the plugin's title as its + /// `x-group` unless it sets one. An option whose type is an array or an + /// object is marked `"x-settings": false`: settings screens edit + /// scalars. + pub(crate) fn settings_schema(&self) -> Value { + let group = &self.title; + let (title, description) = match &self.enabled { + Some(switch) => (switch.title.clone(), switch.description.clone()), + None => (format!("Run {group}"), self.description.clone()), + }; + let enabled = self.enabled_by_default(); + let mut properties = Map::new(); + properties.insert( + ENABLED.to_owned(), + json!({ + "type": "boolean", + "title": title, + "description": description, + "default": enabled, + "x-group": group, + }), + ); + for (key, option) in &self.options { + let mut option = option.clone(); + if let Some(option) = option.as_object_mut() { + option + .entry("x-group") + .or_insert_with(|| Value::String(group.clone())); + let structured = |kind: &Value| matches!(kind.as_str(), Some("array" | "object")); + let structured = match option.get("type") { + Some(Value::Array(kinds)) => kinds.iter().any(structured), + Some(kind) => structured(kind), + None => false, + }; + if structured { + option.insert("x-settings".to_owned(), Value::Bool(false)); + } + } + properties.insert(key.clone(), option); + } + json!({ + "type": "object", + "title": group, + "description": self.description, + "properties": properties, + }) + } +} + +/// `[plugins]`. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct PluginsConfig { + /// The plugins in the order they run; each sees the region trees the + /// ones before it left. Every entry is listed exactly once. + pub(crate) order: Vec<String>, + /// Every entry, by plugin name. + #[serde(flatten)] + pub(crate) entries: BTreeMap<String, Entry>, +} + +/// One plugin's entry: diffr's keys, and the plugin's options. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct Entry { + /// Whether the plugin runs; once resolved, set, from the file or the + /// plugin's default. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) enabled: Option<bool>, + /// The plugin's folder on disk, as written: relative to the + /// configuration file's directory, or absolute. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) path: Option<PathBuf>, + /// The folder, loaded when the configuration resolves: `path`'s, or the + /// bundled plugin's. + #[serde(skip)] + pub(crate) folder: Option<Folder>, + #[serde(flatten)] + pub(crate) options: Map<String, Value>, +} + +/// Where a plugin folder is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Location { + /// Embedded in diffr: `plugins/<name>/`. + Bundled, + /// On disk. Canonical. + Disk(PathBuf), +} + +/// A plugin folder and the `plugin.toml` in it. +#[derive(Clone, Debug)] +pub(crate) struct Folder { + pub(crate) location: Location, + pub(crate) manifest: Manifest, +} + +impl Folder { + /// The bundled plugin `name`'s embedded folder. + fn bundled(name: &str) -> Option<Self> { + builtin::manifest(name).map(|manifest| Self { + location: Location::Bundled, + manifest: manifest.clone(), + }) + } + + /// The folder's query files per language key, each path resolved against + /// the folder: `builtin:<name>/<path>` for an embedded folder, absolute + /// for one on disk. A `builtin:` path stays as written. + pub(crate) fn queries(&self) -> Queries { + self.manifest + .queries + .iter() + .map(|(language, path)| { + let path = match (path.starts_with("builtin:"), &self.location) { + (true, _) => path.clone(), + (false, Location::Bundled) => format!("builtin:{}/{path}", self.manifest.name), + (false, Location::Disk(dir)) => dir.join(path).display().to_string(), + }; + (language.clone(), path) + }) + .collect() + } + + /// Load the folder at `dir` for the entry `name`: its `plugin.toml` must + /// name the entry. + fn load(name: &str, dir: &Path) -> Result<Self, ConfigError> { + let dir = std::fs::canonicalize(dir) + .map_err(|error| ConfigError(format!("{}: {error}", dir.display())))?; + let manifest_path = dir.join(MANIFEST_FILE); + let text = std::fs::read_to_string(&manifest_path) + .map_err(|error| ConfigError(format!("{}: {error}", manifest_path.display())))?; + let manifest = Manifest::parse(&text) + .map_err(|error| ConfigError(format!("{}: {error}", manifest_path.display())))?; + if manifest.name != name { + return Err(ConfigError(format!( + "{}: the plugin is named {:?}, not {name:?}", + manifest_path.display(), + manifest.name + ))); + } + Ok(Self { + location: Location::Disk(dir), + manifest, + }) + } +} + +impl Entry { + /// The entry's folder. Every entry has one once the configuration + /// resolves. + pub(crate) fn folder(&self) -> &Folder { + self.folder + .as_ref() + .expect("a resolved entry has its plugin folder") + } + + /// Whether the plugin runs. + pub(crate) fn is_enabled(&self) -> bool { + self.enabled.expect("a resolved entry is enabled or not") + } +} + +impl Default for PluginsConfig { + fn default() -> Self { + let mut config = Self { + order: builtin::NAMES.map(str::to_owned).to_vec(), + entries: BTreeMap::new(), + }; + config + .resolve(Path::new("")) + .expect("the bundled plugins' defaults are valid"); + config + } +} + +impl PluginsConfig { + /// Load every entry's folder, the bundled plugin's or the one `path` + /// names relative to `base`, check every plugin's options against its + /// schema and fill in the defaults and `enabled`, add an entry for every + /// bundled plugin the file did not write, and check that `order` names + /// every entry exactly once. + pub(crate) fn resolve(&mut self, base: &Path) -> Result<(), ConfigError> { + for name in builtin::NAMES { + self.entries.entry(name.to_owned()).or_default(); + } + for (name, entry) in &mut self.entries { + let folder = match &entry.path { + Some(path) => Folder::load(name, &base.join(path)) + .map_err(|error| ConfigError(format!("plugins.{name}: {error}")))?, + None => Folder::bundled(name).ok_or_else(|| { + ConfigError(format!( + "plugins.{name}: no bundled plugin has this name, and the entry has no path" + )) + })?, + }; + let manifest = &folder.manifest; + entry.enabled.get_or_insert(manifest.enabled_by_default()); + manifest + .validate(&entry.options) + .map_err(|error| ConfigError(format!("plugins.{name}: {error}")))?; + for (key, default) in manifest.defaults() { + entry.options.entry(key).or_insert(default); + } + entry.folder = Some(folder); + } + let mut seen = BTreeSet::new(); + for name in &self.order { + if !self.entries.contains_key(name) { + return Err(ConfigError(format!( + "plugins.order: no plugin entry named {name:?}" + ))); + } + if !seen.insert(name.as_str()) { + return Err(ConfigError(format!( + "plugins.order: {name:?} is listed twice" + ))); + } + } + if let Some(missing) = self + .entries + .keys() + .find(|name| !seen.contains(name.as_str())) + { + return Err(ConfigError(format!( + "plugins.order: the plugin entry {missing:?} is not listed" + ))); + } + Ok(()) + } + + /// The enabled entries, in `order`. + pub(crate) fn enabled(&self) -> impl Iterator<Item = (&str, &Entry)> { + self.order.iter().filter_map(|name| { + self.entries + .get(name) + .filter(|entry| entry.is_enabled()) + .map(|entry| (name.as_str(), entry)) + }) + } + + /// The query files of every enabled plugin, in `order`, resolved + /// against each plugin's folder. + pub(crate) fn enabled_queries(&self) -> Vec<(String, Queries)> { + self.enabled() + .map(|(name, entry)| (name.to_owned(), entry.folder().queries())) + .filter(|(_, queries)| !queries.is_empty()) + .collect() + } + + /// The `plugins` property of `diffr config schema`: `order`, and every + /// bundled plugin's entry. + pub(crate) fn schema() -> Value { + let mut properties = Map::new(); + properties.insert( + "order".to_owned(), + json!({ + "type": "array", + "items": {"type": "string"}, + "description": "The plugins in the order they run; each sees the region trees the ones before it left. Every entry is listed exactly once.", + "default": builtin::NAMES, + "x-settings": false, + }), + ); + for manifest in builtin::manifests() { + properties.insert(manifest.name.clone(), manifest.settings_schema()); + } + json!({ + "type": "object", + "description": "The plugins that decide what starts collapsed, hidden, linked or grouped, and the fold queries they own.", + "properties": properties, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::config::Config; + use serde_json::Value; + + /// Every setting the schema lists, as a settings screen flattens it: + /// `(dotted key, title, group, type)`. A key marked `"x-settings": false` + /// is not a setting. + fn settings(schema: &Value) -> Vec<(String, String, String, String)> { + fn resolve<'a>(node: &'a Value, root: &'a Value) -> &'a Value { + match node.get("$ref").and_then(Value::as_str) { + Some(reference) => { + let name = reference.rsplit('/').next().unwrap(); + &root["$defs"][name] + } + None => node, + } + } + fn walk( + node: &Value, + root: &Value, + key: &str, + out: &mut Vec<(String, String, String, String)>, + ) { + let resolved = resolve(node, root); + if node.get("x-settings") == Some(&Value::Bool(false)) { + return; + } + if let Some(properties) = resolved.get("properties").and_then(Value::as_object) { + for (name, child) in properties { + let key = if key.is_empty() { + name.clone() + } else { + format!("{key}.{name}") + }; + walk(child, root, &key, out); + } + return; + } + let text = |field: &str| { + node.get(field) + .or_else(|| resolved.get(field)) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned() + }; + let kind = resolved + .get("type") + .map(|kind| kind.to_string()) + .or_else(|| resolved.get("enum").map(|_| "enum".to_owned())) + .or_else(|| resolved.get("anyOf").map(|_| "optional".to_owned())) + .expect("a typed setting"); + out.push((key.to_owned(), text("title"), text("x-group"), kind)); + } + let mut out = Vec::new(); + walk(schema, schema, "", &mut out); + out + } + + #[test] + fn every_plugin_setting_has_a_title_and_a_group_and_is_a_scalar() { + let schema = Config::schema(); + let settings = settings(&schema); + let plugins: Vec<_> = settings + .iter() + .filter(|(key, ..)| key.starts_with("plugins.")) + .collect(); + assert!(plugins.len() >= 2, "{plugins:?}"); + for (key, title, group, kind) in &settings { + assert!( + !title.is_empty() && !group.is_empty(), + "{key} has no title or group" + ); + assert!( + !kind.contains("array") && !kind.contains("object"), + "{key} is {kind}" + ); + } + let group = |key: &str| { + plugins + .iter() + .find(|(own, ..)| own == key) + .map(|(_, title, group, _)| (title.as_str(), group.as_str())) + .unwrap_or_else(|| panic!("no setting {key}")) + }; + assert_eq!( + group("plugins.context.enabled"), + ("Collapse unchanged lines", "Context") + ); + assert_eq!(group("plugins.context.lines"), ("Context lines", "Context")); + assert!(schema["properties"].get("languages").is_none()); + let plugins = &schema["properties"]["plugins"]["properties"]; + assert_eq!(plugins["order"]["x-settings"], false); + assert_eq!(plugins["order"]["type"], "array"); + let keys: Vec<&String> = plugins.as_object().unwrap().keys().collect(); + assert_eq!(keys, ["order", "context"]); + } + + #[test] + fn order_names_every_entry_exactly_once() { + let order = |names: &str| Config::from_toml(&format!("[plugins]\norder = [{names}]")); + assert!(order("'context'").is_ok()); + let error = order("").err().unwrap().to_string(); + assert!(error.contains("\"context\" is not listed"), "{error}"); + let error = order("'context', 'mine'").err().unwrap().to_string(); + assert!(error.contains("no plugin entry named \"mine\""), "{error}"); + let error = order("'context', 'context'").err().unwrap().to_string(); + assert!(error.contains("listed twice"), "{error}"); + let error = Config::from_toml("[plugins.mine]\nenabled = true") + .err() + .unwrap() + .to_string(); + assert_eq!( + error, + "plugins.mine: no bundled plugin has this name, and the entry has no path" + ); + } + + #[test] + fn a_path_entry_loads_its_folder_relative_to_the_config_directory() { + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("plugins/mine"); + std::fs::create_dir_all(folder.join("queries")).unwrap(); + std::fs::write( + folder.join("plugin.toml"), + "name = 'mine'\ntitle = 'Mine'\n[options.depth]\ntype = 'integer'\ntitle = 'Depth'\ndefault = 2\n[queries]\nrust = 'queries/rust.scm'\npython = 'builtin:context/queries/python.scm'\n", + ) + .unwrap(); + std::fs::write( + folder.join("queries/rust.scm"), + "((block) @fold (#set! tag \"mine:block\"))\n", + ) + .unwrap(); + let order = "order = ['context', 'mine']"; + let config = Config::from_toml_in( + &format!("[plugins]\n{order}\n[plugins.mine]\npath = 'plugins/mine'\n"), + dir.path(), + ) + .unwrap(); + let entry = &config.plugins.entries["mine"]; + assert_eq!(entry.options["depth"], 2); + assert!(entry.options.get("path").is_none()); + let queries = config.plugins.enabled_queries(); + let (_, mine) = queries.iter().find(|(name, _)| name == "mine").unwrap(); + let canonical = std::fs::canonicalize(&folder).unwrap(); + assert_eq!( + mine["rust"], + canonical.join("queries/rust.scm").display().to_string() + ); + assert_eq!(mine["python"], "builtin:context/queries/python.scm"); + config.compile().unwrap(); + + let error = |toml: &str| { + Config::from_toml_in(toml, dir.path()) + .err() + .unwrap() + .to_string() + }; + let renamed = error(&format!( + "[plugins]\n{}\n[plugins.other]\npath = 'plugins/mine'\n", + order.replace("'mine'", "'other'") + )); + assert!( + renamed.starts_with("plugins.other: ") + && renamed.ends_with("the plugin is named \"mine\", not \"other\""), + "{renamed}" + ); + let missing = error("[plugins.context]\npath = 'plugins/absent'\n"); + assert!(missing.starts_with("plugins.context: "), "{missing}"); + std::fs::write( + folder.join("plugin.toml"), + "name = 'mine'\ntitle = 'Mine'\n[options.path]\ntype = 'string'\ntitle = 'Path'\n", + ) + .unwrap(); + let reserved = error(&format!( + "[plugins]\n{order}\n[plugins.mine]\npath = 'plugins/mine'\n" + )); + assert!(reserved.contains("which diffr owns"), "{reserved}"); + } + + #[test] + fn options_are_checked_against_the_plugin_toml_and_filled_with_its_defaults() { + let config = Config::from_toml("[plugins.context]\nlines = 5\n").unwrap(); + let context = &config.plugins.entries["context"]; + assert_eq!(context.enabled, Some(true)); + assert_eq!(context.options["lines"], 5); + let default = Config::default(); + assert_eq!(default.plugins.entries["context"].options["lines"], 3); + let error = |toml: &str| Config::from_toml(toml).err().unwrap().to_string(); + let typo = error("[plugins.context]\ntypo = 1\n"); + assert!( + typo.starts_with("plugins.context: ") && typo.contains("typo"), + "{typo}" + ); + let mistyped = error("[plugins.context]\nlines = 'many'\n"); + assert!( + mistyped.starts_with("plugins.context: lines: "), + "{mistyped}" + ); + } +} diff --git a/src/plugin/host.rs b/src/plugin/host.rs new file mode 100644 index 000000000..9433889f1 --- /dev/null +++ b/src/plugin/host.rs @@ -0,0 +1,113 @@ +//! diffr's side of the host functions every plugin calls: `read_head`, +//! `git` and `log`. A component reaches them through its imports +//! ([`super::wasm`]); a native plugin through the SDK's host functions, +//! which call [`Native`] ([`super::native`]). Both run this code. +use super::Head; +use crate::constants::Side; +use anyhow::Context as _; +use diffr_plugin_sdk::host::Host as SdkHost; +use diffr_plugin_sdk::types; +use std::cell::RefCell; +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +/// What one call of a plugin reads and runs things in. +#[derive(Clone)] +pub(crate) struct Host { + pub(crate) name: Arc<str>, + pub(crate) workdir: Arc<Path>, + /// The start of either side of the file the call is about: the blob + /// during `classify`, the text as diffed during `mutate`, nothing during + /// `new`. + pub(crate) head: Head, +} + +impl Host { + pub(crate) fn read_head( + &self, + side: types::Side, + max_bytes: u32, + ) -> anyhow::Result<Option<Vec<u8>>> { + let side = match side { + types::Side::Lhs => Side::Left, + types::Side::Rhs => Side::Right, + }; + (self.head)(side, max_bytes as usize) + } + + /// `git`'s stdout when it exits successfully, its stderr otherwise. + /// `Err` is the host failing: git could not be started, or wrote + /// something that is not UTF-8. + pub(crate) fn git(&self, args: &[String]) -> anyhow::Result<Result<String, String>> { + let output = Command::new("git") + .args(args) + .current_dir(&*self.workdir) + .output() + .with_context(|| format!("plugin {}: running git {args:?}", self.name))?; + let text = |bytes: Vec<u8>| { + String::from_utf8(bytes) + .with_context(|| format!("plugin {}: git {args:?} wrote non-UTF-8", self.name)) + }; + Ok(match output.status.success() { + true => Ok(text(output.stdout)?), + false => Err(text(output.stderr)?), + }) + } + + pub(crate) fn log(&self, message: &str) { + eprintln!("diffr plugin {}: {message}", self.name); + } +} + +/// The host of one call of a native plugin. The SDK's host functions cannot +/// fail, as a component's imports cannot; a failure of the host is kept here +/// and fails the call when the plugin returns, as it would trap a component. +pub(crate) struct Native { + host: Host, + failure: RefCell<Option<anyhow::Error>>, +} + +impl Native { + pub(crate) fn new(host: Host) -> Self { + Self { + host, + failure: RefCell::new(None), + } + } + + /// The first failure of the host during the call, if any. + pub(crate) fn finish(&self) -> anyhow::Result<()> { + match self.failure.borrow_mut().take() { + Some(error) => Err(error), + None => Ok(()), + } + } + + fn fail(&self, error: anyhow::Error) { + self.failure.borrow_mut().get_or_insert(error); + } +} + +impl SdkHost for Native { + fn read_head(&self, side: types::Side, max_bytes: u32) -> Option<Vec<u8>> { + self.host + .read_head(side, max_bytes) + .unwrap_or_else(|error| { + self.fail(error); + None + }) + } + + fn git(&self, args: &[String]) -> Result<String, String> { + self.host.git(args).unwrap_or_else(|error| { + let message = format!("{error:#}"); + self.fail(error); + Err(message) + }) + } + + fn log(&self, message: &str) { + self.host.log(message) + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs new file mode 100644 index 000000000..869172678 --- /dev/null +++ b/src/plugin/mod.rs @@ -0,0 +1,366 @@ +//! The plugin host: plugins decide how each diffed file is shown. Nothing in +//! this module is specific to one plugin; the bundled plugins live in +//! `plugins/<name>/`. +//! +//! Every plugin implements the one contract in `wit/plugin.wit`, through the +//! SDK's `Plugin` trait, and diffr loads and runs every plugin the same way. +//! [`config`] reads each enabled entry's folder, embedded or on disk: its +//! `plugin.toml` (name, title, options schema, query files) and, when it has +//! one, its component. [`Pipeline::from_config`] then takes the component +//! ([`wasm`]) or, for a folder without one, the native code registered under +//! the plugin's name ([`native`]), and makes the plugin's one instance for +//! the run from its options. From there a [`Runner`] is a [`Runner`]: each +//! call gets the contract's records, built once per call from the file's +//! manifest entry and its current trees, and the host functions of [`host`]. +//! +//! The pipeline for one file: git lists it and its tags, and each plugin's +//! `classify` adds its own; the diff runs with one fold query per language, +//! assembled from the query files of every enabled plugin ([`queries`]); the +//! projection builds the region trees and pairs them; then each enabled +//! plugin's `mutate` runs, in `plugins.order`, on the finished trees, +//! starting with `context`, which collapses unchanged lines far from any +//! change; then `stats.visible` is recounted and the record is written. +//! Queries decide which regions exist and tag them, plugins decide how they +//! start out, and no plugin matches one side to the other: pairing is the +//! projection's. +//! +//! A plugin returns moves. It never edits a tree itself: the SDK's applier +//! carries the moves out, in order, and the next plugin sees the result. A +//! move that cannot be carried out, or a plugin that fails, stops the run +//! (`complete.aborted`). +pub(crate) mod builtin; +pub(crate) mod config; +pub(crate) mod host; +pub(crate) mod native; +pub(crate) mod queries; + +#[cfg(test)] +mod tests; + +use crate::constants::Side; +use crate::pairing::Pairing; +use crate::protocol::{self, FileChange, FileStatus, SourcePos, SourceRange}; +use anyhow::{anyhow, Context as _}; +use config::PluginsConfig; +use diffr_plugin_sdk::{apply, tree, types}; +use host::Host; +use serde_json::Value; +use std::collections::BTreeSet; +use std::fmt; +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +/// Up to a number of bytes from the start of one side of a file, or `None` +/// when the file has no such side or it is not a regular file. +pub(crate) type Head = Arc<dyn Fn(Side, usize) -> anyhow::Result<Option<Vec<u8>>> + Send + Sync>; + +/// One plugin instance: its native code or its component, made once from +/// its options, behind the contract's two calls on a file. Each call gets +/// the host for that call. +pub(crate) trait Runner: Send + Sync { + fn classify(&self, host: Host, file: &types::FileEntry) -> anyhow::Result<Vec<String>>; + + fn mutate( + &self, + host: Host, + file: &types::FileEntry, + lhs: Option<&types::Source>, + rhs: Option<&types::Source>, + ) -> anyhow::Result<Vec<types::Move>>; +} + +/// A plugin stopped the run: its own failure, or a move it asked for +/// that could not be carried out. The stream reports `mutation_failed`. +#[derive(Debug)] +pub(crate) struct MutationFailed(pub(crate) String); + +impl fmt::Display for MutationFailed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "mutation {}", self.0) + } +} + +impl std::error::Error for MutationFailed {} + +/// Makes a plugin's instance from its options, the entry as a JSON object +/// with defaults filled in, with the host of that call. +pub(crate) type Create<'a> = &'a dyn Fn(Host, &str) -> anyhow::Result<Box<dyn Runner>>; + +/// A plugin in the pipeline. +struct Loaded { + name: Arc<str>, + runner: Box<dyn Runner>, +} + +/// The enabled plugins, in the order they run, and the directory their +/// `git` runs in. +pub(crate) struct Pipeline { + plugins: Vec<Loaded>, + workdir: Arc<Path>, +} + +impl Default for Pipeline { + fn default() -> Self { + Self { + plugins: Vec::new(), + workdir: Path::new(".").into(), + } + } +} + +/// A head that reads nothing: `new` is about no file. +fn no_head() -> Head { + Arc::new(|_, _| Ok(None)) +} + +impl Pipeline { + /// Load every enabled plugin in `plugins.order` and make its instance. A + /// folder with a component runs it; any other folder runs the native + /// code registered under its name. Loading or making a plugin can fail + /// (a component that does not compile or link, options that do not + /// deserialize, a summarizer without an API key), which is a setup error + /// naming the plugin. + pub(crate) fn from_config(config: &PluginsConfig, workdir: &Path) -> anyhow::Result<Self> { + let mut pipeline = Self { + plugins: Vec::new(), + workdir: workdir.into(), + }; + for (name, entry) in config.enabled() { + let options = Value::Object(entry.options.clone()); + let create = native::lookup(name) + .ok_or_else(|| anyhow!("plugins.{name}: diffr has no plugin of that name"))?; + pipeline.push(name, options, &create)?; + } + Ok(pipeline) + } + + /// Make the plugin `name` with `create` from `options` and add it to the + /// end of the pipeline. + fn push(&mut self, name: &str, options: Value, create: Create<'_>) -> anyhow::Result<()> { + let name: Arc<str> = name.into(); + let runner = create(self.host(&name, no_head()), &options.to_string()) + .with_context(|| format!("plugins.{name}"))?; + self.plugins.push(Loaded { name, runner }); + Ok(()) + } + + fn host(&self, name: &Arc<str>, head: Head) -> Host { + Host { + name: Arc::clone(name), + workdir: Arc::clone(&self.workdir), + head, + } + } + + /// The file's tags once every plugin has classified it, in order: each + /// sees the tags the ones before it left. Sorted and deduplicated. + pub(crate) fn classify(&self, file: &FileChange, head: &Head) -> anyhow::Result<Vec<String>> { + let mut entry = file_entry(file); + for plugin in &self.plugins { + let name = &plugin.name; + let path = &entry.path; + let added = plugin + .runner + .classify(self.host(&plugin.name, Arc::clone(head)), &entry) + .with_context(|| format!("plugin {name}: classify {path}"))?; + if let Some(bad) = added.iter().find(|tag| !crate::tags::is_tag(tag)) { + anyhow::bail!( + "plugin {name}: classify {path}: {bad:?} is not a tag; use lowercase letters, digits, '-' and '_'" + ); + } + let tags: BTreeSet<String> = entry.tags.drain(..).chain(added).collect(); + entry.tags = tags.into_iter().collect(); + } + Ok(entry.tags) + } + + /// Run every plugin on one file's sides, returning the file's own + /// visibility. + pub(crate) fn run( + &self, + file: &FileChange, + sides: &mut Pairing<protocol::Source>, + ) -> anyhow::Result<protocol::Visibility> { + let entry = file_entry(file); + let mut trees = match &*sides { + Pairing::Both { lhs, rhs } => tree::Pairing::Both { + lhs: to_tree(lhs), + rhs: to_tree(rhs), + }, + Pairing::LeftOnly { lhs } => tree::Pairing::LeftOnly { lhs: to_tree(lhs) }, + Pairing::RightOnly { rhs } => tree::Pairing::RightOnly { rhs: to_tree(rhs) }, + }; + let mut visibility = types::Visibility::default(); + for plugin in &self.plugins { + let started = Instant::now(); + let lhs = trees.lhs().map(tree::Source::to_record); + let rhs = trees.rhs().map(tree::Source::to_record); + let texts: (Option<Arc<str>>, Option<Arc<str>>) = ( + lhs.as_ref().map(|side| side.text.as_str().into()), + rhs.as_ref().map(|side| side.text.as_str().into()), + ); + let head: Head = Arc::new(move |side, max| { + let text = match side { + Side::Left => &texts.0, + Side::Right => &texts.1, + }; + Ok(text + .as_ref() + .map(|text| text.as_bytes()[..text.len().min(max)].to_vec())) + }); + let moves = plugin + .runner + .mutate( + self.host(&plugin.name, head), + &entry, + lhs.as_ref(), + rhs.as_ref(), + ) + .with_context(|| MutationFailed(plugin.name.to_string()))?; + log::debug!( + "plugin {}: mutate {} took {:?}", + plugin.name, + entry.path, + started.elapsed() + ); + apply::apply(moves, &mut trees, &mut visibility) + .with_context(|| MutationFailed(plugin.name.to_string()))?; + } + match (sides, trees) { + ( + Pairing::Both { lhs, rhs }, + tree::Pairing::Both { + lhs: left, + rhs: right, + }, + ) => { + lhs.regions = from_tree(left.regions); + rhs.regions = from_tree(right.regions); + } + (Pairing::LeftOnly { lhs }, tree::Pairing::LeftOnly { lhs: left }) => { + lhs.regions = from_tree(left.regions); + } + (Pairing::RightOnly { rhs }, tree::Pairing::RightOnly { rhs: right }) => { + rhs.regions = from_tree(right.regions); + } + _ => unreachable!("moves never add or remove a side"), + } + Ok(protocol::Visibility { + collapsed: visibility.collapsed, + label: visibility.label, + }) + } +} + +/// The contract's record of a manifest entry. +pub(crate) fn file_entry(file: &FileChange) -> types::FileEntry { + types::FileEntry { + path: file.file.rhs_or_lhs().path.clone(), + old_path: file.file.lhs().map(|side| side.path.clone()), + status: match file.status { + FileStatus::Added => types::FileStatus::Added, + FileStatus::Deleted => types::FileStatus::Deleted, + FileStatus::Modified => types::FileStatus::Modified, + FileStatus::Renamed => types::FileStatus::Renamed, + FileStatus::Copied => types::FileStatus::Copied, + FileStatus::TypeChanged => types::FileStatus::TypeChanged, + }, + tags: file.tags.clone(), + } +} + +/// One side of the wire as the tree the applier works on. +pub(crate) fn to_tree(side: &protocol::Source) -> tree::Source { + fn regions(list: &[protocol::Region]) -> Vec<tree::Region> { + list.iter() + .map(|region| tree::Region { + id: region.id, + fold_state_id: region.fold_state_id, + range: types::Range { + start: types::Position { + line: region.range.start.line, + column: region.range.start.column, + }, + end: types::Position { + line: region.range.end.line, + column: region.range.end.column, + }, + }, + tags: region.tags.clone(), + visibility: types::Visibility { + collapsed: region.visibility.collapsed, + label: region.visibility.label.clone(), + }, + node: match ®ion.node { + protocol::Node::Leaf { + alignment_id, + changed, + } => tree::Node::Leaf { + alignment_id: *alignment_id, + changed: changed + .iter() + .map(|span| types::Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), + }, + protocol::Node::Fold { children } => tree::Node::Fold { + children: regions(children), + }, + }, + }) + .collect() + } + tree::Source { + text: side.text.clone(), + regions: regions(&side.regions), + } +} + +/// A side's regions back on the wire. +fn from_tree(regions: Vec<tree::Region>) -> Vec<protocol::Region> { + regions + .into_iter() + .map(|region| protocol::Region { + id: region.id, + fold_state_id: region.fold_state_id, + range: SourceRange { + start: SourcePos { + line: region.range.start.line, + column: region.range.start.column, + }, + end: SourcePos { + line: region.range.end.line, + column: region.range.end.column, + }, + }, + tags: region.tags, + visibility: protocol::Visibility { + collapsed: region.visibility.collapsed, + label: region.visibility.label, + }, + node: match region.node { + tree::Node::Leaf { + alignment_id, + changed, + } => protocol::Node::Leaf { + alignment_id, + changed: changed + .into_iter() + .map(|span| protocol::Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), + }, + tree::Node::Fold { children } => protocol::Node::Fold { + children: from_tree(children), + }, + }, + }) + .collect() +} diff --git a/src/plugin/native.rs b/src/plugin/native.rs new file mode 100644 index 000000000..e466e4357 --- /dev/null +++ b/src/plugin/native.rs @@ -0,0 +1,66 @@ +//! The native registry: the bundled plugins' code, compiled into diffr. Each +//! is an SDK [`Plugin`], the same trait a component's source implements, made +//! the same way from the same options string, and diffr calls it with the +//! same records it hands a component. +use super::host::{self, Host}; +use super::Runner; +use anyhow::anyhow; +use diffr_plugin_sdk::types::{FileEntry, Move, Source}; +use diffr_plugin_sdk::Plugin; +use std::rc::Rc; + +/// Makes a native plugin's instance from its options string. +pub(crate) type Constructor = fn(Host, &str) -> anyhow::Result<Box<dyn Runner>>; + +/// Every plugin diffr has native code for, by the name its `plugin.toml` +/// gives it. +pub(crate) const REGISTRY: &[(&str, Constructor)] = + &[("context", native::<diffr_plugin_context::Context>)]; + +/// The constructor of the native plugin `name`, if diffr has one. +pub(crate) fn lookup(name: &str) -> Option<Constructor> { + REGISTRY + .iter() + .find(|(native, _)| *native == name) + .map(|(_, constructor)| *constructor) +} + +/// Make the native plugin `P`: deserialize `options` into `P::Options`, as a +/// component's `new` does, and call `P::new` with `host` behind the host +/// functions. +pub(crate) fn native<P: Plugin + Send + Sync + 'static>( + host: Host, + options: &str, +) -> anyhow::Result<Box<dyn Runner>> { + let options: P::Options = + serde_json::from_str(options).map_err(|error| anyhow!("invalid options: {error}"))?; + let plugin = call(host, || P::new(options))?; + Ok(Box::new(Native(plugin))) +} + +/// Run `call`, one call of a native plugin, inside a host scope of its own. +fn call<R>(host: Host, call: impl FnOnce() -> anyhow::Result<R>) -> anyhow::Result<R> { + let native = Rc::new(host::Native::new(host)); + let result = diffr_plugin_sdk::host::scope(native.clone(), call); + native.finish()?; + result +} + +/// A native plugin's instance. +struct Native<P>(P); + +impl<P: Plugin + Send + Sync> Runner for Native<P> { + fn classify(&self, host: Host, file: &FileEntry) -> anyhow::Result<Vec<String>> { + call(host, || self.0.classify(file)) + } + + fn mutate( + &self, + host: Host, + file: &FileEntry, + lhs: Option<&Source>, + rhs: Option<&Source>, + ) -> anyhow::Result<Vec<Move>> { + call(host, || self.0.mutate(file, lhs, rhs)) + } +} diff --git a/src/plugin/queries.rs b/src/plugin/queries.rs new file mode 100644 index 000000000..c68a2fe34 --- /dev/null +++ b/src/plugin/queries.rs @@ -0,0 +1,273 @@ +//! Load the query files enabled plugins own and assemble one fold query per +//! language. +//! +//! Each plugin lists one query file per language key in its `plugin.toml`. A +//! path there is relative to the plugin's folder, a `builtin:<plugin>/<path>` +//! path naming a file of a bundled plugin embedded in diffr from `plugins/`, +//! or an absolute path. A query file may start with +//! `; inherits: a.scm, b.scm`: those files, relative to the importing file +//! (or `builtin:` paths), come first. Queries every bundled plugin shares +//! live in `plugins/shared/queries/<language>.scm`, imported as +//! `builtin:shared/queries/<language>.scm`. Within one language every file is +//! included at most once, however many plugins import it, and an import cycle +//! is an error. +use super::builtin; +use super::config::Queries; +use crate::config::query::QuerySource; +use crate::config::ConfigError; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +const BUILTIN_PREFIX: &str = "builtin:"; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Location { + /// A normalized path under `builtin:`. + Builtin(String), + /// A canonical filesystem path. + File(PathBuf), +} + +impl Location { + fn name(&self) -> String { + match self { + Self::Builtin(path) => format!("{BUILTIN_PREFIX}{path}"), + Self::File(path) => path.display().to_string(), + } + } + + fn builtin(path: &str) -> Result<Self, ConfigError> { + let mut segments: Vec<&str> = Vec::new(); + for segment in path.split('/') { + match segment { + "" | "." => {} + ".." => { + if segments.pop().is_none() { + return Err(ConfigError(format!( + "{BUILTIN_PREFIX}{path} leaves the bundled plugins" + ))); + } + } + segment => segments.push(segment), + } + } + let normalized = segments.join("/"); + if builtin::file(&normalized).is_none() { + return Err(ConfigError(format!( + "no bundled file {BUILTIN_PREFIX}{normalized}" + ))); + } + Ok(Self::Builtin(normalized)) + } + + fn file(path: &Path) -> Result<Self, ConfigError> { + std::fs::canonicalize(path) + .map(Self::File) + .map_err(|error| ConfigError(format!("{}: {error}", path.display()))) + } + + /// Where `path`, written inside this file, points. + fn resolve(&self, path: &str) -> Result<Self, ConfigError> { + if let Some(builtin) = path.strip_prefix(BUILTIN_PREFIX) { + return Self::builtin(builtin); + } + match self { + Self::Builtin(own) => { + let dir = own.rsplit_once('/').map_or("", |(dir, _)| dir); + Self::builtin(&format!("{dir}/{path}")) + } + Self::File(own) => Self::file( + &own.parent() + .expect("a canonical file path has a parent") + .join(path), + ), + } + } + + fn read(&self) -> Result<String, ConfigError> { + match self { + Self::Builtin(path) => Ok(builtin::file(path) + .expect("builtin locations are checked when resolved") + .to_owned()), + Self::File(path) => std::fs::read_to_string(path) + .map_err(|error| ConfigError(format!("{}: {error}", path.display()))), + } + } +} + +/// The paths a query file's first line imports. +fn imports(text: &str) -> Vec<&str> { + text.lines() + .next() + .and_then(|line| line.trim().strip_prefix(";")) + .and_then(|rest| rest.trim_start().strip_prefix("inherits:")) + .map(|paths| { + paths + .split(',') + .map(str::trim) + .filter(|path| !path.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +/// Everything one language's query is assembled from so far. +#[derive(Default)] +struct Assembly { + sources: Vec<QuerySource>, + included: BTreeSet<Location>, +} + +impl Assembly { + fn include( + &mut self, + location: Location, + stack: &mut Vec<Location>, + ) -> Result<(), ConfigError> { + if let Some(start) = stack.iter().position(|open| *open == location) { + let cycle: Vec<String> = stack[start..] + .iter() + .chain([&location]) + .map(Location::name) + .collect(); + return Err(ConfigError(format!( + "query imports form a cycle: {}", + cycle.join(" -> ") + ))); + } + if self.included.contains(&location) { + return Ok(()); + } + let text = location.read()?; + stack.push(location.clone()); + for import in imports(&text) { + let imported = location + .resolve(import) + .map_err(|error| ConfigError(format!("{}: {error}", location.name())))?; + self.include(imported, stack)?; + } + stack.pop(); + self.sources.push(QuerySource { + name: location.name(), + text, + }); + self.included.insert(location); + Ok(()) + } +} + +/// Per language key, the fold query sources of `plugins`, the enabled +/// plugins in `plugins.order` with their query files, imports before the +/// files that import them. Every path is absolute or `builtin:`, as +/// [`super::config::PluginsConfig::enabled_queries`] resolves them against +/// each plugin's folder. +pub(crate) fn assemble( + plugins: &[(String, Queries)], +) -> Result<BTreeMap<String, Vec<QuerySource>>, ConfigError> { + let mut languages: BTreeMap<String, Assembly> = BTreeMap::new(); + for (plugin, queries) in plugins { + for (language, path) in queries { + let key = format!("plugin {plugin}: queries.{language}"); + let location = match path.strip_prefix(BUILTIN_PREFIX) { + Some(builtin) => Location::builtin(builtin), + None if Path::new(path).is_absolute() => Location::file(Path::new(path)), + None => Err(ConfigError(format!( + "relative path {path} must be absolute or builtin:" + ))), + } + .map_err(|error| ConfigError(format!("{key}: {error}")))?; + languages + .entry(language.clone()) + .or_default() + .include(location, &mut Vec::new()) + .map_err(|error| ConfigError(format!("{key}: {error}")))?; + } + } + Ok(languages + .into_iter() + .map(|(language, assembly)| (language, assembly.sources)) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn write(dir: &Path, name: &str, text: &str) -> String { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, text).unwrap(); + path.display().to_string() + } + + fn names(sources: &[QuerySource]) -> Vec<&str> { + sources.iter().map(|source| source.name.as_str()).collect() + } + + #[test] + fn every_bundled_query_resolves_and_compiles() { + let assembled = assemble(&Config::default().plugins.enabled_queries()).unwrap(); + assert_eq!( + assembled.keys().collect::<Vec<_>>(), + [ + "go", + "javascript", + "javascriptjsx", + "python", + "rust", + "typescript", + "typescripttsx" + ] + ); + assert_eq!(names(&assembled["rust"]), ["builtin:context/queries/rust.scm"]); + assert_eq!( + names(&assembled["typescript"]), + ["builtin:context/queries/javascript.scm"] + ); + Config::default().compile().unwrap(); + } + + #[test] + fn disabled_plugins_contribute_no_queries() { + let config = Config::from_toml("[plugins.context]\nenabled = false\n").unwrap(); + let assembled = assemble(&config.plugins.enabled_queries()).unwrap(); + assert!(assembled.is_empty(), "{:?}", assembled.keys()); + } + + + #[test] + fn import_cycles_and_missing_files_are_errors() { + let dir = tempfile::tempdir().unwrap(); + let a = write(dir.path(), "a.scm", "; inherits: b.scm\n"); + write(dir.path(), "b.scm", "; inherits: a.scm\n"); + let queries = |path: &str| { + [( + "context".to_owned(), + Queries::from([("rust".to_owned(), path.to_owned())]), + )] + }; + let error = assemble(&queries(&a)).err().expect("a cycle").to_string(); + assert!(error.contains("cycle"), "{error}"); + assert!( + error.starts_with("plugin context: queries.rust: "), + "{error}" + ); + let absent = dir.path().join("absent.scm").display().to_string(); + assert!(assemble(&queries(&absent)).is_err()); + let error = assemble(&queries("builtin:context/queries/absent.scm")) + .err() + .expect("unknown builtin") + .to_string(); + assert!( + error.contains("no bundled file builtin:context/queries/absent.scm"), + "{error}" + ); + let error = assemble(&queries("builtin:summarize/../../outside.scm")) + .err() + .expect("a path out of the bundled plugins") + .to_string(); + assert!(error.contains("leaves the bundled plugins"), "{error}"); + } + +} diff --git a/src/plugin/tests/context.rs b/src/plugin/tests/context.rs new file mode 100644 index 000000000..bdb9199e1 --- /dev/null +++ b/src/plugin/tests/context.rs @@ -0,0 +1,566 @@ +use super::*; +use std::collections::BTreeSet; + +/// One rendered line per value, concatenated. +fn repeated(values: impl Iterator<Item = u32>, render: impl Fn(u32) -> String) -> String { + let mut out = String::new(); + for value in values { + out.push_str(&render(value)); + } + out +} + +fn shaped(path: &str, before: &str, after: &str, lines: u32) -> tree::Pairing<tree::Source> { + let (file, mut sides) = project(path, before, after); + run("context", json!({ "lines": lines }), &file, &mut sides); + trees(&sides) +} + +/// Every region's lines and whether it starts collapsed, with its label, +/// in document order. +fn rows(regions: &[tree::Region]) -> Vec<((u32, u32), bool, String, bool)> { + let mut out = Vec::new(); + walk(regions, &mut |region| { + out.push(( + (region.range.start.line, region.range.end.line), + region.visibility.collapsed, + region.visibility.label.clone(), + is_fold(region), + )) + }); + out +} + +/// Lines no collapsed region, or region under one, hides. +fn open_lines(regions: &[tree::Region]) -> BTreeSet<u32> { + fn visit(regions: &[tree::Region], out: &mut BTreeSet<u32>) { + for region in regions { + if region.visibility.collapsed { + // The header of a collapsed fold stays visible. + if is_fold(region) { + out.insert(region.range.start.line); + } + continue; + } + match ®ion.node { + tree::Node::Leaf { .. } => { + out.extend(region.range.start.line..region.range.end.line) + } + tree::Node::Fold { children } => visit(children, out), + } + } + } + let mut out = BTreeSet::new(); + visit(regions, &mut out); + out +} + +#[test] +fn long_unchanged_runs_collapse_to_the_context_width() { + let body = repeated(0..20, |i| format!("x{i} = {i}\n")); + let before = format!("{body}changed = 1\n{body}"); + let after = format!("{body}changed = 2\n{body}"); + let sides = shaped("a.py", &before, &after, 3); + let leaves: Vec<_> = rows(&lhs(&sides).regions) + .into_iter() + .map(|(lines, collapsed, label, _)| (lines, collapsed, label)) + .collect(); + assert_eq!( + leaves, + vec![ + ((0, 17), true, "17 unchanged lines".to_owned()), + ((17, 20), false, String::new()), + ((20, 21), false, String::new()), + ((21, 24), false, String::new()), + ((24, 41), true, "17 unchanged lines".to_owned()), + ] + ); + let sides = shaped("a.py", &before, &after, 1); + assert_eq!( + rows(&rhs(&sides).regions)[0], + ((0, 19), true, "19 unchanged lines".to_owned(), false) + ); +} + +#[test] +fn a_stretch_over_whole_folds_collapses_as_one_group() { + // Three unchanged functions sit between two changes: their folds, + // the lines between them and the cut ends collapse under one row. + let unchanged = repeated(0..3, |i| { + format!("def f{i}():\n a = {i}\n b = {i}\n return a + b\n\n") + }); + let before = format!("first = 1\n\n{unchanged}last = 1\n"); + let after = format!("first = 2\n\n{unchanged}last = 2\n"); + let sides = shaped("a.py", &before, &after, 1); + for source in [lhs(&sides), rhs(&sides)] { + let top: Vec<_> = source + .regions + .iter() + .filter(|region| region.visibility.collapsed) + .map(|region| { + ( + (region.range.start.line, region.range.end.line), + region.visibility.label.clone(), + is_fold(region), + ) + }) + .collect(); + assert_eq!(top, [((2, 16), "14 unchanged lines".to_owned(), true)]); + } + let tree::Pairing::Both { lhs, rhs } = &sides else { + panic!("both sides"); + }; + fn group(source: &tree::Source) -> Option<&tree::Region> { + source + .regions + .iter() + .find(|region| region.visibility.collapsed) + } + let (lhs_group, rhs_group) = (group(lhs).unwrap(), group(rhs).unwrap()); + assert_ne!(lhs_group.id, rhs_group.id); + assert_eq!( + lhs_group.fold_state_id, rhs_group.fold_state_id, + "the two sides' groups open and close together" + ); + assert_eq!(open_lines(&lhs.regions), BTreeSet::from([0, 1, 2, 16, 17])); +} + +#[test] +fn slivers_cut_from_a_stretch_by_a_fold_edge_stay_open() { + // The changed function's fold edge lands inside the stretch above the + // change; the two lines left inside the fold are not worth a row. + let head = repeated(0..8, |i| format!("x{i} = {i}\n")); + let before = format!("{head}\ndef f():\n a = 1\n b = 1\n c = 1\n return 1\n"); + let after = format!("{head}\ndef f():\n a = 1\n b = 1\n c = 1\n return 2\n"); + let sides = shaped("a.py", &before, &after, 1); + let collapsed: Vec<_> = rows(&rhs(&sides).regions) + .into_iter() + .filter(|(_, collapsed, ..)| *collapsed) + .map(|(lines, ..)| lines) + .collect(); + assert_eq!(collapsed, [(0, 9)]); +} + +#[test] +fn the_enclosing_header_stays_open_above_a_deep_change() { + // A change ten lines into a function whose signature runs to three + // lines, under ten unchanged lines of its own. The scope is the whole + // `fn`, so the line it starts on is shown even though it is far outside + // the padding and its body fold opens two lines below it. + let head = repeated(0..10, |i| format!("const C{i}: u32 = {i};\n")); + let body = repeated(0..10, |i| format!(" let a{i} = {i};\n")); + let signature = "fn outer(\n a: u32,\n b: u32,\n c: u32,\n) -> u32 {\n"; + let before = format!("{head}{signature}{body} 1\n}}\n"); + let after = format!("{head}{signature}{body} 2\n}}\n"); + let sides = shaped("a.rs", &before, &after, 1); + let open = open_lines(&rhs(&sides).regions); + assert_eq!(open, BTreeSet::from([10, 24, 25, 26])); + // Without a context query the whole signature collapses with the + // unchanged lines above it. + let config = Config::default(); + let queries = config + .plugins + .enabled_queries() + .into_iter() + .filter(|(plugin, _)| plugin != "context") + .collect(); + let params = config.compile_queries(queries).unwrap(); + let (file, mut sides) = + project_compiled("a.rs", &before, &after, ¶ms, DiffOptions::default()); + run("context", json!({"lines": 1}), &file, &mut sides); + let sides = trees(&sides); + assert_eq!( + open_lines(&rhs(&sides).regions), + BTreeSet::from([24, 25, 26]) + ); +} + +#[test] +fn a_file_the_diff_does_not_parse_has_no_enclosing_header() { + // A generated file is diffed by line without parsing, so its + // context query never runs. + let body = repeated(0..10, |i| format!(" let a{i} = {i};\n")); + let before = format!("fn outer() -> u32 {{\n{body} 1\n}}\n"); + let after = format!("fn outer() -> u32 {{\n{body} 2\n}}\n"); + let (file, mut sides) = project_with( + "a.rs", + &before, + &after, + DiffOptions { + generated: true, + ..DiffOptions::default() + }, + ); + let mut scopes = 0; + walk(&rhs(&trees(&sides)).regions, &mut |region| { + scopes += usize::from(has_tag(region, "context:scope")); + }); + assert_eq!(scopes, 0); + run("context", json!({"lines": 1}), &file, &mut sides); + let sides = trees(&sides); + assert_eq!( + open_lines(&rhs(&sides).regions), + BTreeSet::from([10, 11, 12]) + ); +} + +#[test] +fn a_stretch_crossing_a_fold_end_collapses_on_each_side_of_it() { + // The inner block's closer sits inside a long unchanged stretch that + // continues in the enclosing function. Regions are not refitted + // around it, so the part inside the block and the part after it + // collapse separately. + let body = repeated(1..=7, |n| format!(" u{n}();\n")); + let tail = repeated(1..=5, |n| format!(" v{n}();\n")); + let before = format!("fn f() {{\n if a {{\n x();\n{body} }}\n{tail}}}\n"); + let after = format!("fn f() {{\n if a {{\n y();\n{body} }}\n{tail}}}\n"); + let sides = shaped("a.rs", &before, &after, 1); + for source in [lhs(&sides), rhs(&sides)] { + let collapsed: Vec<_> = rows(&source.regions) + .into_iter() + .filter(|(_, collapsed, ..)| *collapsed) + .map(|(lines, _, label, _)| (lines, label)) + .collect(); + assert_eq!( + collapsed, + [ + ((4, 10), "6 unchanged lines".to_owned()), + ((10, 16), "6 unchanged lines".to_owned()) + ] + ); + // The inner block ends before the line its `}` sits on, and the + // function's scope runs to the brace that closes it: the scope keeps + // the line it opens on and the line it closes on. + let open = open_lines(&source.regions); + assert!(open.contains(&0) && open.contains(&16), "{open:?}"); + } +} + +#[test] +fn identical_files_collapse_whole_and_one_sided_files_stay_open() { + let sides = shaped("a.py", "x = 1\ny = 2\n", "x = 1\ny = 2\n", 3); + assert_eq!( + rows(&lhs(&sides).regions), + [((0, 2), true, "2 unchanged lines".to_owned(), false)] + ); + assert_eq!( + lhs(&sides).regions[0].alignment_id(), + rhs(&sides).regions[0].alignment_id() + ); + let (file, sides) = project("a.py", "", "def f():\n return 1\n"); + let tree::Pairing::Both { rhs: after, .. } = trees(&sides) else { + panic!("both sides"); + }; + let mut sides = tree::Pairing::RightOnly { rhs: after }; + run_trees("context", json!({"lines": 3}), &file, &mut sides); + assert!(rows(&rhs(&sides).regions) + .iter() + .all(|(_, collapsed, ..)| !collapsed)); +} + +#[test] +fn a_line_diff_fallback_has_unpaired_folds() { + const BEFORE: &str = "fn f(a: u32) -> u32 {\n let x = a + 1;\n let y = x * 2;\n let z = y * 2;\n let w = z * 2;\n x + y\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n"; + const AFTER: &str = "fn f(a: u32) -> u32 {\n let x = a + 1;\n let y = x * 2;\n let z = y * 2;\n let w = z * 2;\n x + y + 1\n}\n\nfn keep() -> u32 {\n let k = 1;\n let m = 2;\n k + m\n}\n"; + let (file, mut sides) = project_with( + "a.rs", + BEFORE, + AFTER, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); + run("context", json!({"lines": 1}), &file, &mut sides); + let sides = trees(&sides); + let open = open_lines(&rhs(&sides).regions); + // The parse's folds stand, so the changed function's header does. + assert!(open.contains(&0), "{open:?}"); + assert!(!open.contains(&2), "{open:?}"); + // Nothing matched the nodes the folds belong to, so the two sides' folds + // are unpaired: the unchanged function below collapses on the side the + // stretch is shaped from, and stays open on the other. + let tree::Pairing::Both { lhs: before, .. } = &sides else { + panic!("both sides"); + }; + assert!(!open_lines(&before.regions).contains(&10), "{open:?}"); + assert!(open.contains(&10), "{open:?}"); +} + +#[test] +fn a_fold_whose_matched_partner_holds_changes_stays_open() { + // Fold 1 on the lhs lies inside an unchanged stretch, but its + // matched partner on the rhs (fold 5, sharing the fold state) moved + // below and holds new lines. Collapsing fold 1 would hide it. + let range = |start: u32, end: u32| types::Range { + start: types::Position { + line: start, + column: 0, + }, + end: types::Position { + line: end, + column: 0, + }, + }; + let leaf = |id: u32, alignment: u32, start: u32, end: u32, changed: bool| tree::Region { + id, + fold_state_id: id, + range: range(start, end), + tags: vec![], + visibility: types::Visibility::default(), + node: tree::Node::Leaf { + alignment_id: alignment, + changed: (start..end) + .filter(|_| changed) + .map(|line| types::Span { + line, + start_column: 0, + end_column: 1, + }) + .collect(), + }, + }; + // The rhs leaf paired with the lhs leaf `lhs`, whose id is also its + // alignment id. + let paired = |id: u32, lhs: u32, start: u32, end: u32, changed: bool| tree::Region { + fold_state_id: lhs, + ..leaf(id, lhs, start, end, changed) + }; + let fold = |id: u32, state: u32, child: tree::Region| tree::Region { + id, + fold_state_id: state, + range: child.range, + tags: vec![], + visibility: types::Visibility::default(), + node: tree::Node::Fold { + children: vec![child], + }, + }; + let source = |lines: usize, regions| tree::Source { + text: "x\n".repeat(lines), + regions, + }; + let mut sides = tree::Pairing::Both { + lhs: source( + 6, + vec![ + fold(1, 1, leaf(2, 2, 0, 4, false)), + leaf(7, 7, 4, 5, false), + leaf(3, 3, 5, 6, true), + ], + ), + rhs: source( + 10, + vec![ + paired(8, 2, 0, 4, false), + paired(9, 7, 4, 5, false), + paired(10, 3, 5, 6, true), + fold(5, 1, leaf(6, 6, 6, 10, true)), + ], + ), + }; + let (file, _) = project("a.py", "", ""); + run_trees("context", json!({"lines": 1}), &file, &mut sides); + let collapsed = |source: &tree::Source| { + rows(&source.regions) + .into_iter() + .filter(|(_, collapsed, _, fold)| *collapsed && *fold) + .count() + }; + assert_eq!(collapsed(lhs(&sides)), 0); + assert_eq!(collapsed(rhs(&sides)), 0); +} + +#[test] +fn predicted_group_ids_match_the_applier() { + // The stretch starts inside one leaf and ends inside another, with + // folds between: the group names the pieces the cuts before it made, + // and the applier accepts it on both sides. + let block = repeated(0..8, |i| format!("x{i} = {i}\n")); + let functions = repeated(0..2, |i| { + format!("def g{i}():\n a = {i}\n b = {i}\n\n") + }); + let before = format!("changed = 1\n{block}{functions}{block}changed = 1\n"); + let after = format!("changed = 2\n{block}{functions}{block}changed = 2\n"); + let sides = shaped("a.py", &before, &after, 1); + for source in [lhs(&sides), rhs(&sides)] { + let collapsed: Vec<_> = source + .regions + .iter() + .filter(|region| region.visibility.collapsed) + .map(|region| { + ( + (region.range.start.line, region.range.end.line), + region.visibility.label.clone(), + is_fold(region), + ) + }) + .collect(); + assert_eq!( + collapsed, + [((2, 24), "22 unchanged lines".to_owned(), true)] + ); + assert_eq!( + open_lines(&source.regions), + BTreeSet::from([0, 1, 2, 24, 25]) + ); + } +} + +/// Lines of leaves that no collapsed region hides. Unlike `open_lines`, +/// a collapsed fold's header does not count: it shows the fold, not +/// context. +fn open_leaf_lines(regions: &[tree::Region]) -> BTreeSet<u32> { + fn visit(regions: &[tree::Region], out: &mut BTreeSet<u32>) { + for region in regions.iter().filter(|region| !region.visibility.collapsed) { + match ®ion.node { + tree::Node::Leaf { .. } => { + out.extend(region.range.start.line..region.range.end.line) + } + tree::Node::Fold { children } => visit(children, out), + } + } + } + let mut out = BTreeSet::new(); + visit(regions, &mut out); + out +} + +/// The text of every line of `source` that `open` holds. +fn open_text<'a>(source: &'a str, open: &BTreeSet<u32>) -> Vec<&'a str> { + source + .lines() + .enumerate() + .filter(|(line, _)| open.contains(&(*line as u32))) + .map(|(_, text)| text) + .collect() +} + +#[test] +fn every_enclosing_scope_keeps_its_first_and_last_line() { + let before = "class C:\n def changed(self):\n if False:\n return\n x = 1\n x += 1\n x += 1\n x += 1\n x += 1\n\n def unrelated(self):\n return 999\n"; + let after = before.replace("x = 1", "x = 2"); + let sides = shaped("a.py", before, &after, 0); + let open = open_leaf_lines(&rhs(&sides).regions); + // The class body holds the change, so its first and last line stay: the + // last is the final line of the method below, not a closing brace + // Python does not have. + assert!(open.contains(&0) && open.contains(&11), "{open:?}"); + // The method that holds no change keeps nothing of its own; its body + // collapses apart from the class's last line. + assert!(!open.contains(&5) && !open.contains(&6), "{open:?}"); +} + +#[test] +fn generator_declarations_keep_their_signature_and_closing_brace() { + let body = repeated(0..30, |i| format!(" yield value_{i};\n")); + for extension in ["js", "jsx", "ts", "tsx"] { + for prefix in ["export function*", "export async function*"] { + let before = format!("{prefix} stream(\n chunks,\n) {{\n{body}}}\n"); + let after = before.replace("yield value_20;", "yield changed_value;"); + let sides = shaped(&format!("stream.{extension}"), &before, &after, 0); + for source in [lhs(&sides), rhs(&sides)] { + let open = open_leaf_lines(&source.regions); + assert!( + (0..3).all(|line| open.contains(&line)), + "missing generator signature: {extension}, {prefix}: {open:?}" + ); + assert!( + open.contains(&33), + "missing closing brace: {extension}, {prefix}" + ); + assert!(!open.contains(&10), "unrelated body stays collapsed"); + } + } + } +} + +#[test] +fn a_multiline_python_signature_keeps_the_line_it_starts_on() { + // A scope is the whole `def`, so the line it keeps is the one the + // signature starts on, however far the `:` that opens the body is below + // it. The rest of the signature is ordinary unchanged lines. + let body = repeated(0..30, |i| format!(" value_{i} = {i}\n")); + let before = format!("def run(\n first,\n second,\n):\n{body}"); + let after = before.replace("value_20 = 20", "value_20 = 999"); + let sides = shaped("a.py", &before, &after, 0); + for source in [lhs(&sides), rhs(&sides)] { + let open = open_leaf_lines(&source.regions); + assert!(open.contains(&0), "{open:?}"); + assert!((1..4).all(|line| !open.contains(&line)), "{open:?}"); + } +} + +#[test] +fn a_signature_keeps_its_header_without_neighbouring_statements() { + let before = include_str!("../../../examples/review/real/02-review-175/before.ts"); + let after = include_str!("../../../examples/review/real/02-review-175/after.ts"); + let sides = shaped("a.ts", before, after, 3); + let open = open_text(after, &open_leaf_lines(&rhs(&sides).regions)); + let shows = |text: &str| open.iter().any(|line| line.contains(text)); + assert!(shows("function parseReviewDiffFile(")); + assert!(!shows("return sections.filter(")); + assert!(!shows("const lines = section.split(")); + assert!(shows("let additions = 0;"), "changes keep ordinary context"); +} + +#[test] +fn an_unrelated_tail_return_is_not_context() { + let before = include_str!("../../../examples/review/real/07-ripgrep-3487/before.rs"); + let after = include_str!("../../../examples/review/real/07-ripgrep-3487/after.rs"); + let shows = |lines: u32, text: &str| { + let sides = shaped("a.rs", before, after, lines); + let open = open_leaf_lines(&rhs(&sides).regions); + open_text(after, &open) + .iter() + .any(|line| line.contains(text)) + }; + assert!( + !shows(0, "Ok(if matched"), + "the unrelated return is not context" + ); +} + +#[test] +fn a_changed_entry_keeps_its_enclosing_return_open_to_the_closer() { + let entries = repeated(0..24, |i| format!(" '{i}': {i},\n")); + let before = format!("def values():\n return {{\n{entries} }}\n"); + let after = before.replace("'12': 12", "'12': 999"); + let sides = shaped("a.py", &before, &after, 0); + let open = open_leaf_lines(&rhs(&sides).regions); + assert!(open.contains(&1), "the return opener is beyond the padding"); + assert!( + open.contains(&26), + "the dictionary's closer is beyond the padding" + ); +} + +#[test] +fn a_scope_keeps_the_line_it_closes_on() { + // A scope is the whole construct, so the line its `}` sits on is the + // scope's own last line, whatever column the brace is in. The body fold + // inside it ends above that line. + let body = repeated(0..20, |i| format!(" let x{i} = {i};\n")); + let tail = repeated(0..10, |i| format!(" let y{i} = {i};\n")); + let before = format!("fn changed() {{\n{body}}}\n\nfn unrelated() {{\n{tail}}}\n"); + let after = before.replace("let x1 = 1;", "let x1 = 999;"); + let sides = shaped("a.rs", &before, &after, 0); + for source in [lhs(&sides), rhs(&sides)] { + let open = open_leaf_lines(&source.regions); + assert!(open.contains(&0), "the header stays: {open:?}"); + assert!(open.contains(&2), "the change stays: {open:?}"); + assert!( + !open.contains(&20), + "the body's last line is not the scope's: {open:?}" + ); + assert!( + open.contains(&21), + "the closing `}}` the scope ends on stays: {open:?}" + ); + assert!(!open.contains(&10), "the unchanged body collapses"); + assert!( + !open.contains(&23), + "the untouched function below collapses: {open:?}" + ); + } +} diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs new file mode 100644 index 000000000..9b4dbcd0b --- /dev/null +++ b/src/plugin/tests/mod.rs @@ -0,0 +1,315 @@ +//! The plugin host and the bundled plugins, run the way the stream runs +//! them: a file projected with the bundled queries, then each plugin's +//! native code through a [`Pipeline`]. +mod context; + +use super::*; +use crate::config::{Config, Params}; +use crate::options::DiffOptions; +use crate::protocol::{project, Diff, FileRef}; +use diffr_plugin_sdk::tree::{has_tag, is_fold, walk}; +use diffr_plugin_sdk::{FileEntry, Move, Plugin}; +use serde_json::json; + +/// Project a two-source comparison with the bundled queries, the way the +/// stream does before the plugins run. +pub(crate) fn project( + path: &str, + before: &str, + after: &str, +) -> (FileChange, Pairing<protocol::Source>) { + project_with(path, before, after, DiffOptions::default()) +} + +pub(crate) fn project_with( + path: &str, + before: &str, + after: &str, + options: DiffOptions, +) -> (FileChange, Pairing<protocol::Source>) { + let params = Config::from_toml("").unwrap().compile().unwrap(); + project_compiled(path, before, after, ¶ms, options) +} + +/// Project with the queries `params` was compiled with. +pub(crate) fn project_compiled( + path: &str, + before: &str, + after: &str, + params: &Params, + options: DiffOptions, +) -> (FileChange, Pairing<protocol::Source>) { + let result = crate::summary::DiffResult::from_sources_with_options( + path, + before, + after, + params, + &crate::options::DisplayOptions::default(), + &options, + ) + .unwrap(); + 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, + tags: Vec::new(), + }; + let diff = project::diff( + &result, + project::Inputs { + file: &file.file, + sizes: (before.len() as u64, after.len() as u64), + }, + ); + let Diff::Text { sides, .. } = diff else { + panic!("text diff expected"); + }; + (file, sides) +} + +/// The wire's sides as the trees plugins read. +pub(crate) fn trees(sides: &Pairing<protocol::Source>) -> tree::Pairing<tree::Source> { + match sides { + Pairing::Both { lhs, rhs } => tree::Pairing::Both { + lhs: to_tree(lhs), + rhs: to_tree(rhs), + }, + Pairing::LeftOnly { lhs } => tree::Pairing::LeftOnly { lhs: to_tree(lhs) }, + Pairing::RightOnly { rhs } => tree::Pairing::RightOnly { rhs: to_tree(rhs) }, + } +} + +/// Trees built by hand, as the wire's sides. +pub(crate) fn wire(sides: tree::Pairing<tree::Source>) -> Pairing<protocol::Source> { + let source = |side: tree::Source| protocol::Source { + text: side.text, + regions: from_tree(side.regions), + }; + match sides { + tree::Pairing::Both { lhs, rhs } => Pairing::Both { + lhs: source(lhs), + rhs: source(rhs), + }, + tree::Pairing::LeftOnly { lhs } => Pairing::LeftOnly { lhs: source(lhs) }, + tree::Pairing::RightOnly { rhs } => Pairing::RightOnly { rhs: source(rhs) }, + } +} + +pub(crate) fn lhs(sides: &tree::Pairing<tree::Source>) -> &tree::Source { + sides.lhs().expect("a before side") +} + +pub(crate) fn rhs(sides: &tree::Pairing<tree::Source>) -> &tree::Source { + sides.rhs().expect("an after side") +} + +/// A pipeline of the bundled plugin `name` alone, made with its defaults and +/// `overrides`. +pub(crate) fn bundled(name: &str, overrides: serde_json::Value) -> Pipeline { + let mut options = builtin::manifest(name) + .expect("a bundled plugin") + .defaults(); + let serde_json::Value::Object(overrides) = overrides else { + panic!("overrides are an object"); + }; + options.extend(overrides); + let mut pipeline = Pipeline::default(); + pipeline + .push( + name, + serde_json::Value::Object(options), + &native::lookup(name).expect("native code"), + ) + .unwrap(); + pipeline +} + +/// Run the bundled plugin `name` with `overrides` and carry out its moves. +pub(crate) fn run( + name: &str, + overrides: serde_json::Value, + file: &FileChange, + sides: &mut Pairing<protocol::Source>, +) { + bundled(name, overrides).run(file, sides).unwrap(); +} + +/// Run the bundled plugin `name` with `overrides` on trees built by hand, +/// and carry out its moves. +pub(crate) fn run_trees( + name: &str, + overrides: serde_json::Value, + file: &FileChange, + sides: &mut tree::Pairing<tree::Source>, +) { + let mut wired = wire(sides.clone()); + run(name, overrides, file, &mut wired); + *sides = trees(&wired); +} + +#[test] +fn the_default_pipeline_makes_every_plugin_that_is_on() { + let pipeline = Pipeline::from_config(&PluginsConfig::default(), Path::new(".")).unwrap(); + let made: Vec<&str> = pipeline + .plugins + .iter() + .map(|plugin| &*plugin.name) + .collect(); + assert_eq!(made, ["context"]); +} + +/// Test plugins without options. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct NoOptions {} + +/// A plugin asking for a move that cannot be carried out. +struct Bad; + +impl Plugin for Bad { + type Options = NoOptions; + + fn new(_: NoOptions) -> anyhow::Result<Self> { + Ok(Self) + } + + fn classify(&self, _: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(Vec::new()) + } + + fn mutate( + &self, + _: &FileEntry, + _: Option<&types::Source>, + _: Option<&types::Source>, + ) -> anyhow::Result<Vec<Move>> { + Ok(vec![Move::SetCollapsed { + region: 99_999, + collapsed: true, + }]) + } +} + +/// Plugins adding tags: `a` and `z`; then `b`, checking it sees the tags +/// before it; then one that is not a tag. +struct TagsAZ; +struct TagsB; +struct NotATag; + +impl Plugin for TagsAZ { + type Options = NoOptions; + + fn new(_: NoOptions) -> anyhow::Result<Self> { + Ok(Self) + } + + fn classify(&self, _: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(vec!["a".to_owned(), "z".to_owned()]) + } + + fn mutate( + &self, + _: &FileEntry, + _: Option<&types::Source>, + _: Option<&types::Source>, + ) -> anyhow::Result<Vec<Move>> { + Ok(Vec::new()) + } +} + +impl Plugin for TagsB { + type Options = NoOptions; + + fn new(_: NoOptions) -> anyhow::Result<Self> { + Ok(Self) + } + + fn classify(&self, file: &FileEntry) -> anyhow::Result<Vec<String>> { + assert_eq!(file.tags, ["a", "z"], "a plugin sees the tags before it"); + Ok(vec!["b".to_owned()]) + } + + fn mutate( + &self, + _: &FileEntry, + _: Option<&types::Source>, + _: Option<&types::Source>, + ) -> anyhow::Result<Vec<Move>> { + Ok(Vec::new()) + } +} + +impl Plugin for NotATag { + type Options = NoOptions; + + fn new(_: NoOptions) -> anyhow::Result<Self> { + Ok(Self) + } + + fn classify(&self, _: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(vec!["Not A Tag".to_owned()]) + } + + fn mutate( + &self, + _: &FileEntry, + _: Option<&types::Source>, + _: Option<&types::Source>, + ) -> anyhow::Result<Vec<Move>> { + Ok(Vec::new()) + } +} + +/// A pipeline of test plugins, each made with no options. +fn pipeline(plugins: Vec<(&str, native::Constructor)>) -> Pipeline { + let mut pipeline = Pipeline::default(); + for (name, create) in plugins { + pipeline.push(name, json!({}), &create).unwrap(); + } + pipeline +} + +#[test] +fn classifying_plugins_add_tags_in_order_and_a_bad_tag_is_an_error() { + let (mut file, _) = project("a.rs", "", ""); + file.tags = vec!["z".to_owned()]; + let head = no_head(); + let tagging = pipeline(vec![ + ("first", native::native::<TagsAZ>), + ("second", native::native::<TagsB>), + ]); + assert_eq!(tagging.classify(&file, &head).unwrap(), ["a", "b", "z"]); + let bad = pipeline(vec![("bad", native::native::<NotATag>)]); + assert_eq!( + format!("{:#}", bad.classify(&file, &head).unwrap_err()), + "plugin bad: classify a.rs: \"Not A Tag\" is not a tag; use lowercase letters, digits, '-' and '_'" + ); +} + +#[test] +fn a_move_that_cannot_be_carried_out_fails_naming_the_plugin() { + let (file, mut sides) = project("a.rs", "fn a() {}\n", "fn b() {}\n"); + let bad = pipeline(vec![("bad", native::native::<Bad>)]); + let error = bad.run(&file, &mut sides).unwrap_err(); + assert!(error.downcast_ref::<MutationFailed>().is_some()); + assert_eq!(format!("{error:#}"), "mutation bad: no region 99999"); +} + +#[test] +fn options_that_do_not_deserialize_are_a_setup_error() { + let mut pipeline = Pipeline::default(); + let error = pipeline + .push("bad", json!({"extra": 1}), &native::native::<Bad>) + .unwrap_err(); + assert_eq!( + format!("{error:#}"), + "plugins.bad: invalid options: unknown field `extra`, there are no fields at line 1 column 8" + ); +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 3bfcf37a7..bd8370918 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -39,14 +39,24 @@ pub enum Event { files: Vec<FileChange>, }, /// One result. `file` is byte-identical to the manifest entry it - /// answers; the path pair is the identity. + /// answers; the path pair is the identity. `visibility` is how the + /// file starts out, set by the plugins after diffing: a hidden + /// file is collapsed behind its reason. File { file: Pairing<FileRef>, + #[serde(default, skip_serializing_if = "Visibility::is_unset")] + visibility: Visibility, #[serde(flatten)] outcome: Outcome, }, - /// The footer. - Complete { succeeded: u32, failed: u32 }, + /// 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<Problem>, + }, } /// Exactly one of `diff` or `error` appears on a `file` record. @@ -119,8 +129,8 @@ pub struct FileRef { pub mode: String, } -/// How a region starts out. `label` is shown while collapsed: the fold's -/// placeholder. +/// How a file or region starts out. `label` is shown while collapsed: a +/// reason for a file, a placeholder for a region. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct Visibility { #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -168,26 +178,28 @@ pub struct BinaryRef { } /// The `id` that names the file itself rather than a region. No region -/// carries it: region ids start above it. +/// carries it: region ids start above it. A plugin addresses the file's own +/// `visibility` through it, as the root every region hangs from. pub const ROOT: u32 = 0; /// A range on one side, carrying identities that must never be conflated. /// `id` names the region. `fold_state_id` says what the region *opens and /// closes with*: regions sharing it open and close together, on the same /// side or across sides. Paired leaves and matched folds have the same -/// value on both sides. A +/// value on both sides; a plugin's link gives it to several regions. A /// leaf's `alignment_id` (see `Node::Leaf`) is row alignment; folds have /// none. Consumers key the row zip by leaf `alignment_id`, collapse state by /// `fold_state_id`, and anything about the region itself by `id`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Region { /// Names this region, unique within the file (across both sides), and - /// never [`ROOT`]. + /// never [`ROOT`]. Plugin moves address it. pub id: u32, pub fold_state_id: u32, #[serde(flatten)] pub range: SourceRange, - /// On folds, the tags the fold queries set (`body`, `test`). + /// On folds, the tags the fold queries set, written `<plugin>:<name>` + /// (`deleted-bodies:function`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec<String>, /// Whether the region starts collapsed, and the label shown while it is. @@ -230,6 +242,19 @@ pub struct SourceRange { pub end: SourcePos, } +impl SourceRange { + /// The lines this range touches, half-open. A range ending at column + /// zero does not touch its end line. + pub fn lines(&self) -> std::ops::Range<u32> { + let end = if self.end.column == 0 { + self.end.line + } else { + self.end.line + 1 + }; + self.start.line..end + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct SourcePos { pub line: u32, @@ -245,6 +270,11 @@ pub struct SourcePos { pub struct Stats { /// Lines with any byte change. pub textual: LineCounts, + /// Changed lines still on screen under the default visibility: a + /// changed line inside a region that starts collapsed, or under one, + /// is not counted. Computed after the plugins run, so + /// configuration changes it. + pub visible: LineCounts, #[serde(default, skip_serializing_if = "Option::is_none")] pub fallback: Option<Problem>, } @@ -300,7 +330,7 @@ mod tests { start: pos(0, 0), end: pos(3, 0), }, - tags: vec!["body".to_owned()], + tags: vec!["deleted-bodies:function".to_owned()], visibility: Visibility::default(), node: Node::Fold { children: vec![ @@ -316,6 +346,7 @@ mod tests { lhs: file_ref("3b18e5"), rhs: file_ref("9be2c1"), }, + visibility: Visibility::default(), outcome: Outcome::Diff { diff: Diff::Text { sides: Pairing::Both { @@ -335,6 +366,10 @@ mod tests { added: 1, removed: 1, }, + visible: LineCounts { + added: 1, + removed: 1, + }, fallback: None, }, }, @@ -350,7 +385,7 @@ mod tests { middle["changed"] = json!(spans); } json!({ - "id": first, "fold_state_id": 1, "kind": "fold", "tags": ["body"], + "id": first, "fold_state_id": 1, "kind": "fold", "tags": ["deleted-bodies:function"], "start": {"line": 0, "column": 0}, "end": {"line": 3, "column": 0}, "children": [ {"id": first + 1, "fold_state_id": 2, "kind": "leaf", "alignment_id": 0, "start": {"line": 0, "column": 0}, "end": {"line": 1, "column": 0}}, @@ -370,7 +405,7 @@ mod tests { "lhs": {"text": "fn f() {\n 1\n}\n", "regions": [region(1, json!([]))]}, "rhs": {"text": "fn f() {\n 1 + 2\n}\n", "regions": [region(5, json!([{"line": 1, "start_column": 5, "end_column": 9}]))]}, - "stats": {"textual": {"added": 1, "removed": 1}}, + "stats": {"textual": {"added": 1, "removed": 1}, "visible": {"added": 1, "removed": 1}}, }, }); assert_eq!(serde_json::to_value(example_file()).unwrap(), expected); @@ -406,6 +441,10 @@ mod tests { mode: "100644".to_owned(), }, }, + visibility: Visibility { + collapsed: true, + label: "Deleted file · hidden by default".to_owned(), + }, outcome: Outcome::Diff { diff: Diff::Binary { sides: Pairing::LeftOnly { @@ -427,6 +466,7 @@ mod tests { mode: "100644".to_owned(), }, }, + visibility: Visibility::default(), outcome: Outcome::Error { error: Problem { code: "not_utf8".to_owned(), @@ -437,6 +477,10 @@ mod tests { Event::Complete { succeeded: 2, failed: 1, + aborted: Some(Problem { + code: "mutation_failed".to_owned(), + message: "mutation context: no region 99999".to_owned(), + }), }, ]; for event in events { diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 8be0130fc..3cb7c6c32 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -25,7 +25,8 @@ //! `fold_state_id`. Leaves are split wherever a //! fold starts or ends so that every fold's children tile its line span //! exactly, and a split on one side of a paired leaf is mirrored on the -//! other so paired leaves stay equal in length. Nothing starts collapsed. +//! other so paired leaves stay equal in length. Nothing starts collapsed: +//! which unchanged lines to hide is the `context` plugin's. use super::{ BinaryRef, Diff, FileRef, LineCounts, Node, Problem, Region, Source, SourcePos, SourceRange, Span, Stats, Visibility, ROOT, @@ -107,7 +108,13 @@ fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { }), FileFormat::Binary => unreachable!("binary files never reach text stats"), }; - Stats { textual, fallback } + Stats { + textual, + // Before any plugin runs nothing starts collapsed; the stream + // recounts after plugins. + visible: textual, + fallback, + } } /// The wire code for why a file was diffed by line. The message beside it @@ -538,7 +545,7 @@ fn positions_by_line(positions: &[MatchedPos]) -> BTreeMap<usize, Vec<&MatchedPo #[cfg(test)] mod tests { use super::*; - use crate::config::Params; + use crate::config::body_params; use crate::options::{DiffOptions, DisplayOptions}; fn refs(lhs: bool, rhs: bool) -> Pairing<FileRef> { @@ -568,7 +575,7 @@ mod tests { path, lhs, rhs, - &Params::default(), + &body_params(), &DisplayOptions::default(), &options, ) @@ -656,15 +663,9 @@ mod tests { } impl SourceRange { - /// The lines this range touches, half-open. A range ending at - /// column zero does not touch its end line. fn lines_spanned(&self) -> (u32, u32) { - let end = if self.end.column == 0 { - self.end.line - } else { - self.end.line + 1 - }; - (self.start.line, end) + let range = self.lines(); + (range.start, range.end) } } @@ -725,7 +726,7 @@ mod tests { "a.rs", RUST_LHS, RUST_RHS, - &Params::default(), + &body_params(), &DisplayOptions::default(), &DiffOptions::default(), ) @@ -994,8 +995,8 @@ mod tests { .filter(|r| r.tags.iter().any(|tag| tag == "body")) .map(|r| r.range.start.line) .collect(); - // Nothing is collapsed, so the untouched `keep` is a fold like the - // changed `f` and the new `added`. + // Nothing is collapsed before the plugins run, so the untouched `keep` + // is a fold like the changed `f` and the new `added`. assert_eq!(bodies, vec![1, 7, 13]); assert!(leaves(&rhs.regions) .iter() @@ -1066,7 +1067,8 @@ mod tests { .into_iter() .all(|leaf| !lhs_leaves.contains_key(&alignment(leaf)))); assert_eq!(new_fold.tags, vec!["body"]); - assert_eq!(new_fold.visibility.label, "Body"); + // A label is a plugin's to give; the projection leaves it empty. + assert_eq!(new_fold.visibility.label, ""); } #[test] diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index d1b4d4fa9..7210ac835 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -1,26 +1,43 @@ //! The stdout stream: manifest, one record per file as it finishes, footer. +//! +//! Each file is diffed, projected, and shaped by the plugins +//! before its record is written; `stats.visible` is recounted after them. use super::project::{self, Inputs}; -use super::{Event, FileChange, Outcome, Problem, Snapshot, VERSION}; +use super::{ + Diff, Event, FileChange, LineCounts, Node, Outcome, Problem, Region, Snapshot, Source, + Visibility, VERSION, +}; use crate::engine::QueryConflict; use crate::git::{DiffSession, FileError, LoadedFile}; +use crate::hash::DftHashSet; use crate::options::DisplayOptions; +use crate::pairing::Pairing; +use crate::plugin::{MutationFailed, Pipeline}; use crate::summary::DiffResult; use anyhow::anyhow; use rayon::iter::{ParallelBridge, ParallelIterator}; use std::io::{BufWriter, Write}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc::{sync_channel, SyncSender}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::thread; -/// Returns whether any file failed. Files are diffed on `jobs` workers and -/// emitted as they finish. The queue holds at most one ready record, so -/// computation overlaps output without retaining the whole comparison. +/// What the stream ended with: whether any file failed, and whether a +/// run-level failure cut it short. +pub(crate) struct Ended { + pub(crate) failed: bool, + pub(crate) aborted: bool, +} + +/// Files are diffed on `jobs` workers and emitted as they finish. The queue +/// holds at most one ready record, so computation overlaps output without +/// retaining the whole comparison. pub(crate) fn write( session: DiffSession, jobs: usize, + pipeline: Arc<Pipeline>, output: &mut impl Write, -) -> anyhow::Result<bool> { +) -> anyhow::Result<Ended> { let manifest = manifest(&session); let (sender, receiver) = sync_channel(1); let pool = rayon::ThreadPoolBuilder::new() @@ -29,27 +46,34 @@ pub(crate) fn write( .build()?; let worker = thread::spawn(move || { // A disconnected consumer cancels production after the files in flight. - let _ = produce(session, manifest, &pool, sender); + let _ = produce(session, manifest, &pool, &pipeline, sender); }); let mut output = BufWriter::new(output); - let result: anyhow::Result<bool> = (|| { - let mut failed = false; + let result: anyhow::Result<Ended> = (|| { + let mut ended = Ended { + failed: false, + aborted: false, + }; for event in &receiver { - if let Event::Complete { failed: count, .. } = &event { - failed = *count > 0; + if let Event::Complete { + failed, aborted, .. + } = &event + { + ended.failed = *failed > 0; + ended.aborted = aborted.is_some(); } serde_json::to_writer(&mut output, &event)?; output.write_all(b"\n")?; output.flush()?; } - Ok(failed) + Ok(ended) })(); // Wake a producer blocked on a full queue if writing failed. drop(receiver); let joined = worker.join(); - let failed = result?; + let ended = result?; joined.map_err(|_| anyhow!("diff computation thread panicked"))?; - Ok(failed) + Ok(ended) } /// The consumer went away; production stops after the files in flight. @@ -67,29 +91,45 @@ fn produce( session: DiffSession, manifest: Vec<FileChange>, pool: &rayon::ThreadPool, + pipeline: &Pipeline, sender: SyncSender<Event>, ) -> Result<(), Disconnected> { let send = |event: Event| sender.send(event).map_err(|_| Disconnected); - send(Event::Start { + let start = Event::Start { version: VERSION, lhs: Snapshot::from(&session.comparison.before), rhs: Snapshot::from(&session.comparison.after), files: manifest, - })?; + }; + send(start)?; let succeeded = AtomicU32::new(0); let failed = AtomicU32::new(0); let cancelled = Arc::new(AtomicBool::new(false)); + let aborted: Mutex<Option<anyhow::Error>> = Mutex::new(None); let loader = Loader { session, cancelled: Arc::clone(&cancelled), }; pool.install(|| { loader.par_bridge().for_each(|(file, loaded)| { - let outcome = match loaded.and_then(|loaded| file_diff(&loaded)) { - Ok(diff) => Outcome::Diff { diff }, - Err(error) => Outcome::Error { - error: wire_error(&error), + let (visibility, outcome) = match loaded.and_then(|loaded| diffed(&loaded)) { + Ok((entry, diff)) => match shape(pipeline, &entry, diff) { + Ok((visibility, diff)) => (visibility, Outcome::Diff { diff }), + Err(error) => { + // A run-level failure: stop pulling files, let the ones + // in flight finish, and report why in the footer. + failed.fetch_add(1, Ordering::Relaxed); + cancelled.store(true, Ordering::Relaxed); + aborted.lock().expect("abort reason").get_or_insert(error); + return; + } }, + Err(error) => ( + Visibility::default(), + Outcome::Error { + error: wire_error(&error), + }, + ), }; match &outcome { Outcome::Diff { .. } => succeeded.fetch_add(1, Ordering::Relaxed), @@ -97,6 +137,7 @@ fn produce( }; let event = Event::File { file: file.sides, + visibility, outcome, }; if sender.send(event).is_err() { @@ -104,9 +145,11 @@ fn produce( } }); }); + let aborted = aborted.into_inner().expect("abort reason"); send(Event::Complete { succeeded: succeeded.into_inner(), failed: failed.into_inner(), + aborted: aborted.map(|error| wire_error(&error)), }) } @@ -118,6 +161,8 @@ fn wire_error(error: &anyhow::Error) -> Problem { kind.code() } else if error.downcast_ref::<QueryConflict>().is_some() { "query_conflict" + } else if error.downcast_ref::<MutationFailed>().is_some() { + "mutation_failed" } else { "internal" }; @@ -127,15 +172,44 @@ fn wire_error(error: &anyhow::Error) -> Problem { } } -/// The projected diff of one loaded file. `Err` is this file's failure. -fn file_diff(loaded: &LoadedFile) -> anyhow::Result<super::Diff> { +/// The projected diff of one loaded file and its manifest entry. `Err` is +/// this file's failure. +fn diffed(loaded: &LoadedFile) -> anyhow::Result<(FileChange, Diff)> { + // The stream reads no terminal hunks, so their context is moot. + let result = loaded.diff(&DisplayOptions::default())?; let inputs = Inputs { file: &loaded.file.sides, sizes: loaded.sizes(), }; - // The stream reads no terminal hunks, so their context is moot. - let result = loaded.diff(&DisplayOptions::default())?; - Ok(project::diff(&result, inputs)) + Ok((loaded.file.manifest_entry(), project::diff(&result, inputs))) +} + +/// Run the plugins on a diff and recount what stays visible. A binary diff has +/// no text and no regions, so only moves on the file apply to it. `Err` +/// is a run-level failure. +fn shape( + pipeline: &Pipeline, + entry: &FileChange, + diff: Diff, +) -> anyhow::Result<(Visibility, Diff)> { + match diff { + Diff::Text { + mut sides, + mut stats, + } => { + let visibility = pipeline.run(entry, &mut sides)?; + stats.visible = visible_counts(&sides); + Ok((visibility, Diff::Text { sides, stats })) + } + Diff::Binary { sides } => { + let mut empty = sides.clone().map(|_| Source { + text: String::new(), + regions: Vec::new(), + }); + let visibility = pipeline.run(entry, &mut empty)?; + Ok((visibility, Diff::Binary { sides })) + } + } } /// Reads sources serially on whichever worker pulls next; diffing then @@ -161,78 +235,268 @@ pub(crate) fn write_file( after: &str, sizes: (u64, u64), compute: impl FnOnce() -> Result<DiffResult, QueryConflict>, + pipeline: &Pipeline, output: &mut impl Write, -) -> anyhow::Result<bool> { +) -> anyhow::Result<Ended> { let file = crate::git::FileChange::standalone(before, after); + let entry = file.manifest_entry(); let mut output = BufWriter::new(output); - serde_json::to_writer( - &mut output, - &Event::Start { - version: VERSION, - lhs: Snapshot::Path { - path: before.to_owned(), - }, - rhs: Snapshot::Path { - path: after.to_owned(), - }, - files: vec![file.manifest_entry()], + let start = Event::Start { + version: VERSION, + lhs: Snapshot::Path { + path: before.to_owned(), }, - )?; + rhs: Snapshot::Path { + path: after.to_owned(), + }, + files: vec![entry.clone()], + }; + serde_json::to_writer(&mut output, &start)?; output.write_all(b"\n")?; output.flush()?; - let outcome = match compute() { - Ok(result) => Outcome::Diff { - diff: project::diff( + let (record, failed, aborted) = match compute() { + Err(conflict) => ( + Some(Event::File { + file: file.sides, + visibility: Visibility::default(), + outcome: Outcome::Error { + error: wire_error(&anyhow::Error::from(conflict)), + }, + }), + true, + None, + ), + Ok(result) => { + let projected = project::diff( &result, Inputs { file: &file.sides, sizes, }, - ), - }, - Err(conflict) => Outcome::Error { - error: wire_error(&anyhow::Error::from(conflict)), - }, + ); + match shape(pipeline, &entry, projected) { + Ok((visibility, diff)) => ( + Some(Event::File { + file: file.sides, + visibility, + outcome: Outcome::Diff { diff }, + }), + false, + None, + ), + Err(error) => (None, true, Some(wire_error(&error))), + } + } + }; + if let Some(record) = &record { + serde_json::to_writer(&mut output, record)?; + output.write_all(b"\n")?; + } + let ended = Ended { + failed, + aborted: aborted.is_some(), }; - let failed = matches!(outcome, Outcome::Error { .. }); - serde_json::to_writer( - &mut output, - &Event::File { - file: file.sides, - outcome, - }, - )?; - output.write_all(b"\n")?; serde_json::to_writer( &mut output, &Event::Complete { - succeeded: u32::from(!failed), + succeeded: u32::from(matches!( + &record, + Some(Event::File { + outcome: Outcome::Diff { .. }, + .. + }) + )), failed: u32::from(failed), + aborted, }, )?; output.write_all(b"\n")?; output.flush()?; - Ok(failed) + Ok(ended) +} + +/// Changed lines that start on screen. A line counts when it carries a +/// `changed` span, or when no leaf on the other side shares its +/// `alignment_id` (every line of +/// a one-sided leaf is new or removed, blank ones included). Lines inside a +/// collapsed region, or under one, are not counted. +pub(crate) fn visible_counts(sides: &Pairing<Source>) -> LineCounts { + fn alignments(regions: &[Region], out: &mut DftHashSet<u32>) { + for region in regions { + match ®ion.node { + Node::Leaf { alignment_id, .. } => { + out.insert(*alignment_id); + } + Node::Fold { children } => alignments(children, out), + } + } + } + fn count(regions: &[Region], other: &DftHashSet<u32>, hidden: bool) -> u32 { + let mut total = 0; + for region in regions { + let hidden = hidden || region.visibility.collapsed; + match ®ion.node { + Node::Leaf { + alignment_id, + changed, + } => { + if hidden { + continue; + } + if other.contains(alignment_id) { + let lines: DftHashSet<u32> = changed.iter().map(|span| span.line).collect(); + total += lines.len() as u32; + } else { + total += region.range.lines().len() as u32; + } + } + Node::Fold { children } => total += count(children, other, hidden), + } + } + total + } + let side_alignments = |source: &Source| { + let mut out = DftHashSet::default(); + alignments(&source.regions, &mut out); + out + }; + match sides { + Pairing::Both { lhs, rhs } => LineCounts { + added: count(&rhs.regions, &side_alignments(lhs), false), + removed: count(&lhs.regions, &side_alignments(rhs), false), + }, + Pairing::LeftOnly { lhs } => LineCounts { + added: 0, + removed: count(&lhs.regions, &DftHashSet::default(), false), + }, + Pairing::RightOnly { rhs } => LineCounts { + added: count(&rhs.regions, &DftHashSet::default(), false), + removed: 0, + }, + } +} + +#[cfg(test)] +mod visible_tests { + use super::*; + use crate::protocol::{SourcePos, SourceRange, Span}; + + fn pos(line: u32) -> SourcePos { + SourcePos { line, column: 0 } + } + + fn leaf( + id: u32, + alignment: u32, + lines: (u32, u32), + changed: &[u32], + collapsed: bool, + ) -> Region { + Region { + id, + fold_state_id: id, + range: SourceRange { + start: pos(lines.0), + end: pos(lines.1), + }, + tags: vec![], + visibility: Visibility { + collapsed, + label: String::new(), + }, + node: Node::Leaf { + alignment_id: alignment, + changed: changed + .iter() + .map(|&line| Span { + line, + start_column: 0, + end_column: 1, + }) + .collect(), + }, + } + } + + fn fold(id: u32, lines: (u32, u32), collapsed: bool, children: Vec<Region>) -> Region { + Region { + id, + fold_state_id: id, + range: SourceRange { + start: pos(lines.0), + end: pos(lines.1), + }, + tags: vec![], + visibility: Visibility { + collapsed, + label: String::new(), + }, + node: Node::Fold { children }, + } + } + + fn source(regions: Vec<Region>) -> Source { + Source { + text: String::new(), + regions, + } + } + + #[test] + fn counts_span_lines_and_every_line_of_a_one_sided_leaf() { + let rhs = source(vec![ + // paired leaf: only the lines with spans count (two, one twice) + leaf(0, 1, (0, 3), &[0, 1, 1], false), + // paired leaf without spans: unchanged context, not counted + leaf(1, 9, (3, 4), &[], false), + // one-sided leaf with no spans (blank lines): every line counts + leaf(2, 2, (4, 6), &[], false), + // collapsed leaf: hidden + leaf(3, 3, (6, 9), &[6, 7], true), + // open fold with an open one-sided leaf: every line counts + fold(4, (9, 12), false, vec![leaf(5, 5, (9, 12), &[10], false)]), + // collapsed fold: its open child is hidden by the ancestor + fold( + 6, + (12, 15), + true, + vec![leaf(7, 7, (12, 15), &[13, 14], false)], + ), + ]); + let lhs = source(vec![ + leaf(8, 1, (0, 3), &[0], false), + leaf(9, 9, (3, 4), &[], false), + leaf(10, 8, (4, 7), &[4, 5], true), + ]); + let counts = visible_counts(&Pairing::Both { lhs, rhs }); + assert_eq!(counts.added, 2 + 2 + 3); + assert_eq!(counts.removed, 1); + } + + #[test] + fn a_missing_side_counts_nothing() { + let rhs = source(vec![leaf(0, 1, (0, 1), &[0], false)]); + let counts = visible_counts(&Pairing::RightOnly { rhs }); + assert_eq!(counts.added, 1); + assert_eq!(counts.removed, 0); + } } #[cfg(test)] mod conflict_tests { use super::*; - use crate::config::Config; + use crate::config::try_with_queries; #[test] fn a_query_conflict_is_that_files_error_and_the_run_completes() { - let params = Config::from_toml( - "[languages.rust]\nfolds = '''\n\ - ((block) @fold (#set! tag \"whole\"))\n\ - ((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"inside\"))\n\ - '''", - ) - .unwrap() - .compile() + let params = try_with_queries(&[( + "rust", + "((block) @fold (#set! tag \"context:whole\"))\n\ + ((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"context:inside\"))\n", + )]) .unwrap(); let mut output = Vec::new(); - let failed = write_file( + let ended = write_file( "a.rs", "b.rs", (0, 0), @@ -244,10 +508,11 @@ mod conflict_tests { ¶ms, ) }, + &Pipeline::default(), &mut output, ) .unwrap(); - assert!(failed); + assert!(ended.failed && !ended.aborted); let records: Vec<serde_json::Value> = String::from_utf8(output) .unwrap() .lines() @@ -256,12 +521,12 @@ mod conflict_tests { let error = &records[1]["error"]; assert_eq!(error["code"], "query_conflict", "{error}"); let message = error["message"].as_str().unwrap(); - assert_eq!( - message, - "src/lib.rs:1 (before): fold query patterns 0 and 1 capture the same block \ - with different fold ranges" + assert!(message.starts_with("src/lib.rs:1"), "{message}"); + assert!( + message.contains("capture the same block with different fold ranges"), + "{message}" ); - assert_eq!(records[2]["succeeded"], 0); assert_eq!(records[2]["failed"], 1); + assert!(records[2].get("aborted").is_none()); } } diff --git a/src/tags/mod.rs b/src/tags/mod.rs index 0764fda9c..f2142a6bd 100644 --- a/src/tags/mod.rs +++ b/src/tags/mod.rs @@ -206,20 +206,24 @@ fn linguist_flag(value: AttrValue<'_>) -> Option<bool> { } } +/// A tag is lowercase ASCII letters, digits, `-` and `_`, starting with a +/// letter or digit. +pub(crate) fn is_tag(tag: &str) -> bool { + tag.chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase() || first.is_ascii_digit()) + && tag + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + /// `a,b`: each tag is lowercase ASCII letters, digits, `-` and `_`, starting /// with a letter or digit. fn parse_tags(value: &str) -> Result<Vec<String>, String> { value .split(',') .map(|tag| { - let valid = tag - .chars() - .next() - .is_some_and(|first| first.is_ascii_lowercase() || first.is_ascii_digit()) - && tag - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'); - if valid { + if is_tag(tag) { Ok(tag.to_owned()) } else { Err(format!( diff --git a/wit/plugin.wit b/wit/plugin.wit new file mode 100644 index 000000000..6b6317ddd --- /dev/null +++ b/wit/plugin.wit @@ -0,0 +1,215 @@ +/// diffr's plugin contract. Every plugin implements it, whether diffr runs it +/// natively (a bundled plugin compiled in) or as a WASM component: diffr +/// builds the same records for both and carries out the same moves. +/// +/// A plugin is created once, then shapes each file at two points of its +/// life. `new` runs when diffr builds its pipeline, before any file is +/// listed, and makes the plugin from its options, failing when it cannot run +/// with them. `classify` runs before the stream starts, once per changed +/// file, and returns tags to add to the file's manifest entry: the tags +/// decide how the file is diffed (a `generated` file gets a line diff) and +/// what later plugins see. `mutate` runs after the file is diffed and its +/// region trees are built, and returns moves that decide how the file starts +/// out on screen. Between the two, the tree-sitter queries a plugin lists in +/// its `plugin.toml` decide which folds exist; they are data, not part of +/// this contract. +/// +/// diffr makes one instance of each enabled plugin per run and calls it for +/// every file, so what a plugin keeps from `new` lasts the whole run. +package diffr:plugin@0.1.0; + +interface types { + /// The before (`lhs`) or after (`rhs`) side of a comparison. + enum side { + lhs, + rhs, + } + + /// Git's delta status. + enum file-status { + added, + deleted, + modified, + renamed, + copied, + type-changed, + } + + /// One changed file, as its manifest entry describes it. + record file-entry { + /// The path on the after side, or on the before side when the file + /// was deleted. + path: string, + /// The path on the before side, when the file has one. + old-path: option<string>, + status: file-status, + /// Sorted and deduplicated. During `classify`, the tags so far: the + /// bundled rules, git attributes, and the plugins before this one. + tags: list<string>, + } + + /// 0-based line, and 0-based byte column in the UTF-8 text. + record position { + line: u32, + column: u32, + } + + /// Half-open. + record range { + start: position, + end: position, + } + + /// Bytes painted as changed on one line. + record span { + line: u32, + start-column: u32, + end-column: u32, + } + + /// How a region starts out: collapsed or open, and the label shown while + /// it is collapsed (empty for none). + record visibility { + collapsed: bool, + label: string, + } + + record leaf { + /// Row alignment: the leaf on the other side with the same value + /// lines up with this one. + alignment-id: u32, + changed: list<span>, + } + + /// A leaf tiles the file; a fold's range is the hull of its children. + variant kind { + leaf(leaf), + fold, + } + + /// One region of a side's tree. + record region { + /// Unique across both sides of the file, never 0. + id: u32, + /// The fold that holds this region, or 0 (the file) for a top-level + /// region. + parent: u32, + /// Regions sharing it open and close together, on either side. + fold-state-id: u32, + range: range, + /// The tags the fold queries set, `<plugin>:<name>`. + tags: list<string>, + visibility: visibility, + kind: kind, + } + + /// One side of a diffed file. `regions` lists the tree in preorder: a + /// fold comes before its children, and siblings keep their order. A + /// binary file's sides have empty text and no regions. + record source { + text: string, + regions: list<region>, + } + + record cut { + /// A leaf's id. + region: u32, + /// A line offset relative to the leaf's first line, strictly inside + /// it. + at: u32, + } + + /// What a plugin asks for. A region is named by its id, or the file by + /// 0 where a move allows it. Moves are carried out in order, and the next + /// plugin sees the result; a move that cannot be carried out aborts the + /// run with `mutation_failed`. + /// + /// Fresh ids. When a plugin's moves begin, the next fresh id is one above + /// the largest region id in the file (both sides), and the next fresh + /// alignment id one above the largest leaf alignment id. Moves take fresh + /// ids in order, lhs side before rhs side: + /// + /// - a `cut` takes the next alignment id, which the second pieces share, + /// then gives the second piece on each side that holds the leaf (or its + /// paired leaf) the next id, lhs first. Both second pieces take the lhs + /// piece's id (or the only piece's) as their fold state id. The first + /// piece keeps the leaf's ids. + /// - a `join-folds` gives the new fold on each side that holds the listed + /// regions the next id, lhs first; both share the lhs fold's id as + /// their fold state id. + /// + /// No other move takes an id. So a plugin that cuts a leaf with id `l`, + /// paired on the rhs, when the largest id is `n`, names the lhs second + /// piece `n + 1` and the rhs one `n + 2`. + variant move { + /// Split a leaf, and the leaf paired with it, at a line offset. + cut(cut), + /// Wrap two or more consecutive siblings on each side that holds + /// them in a new open, unlabelled fold without tags. + join-folds(list<u32>), + /// Every region in the listed regions' fold states takes the first + /// region's fold state id and collapsed state. + link-fold-state(list<u32>), + /// Collapse or open every region sharing the region's fold state, or + /// hide or show the file (0). + set-collapsed(tuple<u32, bool>), + /// Set or clear the label of one region, or of the file (0). + set-label(tuple<u32, option<string>>), + /// Replace one region's tags. The file's tags cannot be set here. + set-tags(tuple<u32, list<string>>), + } +} + +/// What diffr gives every plugin. A component also gets WASI, with full +/// access: the repository's working directory is preopened read-write as +/// `.`, the environment is inherited, and the network is open. Its stdout +/// goes to diffr's stderr, since diffr's stdout is the stream. +interface host { + use types.{side}; + + /// Up to `max-bytes` from the start of one side of the file being + /// classified, or none when the file has no such side or it is not a + /// regular file (a symlink or submodule). During `mutate`, the start of + /// the side's text as diffed. During `new`, always none. + read-head: func(side: side, max-bytes: u32) -> option<list<u8>>; + + /// Run `git` with these arguments in the repository's working + /// directory: its stdout when it exits successfully, its stderr + /// otherwise. + git: func(args: list<string>) -> result<string, string>; + + /// Write a line to diffr's stderr, prefixed with the plugin's name. + log: func(message: string); +} + +/// What every plugin exports: the plugin itself, as a resource. +interface guest { + use types.{file-entry, source, move}; + + resource plugin { + /// Make the plugin from `options`, the plugin's entry in + /// `[plugins.<name>]` as a JSON object, defaults filled in and + /// validated against the options schema in `plugin.toml`. It runs + /// once, when diffr builds its pipeline. An error is a setup error: + /// diffr stops before the stream starts and names the plugin. (A + /// static function rather than a constructor, since a constructor + /// cannot fail.) + new: static func(options: string) -> result<plugin, string>; + + /// Tags to add to the file's manifest entry, before any diff runs. + /// Tags are lowercase letters, digits, `-` and `_`. A plugin that + /// does not classify returns an empty list. An error stops diffr + /// before the stream starts. + classify: func(file: file-entry) -> result<list<string>, string>; + + /// The moves that shape how the diffed file starts out. `lhs` and + /// `rhs` are the sides the file has. An error aborts the run with + /// `mutation_failed`. + mutate: func(file: file-entry, lhs: option<source>, rhs: option<source>) -> result<list<move>, string>; + } +} + +world plugin { + import host; + export guest; +} From 0007f4083532a1635b2e9f5497300a6fb6bc35e3 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 06/20] Add plugins that hide files and collapse bodies and runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five more plugins, each a folder under `plugins/` like `context`: - `hide-files` hides a file carrying a listed tag (`generated`, `vendored`, `test`) and any deleted file, behind the reason. - `deleted-bodies` collapses a function body of at least 12 lines with nothing paired under it: `"20 lines removed"`. - `test-bodies` collapses test function bodies and Rust `#[cfg(test)]` modules, on both sides. - `removed-runs` keeps the first and last line of a long one-sided run of removed lines and collapses the rest. - `group` wraps a run of adjacent collapsed regions in one collapsed fold: `"3 collapsed regions · 42 lines"`. The structure they all fold — bodies, collections, imports, comments and strings — is one shared query file per language, `plugins/shared/queries/`, which every plugin's query file imports and which is compiled once however many import it. Each plugin then tags what it needs: a tag belongs to the plugin that set it, so a fold can carry several. This replaces the bundled fold rules the queries were carved out of: `src/config/defaults.toml` and the code compiling it are gone, and every fold query is now a plugin's file. The docstring queries are shared the same way. A plugin that collapses a function body links the body's docstring to it, so the two open and close together, and `docstring_of` finds it: the body's first fold child where the body starts (Python), or the fold just before it in document order, searching out through enclosing folds so another plugin's scope does not hide it. Newness is the leaves' answer. `one_sided` asks only whether a line inside a region pairs with the other side, so a body in a file diffed by line is new only where its lines are, and a body grown in place, whose signature still pairs, is a rewrite. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 --- Cargo.lock | 45 +++ Cargo.toml | 15 + crates/diffr-plugin-sdk/src/lib.rs | 4 +- crates/diffr-plugin-sdk/src/tree.rs | 61 ++++ docs/config.md | 57 +++- docs/streaming.md | 31 +- plugins/context/queries/go.scm | 5 +- plugins/context/queries/javascript.scm | 5 +- plugins/context/queries/python.scm | 5 +- plugins/context/queries/rust.scm | 7 +- plugins/deleted-bodies/Cargo.toml | 12 + plugins/deleted-bodies/plugin.toml | 25 ++ plugins/deleted-bodies/queries/go.scm | 5 + plugins/deleted-bodies/queries/javascript.scm | 14 + plugins/deleted-bodies/queries/python.scm | 3 + plugins/deleted-bodies/queries/rust.scm | 3 + plugins/deleted-bodies/src/lib.rs | 72 +++++ plugins/group/Cargo.toml | 12 + plugins/group/plugin.toml | 7 + plugins/group/src/lib.rs | 258 +++++++++++++++++ plugins/hide-files/Cargo.toml | 12 + plugins/hide-files/plugin.toml | 20 ++ plugins/hide-files/src/lib.rs | 68 +++++ plugins/removed-runs/Cargo.toml | 12 + plugins/removed-runs/plugin.toml | 24 ++ plugins/removed-runs/queries/go.scm | 5 + plugins/removed-runs/queries/javascript.scm | 14 + plugins/removed-runs/queries/python.scm | 3 + plugins/removed-runs/queries/rust.scm | 3 + plugins/removed-runs/src/lib.rs | 127 ++++++++ plugins/shared/queries/go-docstrings.scm | 19 ++ plugins/shared/queries/go.scm | 14 + .../shared/queries/javascript-docstrings.scm | 46 +++ plugins/shared/queries/javascript.scm | 19 ++ plugins/shared/queries/python-docstrings.scm | 9 + plugins/shared/queries/python.scm | 35 +++ plugins/shared/queries/rust-docstrings.scm | 28 ++ plugins/shared/queries/rust.scm | 19 ++ plugins/test-bodies/Cargo.toml | 12 + plugins/test-bodies/plugin.toml | 25 ++ plugins/test-bodies/queries/go.scm | 4 + plugins/test-bodies/queries/javascript.scm | 9 + plugins/test-bodies/queries/python.scm | 4 + plugins/test-bodies/queries/rust.scm | 7 + plugins/test-bodies/src/lib.rs | 80 ++++++ src/config.rs | 146 ++++------ src/config/README.md | 8 +- src/config/defaults.toml | 188 ------------ src/config/store.rs | 17 +- src/parse/fold_tests.rs | 13 +- src/plugin/builtin.rs | 38 ++- src/plugin/config.rs | 91 ++++-- src/plugin/native.rs | 19 +- src/plugin/queries.rs | 138 ++++++++- src/plugin/tests/context.rs | 1 + src/plugin/tests/deleted_bodies.rs | 185 ++++++++++++ src/plugin/tests/group.rs | 232 +++++++++++++++ src/plugin/tests/hide_files.rs | 46 +++ src/plugin/tests/mod.rs | 149 +++++++++- src/plugin/tests/removed_runs.rs | 270 ++++++++++++++++++ src/plugin/tests/test_bodies.rs | 118 ++++++++ src/protocol/project.rs | 7 +- tests/view.rs | 164 +++++++++++ 63 files changed, 2732 insertions(+), 362 deletions(-) create mode 100644 plugins/deleted-bodies/Cargo.toml create mode 100644 plugins/deleted-bodies/plugin.toml create mode 100644 plugins/deleted-bodies/queries/go.scm create mode 100644 plugins/deleted-bodies/queries/javascript.scm create mode 100644 plugins/deleted-bodies/queries/python.scm create mode 100644 plugins/deleted-bodies/queries/rust.scm create mode 100644 plugins/deleted-bodies/src/lib.rs create mode 100644 plugins/group/Cargo.toml create mode 100644 plugins/group/plugin.toml create mode 100644 plugins/group/src/lib.rs create mode 100644 plugins/hide-files/Cargo.toml create mode 100644 plugins/hide-files/plugin.toml create mode 100644 plugins/hide-files/src/lib.rs create mode 100644 plugins/removed-runs/Cargo.toml create mode 100644 plugins/removed-runs/plugin.toml create mode 100644 plugins/removed-runs/queries/go.scm create mode 100644 plugins/removed-runs/queries/javascript.scm create mode 100644 plugins/removed-runs/queries/python.scm create mode 100644 plugins/removed-runs/queries/rust.scm create mode 100644 plugins/removed-runs/src/lib.rs create mode 100644 plugins/shared/queries/go-docstrings.scm create mode 100644 plugins/shared/queries/go.scm create mode 100644 plugins/shared/queries/javascript-docstrings.scm create mode 100644 plugins/shared/queries/javascript.scm create mode 100644 plugins/shared/queries/python-docstrings.scm create mode 100644 plugins/shared/queries/python.scm create mode 100644 plugins/shared/queries/rust-docstrings.scm create mode 100644 plugins/shared/queries/rust.scm create mode 100644 plugins/test-bodies/Cargo.toml create mode 100644 plugins/test-bodies/plugin.toml create mode 100644 plugins/test-bodies/queries/go.scm create mode 100644 plugins/test-bodies/queries/javascript.scm create mode 100644 plugins/test-bodies/queries/python.scm create mode 100644 plugins/test-bodies/queries/rust.scm create mode 100644 plugins/test-bodies/src/lib.rs delete mode 100644 src/config/defaults.toml create mode 100644 src/plugin/tests/deleted_bodies.rs create mode 100644 src/plugin/tests/group.rs create mode 100644 src/plugin/tests/hide_files.rs create mode 100644 src/plugin/tests/removed_runs.rs create mode 100644 src/plugin/tests/test_bodies.rs create mode 100644 tests/view.rs diff --git a/Cargo.lock b/Cargo.lock index 512772620..3f6d438dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,6 +261,38 @@ dependencies = [ "serde", ] +[[package]] +name = "diffr-plugin-deleted-bodies" +version = "0.1.0" +dependencies = [ + "diffr-plugin-sdk", + "serde", +] + +[[package]] +name = "diffr-plugin-group" +version = "0.1.0" +dependencies = [ + "diffr-plugin-sdk", + "serde", +] + +[[package]] +name = "diffr-plugin-hide-files" +version = "0.1.0" +dependencies = [ + "diffr-plugin-sdk", + "serde", +] + +[[package]] +name = "diffr-plugin-removed-runs" +version = "0.1.0" +dependencies = [ + "diffr-plugin-sdk", + "serde", +] + [[package]] name = "diffr-plugin-sdk" version = "0.1.0" @@ -271,6 +303,14 @@ dependencies = [ "wit-bindgen 0.60.0", ] +[[package]] +name = "diffr-plugin-test-bodies" +version = "0.1.0" +dependencies = [ + "diffr-plugin-sdk", + "serde", +] + [[package]] name = "difftastic" version = "0.71.0" @@ -281,7 +321,12 @@ dependencies = [ "cc", "clap", "diffr-plugin-context", + "diffr-plugin-deleted-bodies", + "diffr-plugin-group", + "diffr-plugin-hide-files", + "diffr-plugin-removed-runs", "diffr-plugin-sdk", + "diffr-plugin-test-bodies", "dirs", "encoding_rs", "git2", diff --git a/Cargo.toml b/Cargo.toml index 511c958dc..09adfa9fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,11 @@ jsonschema = { version = "0.56.0", default-features = false } # registry calls, which also builds as a WASM component. diffr-plugin-sdk = { path = "crates/diffr-plugin-sdk" } diffr-plugin-context = { path = "plugins/context" } +diffr-plugin-deleted-bodies = { path = "plugins/deleted-bodies" } +diffr-plugin-group = { path = "plugins/group" } +diffr-plugin-hide-files = { path = "plugins/hide-files" } +diffr-plugin-removed-runs = { path = "plugins/removed-runs" } +diffr-plugin-test-bodies = { path = "plugins/test-bodies" } [target.'cfg(not(any(windows, target_os = "illumos", target_os = "freebsd")))'.dependencies] @@ -169,11 +174,21 @@ version_check = "0.9.4" members = [ "crates/diffr-plugin-sdk", "plugins/context", + "plugins/deleted-bodies", + "plugins/group", + "plugins/hide-files", + "plugins/removed-runs", + "plugins/test-bodies", ] default-members = [ ".", "crates/diffr-plugin-sdk", "plugins/context", + "plugins/deleted-bodies", + "plugins/group", + "plugins/hide-files", + "plugins/removed-runs", + "plugins/test-bodies", ] [profile.release] diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs index 7f2f23355..82569a168 100644 --- a/crates/diffr-plugin-sdk/src/lib.rs +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -18,7 +18,7 @@ //! //! - [`tree`] rebuilds a side's list as a tree ([`tree::sides`]) and holds //! the helpers for reading trees ([`walk`], [`OtherSide`], [`one_sided`], -//! and the rest). +//! [`docstring_of`], and the rest). //! - [`apply`] carries moves out. diffr carries every plugin's moves out with //! it, so [`Draft`], which carries a plugin's moves out on a copy as it //! makes them, and [`apply::Fresh`], which predicts fresh ids, give a @@ -33,7 +33,7 @@ pub use anyhow; pub use draft::Draft; use serde::de::DeserializeOwned; pub use tree::{ - before_and_after_ids, has_tag, is_fold, line_count, one_sided, path_to, + before_and_after_ids, docstring_of, has_tag, is_fold, line_count, one_sided, path_to, siblings_of, sides_with_other_ids, walk, walk_mut, Node, OtherSide, Pairing, Region, Source, }; pub use types::{FileEntry, FileStatus, Move, Position, Range, Side, Span, Visibility, ROOT}; diff --git a/crates/diffr-plugin-sdk/src/tree.rs b/crates/diffr-plugin-sdk/src/tree.rs index aa95b78f2..d5d8bbea0 100644 --- a/crates/diffr-plugin-sdk/src/tree.rs +++ b/crates/diffr-plugin-sdk/src/tree.rs @@ -284,6 +284,67 @@ pub fn has_tag(region: &Region, tag: &str) -> bool { region.tags.iter().any(|own| own == tag) } +/// How many lines (attributes and the signature) may sit between a +/// docstring and the line its function's body opens on. +const MAX_SIGNATURE_LINES: usize = 12; + +/// The `id` of the docstring of `body`, a function body fold on `side`, as +/// `plugin`'s queries tag it (`<plugin>:docstring`, from +/// `plugins/shared/queries/<language>-docstrings.scm`): the body's first +/// fold child when it starts where the body does (Python), or else the fold +/// just before the body in document order, with +/// only a signature's worth of lines between them. The search runs back +/// through the body's siblings and then out through the folds that enclose +/// it, so a fold wrapping the whole function (another plugin's scope) does +/// not hide the docstring above it; it stops at a fold carrying one of +/// `plugin`'s own tags, such as the body of an enclosing function. A plugin +/// that collapses a body links it to its docstring, so the two open and +/// close together. +pub fn docstring_of(side: &Source, body: &Region, plugin: &str) -> Option<u32> { + let tag = format!("{plugin}:docstring"); + let own = format!("{plugin}:"); + if let Node::Fold { children } = &body.node { + if let Some(first) = children + .iter() + .find(|child| is_fold(child)) + .filter(|first| has_tag(first, &tag) && first.range.start.line == body.range.start.line) + { + return Some(first.id); + } + } + let path = path_to(&side.regions, body.id).expect("the body is on this side"); + let mut between = 0; + // From the body's own siblings outwards: at each level, the regions + // before the one on the path. + let mut level: &[Region] = &side.regions; + let mut levels = Vec::new(); + for &index in &path { + levels.push((level, index)); + if let Node::Fold { children } = &level[index].node { + level = children; + } + } + for (depth, (siblings, index)) in levels.iter().enumerate().rev() { + if depth + 1 < path.len() { + // Leaving the fold at `siblings[index]` for the regions before it. + let parent = &siblings[*index]; + if parent.tags.iter().any(|tag| tag.starts_with(&own)) { + return None; + } + } + for region in siblings[..*index].iter().rev() { + if is_fold(region) { + return has_tag(region, &tag).then_some(region.id); + } + between += line_count(region); + if between > MAX_SIGNATURE_LINES { + return None; + } + } + } + None +} + /// Child indices from the root down to the region with this `id`. pub fn path_to(regions: &[Region], id: u32) -> Option<Vec<usize>> { for (index, region) in regions.iter().enumerate() { diff --git a/docs/config.md b/docs/config.md index 6fd78218b..ff0606bb2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -23,7 +23,7 @@ diffr config schema # JSON Schema: title, group, description and defau diffr config show [--json] # the resolved configuration diffr config set diff.graph_limit 5000000 diffr config set theme.name default-light -diffr config set plugins.context.lines 5 +diffr config set plugins.deleted-bodies.min_lines 20 ``` `set` writes one key into the global file (or the `--config` file), keeping @@ -53,7 +53,27 @@ name = "default-dark" # a bundled terminal theme path = "/path/to/theme.toml" # or a Helix-style theme file [plugins] # see "Plugins" below -order = ["context"] +order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "group"] + +[plugins.hide-files] +enabled = true +tags = ["generated", "vendored", "test"] # the first listed tag a file carries names the reason +deleted = true # hide deleted files whatever their tags + +[plugins.deleted-bodies] # deleted function bodies, "12 lines removed" +enabled = true +min_lines = 12 + +[plugins.test-bodies] # test functions and test modules, on both sides +enabled = true +min_lines = 3 + +[plugins.removed-runs] # the middle of long removed stretches +enabled = true +min_lines = 5 + +[plugins.group] # adjacent collapsed regions under one row +enabled = true ``` When a file exceeds a `[diff]` limit it falls back to a line diff: the file's @@ -75,11 +95,31 @@ produce on the wire. ```toml [plugins] -order = ["context"] +order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "group"] [plugins.context] # unchanged lines far from any change collapse enabled = true lines = 3 # kept on either side of a change; -U overrides it + +[plugins.hide-files] +enabled = true +tags = ["generated", "vendored", "test"] # the first listed tag a file carries names the reason +deleted = true # hide deleted files whatever their tags + +[plugins.deleted-bodies] # deleted function bodies, "12 lines removed" +enabled = true +min_lines = 12 + +[plugins.test-bodies] # test functions and test modules, on both sides +enabled = true +min_lines = 3 + +[plugins.removed-runs] # the middle of long removed stretches +enabled = true +min_lines = 5 + +[plugins.group] # adjacent collapsed regions under one row +enabled = true ``` Every bundled plugin has an entry pre-filled with the values above; a file @@ -89,7 +129,7 @@ when the file loads. In an entry, `enabled` belongs to diffr: set `enabled = false` to turn a plugin off. Every other key is one of the plugin's options, checked against the options its `plugin.toml` declares: an unknown option or a value of the wrong type is an error naming the key, such -as `plugins.context: lines: "many" is not of type "integer"`. +as `plugins.deleted-bodies: min_lines: "many" is not of type "integer"`. Every plugin that is on is made (its `new` runs) when diffr starts, before any output, and one that cannot be made stops diffr with an error naming it. @@ -102,7 +142,7 @@ Every bundled plugin is a folder under `plugins/`, laid out the way any plugin is: ```text -plugins/context/ +plugins/deleted-bodies/ plugin.toml # name, title, options, query files queries/rust.scm # one query file per language Cargo.toml, src/lib.rs # the plugin's code, a crate using the plugin SDK @@ -141,9 +181,10 @@ settings schema in the order `plugin.toml` declares them. ### Queries -Tree-sitter queries decide which folds exist and which constructs enclose a -change; plugins decide how they are shown. Each plugin that reads syntax -(today `context`) lists one query file per language +Tree-sitter queries decide which folds exist and what they are, as tags; +plugins decide how they are shown. Each plugin that reads syntax +(`context`, `deleted-bodies`, `test-bodies`, `removed-runs`) +lists one query file per language key (`rust`, `python`, `go`, `javascript`, `javascriptjsx`, `typescript`, `typescripttsx`, or any other lowercase language name) under `[queries]` in its `plugin.toml`. Queries are not configured in `[plugins]`. diff --git a/docs/streaming.md b/docs/streaming.md index 772185be2..f6b92bd31 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -243,6 +243,18 @@ is at least three lines long collapses with a label such as `"142 unchanged lines"`; shorter stretches stay open because a row would save nothing. A file with no change collapses whole however short. + +**Groups** come from the `group` plugin: a run of two or more sibling +regions that start collapsed, whichever plugin collapsed them, is wrapped in +one new fold, with no tags. Between two collapsed regions of a run there may +be open leaves spanning at most two lines in all, such as a blank line and the +next function's header. The label counts the collapsed regions and the lines +the group spans: `"3 collapsed regions · 42 lines"`. It starts collapsed; +expanding it reveals each child's own row. The wrapped children are untouched. +When the other side pairs a region of the run, the run is grouped only if the +other side holds a run that matches it region for region, and then both are +grouped with one fold state. + Region edges cut a stretch. The part of it among one list of siblings collapses when it is at least three lines long or is the whole stretch, so a sliver at a fold edge stays open. A part inside one leaf is that leaf's lines, @@ -331,7 +343,12 @@ every region hangs from: the two new folds take their own `id`s and share one `fold_state_id`. - **Link fold state**: every region in the listed regions' fold states, on both sides, takes the first region's `fold_state_id` and whether it starts - collapsed, so they open and close together. + collapsed, so they open and close together. Today the bundled link is a + docstring: a plugin that collapses a function body (`deleted-bodies`, + `test-bodies`) links the body's docstring, a fold tagged + `deleted-bodies:docstring` or `test-bodies:docstring`, to it. The docstring + then carries the body's `fold_state_id` and starts collapsed, with an empty + label. A docstring whose body no plugin collapses is not linked. - **Set collapsed** on a region: every region sharing its `fold_state_id` starts collapsed, or open. On the file, whether the file starts hidden: the record's `visibility`. @@ -358,8 +375,12 @@ The bundled plugins, in their default order, each a folder under `plugins/`: | Plugin | What starts out differently | | --- | --- | | `context` | Unchanged lines far from any change and outside its enclosing headers collapse: `"142 unchanged lines"`. | - -A binary diff has no regions, so only moves on the file can change how it -starts out. Streaming is the only output mode that runs -plugins. +| `hide-files` | Files with a listed tag (`generated`, `vendored`, `test`), and deleted files, are hidden: `"Generated file · hidden by default"`. | +| `deleted-bodies` | A function body of at least 12 lines with nothing paired under it collapses: `"20 lines removed"`. Its docstring is linked to it. | +| `test-bodies` | Test function bodies (`"test body"`) and Rust `#[cfg(test)]` modules (`"test module"`) collapse on both sides. A test body's docstring is linked to it. | +| `removed-runs` | A one-sided leaf of at least 5 lines in removed (not rewritten) code keeps its first and last line open and collapses the rest: `"8 lines removed"`. | +| `group` | Two or more adjacent collapsed regions are wrapped in one collapsed fold: `"3 collapsed regions · 42 lines"`. An open fold showing only collapsed regions and at most two open lines, such as a function's scope around its collapsed body and closing brace, counts as collapsed. | + +A binary diff has no regions, so only `hide-files` can change how it starts +out. Streaming is the only output mode that runs plugins. diff --git a/plugins/context/queries/go.scm b/plugins/context/queries/go.scm index f815512e1..6c5d165ce 100644 --- a/plugins/context/queries/go.scm +++ b/plugins/context/queries/go.scm @@ -1,8 +1,9 @@ +; inherits: builtin:shared/queries/go.scm ; The scopes whose first and last line stay visible above and below a change ; inside them. A scope is the whole construct, not its body: its first line is ; the line its signature starts on, however many lines that signature runs to, -; and its last is the line that closes it. The body fold the bundled rules -; give the construct covers only the body, so it nests inside the scope and +; and its last is the line that closes it. The body fold the shared query +; gives the construct covers only the body, so it nests inside the scope and ; starts a line later. ((function_declaration) @fold (#set! tag "context:scope")) diff --git a/plugins/context/queries/javascript.scm b/plugins/context/queries/javascript.scm index 379497a74..0037606c3 100644 --- a/plugins/context/queries/javascript.scm +++ b/plugins/context/queries/javascript.scm @@ -1,8 +1,9 @@ +; inherits: builtin:shared/queries/javascript.scm ; The scopes whose first and last line stay visible above and below a change ; inside them. A scope is the whole construct, not its body: its first line is ; the line its signature starts on, however many lines that signature runs to, -; and its last is the line that closes it. The body fold the bundled rules -; give the construct covers only the body, so it nests inside the scope and +; and its last is the line that closes it. The body fold the shared query +; gives the construct covers only the body, so it nests inside the scope and ; starts a line later. ((function_declaration) @fold (#set! tag "context:scope")) diff --git a/plugins/context/queries/python.scm b/plugins/context/queries/python.scm index 4386bbf85..ff0705ef6 100644 --- a/plugins/context/queries/python.scm +++ b/plugins/context/queries/python.scm @@ -1,8 +1,9 @@ +; inherits: builtin:shared/queries/python.scm ; The scopes whose first and last line stay visible above and below a change ; inside them. A scope is the whole construct, not its body: its first line is ; the line its signature starts on, however many lines that signature runs to, -; and its last is the line that closes it. The body fold the bundled rules -; give the construct covers only the body, so it nests inside the scope and +; and its last is the line that closes it. The body fold the shared query +; gives the construct covers only the body, so it nests inside the scope and ; starts a line later. ((function_definition) @fold (#set! tag "context:scope")) diff --git a/plugins/context/queries/rust.scm b/plugins/context/queries/rust.scm index 9d149c46b..a75015104 100644 --- a/plugins/context/queries/rust.scm +++ b/plugins/context/queries/rust.scm @@ -1,8 +1,9 @@ +; inherits: builtin:shared/queries/rust.scm ; The scopes whose first and last line stay visible above and below a change ; inside them. A scope is the whole construct, not its body: its first line is ; the line its signature starts on, however many lines that signature runs to, -; and its last is the line that closes it. The body fold the bundled rules -; give the construct covers only the body, so it nests inside the scope and +; and its last is the line that closes it. The body fold the shared query +; gives the construct covers only the body, so it nests inside the scope and ; starts a line later. ((function_item) @fold (#set! tag "context:scope")) @@ -21,7 +22,7 @@ ((return_expression) @fold (#set! tag "context:scope")) ; A function's tail expression. Blocks, arrays and strings are left out: the -; bundled rules fold them with a range of its own. +; shared query folds them with a range of its own. ((function_item body: (block (_expression) @fold .)) (#not-match? @fold "^(\\{|\\[|b?r?#*\")") (#set! tag "context:scope")) diff --git a/plugins/deleted-bodies/Cargo.toml b/plugins/deleted-bodies/Cargo.toml new file mode 100644 index 000000000..8801bdf90 --- /dev/null +++ b/plugins/deleted-bodies/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "diffr-plugin-deleted-bodies" +description = "The deleted-bodies plugin: collapse deleted function bodies." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +diffr-plugin-sdk = { path = "../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/deleted-bodies/plugin.toml b/plugins/deleted-bodies/plugin.toml new file mode 100644 index 000000000..76d73934f --- /dev/null +++ b/plugins/deleted-bodies/plugin.toml @@ -0,0 +1,25 @@ +name = "deleted-bodies" +title = "Deleted function bodies" +description = "Collapse function bodies that were deleted: nothing under the fold has a counterpart on the after side." + +[enabled] +title = "Collapse deleted function bodies" +description = "Collapse deleted function bodies, keeping their header line visible." + +[options.min_lines] +type = "integer" +minimum = 0 +title = "Shortest body to collapse (lines)" +description = "Deleted bodies shorter than this stay open." +default = 12 + +# Folds tagged `deleted-bodies:function` and, from the shared docstrings +# query, `deleted-bodies:docstring`. +[queries] +rust = "queries/rust.scm" +python = "queries/python.scm" +go = "queries/go.scm" +javascript = "queries/javascript.scm" +javascriptjsx = "queries/javascript.scm" +typescript = "queries/javascript.scm" +typescripttsx = "queries/javascript.scm" diff --git a/plugins/deleted-bodies/queries/go.scm b/plugins/deleted-bodies/queries/go.scm new file mode 100644 index 000000000..f2089c0f2 --- /dev/null +++ b/plugins/deleted-bodies/queries/go.scm @@ -0,0 +1,5 @@ +; inherits: builtin:shared/queries/go.scm, builtin:shared/queries/go-docstrings.scm +((function_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "deleted-bodies:function")) +((method_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "deleted-bodies:function")) diff --git a/plugins/deleted-bodies/queries/javascript.scm b/plugins/deleted-bodies/queries/javascript.scm new file mode 100644 index 000000000..fa94b7867 --- /dev/null +++ b/plugins/deleted-bodies/queries/javascript.scm @@ -0,0 +1,14 @@ +; inherits: builtin:shared/queries/javascript.scm, builtin:shared/queries/javascript-docstrings.scm +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "deleted-bodies:function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "deleted-bodies:function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "deleted-bodies: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 "deleted-bodies:function")) diff --git a/plugins/deleted-bodies/queries/python.scm b/plugins/deleted-bodies/queries/python.scm new file mode 100644 index 000000000..f0f2305e3 --- /dev/null +++ b/plugins/deleted-bodies/queries/python.scm @@ -0,0 +1,3 @@ +; inherits: builtin:shared/queries/python.scm, builtin:shared/queries/python-docstrings.scm +((function_definition ":" @fold.open body: (block) @fold) + (#set! tag "deleted-bodies:function")) diff --git a/plugins/deleted-bodies/queries/rust.scm b/plugins/deleted-bodies/queries/rust.scm new file mode 100644 index 000000000..71fceeae5 --- /dev/null +++ b/plugins/deleted-bodies/queries/rust.scm @@ -0,0 +1,3 @@ +; inherits: builtin:shared/queries/rust.scm, builtin:shared/queries/rust-docstrings.scm +((function_item body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "deleted-bodies:function")) diff --git a/plugins/deleted-bodies/src/lib.rs b/plugins/deleted-bodies/src/lib.rs new file mode 100644 index 000000000..97b0e03fa --- /dev/null +++ b/plugins/deleted-bodies/src/lib.rs @@ -0,0 +1,72 @@ +//! Collapse function bodies that were deleted. +use diffr_plugin_sdk::types::Source; +use diffr_plugin_sdk::{ + anyhow, before_and_after_ids, docstring_of, export, has_tag, is_fold, line_count, one_sided, + tree, walk, Draft, FileEntry, Move, Plugin, +}; +use serde::Deserialize; + +/// The plugin's name, and the tag its queries set on function bodies. +const PLUGIN: &str = "deleted-bodies"; +const FUNCTION: &str = "deleted-bodies:function"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options { + min_lines: usize, +} + +/// Deleted function bodies of at least `min_lines` start collapsed with a +/// line count, linked to their docstrings, which collapse with them. A body +/// counts as deleted only when nothing under it is paired: a fold whose lines +/// still align with the after side is a rewrite, not a removal. +pub struct DeletedBodies { + options: Options, +} + +impl Plugin for DeletedBodies { + type Options = Options; + + fn new(options: Options) -> anyhow::Result<Self> { + Ok(Self { options }) + } + + fn classify(&self, _file: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(Vec::new()) + } + + fn mutate( + &self, + _file: &FileEntry, + lhs: Option<&Source>, + rhs: Option<&Source>, + ) -> anyhow::Result<Vec<Move>> { + let options = &self.options; + let sides = tree::sides(lhs, rhs)?; + let Some((lhs, rhs)) = before_and_after_ids(&sides) else { + return Ok(Vec::new()); + }; + // Each deleted body: its id, line count and docstring. + let mut bodies = Vec::new(); + walk(&lhs.regions, &mut |region| { + let count = line_count(region); + if is_fold(region) + && has_tag(region, FUNCTION) + && one_sided(region, &rhs) + && count >= options.min_lines + { + bodies.push((region.id, count, docstring_of(lhs, region, PLUGIN))); + } + }); + let mut draft = Draft::new(&sides); + for (id, count, docstring) in bodies { + draft.collapse(id, format!("{count} lines removed"))?; + if let Some(docstring) = docstring { + draft.link(&[id, docstring])?; + } + } + Ok(draft.into_moves()) + } +} + +export!(DeletedBodies); diff --git a/plugins/group/Cargo.toml b/plugins/group/Cargo.toml new file mode 100644 index 000000000..667ecad64 --- /dev/null +++ b/plugins/group/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "diffr-plugin-group" +description = "The group plugin: wrap runs of collapsed regions in one fold." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +diffr-plugin-sdk = { path = "../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/group/plugin.toml b/plugins/group/plugin.toml new file mode 100644 index 000000000..1304073be --- /dev/null +++ b/plugins/group/plugin.toml @@ -0,0 +1,7 @@ +name = "group" +title = "Groups" +description = "Wrap runs of adjacent collapsed regions in one collapsed group fold." + +[enabled] +title = "Group adjacent collapsed regions" +description = "Wrap two or more adjacent collapsed regions in one collapsed fold that counts them and their lines." diff --git a/plugins/group/src/lib.rs b/plugins/group/src/lib.rs new file mode 100644 index 000000000..37ed7bf1b --- /dev/null +++ b/plugins/group/src/lib.rs @@ -0,0 +1,258 @@ +//! Wrap runs of adjacent collapsed regions in one collapsed fold. +//! +//! A run is two or more collapsed siblings, with nothing between them or only +//! open leaves spanning at most two lines in all: a blank line and the next +//! function's header, say. It becomes one collapsed fold labelled with how +//! many collapsed regions it wraps and how many lines it spans, as +//! `"3 collapsed regions · 42 lines"`, so the reader sees one row instead of a +//! stack of them. Expanding the group reveals each child's own row. Which +//! plugin collapsed a region, and why, does not matter. +//! +//! An open fold that shows only collapsed regions, with at most two open +//! lines before, between or after them, such as a scope around a collapsed +//! function body and its closing brace, counts as collapsed: it joins a run +//! as the collapsed regions it shows, and its open lines count as separator +//! lines. A run starts on a collapsed row, and a group hides the open lines +//! its last member shows after its last collapsed row. +//! +//! A run is grouped on its side alone when the other side pairs none of its +//! regions. When the other side holds a run that matches it region for +//! region (the same leaf, or a fold sharing the fold state), both runs are +//! grouped in one join, and the two groups open and close together. A +//! run whose regions are paired across sides some other way stays as it +//! is. +use diffr_plugin_sdk::types::Source; +use diffr_plugin_sdk::{ + anyhow, export, is_fold, line_count, sides_with_other_ids, tree, Draft, FileEntry, Move, Node, + Plugin, Region, +}; +use serde::Deserialize; +use std::collections::BTreeSet; + +pub struct Group; + +/// The plugin has no options. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options {} + +/// Open lines a run may show between two of its collapsed regions, and that +/// a fold counting as collapsed may show before, between or after its own. +const MAX_SEPARATOR_LINES: usize = 2; + +/// A run to group: its siblings, and how many collapsed regions they show. +struct Run<'a> { + members: Vec<&'a Region>, + collapsed: usize, +} + +impl Plugin for Group { + type Options = Options; + + fn new(_options: Options) -> anyhow::Result<Self> { + Ok(Self) + } + + fn classify(&self, _file: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(Vec::new()) + } + + fn mutate( + &self, + _file: &FileEntry, + lhs: Option<&Source>, + rhs: Option<&Source>, + ) -> anyhow::Result<Vec<Move>> { + let sides = tree::sides(lhs, rhs)?; + let per_side: Vec<_> = sides_with_other_ids(&sides) + .into_iter() + .map(|(source, other_ids)| (runs(&source.regions), other_ids)) + .collect(); + let mut groups: BTreeSet<BTreeSet<u32>> = BTreeSet::new(); + let mut draft = Draft::new(&sides); + for (side, (runs, other_ids)) in per_side.iter().enumerate() { + // A one-sided file has no other side, and so no other runs. + let other_runs: &[Run<'_>] = match per_side.get(1 - side) { + Some((runs, _)) => runs, + None => &[], + }; + for run in runs { + let mut regions: Vec<u32> = run.members.iter().map(|region| region.id).collect(); + if run.members.iter().any(|region| other_ids.pairs(region)) { + let Some(matched) = other_runs + .iter() + .find(|other| matches(&run.members, &other.members)) + else { + continue; + }; + regions.extend(matched.members.iter().map(|region| region.id)); + } + if groups.insert(regions.iter().copied().collect()) { + draft.group(regions, label(run))?; + } + } + } + Ok(draft.into_moves()) + } +} + +/// Whether two runs on opposite sides pair region for region: the same +/// leaf, or two folds sharing a fold state. +fn matches(run: &[&Region], other: &[&Region]) -> bool { + run.len() == other.len() + && run + .iter() + .zip(other) + .all(|(own, theirs)| match (is_fold(own), is_fold(theirs)) { + (false, false) => own.alignment_id() == theirs.alignment_id(), + (true, true) => own.fold_state_id == theirs.fold_state_id, + _ => false, + }) +} + +/// What a region shows when it counts as collapsed: how many collapsed +/// regions, and the open lines before the first and after the last. +struct Shape { + collapsed: usize, + leading: usize, + trailing: usize, +} + +/// The shape of `region` when it counts as collapsed: it is collapsed, or it +/// is an open fold that shows at least one collapsed region, with at most +/// [`MAX_SEPARATOR_LINES`] open lines before, between or after them. +fn shape(region: &Region) -> Option<Shape> { + /// Each row `regions` show: `None` for a collapsed region, or an open + /// leaf's line count. + fn rows(regions: &[Region], out: &mut Vec<Option<usize>>) { + for region in regions { + match (®ion.node, region.visibility.collapsed) { + (_, true) => out.push(None), + (Node::Leaf { .. }, false) => out.push(Some(line_count(region))), + (Node::Fold { children }, false) => rows(children, out), + } + } + } + let children = match (®ion.node, region.visibility.collapsed) { + (_, true) => { + return Some(Shape { + collapsed: 1, + leading: 0, + trailing: 0, + }) + } + (Node::Leaf { .. }, false) => return None, + (Node::Fold { children }, false) => children, + }; + let mut shown = Vec::new(); + rows(children, &mut shown); + // The open lines in each stretch between collapsed rows, and before the + // first and after the last. + let mut stretches = vec![0]; + for row in shown { + match row { + None => stretches.push(0), + Some(lines) => *stretches.last_mut().expect("never empty") += lines, + } + } + (stretches.len() > 1 && stretches.iter().all(|lines| *lines <= MAX_SEPARATOR_LINES)).then( + || Shape { + collapsed: stretches.len() - 1, + leading: stretches[0], + trailing: stretches[stretches.len() - 1], + }, + ) +} + +/// The runs of `regions` and of the open folds under them that are not in a +/// run themselves, in document order. +fn runs(regions: &[Region]) -> Vec<Run<'_>> { + let mut out = Vec::new(); + collapsed_runs(regions, &mut out); + out +} + +fn collapsed_runs<'a>(regions: &'a [Region], out: &mut Vec<Run<'a>>) { + let mut own = Vec::new(); + let mut run = Run { + members: Vec::new(), + collapsed: 0, + }; + // The open leaves after the run's last member, and the open lines they + // and that member show after its last collapsed region. + let mut separator: Vec<&Region> = Vec::new(); + let mut gap = 0; + for region in regions { + match shape(region) { + // A run starts with a collapsed row: a group never hides open + // lines above its first one. + Some(shape) if run.members.is_empty() && shape.leading > 0 => {} + Some(shape) if run.members.is_empty() || gap + shape.leading <= MAX_SEPARATOR_LINES => { + run.members.append(&mut separator); + run.members.push(region); + run.collapsed += shape.collapsed; + gap = shape.trailing; + continue; + } + Some(shape) => { + push_run(&mut run, &mut own); + separator.clear(); + if shape.leading == 0 { + run.members.push(region); + run.collapsed += shape.collapsed; + gap = shape.trailing; + } + continue; + } + None if !run.members.is_empty() + && !is_fold(region) + && gap + line_count(region) <= MAX_SEPARATOR_LINES => + { + gap += line_count(region); + separator.push(region); + continue; + } + None => {} + } + push_run(&mut run, &mut own); + separator.clear(); + } + push_run(&mut run, &mut own); + let grouped: Vec<u32> = own + .iter() + .flat_map(|run| run.members.iter().map(|region| region.id)) + .collect(); + for region in regions { + // Nothing under a collapsed fold is visible, and a fold in a run is + // grouped whole, so neither needs grouping inside. + if let (Node::Fold { children }, false) = (®ion.node, region.visibility.collapsed) { + if !grouped.contains(®ion.id) { + collapsed_runs(children, out); + } + } + } + out.extend(own); +} + +/// Move `run` into `out` if it has two members or more and shows two +/// collapsed regions or more. +fn push_run<'a>(run: &mut Run<'a>, out: &mut Vec<Run<'a>>) { + let taken = std::mem::replace( + run, + Run { + members: Vec::new(), + collapsed: 0, + }, + ); + if taken.members.len() >= 2 && taken.collapsed >= 2 { + out.push(taken); + } +} + +/// What a group's collapsed row says: `"3 collapsed regions · 42 lines"`. +fn label(run: &Run<'_>) -> String { + let lines: usize = run.members.iter().copied().map(line_count).sum(); + format!("{} collapsed regions · {lines} lines", run.collapsed) +} + +export!(Group); diff --git a/plugins/hide-files/Cargo.toml b/plugins/hide-files/Cargo.toml new file mode 100644 index 000000000..250f0c32a --- /dev/null +++ b/plugins/hide-files/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "diffr-plugin-hide-files" +description = "The hide-files plugin: hide whole files by their tags or status." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +diffr-plugin-sdk = { path = "../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/hide-files/plugin.toml b/plugins/hide-files/plugin.toml new file mode 100644 index 000000000..036c90899 --- /dev/null +++ b/plugins/hide-files/plugin.toml @@ -0,0 +1,20 @@ +name = "hide-files" +title = "Hidden files" +description = "Hide whole files by their tags or status. A hidden file's record carries `visibility`, and frontends show it behind the reason." + +[enabled] +title = "Hide files by tag" +description = "Hide files carrying any of `tags`, and deleted files when `deleted` is set." + +[options.tags] +type = "array" +items = { type = "string" } +title = "Tags that hide a file" +description = "File tags that hide a file, such as `generated`, `vendored` or `test`. The first listed tag a file carries names the reason." +default = ["generated", "vendored", "test"] + +[options.deleted] +type = "boolean" +title = "Hide deleted files" +description = "Hide files that were deleted outright, whatever their tags." +default = true diff --git a/plugins/hide-files/src/lib.rs b/plugins/hide-files/src/lib.rs new file mode 100644 index 000000000..2f62a6027 --- /dev/null +++ b/plugins/hide-files/src/lib.rs @@ -0,0 +1,68 @@ +//! Hide whole files by their tags or status. +use diffr_plugin_sdk::types::Source; +use diffr_plugin_sdk::{anyhow, export, FileEntry, FileStatus, Move, Plugin, ROOT}; +use serde::Deserialize; + +/// A deleted file, when `deleted` is set, or a file carrying one of `tags` +/// starts hidden. The reason names the status or the first listed tag the +/// file carries: "Deleted file · hidden by default", "Generated file · +/// hidden by default". +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options { + tags: Vec<String>, + deleted: bool, +} + +pub struct HideFiles { + options: Options, +} + +impl Plugin for HideFiles { + type Options = Options; + + fn new(options: Options) -> anyhow::Result<Self> { + Ok(Self { options }) + } + + fn classify(&self, _file: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(Vec::new()) + } + + fn mutate( + &self, + file: &FileEntry, + _lhs: Option<&Source>, + _rhs: Option<&Source>, + ) -> anyhow::Result<Vec<Move>> { + let options = &self.options; + let what = if options.deleted && file.status == FileStatus::Deleted { + "Deleted".to_owned() + } else { + match options.tags.iter().find(|tag| file.tags.contains(tag)) { + Some(tag) => capitalized(tag), + None => return Ok(Vec::new()), + } + }; + Ok(vec![ + Move::SetCollapsed { + region: ROOT, + collapsed: true, + }, + Move::SetLabel { + region: ROOT, + label: Some(format!("{what} file · hidden by default")), + }, + ]) + } +} + +fn capitalized(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +export!(HideFiles); diff --git a/plugins/removed-runs/Cargo.toml b/plugins/removed-runs/Cargo.toml new file mode 100644 index 000000000..1ad1ef4fa --- /dev/null +++ b/plugins/removed-runs/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "diffr-plugin-removed-runs" +description = "The removed-runs plugin: collapse the middle of long removed stretches." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +diffr-plugin-sdk = { path = "../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/removed-runs/plugin.toml b/plugins/removed-runs/plugin.toml new file mode 100644 index 000000000..21b73ff1c --- /dev/null +++ b/plugins/removed-runs/plugin.toml @@ -0,0 +1,24 @@ +name = "removed-runs" +title = "Removed stretches" +description = "Collapse the middle of long removed stretches in code that was removed rather than rewritten." + +[enabled] +title = "Collapse long removed stretches" +description = "Collapse removed stretches with no counterpart, keeping their first and last line visible." + +[options.min_lines] +type = "integer" +minimum = 0 +title = "Shortest stretch to collapse (lines)" +description = "Removed stretches shorter than this stay open. A stretch needs at least three lines to keep one open at each end." +default = 5 + +# Folds tagged `removed-runs:function`. +[queries] +rust = "queries/rust.scm" +python = "queries/python.scm" +go = "queries/go.scm" +javascript = "queries/javascript.scm" +javascriptjsx = "queries/javascript.scm" +typescript = "queries/javascript.scm" +typescripttsx = "queries/javascript.scm" diff --git a/plugins/removed-runs/queries/go.scm b/plugins/removed-runs/queries/go.scm new file mode 100644 index 000000000..152d5a7af --- /dev/null +++ b/plugins/removed-runs/queries/go.scm @@ -0,0 +1,5 @@ +; inherits: builtin:shared/queries/go.scm +((function_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "removed-runs:function")) +((method_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "removed-runs:function")) diff --git a/plugins/removed-runs/queries/javascript.scm b/plugins/removed-runs/queries/javascript.scm new file mode 100644 index 000000000..8ec74a43e --- /dev/null +++ b/plugins/removed-runs/queries/javascript.scm @@ -0,0 +1,14 @@ +; inherits: builtin:shared/queries/javascript.scm +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "removed-runs:function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "removed-runs:function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "removed-runs: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 "removed-runs:function")) diff --git a/plugins/removed-runs/queries/python.scm b/plugins/removed-runs/queries/python.scm new file mode 100644 index 000000000..9d048c263 --- /dev/null +++ b/plugins/removed-runs/queries/python.scm @@ -0,0 +1,3 @@ +; inherits: builtin:shared/queries/python.scm +((function_definition ":" @fold.open body: (block) @fold) + (#set! tag "removed-runs:function")) diff --git a/plugins/removed-runs/queries/rust.scm b/plugins/removed-runs/queries/rust.scm new file mode 100644 index 000000000..419a68149 --- /dev/null +++ b/plugins/removed-runs/queries/rust.scm @@ -0,0 +1,3 @@ +; inherits: builtin:shared/queries/rust.scm +((function_item body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "removed-runs:function")) diff --git a/plugins/removed-runs/src/lib.rs b/plugins/removed-runs/src/lib.rs new file mode 100644 index 000000000..37fd6d257 --- /dev/null +++ b/plugins/removed-runs/src/lib.rs @@ -0,0 +1,127 @@ +//! Collapse the middle of long removed stretches. +use diffr_plugin_sdk::types::Source; +use diffr_plugin_sdk::{ + anyhow, before_and_after_ids, export, has_tag, line_count, one_sided, tree, Draft, FileEntry, + Move, Node, OtherSide, Plugin, Region, +}; +use serde::Deserialize; + +/// The tag this plugin's queries set on function bodies. +const FUNCTION: &str = "removed-runs:function"; + +/// Removed stretches with no counterpart and at least `min_lines` lines +/// (never fewer than three) keep their first and last line open and +/// collapse the rest with a line count, 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 qualify. Leaves that already +/// start collapsed, or sit under a fold that does, are left alone. +pub struct RemovedRuns { + options: Options, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options { + min_lines: usize, +} + +impl Plugin for RemovedRuns { + type Options = Options; + + fn new(options: Options) -> anyhow::Result<Self> { + Ok(Self { options }) + } + + fn classify(&self, _file: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(Vec::new()) + } + + fn mutate( + &self, + _file: &FileEntry, + lhs: Option<&Source>, + rhs: Option<&Source>, + ) -> anyhow::Result<Vec<Move>> { + let options = &self.options; + let sides = tree::sides(lhs, rhs)?; + let Some((lhs, rhs)) = before_and_after_ids(&sides) else { + return Ok(Vec::new()); + }; + // A leaf needs a first, a middle, and a last line. + let threshold = options.min_lines.max(3); + let mut leaves = Vec::new(); + visit( + &lhs.regions, + &rhs, + threshold, + false, + Gates::default(), + &mut leaves, + ); + let mut draft = Draft::new(&sides); + for (id, len) in leaves { + let middle = draft.cut_lines(id, 1, len - 1)?; + draft.collapse(middle, format!("{} lines removed", len - 2))?; + } + Ok(draft.into_moves()) + } +} + +/// Whether the enclosing folds are one-sided: the nearest function fold, +/// and the nearest fold of any kind. `None` when there is no such fold. +#[derive(Clone, Copy, Default)] +struct Gates { + function: Option<bool>, + any: Option<bool>, +} + +impl Gates { + fn enter(self, fold: &Region, rhs: &OtherSide) -> Self { + let unpaired = one_sided(fold, rhs); + Self { + function: if has_tag(fold, 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) + } +} + +/// Every leaf whose middle collapses, with its line count, in document +/// order. +fn visit( + regions: &[Region], + rhs: &OtherSide, + threshold: usize, + under_collapsed: bool, + gates: Gates, + leaves: &mut Vec<(u32, u32)>, +) { + for region in regions { + let collapsed = under_collapsed || region.visibility.collapsed; + match ®ion.node { + Node::Fold { children } => { + let inner = gates.enter(region, rhs); + visit(children, rhs, threshold, collapsed, inner, leaves); + } + Node::Leaf { .. } => { + let len = line_count(region); + if !collapsed && gates.open() && !rhs.pairs(region) && len >= threshold { + leaves.push((region.id, len as u32)); + } + } + } + } +} + +export!(RemovedRuns); diff --git a/plugins/shared/queries/go-docstrings.scm b/plugins/shared/queries/go-docstrings.scm new file mode 100644 index 000000000..28425c781 --- /dev/null +++ b/plugins/shared/queries/go-docstrings.scm @@ -0,0 +1,19 @@ +; Docstrings, imported by the plugins that collapse function bodies: each +; links a body it collapses to the docstring tagged with its own name (see +; `docstring_of` in crates/diffr-plugin-sdk/src/tree.rs). A file is included once however many +; plugins import it, so a docstring carries one tag per importer. +; +; A docstring: the run of comments directly above a function. It is tagged +; only when the function's body spans lines, and so is a region: the fold +; just before a body is then never the docstring of a one-line function +; above it. +((comment)+ @fold . [(function_declaration) (method_declaration)]) +((comment)+ @fold + . + [ + (function_declaration body: (block) @_body) + (method_declaration body: (block) @_body) + ] + (#match? @_body "\n") + (#set! tag "deleted-bodies:docstring") + (#set! tag "test-bodies:docstring")) diff --git a/plugins/shared/queries/go.scm b/plugins/shared/queries/go.scm new file mode 100644 index 000000000..52729a385 --- /dev/null +++ b/plugins/shared/queries/go.scm @@ -0,0 +1,14 @@ +; Structure every Go view shares: blocks, literals, imports and strings are +; folds whichever plugin imports this file. Comments are left to +; go-docstrings.scm, which folds a run of them as one region. These patterns +; set no tags; the plugins that import them tag what they need. +[ + (block "{" @fold.open "}" @fold.close) + (field_declaration_list "{" @fold.open "}" @fold.close) +] @fold +(literal_value "{" @fold.open "}" @fold.close) @fold +(import_declaration) @fold +[ + (raw_string_literal) + (interpreted_string_literal) +] @fold diff --git a/plugins/shared/queries/javascript-docstrings.scm b/plugins/shared/queries/javascript-docstrings.scm new file mode 100644 index 000000000..f9ccb7a70 --- /dev/null +++ b/plugins/shared/queries/javascript-docstrings.scm @@ -0,0 +1,46 @@ +; Docstrings, imported by the plugins that collapse function bodies: each +; links a body it collapses to the docstring tagged with its own name (see +; `docstring_of` in crates/diffr-plugin-sdk/src/tree.rs). A file is included once however many +; plugins import it, so a docstring carries one tag per importer. +; +; A docstring: the run of comments directly above a function, a method, a +; declaration or an export. It is tagged only above a function whose body +; spans lines, and so is a region: the fold just before a body is then never +; the docstring of a one-line function or a declaration above it. +((comment)+ @fold + . + [ + (function_declaration) + (generator_function_declaration) + (method_definition) + (lexical_declaration) + (variable_declaration) + (export_statement) + ]) +((comment)+ @fold + . + [ + (function_declaration body: (statement_block) @_body) + (generator_function_declaration body: (statement_block) @_body) + (method_definition body: (statement_block) @_body) + (export_statement + declaration: [ + (function_declaration body: (statement_block) @_body) + (generator_function_declaration body: (statement_block) @_body) + ]) + (lexical_declaration + (variable_declarator + value: [ + (arrow_function body: (statement_block) @_body) + (function_expression body: (statement_block) @_body) + ])) + (variable_declaration + (variable_declarator + value: [ + (arrow_function body: (statement_block) @_body) + (function_expression body: (statement_block) @_body) + ])) + ] + (#match? @_body "\n") + (#set! tag "deleted-bodies:docstring") + (#set! tag "test-bodies:docstring")) diff --git a/plugins/shared/queries/javascript.scm b/plugins/shared/queries/javascript.scm new file mode 100644 index 000000000..110044f16 --- /dev/null +++ b/plugins/shared/queries/javascript.scm @@ -0,0 +1,19 @@ +; Structure every JavaScript and TypeScript view shares: blocks, collections, +; imports and strings are folds whichever plugin imports this file. +; Comments are left to javascript-docstrings.scm, which folds a run of them as +; one region. These patterns set no tags; the plugins that import them tag +; what they need. +[ + (statement_block "{" @fold.open "}" @fold.close) + (class_body "{" @fold.open "}" @fold.close) + (switch_body "{" @fold.open "}" @fold.close) +] @fold +[ + (object "{" @fold.open "}" @fold.close) + (array "[" @fold.open "]" @fold.close) +] @fold +(import_statement) @fold +[ + (string) + (template_string) +] @fold diff --git a/plugins/shared/queries/python-docstrings.scm b/plugins/shared/queries/python-docstrings.scm new file mode 100644 index 000000000..0e41c92f0 --- /dev/null +++ b/plugins/shared/queries/python-docstrings.scm @@ -0,0 +1,9 @@ +; Docstrings, imported by the plugins that collapse function bodies: each +; links a body it collapses to the docstring tagged with its own name (see +; `docstring_of` in crates/diffr-plugin-sdk/src/tree.rs). A file is included once however many +; plugins import it, so a docstring carries one tag per importer. +; +; A docstring: the string that is a function body's first statement. +((function_definition body: (block . (expression_statement (string) @fold))) + (#set! tag "deleted-bodies:docstring") + (#set! tag "test-bodies:docstring")) diff --git a/plugins/shared/queries/python.scm b/plugins/shared/queries/python.scm new file mode 100644 index 000000000..174aeca6a --- /dev/null +++ b/plugins/shared/queries/python.scm @@ -0,0 +1,35 @@ +; Structure every Python view shares: blocks, collections, imports, comments +; and strings are folds whichever plugin imports this file. These patterns +; set no tags; the plugins that import them tag what they need. +; +; A block folds from the `:` that opens it, so its fold starts on the +; header line, as a Rust body folds from its `{`. A plugin that tags a block +; must capture the same `:` or its fold range conflicts with this one. +[ + (function_definition ":" @fold.open body: (block) @fold) + (class_definition ":" @fold.open body: (block) @fold) + (if_statement ":" @fold.open consequence: (block) @fold) + (elif_clause ":" @fold.open consequence: (block) @fold) + (else_clause ":" @fold.open body: (block) @fold) + (for_statement ":" @fold.open body: (block) @fold) + (while_statement ":" @fold.open body: (block) @fold) + (with_statement ":" @fold.open body: (block) @fold) + (try_statement ":" @fold.open body: (block) @fold) + (except_clause ":" @fold.open (block) @fold) + (finally_clause ":" @fold.open (block) @fold) + (match_statement ":" @fold.open body: (block) @fold) + (case_clause ":" @fold.open consequence: (block) @fold) +] +[ + (list "[" @fold.open "]" @fold.close) + (dictionary "{" @fold.open "}" @fold.close) + (set "{" @fold.open "}" @fold.close) + (tuple "(" @fold.open ")" @fold.close) +] @fold +((tuple) @fold (#not-match? @fold "^\\(")) +[ + (import_statement) + (import_from_statement) +] @fold +(comment) @fold +(string) @fold diff --git a/plugins/shared/queries/rust-docstrings.scm b/plugins/shared/queries/rust-docstrings.scm new file mode 100644 index 000000000..e7e5ed2c0 --- /dev/null +++ b/plugins/shared/queries/rust-docstrings.scm @@ -0,0 +1,28 @@ +; Docstrings, imported by the plugins that collapse function bodies: each +; links a body it collapses to the docstring tagged with its own name (see +; `docstring_of` in crates/diffr-plugin-sdk/src/tree.rs). A file is included once however many +; plugins import it, so a docstring carries one tag per importer. +; +; A docstring: the run of doc comments among a function's attributes, or the +; run of line comments directly above it. It is tagged only when the +; function's body spans lines, and so is a region: the fold just before a +; body is then never the docstring of a one-line function above it. +(function_item (attributes (line_outer_doc_comment)+ @fold)) +(function_item (attributes (block_outer_doc_comment) @fold)) +((line_comment)+ @fold . (function_item)) +((function_item + (attributes (line_outer_doc_comment)+ @fold) + body: (block) @_body) + (#match? @_body "\n") + (#set! tag "deleted-bodies:docstring") + (#set! tag "test-bodies:docstring")) +((function_item + (attributes (block_outer_doc_comment) @fold) + body: (block) @_body) + (#match? @_body "\n") + (#set! tag "deleted-bodies:docstring") + (#set! tag "test-bodies:docstring")) +((line_comment)+ @fold . (function_item body: (block) @_body) + (#match? @_body "\n") + (#set! tag "deleted-bodies:docstring") + (#set! tag "test-bodies:docstring")) diff --git a/plugins/shared/queries/rust.scm b/plugins/shared/queries/rust.scm new file mode 100644 index 000000000..d3c532044 --- /dev/null +++ b/plugins/shared/queries/rust.scm @@ -0,0 +1,19 @@ +; Structure every Rust view shares: bodies, collections, imports, block +; comments and strings are folds whichever plugin imports this file. These +; patterns set no tags; the plugins that import them tag what they need. +[ + (block "{" @fold.open "}" @fold.close) + (field_declaration_list "{" @fold.open "}" @fold.close) + (declaration_list "{" @fold.open "}" @fold.close) + (match_block "{" @fold.open "}" @fold.close) +] @fold +[ + (array_expression "[" @fold.open "]" @fold.close) + (field_initializer_list "{" @fold.open "}" @fold.close) +] @fold +(use_declaration) @fold +(block_comment) @fold +[ + (string_literal) + (raw_string_literal) +] @fold diff --git a/plugins/test-bodies/Cargo.toml b/plugins/test-bodies/Cargo.toml new file mode 100644 index 000000000..1c6f4e671 --- /dev/null +++ b/plugins/test-bodies/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "diffr-plugin-test-bodies" +description = "The test-bodies plugin: collapse test bodies and test modules." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +diffr-plugin-sdk = { path = "../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/test-bodies/plugin.toml b/plugins/test-bodies/plugin.toml new file mode 100644 index 000000000..6775e3605 --- /dev/null +++ b/plugins/test-bodies/plugin.toml @@ -0,0 +1,25 @@ +name = "test-bodies" +title = "Test bodies" +description = "Collapse test bodies and test modules on both sides, so a diff reads as the code under test first." + +[enabled] +title = "Collapse test bodies" +description = "Collapse the bodies of test functions and test modules, header visible." + +[options.min_lines] +type = "integer" +minimum = 0 +title = "Shortest test body to collapse (lines)" +description = "Test bodies shorter than this stay open: a one-line assertion is cheaper to read than a fold row." +default = 3 + +# Folds tagged `test-bodies:test`, `test-bodies:module` and, from the shared +# docstrings query, `test-bodies:docstring`. +[queries] +rust = "queries/rust.scm" +python = "queries/python.scm" +go = "queries/go.scm" +javascript = "queries/javascript.scm" +javascriptjsx = "queries/javascript.scm" +typescript = "queries/javascript.scm" +typescripttsx = "queries/javascript.scm" diff --git a/plugins/test-bodies/queries/go.scm b/plugins/test-bodies/queries/go.scm new file mode 100644 index 000000000..2751fc490 --- /dev/null +++ b/plugins/test-bodies/queries/go.scm @@ -0,0 +1,4 @@ +; inherits: builtin:shared/queries/go.scm, builtin:shared/queries/go-docstrings.scm +((function_declaration name: (identifier) @_name body: (block "{" @fold.open "}" @fold.close) @fold) + (#match? @_name "^Test") + (#set! tag "test-bodies:test")) diff --git a/plugins/test-bodies/queries/javascript.scm b/plugins/test-bodies/queries/javascript.scm new file mode 100644 index 000000000..0ece849ef --- /dev/null +++ b/plugins/test-bodies/queries/javascript.scm @@ -0,0 +1,9 @@ +; inherits: builtin:shared/queries/javascript.scm, builtin:shared/queries/javascript-docstrings.scm +((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-bodies:test")) diff --git a/plugins/test-bodies/queries/python.scm b/plugins/test-bodies/queries/python.scm new file mode 100644 index 000000000..80be07795 --- /dev/null +++ b/plugins/test-bodies/queries/python.scm @@ -0,0 +1,4 @@ +; inherits: builtin:shared/queries/python.scm, builtin:shared/queries/python-docstrings.scm +((function_definition name: (identifier) @_name ":" @fold.open body: (block) @fold) + (#match? @_name "^test_") + (#set! tag "test-bodies:test")) diff --git a/plugins/test-bodies/queries/rust.scm b/plugins/test-bodies/queries/rust.scm new file mode 100644 index 000000000..8d0ad747e --- /dev/null +++ b/plugins/test-bodies/queries/rust.scm @@ -0,0 +1,7 @@ +; inherits: builtin:shared/queries/rust.scm, builtin:shared/queries/rust-docstrings.scm +((function_item attributes: (attributes (attribute_item) @_attribute) body: (block "{" @fold.open "}" @fold.close) @fold) + (#match? @_attribute "test") + (#set! tag "test-bodies:test")) +((mod_item attributes: (attributes (attribute_item) @_attribute) body: (declaration_list "{" @fold.open "}" @fold.close) @fold) + (#match? @_attribute "cfg\\(test\\)") + (#set! tag "test-bodies:module")) diff --git a/plugins/test-bodies/src/lib.rs b/plugins/test-bodies/src/lib.rs new file mode 100644 index 000000000..a1ae4e3e2 --- /dev/null +++ b/plugins/test-bodies/src/lib.rs @@ -0,0 +1,80 @@ +//! Collapse test bodies and test modules. +use diffr_plugin_sdk::types::Source; +use diffr_plugin_sdk::{ + anyhow, docstring_of, export, has_tag, is_fold, line_count, tree, walk, Draft, FileEntry, Move, + Plugin, +}; +use serde::Deserialize; +use std::collections::BTreeMap; + +/// The plugin's name, and the tags its queries set: a test function's body, +/// and a test module's body. +const PLUGIN: &str = "test-bodies"; +const TEST: &str = "test-bodies:test"; +const MODULE: &str = "test-bodies:module"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options { + min_lines: usize, +} + +/// Test bodies of at least `min_lines` 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. A test body is linked to its docstring, which collapses +/// with it. +pub struct TestBodies { + options: Options, +} + +impl Plugin for TestBodies { + type Options = Options; + + fn new(options: Options) -> anyhow::Result<Self> { + Ok(Self { options }) + } + + fn classify(&self, _file: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(Vec::new()) + } + + fn mutate( + &self, + _file: &FileEntry, + lhs: Option<&Source>, + rhs: Option<&Source>, + ) -> anyhow::Result<Vec<Move>> { + let options = &self.options; + let sides = tree::sides(lhs, rhs)?; + // Every fold has its own id, so each side's fold is its own target + // and takes its own label. A test body's docstring, when it has one, + // is linked after it collapses. + let mut labels: BTreeMap<u32, (&str, Option<u32>)> = BTreeMap::new(); + for source in sides.sides() { + walk(&source.regions, &mut |region| { + if !is_fold(region) || line_count(region) < options.min_lines { + return; + } + if has_tag(region, MODULE) { + labels.insert(region.id, ("test module", None)); + } else if has_tag(region, TEST) { + labels + .entry(region.id) + .or_insert(("test body", docstring_of(source, region, PLUGIN))); + } + }); + } + let mut draft = Draft::new(&sides); + for (id, (label, docstring)) in labels { + draft.collapse(id, label.to_owned())?; + if let Some(docstring) = docstring { + draft.link(&[id, docstring])?; + } + } + Ok(draft.into_moves()) + } +} + +export!(TestBodies); diff --git a/src/config.rs b/src/config.rs index d9333b996..79875b978 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,14 +21,11 @@ pub(crate) mod store; use crate::hash::DftHashMap; use crate::options::DiffOptions; use crate::parse::{guess_language::Language, tree_sitter_parser}; -use crate::plugin::config::PluginsConfig; -#[cfg(test)] -use crate::plugin::config::Queries; +use crate::plugin::config::{PluginsConfig, Queries}; use crate::plugin::queries; -use query::{AnnotationQuery, QuerySource}; +use query::AnnotationQuery; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use strum::IntoEnumIterator; @@ -226,36 +223,22 @@ impl Config { schema } - /// Compile with the query files of every enabled plugin, after the - /// bundled fold rules no plugin owns yet. + /// Compile with the query files of every enabled plugin. pub(crate) fn compile(self) -> Result<Params, ConfigError> { let queries = self.plugins.enabled_queries(); - let mut assembled = queries::assemble(&queries)?; - for (name, bundled) in bundled_folds() { - assembled.entry(name).or_default().insert(0, bundled); - } - self.compile_assembled(assembled) + self.compile_queries(queries) } /// Compile with `queries`, the enabled plugins' query files in /// `plugins.order`. - #[cfg(test)] pub(crate) fn compile_queries( self, queries: Vec<(String, Queries)>, - ) -> Result<Params, ConfigError> { - let assembled = queries::assemble(&queries)?; - self.compile_assembled(assembled) - } - - fn compile_assembled( - self, - assembled: BTreeMap<String, Vec<QuerySource>>, ) -> Result<Params, ConfigError> { let mut languages: DftHashMap<_, _> = Language::iter() .map(|language| (language, OnceLock::new())) .collect(); - for (name, sources) in assembled { + for (name, sources) in queries::assemble(&queries)? { let language = Language::iter() .find(|language| format!("{language:?}").to_lowercase() == name) .ok_or_else(|| ConfigError(format!("unknown language: {name}")))?; @@ -279,44 +262,10 @@ impl Config { } } -/// The name of the bundled fold queries: config/defaults.toml. -const BUNDLED_FOLDS: &str = "config/defaults.toml"; - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct BundledFolds { - languages: BTreeMap<String, BundledLanguage>, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct BundledLanguage { - folds: String, -} - -/// Each language's bundled fold query. No bundled plugin owns fold queries -/// yet, so they stay in config/defaults.toml and come before the plugins' -/// query files. -fn bundled_folds() -> impl Iterator<Item = (String, QuerySource)> { - let bundled: BundledFolds = toml::from_str(include_str!("config/defaults.toml")) - .expect("the bundled fold queries parse"); - bundled.languages.into_iter().map(|(name, language)| { - let source = QuerySource { - name: format!("{BUNDLED_FOLDS}: languages.{name}.folds"), - text: language.folds, - }; - (name, source) - }) -} - /// Every tag a fold query sets is `<plugin>:<name>`, naming a plugin in -/// `plugins.order`: a plugin reads only the tags its own queries set. The -/// bundled fold queries' tags predate plugins and carry no prefix. +/// `plugins.order`: a plugin reads only the tags its own queries set. fn check_tags(query: &AnnotationQuery, order: &[String]) -> Result<(), ConfigError> { for pattern in &query.patterns { - if query.sources[pattern.source].starts_with(BUNDLED_FOLDS) { - continue; - } for tag in &pattern.tags { let owned = tag.split_once(':').is_some_and(|(plugin, name)| { !name.is_empty() && order.iter().any(|own| own == plugin) @@ -403,8 +352,10 @@ mod tests { result.file_format, crate::summary::FileFormat::SupportedLanguage(Language::C) )); - assert!(result.has_syntactic_changes); - assert!(!result.rhs_positions.is_empty()); + assert!(result + .rhs_positions + .iter() + .any(|position| position.kind.is_novel())); assert!(result.rhs_folds.is_empty()); } @@ -413,16 +364,16 @@ mod tests { let params = with_queries(&[ ( "javascript", - r#"((statement_block) @fold (#set! tag "context:plain-js"))"#, + r#"((statement_block) @fold (#set! tag "removed-runs:plain-js"))"#, ), ( "javascriptjsx", - r#"((statement_block) @fold (#set! tag "context:jsx"))"#, + r#"((statement_block) @fold (#set! tag "removed-runs:jsx"))"#, ), ]); for (path, tag) in [ - ("file.js", "context:plain-js"), - ("file.jsx", "context:jsx"), + ("file.js", "removed-runs:plain-js"), + ("file.jsx", "removed-runs:jsx"), ] { let result = DiffResult::from_sources_with_params( path, @@ -439,7 +390,7 @@ mod tests { fn embedded_languages_use_the_configured_queries() { let params = with_queries(&[( "javascript", - r#"((statement_block) @fold (#set! tag "context:embedded"))"#, + r#"((statement_block) @fold (#set! tag "removed-runs:embedded"))"#, )]); let result = DiffResult::from_sources_with_params( "page.html", @@ -448,7 +399,7 @@ mod tests { ¶ms, ); assert_eq!(result.rhs_folds.len(), 1); - assert_eq!(result.rhs_folds[0].tags, ["context:embedded"]); + assert_eq!(result.rhs_folds[0].tags, ["removed-runs:embedded"]); } #[test] @@ -457,10 +408,10 @@ mod tests { assert!(Config::from_toml("[languages.rust]\ncontext = '(block) @context'").is_err()); let error = Config::default() .compile_queries(vec![( - "context".to_owned(), + "removed-runs".to_owned(), Queries::from([( "klingon".to_owned(), - "builtin:context/queries/rust.scm".to_owned(), + "builtin:removed-runs/queries/rust.scm".to_owned(), )]), )]) .err() @@ -483,25 +434,25 @@ mod tests { .err() .expect(query) .to_string(); - assert!(error.contains("context.scm"), "{error}"); + assert!(error.contains("removed-runs.scm"), "{error}"); assert!(error.contains(message), "{error}"); } } } /// Params whose only fold query per listed language is the given text, -/// written to a file the context plugin owns; no other plugin +/// written to a file the removed-runs plugin owns; no other plugin /// contributes queries. #[cfg(test)] pub(crate) fn try_with_queries(queries: &[(&str, &str)]) -> Result<Params, ConfigError> { let dir = tempfile::tempdir().expect("a temporary directory"); let mut files = Queries::new(); for (language, query) in queries { - let path = dir.path().join(format!("{language}-context.scm")); + let path = dir.path().join(format!("{language}-removed-runs.scm")); std::fs::write(&path, query).expect("a query file"); files.insert((*language).to_owned(), path.display().to_string()); } - Config::default().compile_queries(vec![("context".to_owned(), files)]) + Config::default().compile_queries(vec![("removed-runs".to_owned(), files)]) } #[cfg(test)] @@ -509,9 +460,9 @@ fn with_queries(queries: &[(&str, &str)]) -> Params { try_with_queries(queries).expect("the test queries compile") } -/// Params with the bundled fold rules alone: the bodies and collections the -/// engine's tests are written against, without the scopes `context` lays -/// over them. +/// Params with the folds of every bundled plugin's queries but `context`'s: +/// the bodies, collections and docstrings the engine's tests are written +/// against, without the scopes `context` lays over them. #[cfg(test)] pub(crate) fn body_params() -> Params { Config::from_toml("[plugins.context]\nenabled = false\n") @@ -529,12 +480,12 @@ mod query_tests { fn arbitrary_tags_and_delimiter_captures_reach_the_domain() { let params = with_queries(&[( "rust", - r#"((block "{" @fold.open "}" @fold.close) @fold (#set! tag "context:user.validation"))"#, + r#"((block "{" @fold.open "}" @fold.close) @fold (#set! tag "removed-runs:user.validation"))"#, )]); let source = "fn f() { println!(\"☕\"); }"; let diff = DiffResult::from_sources_with_params("a.rs", "", source, ¶ms); let fold = &diff.rhs_folds[0]; - assert_eq!(fold.tags, ["context:user.validation"]); + assert_eq!(fold.tags, ["removed-runs:user.validation"]); assert_eq!( &source[fold.range.start.byte_column..fold.range.end.byte_column], " println!(\"☕\"); " @@ -554,7 +505,7 @@ mod query_tests { Ok(_) => panic!("accepted {query}"), Err(error) => error.to_string(), }; - assert!(error.contains("rust-context.scm: "), "{error}"); + assert!(error.contains("rust-removed-runs.scm: "), "{error}"); } } @@ -605,9 +556,9 @@ mod tag_tests { let params = with_queries(&[( "rust", r#" - ((block) @fold (#set! tag "context:user.check")) - ((block) @fold (#set! tag "context:body")) - ((block) @fold (#set! tag "context:user.check")) + ((block) @fold (#set! tag "removed-runs:user.check")) + ((block) @fold (#set! tag "removed-runs:body")) + ((block) @fold (#set! tag "removed-runs:user.check")) "#, )]); let result = @@ -615,13 +566,13 @@ mod tag_tests { assert_eq!(result.rhs_folds.len(), 1); assert_eq!( result.rhs_folds[0].tags, - ["context:body", "context:user.check"] + ["removed-runs:body", "removed-runs:user.check"] ); } #[test] fn an_opening_capture_alone_folds_to_the_end_of_the_fold_node() { - let query = r#"((function_definition ":" @fold.open body: (block) @fold) (#set! tag "context:body"))"#; + let query = r#"((function_definition ":" @fold.open body: (block) @fold) (#set! tag "removed-runs:body"))"#; let params = with_queries(&[("python", query)]); let rhs = "def f(a):\n x = a\n return x\n"; let result = DiffResult::from_sources_with_params("a.py", "", rhs, ¶ms); @@ -639,13 +590,13 @@ mod tag_tests { fn a_node_captured_with_two_ranges_is_a_query_conflict_naming_both_files() { let whole = "((block) @fold (#set! tag \"context:whole\"))"; let interior = - "((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"context:inside\"))"; + "((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"removed-runs:inside\"))"; let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("queries/rust")).unwrap(); - let whole_file = dir.path().join("queries/rust/whole.scm"); - let interior_file = dir.path().join("queries/rust/interior.scm"); - std::fs::write(&whole_file, whole).unwrap(); - std::fs::write(&interior_file, interior).unwrap(); + let context_file = dir.path().join("queries/rust/context.scm"); + let removed = dir.path().join("queries/rust/removed-runs.scm"); + std::fs::write(&context_file, whole).unwrap(); + std::fs::write(&removed, interior).unwrap(); let queries = |plugin: &str, path: &std::path::Path| { ( plugin.to_owned(), @@ -654,12 +605,12 @@ mod tag_tests { }; for order in [ [ - queries("context", &whole_file), - queries("context", &interior_file), + queries("context", &context_file), + queries("removed-runs", &removed), ], [ - queries("context", &interior_file), - queries("context", &whole_file), + queries("removed-runs", &removed), + queries("context", &context_file), ], ] { let params = Config::default().compile_queries(order.to_vec()).unwrap(); @@ -673,9 +624,9 @@ mod tag_tests { let message = conflict.to_string(); assert!(message.starts_with("src/lib.rs:1: "), "{message}"); assert!( - message.contains("queries/rust/interior.scm and ") + message.contains("queries/rust/context.scm and ") && message.contains( - "queries/rust/whole.scm capture the same block with different fold ranges" + "queries/rust/removed-runs.scm capture the same block with different fold ranges" ), "{message}" ); @@ -697,8 +648,13 @@ 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"]); + let tags = [ + "deleted-bodies:function", + "removed-runs:function", + "test-bodies:test", + ]; + assert_eq!(result.lhs_folds[0].tags, tags); + assert_eq!(result.rhs_folds[0].tags, tags); assert!(matches!( result.lhs_folds[0].match_kind, FoldMatch::Matched { .. } diff --git a/src/config/README.md b/src/config/README.md index f25977cff..9a92fb412 100644 --- a/src/config/README.md +++ b/src/config/README.md @@ -18,16 +18,16 @@ file each pattern came from (see `src/plugin/queries.rs` and [docs/config.md](../../docs/config.md#queries)): ```toml -# plugins/mine/plugin.toml +# plugins/deleted-bodies/plugin.toml [queries] -rust = "queries/rust.scm" +rust = "queries/rust.scm" # builtin:deleted-bodies/queries/rust.scm typescript = "queries/javascript.scm" ``` ```scheme ; inherits: builtin:shared/queries/rust.scm -((function_item) @fold - (#set! tag "context:scope")) +((function_item body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "deleted-bodies:function")) ``` The `context` plugin's files are fold queries like any other: they tag diff --git a/src/config/defaults.toml b/src/config/defaults.toml deleted file mode 100644 index f47f90126..000000000 --- a/src/config/defaults.toml +++ /dev/null @@ -1,188 +0,0 @@ -[languages.rust] -folds = ''' -([ - (block "{" @fold.open "}" @fold.close) - (field_declaration_list "{" @fold.open "}" @fold.close) - (declaration_list "{" @fold.open "}" @fold.close) - (match_block "{" @fold.open "}" @fold.close) -] @fold - (#set! tag "body")) -([ - (array_expression "[" @fold.open "]" @fold.close) - (field_initializer_list "{" @fold.open "}" @fold.close) -] @fold - (#set! tag "collection")) -((use_declaration) @fold - (#set! tag "import")) -([ - (line_comment) - (block_comment) -] @fold - (#set! tag "comment")) -([ - (string_literal) - (raw_string_literal) -] @fold - (#set! tag "string")) -((function_item attributes: (attributes (attribute_item) @_attribute) body: (block "{" @fold.open "}" @fold.close) @fold) - (#match? @_attribute "test") - (#set! tag "test")) -''' - -[languages.python] -folds = ''' -([ - (function_definition ":" @fold.open body: (block) @fold) - (class_definition ":" @fold.open body: (block) @fold) - (if_statement ":" @fold.open consequence: (block) @fold) - (elif_clause ":" @fold.open consequence: (block) @fold) - (else_clause ":" @fold.open body: (block) @fold) - (for_statement ":" @fold.open body: (block) @fold) - (while_statement ":" @fold.open body: (block) @fold) - (with_statement ":" @fold.open body: (block) @fold) - (try_statement ":" @fold.open body: (block) @fold) - (except_clause ":" @fold.open (block) @fold) - (finally_clause ":" @fold.open (block) @fold) - (match_statement ":" @fold.open body: (block) @fold) - (case_clause ":" @fold.open consequence: (block) @fold) -] - (#set! tag "body")) -([ - (list "[" @fold.open "]" @fold.close) - (dictionary "{" @fold.open "}" @fold.close) - (set "{" @fold.open "}" @fold.close) - (tuple "(" @fold.open ")" @fold.close) -] @fold - (#set! tag "collection")) -((tuple) @fold (#not-match? @fold "^\\(") (#set! tag "collection")) -([ - (import_statement) - (import_from_statement) -] @fold - (#set! tag "import")) -((comment) @fold - (#set! tag "comment")) -((string) @fold - (#set! tag "string")) -(function_definition name: (identifier) @_name ":" @fold.open body: (block) @fold - (#match? @_name "^test_") (#set! tag "test")) -''' - -[languages.go] -folds = ''' -([ - (block "{" @fold.open "}" @fold.close) - (field_declaration_list "{" @fold.open "}" @fold.close) -] @fold - (#set! tag "body")) -((literal_value "{" @fold.open "}" @fold.close) @fold - (#set! tag "collection")) -((import_declaration) @fold - (#set! tag "import")) -((comment) @fold - (#set! tag "comment")) -([ - (raw_string_literal) - (interpreted_string_literal) -] @fold - (#set! tag "string")) -(function_declaration name: (identifier) @_name body: (block "{" @fold.open "}" @fold.close) @fold - (#match? @_name "^Test") (#set! tag "test")) -''' - -[languages.javascript] -folds = ''' -([ - (statement_block "{" @fold.open "}" @fold.close) - (class_body "{" @fold.open "}" @fold.close) - (switch_body "{" @fold.open "}" @fold.close) -] @fold - (#set! tag "body")) -([ - (object "{" @fold.open "}" @fold.close) - (array "[" @fold.open "]" @fold.close) -] @fold - (#set! tag "collection")) -((import_statement) @fold - (#set! tag "import")) -((comment) @fold - (#set! tag "comment")) -([ - (string) - (template_string) -] @fold - (#set! tag "string")) -''' - -[languages.javascriptjsx] -folds = ''' -([ - (statement_block "{" @fold.open "}" @fold.close) - (class_body "{" @fold.open "}" @fold.close) - (switch_body "{" @fold.open "}" @fold.close) -] @fold - (#set! tag "body")) -([ - (object "{" @fold.open "}" @fold.close) - (array "[" @fold.open "]" @fold.close) -] @fold - (#set! tag "collection")) -((import_statement) @fold - (#set! tag "import")) -((comment) @fold - (#set! tag "comment")) -([ - (string) - (template_string) -] @fold - (#set! tag "string")) -''' - -[languages.typescript] -folds = ''' -([ - (statement_block "{" @fold.open "}" @fold.close) - (class_body "{" @fold.open "}" @fold.close) - (switch_body "{" @fold.open "}" @fold.close) -] @fold - (#set! tag "body")) -([ - (object "{" @fold.open "}" @fold.close) - (array "[" @fold.open "]" @fold.close) -] @fold - (#set! tag "collection")) -((import_statement) @fold - (#set! tag "import")) -((comment) @fold - (#set! tag "comment")) -([ - (string) - (template_string) -] @fold - (#set! tag "string")) -''' - -[languages.typescripttsx] -folds = ''' -([ - (statement_block "{" @fold.open "}" @fold.close) - (class_body "{" @fold.open "}" @fold.close) - (switch_body "{" @fold.open "}" @fold.close) -] @fold - (#set! tag "body")) -([ - (object "{" @fold.open "}" @fold.close) - (array "[" @fold.open "]" @fold.close) -] @fold - (#set! tag "collection")) -((import_statement) @fold - (#set! tag "import")) -((comment) @fold - (#set! tag "comment")) -([ - (string) - (template_string) -] @fold - (#set! tag "string")) -''' - diff --git a/src/config/store.rs b/src/config/store.rs index 2985aa621..a6be01da1 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -205,13 +205,16 @@ mod tests { fn plugin_keys_are_written_under_their_quoted_names() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - set(&path, "plugins.context.lines", "30").unwrap(); + set(&path, "plugins.deleted-bodies.min_lines", "30").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); let config = Config::from_toml(&text).unwrap(); - assert_eq!(config.plugins.entries["context"].options["lines"], 30); - assert!(set(&path, "plugins.context.typo", "1").is_err()); - assert!(set(&path, "plugins.context.lines", "-1").is_err()); - assert!(set(&path, "plugins.order", "[\"mine\"]").is_err()); + assert_eq!( + config.plugins.entries["deleted-bodies"].options["min_lines"], + 30 + ); + assert!(set(&path, "plugins.deleted-bodies.typo", "1").is_err()); + assert!(set(&path, "plugins.deleted-bodies.min_lines", "-1").is_err()); + assert!(set(&path, "plugins.order", "[\"group\"]").is_err()); let error = |key: &str, value: &str| set(&path, key, value).unwrap_err().to_string(); assert_eq!( error("plugins.context.enabled", "yes"), @@ -235,8 +238,8 @@ mod tests { ); let shown = show(&config); assert_eq!( - shown["plugins"]["context"], - serde_json::json!({"enabled": true, "lines": 30}) + shown["plugins"]["deleted-bodies"], + serde_json::json!({"enabled": true, "min_lines": 30}) ); } diff --git a/src/parse/fold_tests.rs b/src/parse/fold_tests.rs index 12690f960..8004c7241 100644 --- a/src/parse/fold_tests.rs +++ b/src/parse/fold_tests.rs @@ -79,7 +79,7 @@ mod folds { assert!(result .rhs_folds .iter() - .any(|fold| fold.tags.iter().any(|tag| tag == "test"))); + .any(|fold| fold.tags.iter().any(|tag| tag == "test-bodies:test"))); assert!(result .rhs_folds .iter() @@ -97,7 +97,14 @@ mod folds { assert_eq!(result.lhs_folds.len(), 2); assert_eq!(result.rhs_folds.len(), 2); let fold = &result.lhs_folds[0]; - assert_eq!(fold.tags, ["body", "test"]); + assert_eq!( + fold.tags, + [ + "deleted-bodies:function", + "removed-runs:function", + "test-bodies:test" + ] + ); let (left, right) = paired(fold, &result.rhs_folds).expect("reuse the replaced-string correspondence"); assert_eq!( @@ -132,7 +139,7 @@ mod folds { } assert_eq!(folds.len(), 2); for (fold, expected) in folds.iter().zip(["import os", "import sys"]) { - assert_eq!(fold.tags, ["import"]); + assert!(fold.tags.is_empty(), "imports are untagged structure"); assert_eq!(text(own, &fold.range), expected); match counterpart(fold, opposite_folds) { Some(other) => assert_eq!(text(opposite, &other.range), expected), diff --git a/src/plugin/builtin.rs b/src/plugin/builtin.rs index 29029e24b..4db70f709 100644 --- a/src/plugin/builtin.rs +++ b/src/plugin/builtin.rs @@ -4,11 +4,22 @@ //! plugin SDK. The code is compiled in and reached through the native //! registry ([`super::native`]); `plugin.toml` and the query files are //! embedded here, so `builtin:<plugin>/<path>` names `plugins/<plugin>/<path>`. +//! `plugins/shared/` is not a plugin: it holds the query files every bundled +//! plugin imports, as `builtin:shared/queries/<language>.scm`, and the +//! docstrings the plugins that collapse function bodies import, as +//! `builtin:shared/queries/<language>-docstrings.scm`. use super::config::Manifest; use std::sync::OnceLock; /// The bundled plugins, in their default order. -pub(crate) const NAMES: [&str; 1] = ["context"]; +pub(crate) const NAMES: [&str; 6] = [ + "context", + "hide-files", + "deleted-bodies", + "test-bodies", + "removed-runs", + "group", +]; macro_rules! embed { ($($path:literal),* $(,)?) => { @@ -18,11 +29,36 @@ macro_rules! embed { /// Every embedded file, by its path under `plugins/`. const FILES: &[(&str, &str)] = embed![ + "shared/queries/go.scm", + "shared/queries/go-docstrings.scm", + "shared/queries/javascript.scm", + "shared/queries/javascript-docstrings.scm", + "shared/queries/python.scm", + "shared/queries/python-docstrings.scm", + "shared/queries/rust.scm", + "shared/queries/rust-docstrings.scm", "context/plugin.toml", "context/queries/go.scm", "context/queries/javascript.scm", "context/queries/python.scm", "context/queries/rust.scm", + "hide-files/plugin.toml", + "deleted-bodies/plugin.toml", + "deleted-bodies/queries/go.scm", + "deleted-bodies/queries/javascript.scm", + "deleted-bodies/queries/python.scm", + "deleted-bodies/queries/rust.scm", + "test-bodies/plugin.toml", + "test-bodies/queries/go.scm", + "test-bodies/queries/javascript.scm", + "test-bodies/queries/python.scm", + "test-bodies/queries/rust.scm", + "removed-runs/plugin.toml", + "removed-runs/queries/go.scm", + "removed-runs/queries/javascript.scm", + "removed-runs/queries/python.scm", + "removed-runs/queries/rust.scm", + "group/plugin.toml", ]; /// An embedded file by its normalized path under `plugins/`. diff --git a/src/plugin/config.rs b/src/plugin/config.rs index e6facfc0d..926516584 100644 --- a/src/plugin/config.rs +++ b/src/plugin/config.rs @@ -510,7 +510,7 @@ mod tests { .iter() .filter(|(key, ..)| key.starts_with("plugins.")) .collect(); - assert!(plugins.len() >= 2, "{plugins:?}"); + assert!(plugins.len() > 10, "{plugins:?}"); for (key, title, group, kind) in &settings { assert!( !title.is_empty() && !group.is_empty(), @@ -529,27 +529,55 @@ mod tests { .unwrap_or_else(|| panic!("no setting {key}")) }; assert_eq!( - group("plugins.context.enabled"), - ("Collapse unchanged lines", "Context") + group("plugins.deleted-bodies.enabled"), + ( + "Collapse deleted function bodies", + "Deleted function bodies" + ) ); - assert_eq!(group("plugins.context.lines"), ("Context lines", "Context")); assert!(schema["properties"].get("languages").is_none()); let plugins = &schema["properties"]["plugins"]["properties"]; assert_eq!(plugins["order"]["x-settings"], false); assert_eq!(plugins["order"]["type"], "array"); + assert_eq!( + plugins["hide-files"]["properties"]["tags"]["x-settings"], + false + ); let keys: Vec<&String> = plugins.as_object().unwrap().keys().collect(); - assert_eq!(keys, ["order", "context"]); + assert_eq!( + keys, + [ + "order", + "context", + "hide-files", + "deleted-bodies", + "test-bodies", + "removed-runs", + "group" + ] + ); } #[test] fn order_names_every_entry_exactly_once() { let order = |names: &str| Config::from_toml(&format!("[plugins]\norder = [{names}]")); - assert!(order("'context'").is_ok()); - let error = order("").err().unwrap().to_string(); - assert!(error.contains("\"context\" is not listed"), "{error}"); - let error = order("'context', 'mine'").err().unwrap().to_string(); + assert!(order("'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group'").is_ok()); + let error = order( + "'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs'", + ) + .err() + .unwrap() + .to_string(); + assert!(error.contains("\"group\" is not listed"), "{error}"); + let error = order("'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group', 'mine'") + .err() + .unwrap() + .to_string(); assert!(error.contains("no plugin entry named \"mine\""), "{error}"); - let error = order("'context', 'context'").err().unwrap().to_string(); + let error = order("'context', 'hide-files', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group'") + .err() + .unwrap() + .to_string(); assert!(error.contains("listed twice"), "{error}"); let error = Config::from_toml("[plugins.mine]\nenabled = true") .err() @@ -568,7 +596,7 @@ mod tests { std::fs::create_dir_all(folder.join("queries")).unwrap(); std::fs::write( folder.join("plugin.toml"), - "name = 'mine'\ntitle = 'Mine'\n[options.depth]\ntype = 'integer'\ntitle = 'Depth'\ndefault = 2\n[queries]\nrust = 'queries/rust.scm'\npython = 'builtin:context/queries/python.scm'\n", + "name = 'mine'\ntitle = 'Mine'\n[options.depth]\ntype = 'integer'\ntitle = 'Depth'\ndefault = 2\n[queries]\nrust = 'queries/rust.scm'\npython = 'builtin:shared/queries/python.scm'\n", ) .unwrap(); std::fs::write( @@ -576,7 +604,7 @@ mod tests { "((block) @fold (#set! tag \"mine:block\"))\n", ) .unwrap(); - let order = "order = ['context', 'mine']"; + let order = "order = ['context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group', 'mine']"; let config = Config::from_toml_in( &format!("[plugins]\n{order}\n[plugins.mine]\npath = 'plugins/mine'\n"), dir.path(), @@ -592,7 +620,7 @@ mod tests { mine["rust"], canonical.join("queries/rust.scm").display().to_string() ); - assert_eq!(mine["python"], "builtin:context/queries/python.scm"); + assert_eq!(mine["python"], "builtin:shared/queries/python.scm"); config.compile().unwrap(); let error = |toml: &str| { @@ -610,8 +638,8 @@ mod tests { && renamed.ends_with("the plugin is named \"mine\", not \"other\""), "{renamed}" ); - let missing = error("[plugins.context]\npath = 'plugins/absent'\n"); - assert!(missing.starts_with("plugins.context: "), "{missing}"); + let missing = error("[plugins.group]\npath = 'plugins/absent'\n"); + assert!(missing.starts_with("plugins.group: "), "{missing}"); std::fs::write( folder.join("plugin.toml"), "name = 'mine'\ntitle = 'Mine'\n[options.path]\ntype = 'string'\ntitle = 'Path'\n", @@ -625,22 +653,35 @@ mod tests { #[test] fn options_are_checked_against_the_plugin_toml_and_filled_with_its_defaults() { - let config = Config::from_toml("[plugins.context]\nlines = 5\n").unwrap(); - let context = &config.plugins.entries["context"]; - assert_eq!(context.enabled, Some(true)); - assert_eq!(context.options["lines"], 5); - let default = Config::default(); - assert_eq!(default.plugins.entries["context"].options["lines"], 3); + let config = Config::from_toml( + "[plugins.deleted-bodies]\nmin_lines = 30\n[plugins.hide-files]\nenabled = false\n", + ) + .unwrap(); + let deleted = &config.plugins.entries["deleted-bodies"]; + assert_eq!(deleted.enabled, Some(true)); + assert_eq!(deleted.options["min_lines"], 30); + let hide = &config.plugins.entries["hide-files"]; + assert_eq!(hide.enabled, Some(false)); + assert_eq!(hide.options["deleted"], true); + assert_eq!( + hide.options["tags"], + serde_json::json!(["generated", "vendored", "test"]) + ); let error = |toml: &str| Config::from_toml(toml).err().unwrap().to_string(); - let typo = error("[plugins.context]\ntypo = 1\n"); + let typo = error("[plugins.deleted-bodies]\ntypo = 1\n"); assert!( - typo.starts_with("plugins.context: ") && typo.contains("typo"), + typo.starts_with("plugins.deleted-bodies: ") && typo.contains("typo"), "{typo}" ); - let mistyped = error("[plugins.context]\nlines = 'many'\n"); + let mistyped = error("[plugins.deleted-bodies]\nmin_lines = 'many'\n"); assert!( - mistyped.starts_with("plugins.context: lines: "), + mistyped.starts_with("plugins.deleted-bodies: min_lines: "), "{mistyped}" ); + let negative = error("[plugins.removed-runs]\nmin_lines = -1\n"); + assert!( + negative.starts_with("plugins.removed-runs: min_lines: "), + "{negative}" + ); } } diff --git a/src/plugin/native.rs b/src/plugin/native.rs index e466e4357..b9d2cb2ec 100644 --- a/src/plugin/native.rs +++ b/src/plugin/native.rs @@ -14,8 +14,23 @@ pub(crate) type Constructor = fn(Host, &str) -> anyhow::Result<Box<dyn Runner>>; /// Every plugin diffr has native code for, by the name its `plugin.toml` /// gives it. -pub(crate) const REGISTRY: &[(&str, Constructor)] = - &[("context", native::<diffr_plugin_context::Context>)]; +pub(crate) const REGISTRY: &[(&str, Constructor)] = &[ + ("context", native::<diffr_plugin_context::Context>), + ("hide-files", native::<diffr_plugin_hide_files::HideFiles>), + ( + "deleted-bodies", + native::<diffr_plugin_deleted_bodies::DeletedBodies>, + ), + ( + "test-bodies", + native::<diffr_plugin_test_bodies::TestBodies>, + ), + ( + "removed-runs", + native::<diffr_plugin_removed_runs::RemovedRuns>, + ), + ("group", native::<diffr_plugin_group::Group>), +]; /// The constructor of the native plugin `name`, if diffr has one. pub(crate) fn lookup(name: &str) -> Option<Constructor> { diff --git a/src/plugin/queries.rs b/src/plugin/queries.rs index c68a2fe34..279abed74 100644 --- a/src/plugin/queries.rs +++ b/src/plugin/queries.rs @@ -220,21 +220,91 @@ mod tests { "typescripttsx" ] ); - assert_eq!(names(&assembled["rust"]), ["builtin:context/queries/rust.scm"]); + assert_eq!( + names(&assembled["rust"]), + [ + "builtin:shared/queries/rust.scm", + "builtin:context/queries/rust.scm", + "builtin:shared/queries/rust-docstrings.scm", + "builtin:deleted-bodies/queries/rust.scm", + "builtin:test-bodies/queries/rust.scm", + "builtin:removed-runs/queries/rust.scm", + ], + "the shared imports are included once, before their first importer" + ); assert_eq!( names(&assembled["typescript"]), - ["builtin:context/queries/javascript.scm"] + [ + "builtin:shared/queries/javascript.scm", + "builtin:context/queries/javascript.scm", + "builtin:shared/queries/javascript-docstrings.scm", + "builtin:deleted-bodies/queries/javascript.scm", + "builtin:test-bodies/queries/javascript.scm", + "builtin:removed-runs/queries/javascript.scm", + ], ); Config::default().compile().unwrap(); } #[test] fn disabled_plugins_contribute_no_queries() { - let config = Config::from_toml("[plugins.context]\nenabled = false\n").unwrap(); + let config = Config::from_toml( + "[plugins.deleted-bodies]\nenabled = false\n[plugins.test-bodies]\nenabled = false\n", + ) + .unwrap(); let assembled = assemble(&config.plugins.enabled_queries()).unwrap(); - assert!(assembled.is_empty(), "{:?}", assembled.keys()); + let names = names(&assembled["rust"]); + assert!(!names.iter().any(|name| name.contains("deleted-bodies"))); + assert!(!names.iter().any(|name| name.contains("test-bodies"))); } + #[test] + fn imports_resolve_against_their_files() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "queries/shared.scm", "(block) @fold\n"); + let mine = write( + dir.path(), + "queries/rust/mine.scm", + "; inherits: ../shared.scm, builtin:shared/queries/rust.scm\n((function_item body: (block) @fold) (#set! tag \"removed-runs:function\"))\n", + ); + let plugins = [ + ( + "deleted-bodies".to_owned(), + Queries::from([( + "rust".to_owned(), + "builtin:deleted-bodies/queries/rust.scm".to_owned(), + )]), + ), + ( + "removed-runs".to_owned(), + Queries::from([("rust".to_owned(), mine)]), + ), + ]; + let assembled = assemble(&plugins).unwrap(); + let names = names(&assembled["rust"]); + let position = |suffix: &str| names.iter().position(|name| name.ends_with(suffix)); + assert!( + position("queries/shared.scm").unwrap() < position("queries/rust/mine.scm").unwrap(), + "{names:?}" + ); + assert_eq!( + names[..3], + [ + "builtin:shared/queries/rust.scm", + "builtin:shared/queries/rust-docstrings.scm", + "builtin:deleted-bodies/queries/rust.scm" + ], + "imports come before their importer" + ); + assert_eq!( + names + .iter() + .filter(|name| **name == "builtin:shared/queries/rust.scm") + .count(), + 1, + "{names:?}" + ); + } #[test] fn import_cycles_and_missing_files_are_errors() { @@ -243,31 +313,81 @@ mod tests { write(dir.path(), "b.scm", "; inherits: a.scm\n"); let queries = |path: &str| { [( - "context".to_owned(), + "removed-runs".to_owned(), Queries::from([("rust".to_owned(), path.to_owned())]), )] }; let error = assemble(&queries(&a)).err().expect("a cycle").to_string(); assert!(error.contains("cycle"), "{error}"); assert!( - error.starts_with("plugin context: queries.rust: "), + error.starts_with("plugin removed-runs: queries.rust: "), "{error}" ); let absent = dir.path().join("absent.scm").display().to_string(); assert!(assemble(&queries(&absent)).is_err()); - let error = assemble(&queries("builtin:context/queries/absent.scm")) + let error = assemble(&queries("builtin:removed-runs/queries/absent.scm")) .err() .expect("unknown builtin") .to_string(); assert!( - error.contains("no bundled file builtin:context/queries/absent.scm"), + error.contains("no bundled file builtin:removed-runs/queries/absent.scm"), "{error}" ); - let error = assemble(&queries("builtin:summarize/../../outside.scm")) + let error = assemble(&queries("builtin:removed-runs/../../outside.scm")) .err() .expect("a path out of the bundled plugins") .to_string(); assert!(error.contains("leaves the bundled plugins"), "{error}"); } + #[test] + fn unbundled_plugin_queries_join_in_order_and_must_be_absolute_or_builtin() { + let dir = tempfile::tempdir().unwrap(); + let absolute = write( + dir.path(), + "plugin/rust.scm", + "; inherits: builtin:shared/queries/rust.scm\n((block) @fold (#set! tag \"mine:block\"))\n", + ); + let mut plugins = Config::default().plugins.enabled_queries(); + let removed_runs = plugins + .iter() + .position(|(plugin, _)| plugin == "removed-runs") + .unwrap(); + plugins.insert( + removed_runs, + ( + "mine".to_owned(), + Queries::from([("rust".to_owned(), absolute)]), + ), + ); + let assembled = assemble(&plugins).unwrap(); + let names = names(&assembled["rust"]); + let position = |suffix: &str| { + names + .iter() + .position(|name| name.ends_with(suffix)) + .unwrap() + }; + assert!( + position("test-bodies/queries/rust.scm") < position("plugin/rust.scm"), + "{names:?}" + ); + assert!( + position("plugin/rust.scm") < position("removed-runs/queries/rust.scm"), + "{names:?}" + ); + + let relative = [( + "mine".to_owned(), + Queries::from([("rust".to_owned(), "plugin/rust.scm".to_owned())]), + )]; + let error = assemble(&relative) + .err() + .expect("a relative plugin query") + .to_string(); + assert_eq!( + error, + "plugin mine: queries.rust: relative path plugin/rust.scm must be absolute or builtin:" + ); + } } diff --git a/src/plugin/tests/context.rs b/src/plugin/tests/context.rs index bdb9199e1..890316e1b 100644 --- a/src/plugin/tests/context.rs +++ b/src/plugin/tests/context.rs @@ -519,6 +519,7 @@ fn an_unrelated_tail_return_is_not_context() { !shows(0, "Ok(if matched"), "the unrelated return is not context" ); + assert!(shows(3, "fn run(")); } #[test] diff --git a/src/plugin/tests/deleted_bodies.rs b/src/plugin/tests/deleted_bodies.rs new file mode 100644 index 000000000..f83f873e6 --- /dev/null +++ b/src/plugin/tests/deleted_bodies.rs @@ -0,0 +1,185 @@ +use super::*; + +const FUNCTION: &str = "deleted-bodies:function"; + +fn range(start: u32, end: u32) -> types::Range { + types::Range { + start: types::Position { + line: start, + column: 0, + }, + end: types::Position { + line: end, + column: 0, + }, + } +} + +fn leaf(id: u32, alignment: u32, start: u32, end: u32) -> tree::Region { + tree::Region { + id, + fold_state_id: id, + range: range(start, end), + tags: vec![], + visibility: types::Visibility::default(), + node: tree::Node::Leaf { + alignment_id: alignment, + changed: vec![], + }, + } +} + +fn function(id: u32, start: u32, end: u32, children: Vec<tree::Region>) -> tree::Region { + tree::Region { + id, + fold_state_id: id, + range: range(start, end), + tags: vec![FUNCTION.to_owned()], + visibility: types::Visibility::default(), + node: tree::Node::Fold { children }, + } +} + +fn modified() -> FileChange { + FileChange { + file: Pairing::Both { + lhs: FileRef { + path: "a.py".to_owned(), + oid: String::new(), + mode: String::new(), + }, + rhs: FileRef { + path: "a.py".to_owned(), + oid: String::new(), + mode: String::new(), + }, + }, + status: FileStatus::Modified, + tags: vec![], + } +} + +#[test] +fn deleted_bodies_skip_folds_with_paired_content() { + // The fold is unmatched (no rhs region shares its fold state), but + // the body lines align. + let rewritten = function(1, 0, 20, vec![leaf(2, 0, 0, 20)]); + let removed = function(3, 20, 40, vec![leaf(4, 1, 20, 40)]); + let source = |regions| tree::Source { + text: String::new(), + regions, + }; + let mut sides = tree::Pairing::Both { + lhs: source(vec![rewritten, removed]), + rhs: source(vec![leaf(5, 0, 0, 20)]), + }; + run_trees( + "deleted-bodies", + json!({"min_lines": 3}), + &modified(), + &mut sides, + ); + let lhs = &lhs(&sides).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 a_matched_function_whose_lines_all_went_away_is_a_removal() { + // Function 1 moved below function 3 on the rhs. The rhs fold has its + // own id (5) and shares the fold state. None of the moved body's + // lines align, and a fold state is not what makes a body survive: + // newness comes from the lines, so on this side the body is gone. + let moved = function(1, 0, 20, vec![leaf(2, 0, 0, 20)]); + let kept = function(3, 20, 40, vec![leaf(4, 1, 20, 40)]); + let mut kept_rhs = function(7, 0, 20, vec![leaf(8, 1, 0, 20)]); + kept_rhs.fold_state_id = 3; + let mut moved_rhs = function(5, 20, 40, vec![leaf(6, 2, 20, 40)]); + moved_rhs.fold_state_id = 1; + let source = |regions| tree::Source { + text: String::new(), + regions, + }; + let mut sides = tree::Pairing::Both { + lhs: source(vec![moved, kept]), + rhs: source(vec![kept_rhs, moved_rhs]), + }; + run_trees( + "deleted-bodies", + json!({"min_lines": 3}), + &modified(), + &mut sides, + ); + let lhs = &lhs(&sides).regions; + assert!(lhs[0].visibility.collapsed); + assert_eq!(lhs[0].visibility.label, "20 lines removed"); + assert!( + !lhs[1].visibility.collapsed, + "the function whose lines still align stays open" + ); +} + +/// Every docstring region on one side: its first line, fold state, +/// collapsed state and label. +fn docstrings(source: &tree::Source) -> Vec<(u32, u32, bool, String)> { + let mut out = Vec::new(); + walk(&source.regions, &mut |region| { + if has_tag(region, "deleted-bodies:docstring") { + out.push(( + region.range.start.line, + region.fold_state_id, + region.visibility.collapsed, + region.visibility.label.clone(), + )); + } + }); + out +} + +/// The collapsed function body on one side: its fold state. +fn collapsed_body(source: &tree::Source) -> u32 { + let mut states = Vec::new(); + walk(&source.regions, &mut |region| { + if has_tag(region, FUNCTION) && region.visibility.collapsed { + states.push(region.fold_state_id); + } + }); + assert_eq!(states.len(), 1, "{states:?}"); + states[0] +} + +#[test] +fn a_deleted_body_links_its_docstring_which_collapses_with_it() { + let before = "fn keep() {}\n\n/// Documented.\n/// Really.\nfn gone() -> u32 {\n let a = 1;\n let b = 2;\n a + b\n}\n"; + let (file, mut sides) = project("a.rs", before, "fn keep() {}\n"); + run("deleted-bodies", json!({"min_lines": 3}), &file, &mut sides); + let sides = trees(&sides); + let lhs = lhs(&sides); + let state = collapsed_body(lhs); + assert_eq!(docstrings(lhs), [(2, state, true, String::new())]); + + let before = "def keep():\n pass\n\ndef gone(a):\n \"\"\"Double a.\n\n Returns an int.\n \"\"\"\n b = a\n return b * 2\n"; + let (file, mut sides) = project("a.py", before, "def keep():\n pass\n"); + run("deleted-bodies", json!({"min_lines": 3}), &file, &mut sides); + let sides = trees(&sides); + let lhs = self::lhs(&sides); + let state = collapsed_body(lhs); + assert_eq!(docstrings(lhs), [(4, state, true, String::new())]); +} + +#[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) = project("m.py", before, after); + run("deleted-bodies", json!({"min_lines": 3}), &file, &mut sides); + let sides = trees(&sides); + let mut collapsed = Vec::new(); + walk(&lhs(&sides).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/plugin/tests/group.rs b/src/plugin/tests/group.rs new file mode 100644 index 000000000..726aee514 --- /dev/null +++ b/src/plugin/tests/group.rs @@ -0,0 +1,232 @@ +use super::*; +use diffr_plugin_sdk::tree::line_count; +use std::collections::BTreeSet; + +fn range(start: u32, end: u32) -> types::Range { + types::Range { + start: types::Position { + line: start, + column: 0, + }, + end: types::Position { + line: end, + column: 0, + }, + } +} + +fn leaf(id: u32, alignment: u32, start: u32, end: u32) -> tree::Region { + tree::Region { + id, + fold_state_id: id, + range: range(start, end), + tags: vec![], + visibility: types::Visibility::default(), + node: tree::Node::Leaf { + alignment_id: alignment, + changed: vec![], + }, + } +} + +/// A fold over one leaf, collapsed or not, in fold state `state`. +fn fold(id: u32, state: u32, start: u32, end: u32, collapsed: bool, label: &str) -> tree::Region { + tree::Region { + id, + fold_state_id: state, + range: range(start, end), + tags: vec![], + visibility: types::Visibility { + collapsed, + label: label.to_owned(), + }, + node: tree::Node::Fold { + children: vec![leaf(id + 100, id + 100, start, end)], + }, + } +} + +fn source(regions: Vec<tree::Region>) -> tree::Source { + tree::Source { + text: String::new(), + regions, + } +} + +#[test] +fn collapsed_regions_group_across_short_separators_whatever_their_labels() { + let mut sides = tree::Pairing::LeftOnly { + lhs: source(vec![ + fold(1, 1, 0, 5, true, "5 lines removed"), + leaf(2, 2, 5, 7), + fold(3, 3, 7, 12, true, "5 unchanged lines"), + leaf(4, 4, 12, 15), + fold(5, 5, 15, 20, true, "test body"), + ]), + }; + let (file, _) = project("m.py", "", ""); + run_trees("group", json!({}), &file, &mut sides); + let regions = &lhs(&sides).regions; + assert_eq!( + regions.len(), + 3, + "three open lines end the run: {regions:?}" + ); + assert_eq!( + regions[0].visibility.label, + "2 collapsed regions · 12 lines" + ); + assert!(regions[0].visibility.collapsed); + let tree::Node::Fold { children } = ®ions[0].node else { + panic!("a group is a fold"); + }; + let ids: Vec<u32> = children.iter().map(|child| child.id).collect(); + assert_eq!(ids, [1, 2, 3]); +} + +#[test] +fn deleted_bodies_under_their_own_headers_are_not_grouped() { + // Each deleted body collapses inside the scope of its `def`, which + // shows that `def` line above the collapsed row. A group never hides + // open lines above its first collapsed row, so the run never starts and + // the reader keeps the name of every function that went. + 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); + run("deleted-bodies", json!({"min_lines": 3}), &file, &mut sides); + run("group", json!({}), &file, &mut sides); + let sides = trees(&sides); + assert!( + !lhs(&sides) + .regions + .iter() + .any(|region| region.visibility.label.contains("collapsed regions")), + "no group hides the `def` lines" + ); + let mut bodies = Vec::new(); + let mut headers = Vec::new(); + walk(&lhs(&sides).regions, &mut |region| { + if region.visibility.collapsed { + bodies.push(region.range.lines()); + } + if has_tag(region, "context:scope") { + headers.push(region.range.start.line); + } + }); + assert_eq!(bodies.len(), 3, "{bodies:?}"); + assert_eq!(headers, [0, 5, 10]); + // Each collapsed body starts under the `def` line of its own scope. + for (body, header) in bodies.iter().zip(&headers) { + assert_eq!(body.start, header + 1); + } + let mut seen = BTreeSet::new(); + walk(&lhs(&sides).regions, &mut |region| { + assert!(seen.insert(region.id), "duplicate id {}", region.id); + }); +} + +#[test] +fn documented_test_bodies_group_with_their_docstrings() { + let before = "fn keep() {}\n"; + let after = "fn keep() {}\n\n/// First.\n/// Doc.\n#[test]\nfn a() {\n x();\n y();\n z();\n}\n\n/// Second.\n/// Doc.\n#[test]\nfn b() {\n x();\n y();\n z();\n}\n\n/// Third.\n/// Doc.\n#[test]\nfn c() {\n x();\n y();\n z();\n}\n"; + let (file, mut sides) = project("m.rs", before, after); + run("test-bodies", json!({"min_lines": 3}), &file, &mut sides); + run("group", json!({}), &file, &mut sides); + let sides = trees(&sides); + let groups: Vec<&tree::Region> = rhs(&sides) + .regions + .iter() + .filter(|region| region.visibility.label.contains("collapsed regions")) + .collect(); + assert_eq!(groups.len(), 1, "one group for the three documented tests"); + let group = groups[0]; + let tree::Node::Fold { children } = &group.node else { + panic!("a group is a fold"); + }; + let mut collapsed = Vec::new(); + walk(children, &mut |region| { + if region.visibility.collapsed { + collapsed.push(region.range); + } + }); + assert_eq!( + collapsed.len(), + 6, + "every body and docstring is inside the group" + ); + assert_eq!( + group.range.start, collapsed[0].start, + "the group starts at the first docstring" + ); + assert_eq!( + group.visibility.label, + format!( + "6 collapsed regions · {} lines", + children.iter().map(line_count).sum::<usize>() + ) + ); +} + +#[test] +fn matched_runs_are_grouped_on_both_sides_with_one_fold_state() { + // Two collapsed test bodies moved below `keep`: each matched pair + // shares its fold state, so the runs match region for region. + let mut sides = tree::Pairing::Both { + lhs: source(vec![ + fold(1, 1, 0, 4, true, "test body"), + fold(2, 2, 4, 8, true, "test body"), + leaf(3, 3, 8, 11), + ]), + rhs: source(vec![ + leaf(6, 3, 0, 3), + fold(4, 1, 3, 7, true, "test body"), + fold(5, 2, 7, 11, true, "test body"), + ]), + }; + let (file, _) = project("m.py", "", ""); + let joins: Vec<Move> = moves(&bundled("group", json!({})), &file, &wire(sides.clone())) + .unwrap() + .into_iter() + .filter(|next| matches!(next, Move::JoinFolds { .. })) + .collect(); + assert_eq!(joins.len(), 1, "one join lists both runs: {joins:?}"); + run_trees("group", json!({}), &file, &mut sides); + assert_eq!(lhs(&sides).regions.len(), 2); + assert_eq!(rhs(&sides).regions.len(), 2); + let (lhs_group, rhs_group) = (&lhs(&sides).regions[0], &rhs(&sides).regions[1]); + assert_eq!(lhs_group.visibility.label, "2 collapsed regions · 8 lines"); + assert_eq!(rhs_group.visibility.label, "2 collapsed regions · 8 lines"); + assert_ne!(lhs_group.id, rhs_group.id); + assert_eq!(lhs_group.fold_state_id, rhs_group.fold_state_id); +} + +#[test] +fn a_run_paired_with_a_different_run_is_left_alone() { + // The lhs run's second body is matched with a fold outside any run + // on the rhs. + let sides = tree::Pairing::Both { + lhs: source(vec![ + fold(1, 1, 0, 4, true, "test body"), + fold(2, 2, 4, 8, true, "test body"), + ]), + rhs: source(vec![ + fold(4, 1, 0, 4, true, "test body"), + fold(5, 2, 4, 8, false, "test body"), + ]), + }; + let (file, _) = project("m.py", "", ""); + assert!(moves(&bundled("group", json!({})), &file, &wire(sides)) + .unwrap() + .is_empty()); +} + +#[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); + run("deleted-bodies", json!({"min_lines": 3}), &file, &mut sides); + assert!(moves(&bundled("group", json!({})), &file, &sides) + .unwrap() + .is_empty()); +} diff --git a/src/plugin/tests/hide_files.rs b/src/plugin/tests/hide_files.rs new file mode 100644 index 000000000..fcde84ac2 --- /dev/null +++ b/src/plugin/tests/hide_files.rs @@ -0,0 +1,46 @@ +use super::*; + +fn reasons(options: serde_json::Value, status: FileStatus, tags: &[&str]) -> Vec<Move> { + let sides = tree::Pairing::RightOnly { + rhs: tree::Source { + text: String::new(), + regions: vec![], + }, + }; + let mut file = manifest("x", &sides, status); + file.tags = tags.iter().map(|tag| (*tag).to_owned()).collect(); + moves(&bundled("hide-files", options), &file, &wire(sides)).unwrap() +} + +fn hide(reason: &str) -> Vec<Move> { + vec![ + Move::SetCollapsed { + region: types::ROOT, + collapsed: true, + }, + Move::SetLabel { + region: types::ROOT, + label: Some(reason.to_owned()), + }, + ] +} + +#[test] +fn listed_tags_hide_a_file_and_name_the_reason() { + let rule = || json!({"tags": ["generated", "test"], "deleted": false}); + assert_eq!( + reasons(rule(), FileStatus::Added, &["test", "generated"]), + hide("Generated file · hidden by default") + ); + assert!(reasons(rule(), FileStatus::Added, &["docs"]).is_empty()); + assert!(reasons(rule(), FileStatus::Deleted, &[]).is_empty()); +} + +#[test] +fn deleted_files_are_hidden_whatever_their_tags() { + let rule = json!({"tags": ["test"], "deleted": true}); + assert_eq!( + reasons(rule, FileStatus::Deleted, &["test"]), + hide("Deleted file · hidden by default") + ); +} diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs index 9b4dbcd0b..71c89ec7d 100644 --- a/src/plugin/tests/mod.rs +++ b/src/plugin/tests/mod.rs @@ -2,12 +2,17 @@ //! them: a file projected with the bundled queries, then each plugin's //! native code through a [`Pipeline`]. mod context; +mod deleted_bodies; +mod group; +mod hide_files; +mod removed_runs; +mod test_bodies; use super::*; use crate::config::{Config, Params}; use crate::options::DiffOptions; use crate::protocol::{project, Diff, FileRef}; -use diffr_plugin_sdk::tree::{has_tag, is_fold, walk}; +use diffr_plugin_sdk::tree::{docstring_of, has_tag, is_fold, walk}; use diffr_plugin_sdk::{FileEntry, Move, Plugin}; use serde_json::json; @@ -74,6 +79,27 @@ pub(crate) fn project_compiled( (file, sides) } +/// A manifest entry for `path` on the sides `sides` names. +pub(crate) fn manifest<T>(path: &str, sides: &tree::Pairing<T>, status: FileStatus) -> FileChange { + let file_ref = || FileRef { + path: path.to_owned(), + oid: String::new(), + mode: String::new(), + }; + FileChange { + file: match sides { + tree::Pairing::Both { .. } => Pairing::Both { + lhs: file_ref(), + rhs: file_ref(), + }, + tree::Pairing::LeftOnly { .. } => Pairing::LeftOnly { lhs: file_ref() }, + tree::Pairing::RightOnly { .. } => Pairing::RightOnly { rhs: file_ref() }, + }, + status, + tags: Vec::new(), + } +} + /// The wire's sides as the trees plugins read. pub(crate) fn trees(sides: &Pairing<protocol::Source>) -> tree::Pairing<tree::Source> { match sides { @@ -154,6 +180,115 @@ pub(crate) fn run_trees( *sides = trees(&wired); } +/// The moves the only plugin of `pipeline` asks for, not carried out. +pub(crate) fn moves( + pipeline: &Pipeline, + file: &FileChange, + sides: &Pairing<protocol::Source>, +) -> anyhow::Result<Vec<Move>> { + let [plugin] = &pipeline.plugins[..] else { + panic!("one plugin"); + }; + let sides = trees(sides); + let (lhs, rhs) = ( + sides.lhs().map(tree::Source::to_record), + sides.rhs().map(tree::Source::to_record), + ); + plugin.runner.mutate( + pipeline.host(&plugin.name, no_head()), + &file_entry(file), + lhs.as_ref(), + rhs.as_ref(), + ) +} + +/// For each `deleted-bodies:function` body on the after side, the first line +/// of the body and the lines of its docstring, as `docstring_of` finds it. +fn documented(path: &str, after: &str) -> Vec<(u32, Option<(u32, u32)>)> { + let (_, sides) = project(path, "", after); + let sides = trees(&sides); + let source = rhs(&sides); + let mut bodies = Vec::new(); + walk(&source.regions, &mut |region| { + if is_fold(region) && has_tag(region, "deleted-bodies:function") { + let docstring = docstring_of(source, region, "deleted-bodies").map(|id| { + let mut lines = None; + walk(&source.regions, &mut |docstring| { + if docstring.id == id { + let range = docstring.range.lines(); + lines = Some((range.start, range.end)); + } + }); + lines.expect("the docstring is on this side") + }); + bodies.push((region.range.start.line, docstring)); + } + }); + bodies +} + +#[test] +fn rust_doc_and_line_comments_above_a_function_are_its_docstring() { + let after = "fn keep() -> u32 {\n let x = 1;\n x\n}\n\n/// Adds one.\n/// Twice, really.\nfn add(\n a: u32,\n) -> u32 {\n let b = a;\n b + 2\n}\n\n// Plain comment.\n// Two lines.\n#[inline]\nfn sub(a: u32) -> u32 {\n let b = a;\n b - 1\n}\n\n// One line.\nfn one(a: u32) -> u32 {\n let b = a;\n b - 1\n}\n\n/**\n * Block.\n */\nfn block() {\n x();\n y();\n}\n"; + assert_eq!( + documented("a.rs", after), + [ + (1, None), + (10, Some((5, 7))), + (18, Some((14, 16))), + (24, None), + (32, Some((28, 31))) + ], + "a one-line docstring is not a region" + ); +} + +#[test] +fn a_comment_separated_from_the_function_by_code_does_not_count() { + let after = + "// About the constant.\n// Really.\nconst X: u32 = 1;\nfn f() -> u32 {\n let y = X;\n y\n}\n"; + assert_eq!(documented("a.rs", after), [(4, None)]); +} + +#[test] +fn a_docstring_is_not_found_past_a_one_line_function() { + let after = "/// First.\n/// Documented.\nfn a() {}\nfn b() {\n x();\n y();\n}\n"; + assert_eq!(documented("a.rs", after), [(4, None)]); + let after = + "// First.\n// Documented.\nexport const a = 1;\nfunction b() {\n x();\n y();\n}\n"; + assert_eq!(documented("a.ts", after), [(4, None)]); +} + +#[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"; + assert_eq!(documented("a.py", after), [(1, Some((1, 5)))]); +} + +#[test] +fn go_and_javascript_comment_runs_document_functions() { + for (path, source, expected) in [ + ( + "a.go", + "package a\n\n// Sum adds.\n// Twice.\nfunc Sum(a int) int {\n\tb := a\n\treturn a + b\n}\n", + (5, Some((2, 4))), + ), + ( + "a.ts", + "// Sum adds.\n// Twice.\nexport function sum(a: number) {\n const b = a;\n return a + b;\n}\n", + (3, Some((0, 2))), + ), + ( + "a.js", + "/**\n * Sum adds.\n */\nconst sum = (a) => {\n const b = a;\n return a + b;\n};\n", + (4, Some((0, 3))), + ), + ] { + assert_eq!(documented(path, source), [expected], "{path}"); + } +} + #[test] fn the_default_pipeline_makes_every_plugin_that_is_on() { let pipeline = Pipeline::from_config(&PluginsConfig::default(), Path::new(".")).unwrap(); @@ -162,7 +297,17 @@ fn the_default_pipeline_makes_every_plugin_that_is_on() { .iter() .map(|plugin| &*plugin.name) .collect(); - assert_eq!(made, ["context"]); + assert_eq!( + made, + [ + "context", + "hide-files", + "deleted-bodies", + "test-bodies", + "removed-runs", + "group" + ] + ); } /// Test plugins without options. diff --git a/src/plugin/tests/removed_runs.rs b/src/plugin/tests/removed_runs.rs new file mode 100644 index 000000000..79801cead --- /dev/null +++ b/src/plugin/tests/removed_runs.rs @@ -0,0 +1,270 @@ +use super::*; + +fn range(start: u32, end: u32) -> types::Range { + types::Range { + start: types::Position { + line: start, + column: 0, + }, + end: types::Position { + line: end, + column: 0, + }, + } +} + +fn removed_leaf(id: u32, alignment: u32, start: u32, end: u32, changed: &[u32]) -> tree::Region { + tree::Region { + id, + fold_state_id: id, + range: range(start, end), + tags: vec![], + visibility: types::Visibility::default(), + node: tree::Node::Leaf { + alignment_id: alignment, + changed: changed + .iter() + .map(|&line| types::Span { + line, + start_column: 0, + end_column: 4, + }) + .collect(), + }, + } +} + +fn function_fold(id: u32, start: u32, end: u32, children: Vec<tree::Region>) -> tree::Region { + tree::Region { + id, + fold_state_id: id, + range: range(start, end), + tags: vec!["removed-runs:function".to_owned()], + visibility: types::Visibility::default(), + node: tree::Node::Fold { children }, + } +} + +fn source(regions: Vec<tree::Region>) -> tree::Source { + tree::Source { + text: String::new(), + regions, + } +} + +fn deleted() -> FileChange { + FileChange { + file: Pairing::LeftOnly { + lhs: FileRef { + path: "x".to_owned(), + oid: String::new(), + mode: String::new(), + }, + }, + status: FileStatus::Deleted, + tags: vec![], + } +} + +/// `(id, alignment_id, start, end, collapsed, label, changed lines)`. +type Shape = (u32, u32, u32, u32, bool, String, Vec<u32>); + +fn shape(regions: &[tree::Region]) -> Vec<Shape> { + regions + .iter() + .map(|region| { + let tree::Node::Leaf { + alignment_id, + changed, + } = ®ion.node + else { + panic!("leaf expected"); + }; + let lines = region.range.lines(); + ( + region.id, + *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 = tree::Pairing::LeftOnly { + lhs: source(vec![removed_leaf( + 1, + 0, + 10, + 17, + &[10, 11, 12, 13, 14, 15, 16], + )]), + }; + run_trees( + "removed-runs", + json!({"min_lines": 5}), + &deleted(), + &mut sides, + ); + assert_eq!( + shape(&lhs(&sides).regions), + vec![ + (1, 0, 10, 11, false, String::new(), vec![10]), + ( + 2, + 1, + 11, + 16, + true, + "5 lines removed".to_owned(), + vec![11, 12, 13, 14, 15] + ), + (3, 2, 16, 17, false, String::new(), vec![16]), + ] + ); +} + +#[test] +fn removed_runs_respect_the_threshold() { + let mut sides = tree::Pairing::LeftOnly { + lhs: source(vec![removed_leaf(1, 0, 0, 4, &[0, 1, 2, 3])]), + }; + run_trees( + "removed-runs", + json!({"min_lines": 5}), + &deleted(), + &mut sides, + ); + assert_eq!(lhs(&sides).regions.len(), 1); + // A tiny threshold still needs three lines. + let mut sides = tree::Pairing::LeftOnly { + lhs: source(vec![removed_leaf(1, 0, 0, 2, &[0, 1])]), + }; + run_trees( + "removed-runs", + json!({"min_lines": 1}), + &deleted(), + &mut sides, + ); + assert_eq!(lhs(&sides).regions.len(), 1); +} + +#[test] +fn removed_runs_skip_paired_leaves_and_collapsed_ancestors() { + let paired = removed_leaf(7, 7, 0, 8, &[]); + let mut paired_rhs = removed_leaf(10, 7, 0, 8, &[]); + paired_rhs.fold_state_id = 7; + let mut sides = tree::Pairing::Both { + lhs: source(vec![ + paired, + tree::Region { + id: 1, + fold_state_id: 1, + range: range(8, 20), + tags: vec![], + visibility: types::Visibility { + collapsed: true, + label: "12 lines removed".to_owned(), + }, + node: tree::Node::Fold { + children: vec![removed_leaf(2, 2, 8, 20, &[])], + }, + }, + removed_leaf(3, 3, 20, 30, &[]), + ]), + rhs: source(vec![paired_rhs]), + }; + run_trees( + "removed-runs", + json!({"min_lines": 5}), + &deleted(), + &mut sides, + ); + let lhs = &lhs(&sides).regions; + assert_eq!(lhs.len(), 5, "paired leaf, collapsed fold, three pieces"); + assert_eq!(lhs[0].id, 7); + let tree::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, 3, 20, 21, false, String::new(), vec![]), + (11, 8, 21, 29, true, "8 lines removed".to_owned(), vec![]), + (12, 9, 29, 30, false, String::new(), vec![]), + ] + ); +} + +#[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, 2, 0, 10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + removed_leaf(3, 3, 10, 20, &[]), + ], + ); + // Wholly removed: nothing under it is on the rhs. + let removed = function_fold( + 4, + 20, + 40, + vec![removed_leaf( + 5, + 5, + 20, + 40, + &[20, 21, 22, 23, 24, 25, 26, 27], + )], + ); + let mut sides = tree::Pairing::Both { + lhs: source(vec![rewritten, removed]), + rhs: source(vec![removed_leaf(6, 3, 0, 10, &[])]), + }; + run_trees( + "removed-runs", + json!({"min_lines": 5}), + &deleted(), + &mut sides, + ); + let lhs = &lhs(&sides).regions; + let tree::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 tree::Node::Fold { children } = &lhs[1].node else { + panic!("fold expected"); + }; + assert_eq!( + shape(children), + vec![ + (5, 5, 20, 21, false, String::new(), vec![20]), + ( + 7, + 6, + 21, + 39, + true, + "18 lines removed".to_owned(), + (21..28).collect() + ), + (8, 7, 39, 40, false, String::new(), vec![]), + ] + ); +} diff --git a/src/plugin/tests/test_bodies.rs b/src/plugin/tests/test_bodies.rs new file mode 100644 index 000000000..f7fc24b58 --- /dev/null +++ b/src/plugin/tests/test_bodies.rs @@ -0,0 +1,118 @@ +use super::*; + +const TEST: &str = "test-bodies:test"; + +/// Every fold: whether it is a test body, whether it starts collapsed, and +/// its label. +fn folds(source: &tree::Source) -> Vec<(bool, bool, String)> { + let mut folds = Vec::new(); + walk(&source.regions, &mut |region| { + if is_fold(region) { + folds.push(( + has_tag(region, TEST), + region.visibility.collapsed, + region.visibility.label.clone(), + )); + } + }); + folds +} + +#[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) = project("a.rs", before, after); + run("test-bodies", json!({"min_lines": 3}), &file, &mut sides); + let sides = trees(&sides); + let tree::Pairing::Both { + lhs: before, + rhs: after, + } = &sides + else { + panic!("both sides"); + }; + for source in [before, after] { + assert_eq!( + folds(source), + vec![ + // The context queries wrap each function in a scope fold, + // which the body fold nests inside. + (false, false, String::new()), + (true, true, "test body".to_owned()), + (false, false, String::new()), + // No plugin collapsed `f`'s body, so it has no label. + (false, false, String::new()) + ] + ); + } + // A `#[cfg(test)]` module collapses as one labelled fold; the test + // bodies inside keep their own label. + let (file, mut sides) = project( + "a.rs", + "", + "#[cfg(test)]\nmod tests {\n #[test]\n fn t() {\n a();\n b();\n c();\n }\n}\n", + ); + run("test-bodies", json!({"min_lines": 3}), &file, &mut sides); + let sides = trees(&sides); + let mut labels = Vec::new(); + walk(&rhs(&sides).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) = project("a.rs", "", "#[test]\nfn t() {\n a();\n}\n"); + run("test-bodies", json!({"min_lines": 3}), &file, &mut sides); + let sides = trees(&sides); + walk(&rhs(&sides).regions, &mut |region| { + assert!(!region.visibility.collapsed); + }); +} + +#[test] +fn a_test_body_links_its_docstring_on_each_side() { + let before = "/// Stays.\n/// Here.\n#[test]\nfn t() {\n a();\n b();\n c();\n}\n"; + let after = "/// Stays.\n/// Here.\n#[test]\nfn t() {\n a();\n b();\n d();\n}\n"; + let (file, mut sides) = project("a.rs", before, after); + run("test-bodies", json!({"min_lines": 3}), &file, &mut sides); + let sides = trees(&sides); + let tree::Pairing::Both { lhs, rhs } = &sides else { + panic!("both sides"); + }; + let mut states = Vec::new(); + for source in [lhs, rhs] { + walk(&source.regions, &mut |region| { + if has_tag(region, TEST) || has_tag(region, "test-bodies:docstring") { + assert!(region.visibility.collapsed); + states.push(region.fold_state_id); + } + }); + } + assert_eq!(states.len(), 4, "{states:?}"); + assert!( + states.iter().all(|state| *state == states[0]), + "the matched docstrings and bodies share one fold state: {states:?}" + ); +} + +#[test] +fn javascript_test_callbacks_collapse() { + for path in ["a.test.ts", "a.test.js", "a.test.tsx"] { + let (file, mut sides) = project( + path, + "", + "it('adds', () => {\n expect(1).toBe(1);\n expect(2).toBe(2);\n});\n\nrun('z', () => {\n go();\n go();\n});\n", + ); + run("test-bodies", json!({"min_lines": 2}), &file, &mut sides); + let sides = trees(&sides); + let mut collapsed = Vec::new(); + walk(&rhs(&sides).regions, &mut |region| { + if region.visibility.collapsed { + collapsed.push(region.range.start.line); + } + }); + assert_eq!(collapsed, [1], "{path}"); + } +} diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 3cb7c6c32..bbc45d2ad 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -992,7 +992,7 @@ mod tests { let rhs = rhs.unwrap(); let bodies: Vec<_> = all(&rhs.regions) .into_iter() - .filter(|r| r.tags.iter().any(|tag| tag == "body")) + .filter(|r| r.tags.iter().any(|tag| tag == "deleted-bodies:function")) .map(|r| r.range.start.line) .collect(); // Nothing is collapsed before the plugins run, so the untouched `keep` @@ -1066,7 +1066,10 @@ mod tests { assert!(leaves(std::slice::from_ref(new_fold)) .into_iter() .all(|leaf| !lhs_leaves.contains_key(&alignment(leaf)))); - assert_eq!(new_fold.tags, vec!["body"]); + assert_eq!( + new_fold.tags, + vec!["deleted-bodies:function", "removed-runs:function"] + ); // A label is a plugin's to give; the projection leaves it empty. assert_eq!(new_fold.visibility.label, ""); } diff --git a/tests/view.rs b/tests/view.rs new file mode 100644 index 000000000..20642df4a --- /dev/null +++ b/tests/view.rs @@ -0,0 +1,164 @@ +//! The plugins on the stream: hidden files, collapsed and linked regions, and +//! a run cut short by a failing plugin. +use git2::{IndexAddOption, Repository, Signature, Time}; +use serde_json::Value; +use std::fs; +use std::process::{Command, Output}; +use tempfile::TempDir; + +struct Fixture { + dir: TempDir, + repo: Repository, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path().join("repo")).unwrap(); + fs::create_dir_all(dir.path().join("config/diffr")).unwrap(); + Self { dir, repo } + } + + fn write(&self, path: &str, text: &str) { + let path = self.dir.path().join("repo").join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + + fn commit(&self) -> String { + let mut index = self.repo.index().unwrap(); + index.add_all(["*"], IndexAddOption::DEFAULT, None).unwrap(); + index.update_all(["*"], None).unwrap(); + index.write().unwrap(); + let tree = self.repo.find_tree(index.write_tree().unwrap()).unwrap(); + let signature = Signature::new( + "Fixture", + "fixture@example.invalid", + &Time::new(946684800, 0), + ) + .unwrap(); + let parent = self + .repo + .head() + .ok() + .map(|head| head.peel_to_commit().unwrap()); + let parents: Vec<_> = parent.iter().collect(); + self.repo + .commit( + Some("HEAD"), + &signature, + &signature, + "fixture\n", + &tree, + &parents, + ) + .unwrap() + .to_string() + } + + fn run(&self, base: &str, head: &str) -> Output { + self.diffr(&[base, head, "--format", "ndjson"]) + } + + fn diffr(&self, args: &[&str]) -> Output { + Command::new(assert_cmd::cargo_bin!("diffr")) + .arg("--repo") + .arg(self.dir.path().join("repo")) + .args(args) + .env("XDG_CONFIG_HOME", self.dir.path().join("config")) + .env_remove("GIT_DIR") + .env_remove("GEMINI_API_KEY") + .env_remove("GOOGLE_API_KEY") + .output() + .unwrap() + } +} + +fn records(output: &Output) -> Vec<Value> { + String::from_utf8(output.stdout.clone()) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +/// The `file` record for a path. +fn record<'a>(records: &'a [Value], path: &str) -> &'a Value { + records + .iter() + .find(|record| { + record["type"] == "file" + && ["lhs", "rhs"] + .iter() + .any(|side| record["file"][side]["path"] == path) + }) + .unwrap_or_else(|| panic!("no record for {path}")) +} + +/// Every region on one side of a file record, parents first. +fn regions(record: &Value, side: &str) -> Vec<Value> { + fn walk(regions: &Value, out: &mut Vec<Value>) { + for region in regions.as_array().into_iter().flatten() { + out.push(region.clone()); + walk(®ion["children"], out); + } + } + let mut out = Vec::new(); + walk(&record["diff"][side]["regions"], &mut out); + out +} + +const GONE: &str = "/// Gone for good.\n/// Really.\nfn gone() -> u32 {\n let a = 1;\n let b = 2;\n let c = 3;\n let d = 4;\n let e = 5;\n let f = 6;\n let g = 7;\n let h = 8;\n let i = 9;\n let j = 10;\n let k = 11;\n a + b + c + d + e + f + g + h + i + j + k\n}\n"; + +#[test] +fn the_default_view_hides_links_and_collapses() { + let fixture = Fixture::new(); + fixture.write("src/lib.rs", &format!("fn keep() {{}}\n\n{GONE}")); + fixture.write("Cargo.lock", "# generated\n[[package]]\nname = \"a\"\n"); + let base = fixture.commit(); + fixture.write("src/lib.rs", "fn keep() {}\n"); + fixture.write("Cargo.lock", "# generated\n[[package]]\nname = \"b\"\n"); + let head = fixture.commit(); + let output = fixture.run(&base, &head); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let records = records(&output); + + let lock = record(&records, "Cargo.lock"); + assert_eq!( + lock["visibility"], + serde_json::json!({"collapsed": true, "label": "Generated file · hidden by default"}) + ); + let start = &records[0]; + assert!(start["files"] + .as_array() + .unwrap() + .iter() + .all(|file| file.get("visibility").is_none())); + + let lib = record(&records, "src/lib.rs"); + assert!(lib.get("visibility").is_none()); + let lhs = regions(lib, "lhs"); + let body = lhs + .iter() + .find(|region| { + region["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|tag| tag == "deleted-bodies:function")) + }) + .expect("the deleted body"); + assert_eq!(body["visibility"]["label"], "12 lines removed"); + let docstring = lhs + .iter() + .find(|region| { + region["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|tag| tag == "deleted-bodies:docstring")) + }) + .expect("the docstring"); + assert_eq!(docstring["fold_state_id"], body["fold_state_id"]); + assert_eq!(docstring["visibility"]["collapsed"], true); +} From 27862c3a2efecb6c4478e2f65ea2cf114d63759c Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 07/20] Summarize new function bodies with a model The `summarize` plugin collapses a new function body of at least 20 lines behind pseudocode from a model, so a large addition reads as a few lines and opens to the source. A body is new when no line inside it pairs with the other side, which it asks before its docstring is linked to it, so the pseudocode may quote the docstring. The plugin holds its HTTP client, its runtime and a semaphore bounding the calls in flight, and checks its key in `new`: it ships `enabled = false` and turning it on without `api_key`, `GEMINI_API_KEY` or `GOOGLE_API_KEY` stops diffr before the stream starts, with an error naming the plugin and the key. `system_prompt` is the model's system instruction, and diffr's own is the default; each request's message, the file's numbered lines and the folds to summarize, is built by the plugin. `diffr config show` redacts the API key unless `--reveal` is given. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 --- Cargo.lock | 865 +++++++++++++++++- Cargo.toml | 3 + docs/config.md | 34 +- docs/streaming.md | 17 +- plugins/shared/queries/go-docstrings.scm | 1 + .../shared/queries/javascript-docstrings.scm | 1 + plugins/shared/queries/python-docstrings.scm | 1 + plugins/shared/queries/rust-docstrings.scm | 3 + plugins/summarize/Cargo.toml | 15 + plugins/summarize/plugin.toml | 84 ++ plugins/summarize/queries/go.scm | 8 + plugins/summarize/queries/javascript.scm | 22 + plugins/summarize/queries/python.scm | 6 + plugins/summarize/queries/rust.scm | 6 + plugins/summarize/src/lib.rs | 459 ++++++++++ src/cli.rs | 10 +- src/config.rs | 35 +- src/config/store.rs | 110 ++- src/parse/fold_tests.rs | 2 + src/plugin/builtin.rs | 8 +- src/plugin/config.rs | 38 +- src/plugin/native.rs | 1 + src/plugin/queries.rs | 18 +- src/plugin/tests/mod.rs | 21 +- src/plugin/tests/summarize.rs | 401 ++++++++ src/protocol/project.rs | 6 +- tests/cli.rs | 31 + tests/view.rs | 36 + 28 files changed, 2184 insertions(+), 58 deletions(-) create mode 100644 plugins/summarize/Cargo.toml create mode 100644 plugins/summarize/plugin.toml create mode 100644 plugins/summarize/queries/go.scm create mode 100644 plugins/summarize/queries/javascript.scm create mode 100644 plugins/summarize/queries/python.scm create mode 100644 plugins/summarize/queries/rust.scm create mode 100644 plugins/summarize/src/lib.rs create mode 100644 src/plugin/tests/summarize.rs diff --git a/Cargo.lock b/Cargo.lock index 3f6d438dd..ed5a23c71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,12 +102,53 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" 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 = "bit-set" version = "0.8.0" @@ -158,6 +199,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.2.54" @@ -176,6 +223,23 @@ 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" @@ -204,12 +268,56 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +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" @@ -303,6 +411,17 @@ dependencies = [ "wit-bindgen 0.60.0", ] +[[package]] +name = "diffr-plugin-summarize" +version = "0.1.0" +dependencies = [ + "diffr-plugin-sdk", + "reqwest", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "diffr-plugin-test-bodies" version = "0.1.0" @@ -326,6 +445,7 @@ dependencies = [ "diffr-plugin-hide-files", "diffr-plugin-removed-runs", "diffr-plugin-sdk", + "diffr-plugin-summarize", "diffr-plugin-test-bodies", "dirs", "encoding_rs", @@ -467,6 +587,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -575,6 +701,12 @@ dependencies = [ "serde", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -606,6 +738,51 @@ dependencies = [ "num-bigint", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -613,8 +790,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -638,8 +817,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -674,6 +856,25 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -706,6 +907,45 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "humansize" version = "2.1.3" @@ -721,6 +961,65 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +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", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -867,6 +1166,12 @@ dependencies = [ "serde_core", ] +[[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" @@ -890,6 +1195,55 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.106", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.106", +] + [[package]] name = "jobserver" version = "0.1.32" @@ -906,6 +1260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -1058,6 +1413,12 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "macro-string" version = "0.2.0" @@ -1081,6 +1442,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nom" version = "8.0.0" @@ -1180,6 +1552,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1328,6 +1706,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +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]] name = "quote" version = "1.0.47" @@ -1355,6 +1790,32 @@ 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" @@ -1392,7 +1853,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1461,12 +1922,73 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" 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" @@ -1493,6 +2015,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +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" @@ -1514,6 +2111,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "1.2.2" @@ -1545,6 +2151,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1645,12 +2274,44 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1690,6 +2351,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.106" @@ -1712,6 +2379,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +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" version = "0.13.2" @@ -1767,7 +2443,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", ] [[package]] @@ -1781,6 +2466,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tikv-jemalloc-sys" version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" @@ -1811,13 +2507,48 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", "pin-project-lite", + "tokio", ] [[package]] @@ -1898,6 +2629,70 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "tree-sitter" version = "0.26.10" @@ -2499,6 +3294,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ts-parser-perl" version = "1.2.1" @@ -2539,6 +3340,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2610,6 +3417,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2638,6 +3454,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -2704,6 +3530,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.9" @@ -3067,6 +3922,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 09adfa9fc..a88dbe812 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,6 +150,7 @@ diffr-plugin-deleted-bodies = { path = "plugins/deleted-bodies" } diffr-plugin-group = { path = "plugins/group" } diffr-plugin-hide-files = { path = "plugins/hide-files" } diffr-plugin-removed-runs = { path = "plugins/removed-runs" } +diffr-plugin-summarize = { path = "plugins/summarize" } diffr-plugin-test-bodies = { path = "plugins/test-bodies" } @@ -178,6 +179,7 @@ members = [ "plugins/group", "plugins/hide-files", "plugins/removed-runs", + "plugins/summarize", "plugins/test-bodies", ] default-members = [ @@ -188,6 +190,7 @@ default-members = [ "plugins/group", "plugins/hide-files", "plugins/removed-runs", + "plugins/summarize", "plugins/test-bodies", ] diff --git a/docs/config.md b/docs/config.md index ff0606bb2..a00271f78 100644 --- a/docs/config.md +++ b/docs/config.md @@ -53,7 +53,7 @@ name = "default-dark" # a bundled terminal theme path = "/path/to/theme.toml" # or a Helix-style theme file [plugins] # see "Plugins" below -order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "group"] +order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "summarize", "group"] [plugins.hide-files] enabled = true @@ -72,6 +72,18 @@ min_lines = 3 enabled = true min_lines = 5 +[plugins.summarize] # pseudocode for large new function bodies +enabled = false # off by default: it needs an API key +provider = "gemini" +model = "gemini-3.8-flash" +min_lines = 20 +api_key = "…" # or GEMINI_API_KEY / GOOGLE_API_KEY +endpoint = "https://…" # optional base URL override +request_timeout_ms = 60000 +max_concurrency = 16 +retries = 3 +system_prompt = """…""" # the model's system instruction; defaults to diffr's own + [plugins.group] # adjacent collapsed regions under one row enabled = true ``` @@ -95,7 +107,7 @@ produce on the wire. ```toml [plugins] -order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "group"] +order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "summarize", "group"] [plugins.context] # unchanged lines far from any change collapse enabled = true @@ -118,6 +130,18 @@ min_lines = 3 enabled = true min_lines = 5 +[plugins.summarize] # pseudocode for large new function bodies +enabled = false # off by default: it needs an API key +provider = "gemini" +model = "gemini-3.8-flash" +min_lines = 20 +api_key = "…" # or GEMINI_API_KEY / GOOGLE_API_KEY +endpoint = "https://…" # optional base URL override +request_timeout_ms = 60000 +max_concurrency = 16 +retries = 3 +system_prompt = """…""" # the model's system instruction; defaults to diffr's own + [plugins.group] # adjacent collapsed regions under one row enabled = true ``` @@ -133,6 +157,9 @@ as `plugins.deleted-bodies: min_lines: "many" is not of type "integer"`. Every plugin that is on is made (its `new` runs) when diffr starts, before any output, and one that cannot be made stops diffr with an error naming it. +The summarizer cannot be made without an API key, which is why it ships off: +turn it on with `enabled = true` once `api_key`, `GEMINI_API_KEY` or +`GOOGLE_API_KEY` holds a key. An entry that names no bundled plugin is an error. @@ -183,7 +210,8 @@ settings schema in the order `plugin.toml` declares them. Tree-sitter queries decide which folds exist and what they are, as tags; plugins decide how they are shown. Each plugin that reads syntax -(`context`, `deleted-bodies`, `test-bodies`, `removed-runs`) +(`context`, `deleted-bodies`, `test-bodies`, `removed-runs`, +`summarize`) lists one query file per language key (`rust`, `python`, `go`, `javascript`, `javascriptjsx`, `typescript`, `typescripttsx`, or any other lowercase language name) under `[queries]` in diff --git a/docs/streaming.md b/docs/streaming.md index f6b92bd31..5d04216b2 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -108,15 +108,18 @@ One shape everywhere: `{"code": "<snake_case>", "message": "<prose>"}`. syntax node with different fold ranges; the message names the line and both files), or `internal` for a failure diffr did not classify. - On `complete`, `aborted` reports a run-level failure in a plugin: - `mutation_failed` when a plugin fails or asks for a move that cannot be - carried out. The message names the plugin. diffr + `mutation_failed` when a plugin's `mutate` returns an error (the + summarizer's model call failing after its retries, say) or when a plugin + asks for a move that cannot be carried out. The message names the + plugin. diffr stops pulling files, lets the ones in flight finish, and exits 2. Every `file` record already written stays valid; the file that failed has no record. - Setup failures (bad revision, unreadable config, a query file that does not compile, a malformed `diffr-tags` attribute, a plugin option that does not - match its schema, a plugin that cannot be made, or a `classify` that fails - or returns a malformed tag) write to stderr + match its schema, a plugin that cannot be made, such as a summarizer + without an API key, or a `classify` that fails or returns a malformed tag) + 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 @@ -345,8 +348,9 @@ every region hangs from: both sides, takes the first region's `fold_state_id` and whether it starts collapsed, so they open and close together. Today the bundled link is a docstring: a plugin that collapses a function body (`deleted-bodies`, - `test-bodies`) links the body's docstring, a fold tagged - `deleted-bodies:docstring` or `test-bodies:docstring`, to it. The docstring + `test-bodies`, `summarize`) links the body's docstring, a fold tagged + `deleted-bodies:docstring`, `test-bodies:docstring` or (when the summarizer + is on) `summarize:docstring`, to it. The docstring then carries the body's `fold_state_id` and starts collapsed, with an empty label. A docstring whose body no plugin collapses is not linked. - **Set collapsed** on a region: every region sharing its `fold_state_id` @@ -379,6 +383,7 @@ The bundled plugins, in their default order, each a folder under `plugins/`: | `deleted-bodies` | A function body of at least 12 lines with nothing paired under it collapses: `"20 lines removed"`. Its docstring is linked to it. | | `test-bodies` | Test function bodies (`"test body"`) and Rust `#[cfg(test)]` modules (`"test module"`) collapse on both sides. A test body's docstring is linked to it. | | `removed-runs` | A one-sided leaf of at least 5 lines in removed (not rewritten) code keeps its first and last line open and collapses the rest: `"8 lines removed"`. | +| `summarize` | Off by default. A new function body of at least 20 lines collapses behind pseudocode from the configured model. The body is chosen as new before its docstring is linked to it; the pseudocode may quote the docstring. On without an API key, diffr stops before the stream starts. | | `group` | Two or more adjacent collapsed regions are wrapped in one collapsed fold: `"3 collapsed regions · 42 lines"`. An open fold showing only collapsed regions and at most two open lines, such as a function's scope around its collapsed body and closing brace, counts as collapsed. | A binary diff has no regions, so only `hide-files` can change how it starts diff --git a/plugins/shared/queries/go-docstrings.scm b/plugins/shared/queries/go-docstrings.scm index 28425c781..60aecbf33 100644 --- a/plugins/shared/queries/go-docstrings.scm +++ b/plugins/shared/queries/go-docstrings.scm @@ -16,4 +16,5 @@ ] (#match? @_body "\n") (#set! tag "deleted-bodies:docstring") + (#set! tag "summarize:docstring") (#set! tag "test-bodies:docstring")) diff --git a/plugins/shared/queries/javascript-docstrings.scm b/plugins/shared/queries/javascript-docstrings.scm index f9ccb7a70..7d1f16ef2 100644 --- a/plugins/shared/queries/javascript-docstrings.scm +++ b/plugins/shared/queries/javascript-docstrings.scm @@ -43,4 +43,5 @@ ] (#match? @_body "\n") (#set! tag "deleted-bodies:docstring") + (#set! tag "summarize:docstring") (#set! tag "test-bodies:docstring")) diff --git a/plugins/shared/queries/python-docstrings.scm b/plugins/shared/queries/python-docstrings.scm index 0e41c92f0..701c5a2f0 100644 --- a/plugins/shared/queries/python-docstrings.scm +++ b/plugins/shared/queries/python-docstrings.scm @@ -6,4 +6,5 @@ ; A docstring: the string that is a function body's first statement. ((function_definition body: (block . (expression_statement (string) @fold))) (#set! tag "deleted-bodies:docstring") + (#set! tag "summarize:docstring") (#set! tag "test-bodies:docstring")) diff --git a/plugins/shared/queries/rust-docstrings.scm b/plugins/shared/queries/rust-docstrings.scm index e7e5ed2c0..adb3be0e8 100644 --- a/plugins/shared/queries/rust-docstrings.scm +++ b/plugins/shared/queries/rust-docstrings.scm @@ -15,14 +15,17 @@ body: (block) @_body) (#match? @_body "\n") (#set! tag "deleted-bodies:docstring") + (#set! tag "summarize:docstring") (#set! tag "test-bodies:docstring")) ((function_item (attributes (block_outer_doc_comment) @fold) body: (block) @_body) (#match? @_body "\n") (#set! tag "deleted-bodies:docstring") + (#set! tag "summarize:docstring") (#set! tag "test-bodies:docstring")) ((line_comment)+ @fold . (function_item body: (block) @_body) (#match? @_body "\n") (#set! tag "deleted-bodies:docstring") + (#set! tag "summarize:docstring") (#set! tag "test-bodies:docstring")) diff --git a/plugins/summarize/Cargo.toml b/plugins/summarize/Cargo.toml new file mode 100644 index 000000000..c21dd837b --- /dev/null +++ b/plugins/summarize/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "diffr-plugin-summarize" +description = "The summarize plugin: pseudocode summaries for large new function bodies." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +diffr-plugin-sdk = { path = "../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "http2"] } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time", "sync"] } diff --git a/plugins/summarize/plugin.toml b/plugins/summarize/plugin.toml new file mode 100644 index 000000000..ee5dfd5be --- /dev/null +++ b/plugins/summarize/plugin.toml @@ -0,0 +1,84 @@ +name = "summarize" +title = "Summaries" +description = "Pseudocode summaries for large new function bodies." + +# Off by default: the summarizer needs an API key, and diffr stops at +# startup when a plugin that is on cannot be made. +[enabled] +title = "Summarize new functions" +description = "Summarize large new function bodies as pseudocode. Needs an API key: `api_key`, or `GEMINI_API_KEY` or `GOOGLE_API_KEY` in the environment." +default = false + +[options.provider] +enum = ["gemini"] +title = "Provider" +description = "Which model API to call." +default = "gemini" + +[options.model] +type = "string" +title = "Model" +description = "The model name sent to the provider." +default = "gemini-3.8-flash" + +[options.min_lines] +type = "integer" +minimum = 0 +title = "Shortest body to summarize (lines)" +description = "New function bodies shorter than this are shown as code, not summarized: pseudocode only pays off once the body is long." +default = 20 + +[options.api_key] +type = "string" +title = "API key" +description = "The provider's API key. `GEMINI_API_KEY` or `GOOGLE_API_KEY` in the environment is used when this is unset." + +[options.endpoint] +type = "string" +title = "Endpoint URL" +description = "Override the provider's base URL, for proxies and tests." + +[options.request_timeout_ms] +type = "integer" +minimum = 1 +title = "Request timeout (ms)" +description = "Per-request limit in milliseconds." +default = 60000 + +[options.max_concurrency] +type = "integer" +minimum = 1 +title = "Parallel requests" +description = "Requests in flight at once across files." +default = 16 + +[options.retries] +type = "integer" +minimum = 0 +title = "Retries" +description = "Retries after a timeout, rate limit, or server error before the run is aborted." +default = 3 + +[options.system_prompt] +type = "string" +title = "System prompt" +description = "The system instruction sent with every request. Each request's own message, the file's numbered lines and the folds to summarize, is built by diffr." +"x-settings" = false +default = """\ +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.""" + +# Folds tagged `summarize:function`, `summarize:test` and, from the shared +# docstrings query, `summarize:docstring`. +[queries] +rust = "queries/rust.scm" +python = "queries/python.scm" +go = "queries/go.scm" +javascript = "queries/javascript.scm" +javascriptjsx = "queries/javascript.scm" +typescript = "queries/javascript.scm" +typescripttsx = "queries/javascript.scm" diff --git a/plugins/summarize/queries/go.scm b/plugins/summarize/queries/go.scm new file mode 100644 index 000000000..7254e4a26 --- /dev/null +++ b/plugins/summarize/queries/go.scm @@ -0,0 +1,8 @@ +; inherits: builtin:shared/queries/go.scm, builtin:shared/queries/go-docstrings.scm +((function_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "summarize:function")) +((method_declaration body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "summarize:function")) +((function_declaration name: (identifier) @_name body: (block "{" @fold.open "}" @fold.close) @fold) + (#match? @_name "^Test") + (#set! tag "summarize:test")) diff --git a/plugins/summarize/queries/javascript.scm b/plugins/summarize/queries/javascript.scm new file mode 100644 index 000000000..3f29fbeb6 --- /dev/null +++ b/plugins/summarize/queries/javascript.scm @@ -0,0 +1,22 @@ +; inherits: builtin:shared/queries/javascript.scm, builtin:shared/queries/javascript-docstrings.scm +((function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "summarize:function")) +((generator_function_declaration body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "summarize:function")) +((method_definition body: (statement_block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "summarize: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 "summarize:function")) +((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 "summarize:test")) diff --git a/plugins/summarize/queries/python.scm b/plugins/summarize/queries/python.scm new file mode 100644 index 000000000..4e2d39b1a --- /dev/null +++ b/plugins/summarize/queries/python.scm @@ -0,0 +1,6 @@ +; inherits: builtin:shared/queries/python.scm, builtin:shared/queries/python-docstrings.scm +((function_definition ":" @fold.open body: (block) @fold) + (#set! tag "summarize:function")) +((function_definition name: (identifier) @_name ":" @fold.open body: (block) @fold) + (#match? @_name "^test_") + (#set! tag "summarize:test")) diff --git a/plugins/summarize/queries/rust.scm b/plugins/summarize/queries/rust.scm new file mode 100644 index 000000000..2a325338c --- /dev/null +++ b/plugins/summarize/queries/rust.scm @@ -0,0 +1,6 @@ +; inherits: builtin:shared/queries/rust.scm, builtin:shared/queries/rust-docstrings.scm +((function_item body: (block "{" @fold.open "}" @fold.close) @fold) + (#set! tag "summarize:function")) +((function_item attributes: (attributes (attribute_item) @_attribute) body: (block "{" @fold.open "}" @fold.close) @fold) + (#match? @_attribute "test") + (#set! tag "summarize:test")) diff --git a/plugins/summarize/src/lib.rs b/plugins/summarize/src/lib.rs new file mode 100644 index 000000000..ac2529328 --- /dev/null +++ b/plugins/summarize/src/lib.rs @@ -0,0 +1,459 @@ +//! The summarizer: large new function bodies become short pseudocode, shown +//! in place of the collapsed body. +//! +//! It needs an API key: `new` fails without one, naming how to set it or +//! turn the plugin off, which is why the bundled configuration ships it off. +//! The plugin holds its HTTP client and the runtime every request runs on, +//! and at most `max_concurrency` requests are in flight at once across +//! files. +use diffr_plugin_sdk::anyhow::{self, anyhow, Context as _}; +use diffr_plugin_sdk::types::Source as SourceRecord; +use diffr_plugin_sdk::{ + docstring_of, export, has_tag, is_fold, line_count, one_sided, tree, walk, Draft, FileEntry, + Move, Node, OtherSide, Pairing, Plugin, Region, Source, +}; +use serde::Deserialize; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::Duration; +use tokio::runtime::Runtime; +use tokio::sync::Semaphore; + +/// The plugin's name, and the tags its queries set: a function body, and a +/// test body, which is never summarized. +const PLUGIN: &str = "summarize"; +const FUNCTION: &str = "summarize:function"; +const TEST: &str = "summarize:test"; + +/// The plugin's options, as `plugins/summarize/plugin.toml` declares them. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options { + pub provider: Provider, + pub model: String, + pub min_lines: usize, + pub api_key: Option<String>, + pub endpoint: Option<String>, + pub request_timeout_ms: u64, + pub max_concurrency: usize, + pub retries: u32, + /// The system instruction sent with every request. + pub system_prompt: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + Gemini, +} + +const DEFAULT_ENDPOINT: &str = "https://generativelanguage.googleapis.com"; + +/// The summarizer: its options, its API key and endpoint, its HTTP client, +/// the runtime its requests run on, and the requests in flight across files. +pub struct Summarize { + options: Options, + api_key: String, + endpoint: String, + client: reqwest::Client, + runtime: Runtime, + limit: Semaphore, +} + +/// One fold to summarize: its region id, 1-based inclusive line range, and +/// its docstring region and that region's text when it has one. +struct Request { + id: u32, + first_line: u32, + last_line: u32, + docstring: Option<u32>, + doc: Option<String>, +} + +#[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<String>, + pseudocode: String, +} + +impl Summarize { + fn url(&self) -> String { + match self.options.provider { + Provider::Gemini => format!( + "{}/v1beta/models/{}:generateContent", + self.endpoint.trim_end_matches('/'), + self.options.model + ), + } + } + + fn prompt(&self, path: &str, src: &str, folds: &[Request]) -> String { + let numbered: Vec<String> = src + .split_terminator('\n') + .enumerate() + .map(|(index, line)| format!("{:5} | {line}", index + 1)) + .collect(); + let ranges: Vec<String> = 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{}", + numbered.join("\n"), + ranges.join("\n") + ) + } + + /// One request per file, retried on transient failures. Any other + /// failure is a run-level failure. + fn complete( + &self, + path: &str, + src: &str, + folds: &[Request], + ) -> anyhow::Result<BTreeMap<u32, Summary>> { + let body = json!({ + "systemInstruction": {"parts": [{"text": self.options.system_prompt}]}, + "contents": [{"role": "user", "parts": [{"text": self.prompt(path, src, folds)}]}], + "generationConfig": { + "temperature": 0, + "maxOutputTokens": 600 * folds.len() + 200, + "thinkingConfig": {"thinkingBudget": 0}, + "responseMimeType": "application/json", + "responseSchema": { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "properties": { + "id": {"type": "INTEGER"}, + "summary": {"type": "STRING"}, + "pseudocode": {"type": "STRING"}, + }, + "required": ["id", "pseudocode"], + }, + }, + }, + }); + let url = self.url(); + let failed = |message: String| anyhow!("{}: {message}", self.options.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::<serde_json::Value>() + .await + .map_err(|error| failed(error.to_string())); + } + Ok(response) + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS + || response.status().is_server_error() => + { + format!("HTTP {}", response.status()) + } + Ok(response) => { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(failed(format!( + "HTTP {status} {}", + detail.chars().take(200).collect::<String>() + ))); + } + Err(error) + if error.is_timeout() || error.is_connect() || error.is_request() => + { + error.to_string() + } + Err(error) => return Err(failed(error.to_string())), + }; + if attempt >= self.options.retries { + return Err(failed(format!("{retry} after {} attempts", attempt + 1))); + } + attempt += 1; + tokio::time::sleep(Duration::from_millis(250 * (1 << attempt.min(6)))).await; + } + })?; + let content = text["candidates"][0]["content"]["parts"] + .as_array() + .and_then(|parts| parts.last()) + .and_then(|part| part["text"].as_str()) + .ok_or_else(|| failed("no text in the response".to_owned()))?; + let answers: Vec<Answer> = + serde_json::from_str(content).map_err(|error| failed(format!("{error}: {content}")))?; + let mut texts = BTreeMap::new(); + for answer in answers { + if !folds.iter().any(|fold| fold.id == answer.id) { + return Err(failed(format!("answered for unknown fold {}", answer.id))); + } + if !answer.pseudocode.trim().is_empty() { + let doc = folds + .iter() + .find(|fold| fold.id == answer.id) + .and_then(|fold| fold.doc.as_deref()); + texts.insert( + answer.id, + Summary { + quote: quoted(doc, &answer.summary), + pseudocode: answer.pseudocode.trim().to_owned(), + }, + ); + } + } + Ok(texts) + } +} + +/// New function bodies on the after side of at least `min_lines` lines: +/// function folds with no line inside them paired with the before side (see +/// `one_sided`), so a body that only moved, or a file diffed by line whose +/// bodies still align, is not new. Only the outermost qualifying body is +/// taken, never one nested inside it; test bodies and folds that already +/// start collapsed are skipped. Each is its id, its 1-based inclusive line +/// range, and its docstring's id. Selection runs before this plugin links +/// any docstring, so a docstring matched across sides never makes the new +/// body under it look paired. +pub fn select(sides: &Pairing<Source>, min_lines: usize) -> Vec<(u32, u32, u32, Option<u32>)> { + let (lhs, rhs) = match sides { + Pairing::Both { lhs, rhs } => (OtherSide::of(&lhs.regions), rhs), + Pairing::RightOnly { rhs } => (OtherSide::default(), rhs), + Pairing::LeftOnly { .. } => return Vec::new(), + }; + let mut selected = Vec::new(); + fn visit( + rhs: &Source, + regions: &[Region], + lhs: &OtherSide, + min_lines: usize, + selected: &mut Vec<(u32, u32, u32, Option<u32>)>, + ) { + for region in regions { + if is_fold(region) + && has_tag(region, FUNCTION) + && !has_tag(region, TEST) + && !region.visibility.collapsed + && one_sided(region, lhs) + && line_count(region) >= min_lines + { + let lines = region.range.lines(); + selected.push(( + region.id, + lines.start + 1, + lines.end, + docstring_of(rhs, region, PLUGIN), + )); + continue; + } + if let Node::Fold { children } = ®ion.node { + visit(rhs, children, lhs, min_lines, selected); + } + } + } + visit(rhs, &rhs.regions, &lhs, min_lines, &mut selected); + selected +} + +/// The text of the docstring region with this id, with comment markers and +/// quotes stripped, or `None` when it says nothing. +fn documentation(source: &Source, docstring: u32) -> Option<String> { + let lines: Vec<&str> = source.text.split_terminator('\n').collect(); + let mut words = Vec::new(); + walk(&source.regions, &mut |region| { + if region.id != docstring { + return; + } + for line in region.range.lines() { + let stripped = strip_markers(lines[line as usize]); + if !stripped.is_empty() { + words.push(stripped.to_owned()); + } + } + }); + let text = words.join(" "); + (!text.is_empty()).then_some(text) +} + +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() +} + +/// 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<String> { + let squash = |text: &str| text.split_whitespace().collect::<Vec<_>>().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 API key: the `api_key` option, or else `GEMINI_API_KEY`, or else +/// `GOOGLE_API_KEY`, the first that is set and not empty. +fn resolve_key(config: &Options) -> anyhow::Result<String> { + let set = |key: &String| !key.is_empty(); + if let Some(key) = config.api_key.clone().filter(set) { + return Ok(key); + } + for variable in ["GEMINI_API_KEY", "GOOGLE_API_KEY"] { + if let Some(key) = std::env::var_os(variable) { + let key = key + .into_string() + .map_err(|_| anyhow!("{variable} is not valid UTF-8"))?; + if set(&key) { + return Ok(key); + } + } + } + anyhow::bail!( + "no API key: set plugins.summarize.api_key, or GEMINI_API_KEY or GOOGLE_API_KEY in the environment, or turn the summarizer off with plugins.summarize.enabled = false" + ) +} + +impl Plugin for Summarize { + type Options = Options; + + fn new(options: Options) -> anyhow::Result<Self> { + let api_key = resolve_key(&options)?; + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(options.request_timeout_ms)) + .build()?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("diffr-summarizer") + .enable_all() + .build()?; + Ok(Self { + api_key, + endpoint: options + .endpoint + .clone() + .unwrap_or_else(|| DEFAULT_ENDPOINT.to_owned()), + client, + runtime, + limit: Semaphore::new(options.max_concurrency), + options, + }) + } + + fn classify(&self, _file: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(Vec::new()) + } + + fn mutate( + &self, + file: &FileEntry, + lhs: Option<&SourceRecord>, + rhs: Option<&SourceRecord>, + ) -> anyhow::Result<Vec<Move>> { + let sides = tree::sides(lhs, rhs)?; + let selected = select(&sides, self.options.min_lines); + let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { + return Ok(Vec::new()); + }; + let folds: Vec<Request> = selected + .into_iter() + .map(|(id, first_line, last_line, docstring)| Request { + id, + first_line, + last_line, + docstring, + doc: docstring.and_then(|docstring| documentation(rhs, docstring)), + }) + .collect(); + if folds.is_empty() { + return Ok(Vec::new()); + } + let lines: Vec<&str> = rhs.text.split_terminator('\n').collect(); + let mut texts = self + .complete(&file.path, &rhs.text, &folds) + .context("summarizer")?; + texts.retain(|id, summary| { + let fold = folds + .iter() + .find(|fold| fold.id == *id) + .expect("answered fold"); + let body = &lines[fold.first_line as usize - 1..fold.last_line as usize]; + compresses(&summary.pseudocode, body) + }); + // Collapse every summarized body first, then link each to its + // docstring: a summary and its docstring are one thing. + let mut draft = Draft::new(&sides); + let mut links = Vec::new(); + for (id, summary) in texts { + let text = match &summary.quote { + Some(quote) => format!("{quote}\n{}", summary.pseudocode), + None => summary.pseudocode.clone(), + }; + draft.collapse(id, text)?; + let fold = folds + .iter() + .find(|fold| fold.id == id) + .expect("answered fold"); + if let Some(docstring) = fold.docstring { + links.push([id, docstring]); + } + } + for link in links { + draft.link(&link)?; + } + Ok(draft.into_moves()) + } +} + +export!(Summarize); diff --git a/src/cli.rs b/src/cli.rs index 74f8660a7..f3d2db03b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -78,7 +78,8 @@ pub(crate) fn run() -> Result<i32> { .subcommand( Command::new("show") .about("Print the resolved configuration") - .arg(flag("json")), + .arg(flag("json")) + .arg(flag("reveal").help("Do not redact the API key")), ) .subcommand( Command::new("set") @@ -542,11 +543,14 @@ fn run_config(args: &ArgMatches, sub: &ArgMatches) -> Result<i32> { } Some(("show", show)) => { let config = load_config(args)?; + let reveal = show.get_flag("reveal"); if show.get_flag("json") { - serde_json::to_writer_pretty(&mut stdout, &config::store::show(&config))?; + serde_json::to_writer_pretty(&mut stdout, &config::store::show(&config, reveal))?; stdout.write_all(b"\n")?; } else { - stdout.write_all(toml::to_string_pretty(&config)?.as_bytes())?; + stdout.write_all( + toml::to_string_pretty(&config::store::redacted(&config, reveal))?.as_bytes(), + )?; } } Some(("set", set)) => { diff --git a/src/config.rs b/src/config.rs index 79875b978..c2e984fe9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -460,12 +460,13 @@ fn with_queries(queries: &[(&str, &str)]) -> Params { try_with_queries(queries).expect("the test queries compile") } -/// Params with the folds of every bundled plugin's queries but `context`'s: -/// the bodies, collections and docstrings the engine's tests are written -/// against, without the scopes `context` lays over them. +/// Params with the folds of every bundled plugin's queries but `context`'s, +/// the summarizer's included: the bodies, collections and docstrings the +/// engine's tests are written against, without the scopes `context` lays +/// over them. #[cfg(test)] pub(crate) fn body_params() -> Params { - Config::from_toml("[plugins.context]\nenabled = false\n") + Config::from_toml("[plugins.context]\nenabled = false\n[plugins.summarize]\nenabled = true\n") .expect("a valid configuration") .compile() .expect("the bundled queries compile") @@ -588,15 +589,15 @@ mod tag_tests { #[test] fn a_node_captured_with_two_ranges_is_a_query_conflict_naming_both_files() { - let whole = "((block) @fold (#set! tag \"context:whole\"))"; + let whole = "((block) @fold (#set! tag \"removed-runs:whole\"))"; let interior = - "((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"removed-runs:inside\"))"; + "((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"summarize:inside\"))"; let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("queries/rust")).unwrap(); - let context_file = dir.path().join("queries/rust/context.scm"); - let removed = dir.path().join("queries/rust/removed-runs.scm"); - std::fs::write(&context_file, whole).unwrap(); - std::fs::write(&removed, interior).unwrap(); + let removed_runs = dir.path().join("queries/rust/removed-runs.scm"); + let summarize = dir.path().join("queries/rust/summarize.scm"); + std::fs::write(&removed_runs, whole).unwrap(); + std::fs::write(&summarize, interior).unwrap(); let queries = |plugin: &str, path: &std::path::Path| { ( plugin.to_owned(), @@ -605,12 +606,12 @@ mod tag_tests { }; for order in [ [ - queries("context", &context_file), - queries("removed-runs", &removed), + queries("removed-runs", &removed_runs), + queries("summarize", &summarize), ], [ - queries("removed-runs", &removed), - queries("context", &context_file), + queries("summarize", &summarize), + queries("removed-runs", &removed_runs), ], ] { let params = Config::default().compile_queries(order.to_vec()).unwrap(); @@ -624,9 +625,9 @@ mod tag_tests { let message = conflict.to_string(); assert!(message.starts_with("src/lib.rs:1: "), "{message}"); assert!( - message.contains("queries/rust/context.scm and ") + message.contains("queries/rust/removed-runs.scm and ") && message.contains( - "queries/rust/removed-runs.scm capture the same block with different fold ranges" + "queries/rust/summarize.scm capture the same block with different fold ranges" ), "{message}" ); @@ -651,6 +652,8 @@ mod tag_tests { let tags = [ "deleted-bodies:function", "removed-runs:function", + "summarize:function", + "summarize:test", "test-bodies:test", ]; assert_eq!(result.lhs_folds[0].tags, tags); diff --git a/src/config/store.rs b/src/config/store.rs index a6be01da1..8439c9f10 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -1,12 +1,32 @@ //! `diffr config`: read the schema and resolved values, and write one key to //! the global file. use super::{directory_of, Config, ConfigError}; -use serde_json::Value; +use crate::plugin::config::PATH; +use serde_json::{json, Value}; use std::path::Path; /// The resolved configuration as JSON, with the same nesting as the TOML. -pub(crate) fn show(config: &Config) -> serde_json::Value { - serde_json::to_value(config).expect("config serializes") +/// The summarizer's API key is redacted unless `reveal` is set. +pub(crate) fn show(config: &Config, reveal: bool) -> serde_json::Value { + serde_json::to_value(redacted(config, reveal)).expect("config serializes") +} + +/// The configuration to print: the summarizer's API key replaced by +/// `<redacted>` unless `reveal` is set. +pub(crate) fn redacted(config: &Config, reveal: bool) -> Config { + let mut shown = config.clone(); + let key = shown + .plugins + .entries + .get_mut("summarize") + .expect("the summarize plugin always has an entry") + .options + .get_mut("api_key"); + if let Some(key) = key.filter(|key| !reveal && key.as_str().is_some_and(|key| !key.is_empty())) + { + *key = serde_json::Value::String("<redacted>".to_owned()); + } + shown } /// Write `key = value` into the global file, keeping everything else in it @@ -18,17 +38,18 @@ 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 typed = typed_value(key, &setting_schema(key)?, value)?; 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 directory = directory_of(path); + let typed = typed_value(key, &setting_schema(key, &existing, directory)?, value)?; let mut document: toml_edit::DocumentMut = existing .parse() .map_err(|error| ConfigError(format!("{}: {error}", path.display())))?; assign(&mut document, key, &typed)?; - Config::from_toml_in(&document.to_string(), directory_of(path))?; + Config::from_toml_in(&document.to_string(), directory)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|error| ConfigError(format!("{}: {error}", parent.display())))?; @@ -38,9 +59,30 @@ pub(crate) fn set(path: &Path, key: &str, value: &str) -> Result<(), ConfigError } /// The JSON Schema of the setting at the dotted `key`, as `diffr config -/// schema` describes it. -fn setting_schema(key: &str) -> Result<Value, ConfigError> { +/// schema` describes it. A plugin entry's keys come from the entry's own +/// `plugin.toml`, which for an entry with `path` is in the folder the file `existing` +/// (in `directory`) points it at; `path` is diffr's, and a string. +fn setting_schema(key: &str, existing: &str, directory: &Path) -> Result<Value, ConfigError> { let unknown = || ConfigError(format!("{key}: unknown key")); + if let ["plugins", name, field] = key.split('.').collect::<Vec<_>>().as_slice() { + if *name != "order" { + if *field == PATH { + return Ok(json!({"type": "string"})); + } + let config = Config::from_toml_in(existing, directory)?; + let manifest = &config + .plugins + .entries + .get(*name) + .ok_or_else(unknown)? + .folder() + .manifest; + return manifest.settings_schema()["properties"] + .get(*field) + .cloned() + .ok_or_else(unknown); + } + } let root = Config::schema(); let resolve = |node: &Value| -> Value { match node.get("$ref").and_then(Value::as_str) { @@ -206,6 +248,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); set(&path, "plugins.deleted-bodies.min_lines", "30").unwrap(); + set(&path, "plugins.summarize.api_key", "secret").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); let config = Config::from_toml(&text).unwrap(); assert_eq!( @@ -226,6 +269,10 @@ mod tests { error("plugins.context.lines", "many"), "plugins.context.lines: expected an integer, got \"many\"" ); + assert_eq!( + error("plugins.summarize.provider", "openai"), + "plugins.summarize.provider: expected one of \"gemini\", got \"openai\"" + ); assert_eq!( error("plugins.mine.enabled", "true"), "plugins.mine.enabled: unknown key" @@ -236,11 +283,58 @@ mod tests { Config::from_toml(&text).unwrap().plugins.entries["context"].enabled, Some(false) ); - let shown = show(&config); + let shown = show(&config, false); + assert_eq!(shown["plugins"]["summarize"]["api_key"], "<redacted>"); + assert_eq!( + show(&config, true)["plugins"]["summarize"]["api_key"], + "secret" + ); assert_eq!( shown["plugins"]["deleted-bodies"], serde_json::json!({"enabled": true, "min_lines": 30}) ); + assert!(shown["plugins"]["summarize"]["system_prompt"] + .as_str() + .unwrap() + .starts_with("For each listed fold")); + let text = toml::to_string_pretty(&redacted(&config, false)).unwrap(); + assert!(text.contains("[plugins.summarize]"), "{text}"); + assert!(text.contains("system_prompt = "), "{text}"); + } + + #[test] + fn a_wasm_plugin_option_takes_the_type_its_folder_declares() { + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("plugins/mine"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write( + folder.join("plugin.toml"), + "name = 'mine'\ntitle = 'Mine'\n[options.depth]\ntype = 'integer'\ntitle = 'Depth'\ndefault = 2\n", + ) + .unwrap(); + let path = dir.path().join("config.toml"); + let order = "order = ['context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'summarize', 'group', 'mine']"; + std::fs::write( + &path, + format!("[plugins]\n{order}\n[plugins.mine]\npath = 'plugins/mine'\n"), + ) + .unwrap(); + set(&path, "plugins.mine.depth", "3").unwrap(); + set(&path, "plugins.mine.enabled", "false").unwrap(); + set(&path, "plugins.mine.path", "plugins/mine").unwrap(); + let error = |key: &str, value: &str| set(&path, key, value).unwrap_err().to_string(); + assert_eq!( + error("plugins.mine.depth", "deep"), + "plugins.mine.depth: expected an integer, got \"deep\"" + ); + assert_eq!( + error("plugins.mine.typo", "1"), + "plugins.mine.typo: unknown key" + ); + let text = std::fs::read_to_string(&path).unwrap(); + let config = Config::from_toml_in(&text, dir.path()).unwrap(); + assert_eq!(config.plugins.entries["mine"].options["depth"], 3); + assert_eq!(config.plugins.entries["mine"].enabled, Some(false)); } #[test] diff --git a/src/parse/fold_tests.rs b/src/parse/fold_tests.rs index 8004c7241..a31ef629e 100644 --- a/src/parse/fold_tests.rs +++ b/src/parse/fold_tests.rs @@ -102,6 +102,8 @@ mod folds { [ "deleted-bodies:function", "removed-runs:function", + "summarize:function", + "summarize:test", "test-bodies:test" ] ); diff --git a/src/plugin/builtin.rs b/src/plugin/builtin.rs index 4db70f709..e5dfc8015 100644 --- a/src/plugin/builtin.rs +++ b/src/plugin/builtin.rs @@ -12,12 +12,13 @@ use super::config::Manifest; use std::sync::OnceLock; /// The bundled plugins, in their default order. -pub(crate) const NAMES: [&str; 6] = [ +pub(crate) const NAMES: [&str; 7] = [ "context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", + "summarize", "group", ]; @@ -58,6 +59,11 @@ const FILES: &[(&str, &str)] = embed![ "removed-runs/queries/javascript.scm", "removed-runs/queries/python.scm", "removed-runs/queries/rust.scm", + "summarize/plugin.toml", + "summarize/queries/go.scm", + "summarize/queries/javascript.scm", + "summarize/queries/python.scm", + "summarize/queries/rust.scm", "group/plugin.toml", ]; diff --git a/src/plugin/config.rs b/src/plugin/config.rs index 926516584..7d855682e 100644 --- a/src/plugin/config.rs +++ b/src/plugin/config.rs @@ -535,6 +535,11 @@ mod tests { "Deleted function bodies" ) ); + assert_eq!(group("plugins.summarize.model"), ("Model", "Summaries")); + assert_eq!( + group("plugins.summarize.provider"), + ("Provider", "Summaries") + ); assert!(schema["properties"].get("languages").is_none()); let plugins = &schema["properties"]["plugins"]["properties"]; assert_eq!(plugins["order"]["x-settings"], false); @@ -543,6 +548,12 @@ mod tests { plugins["hide-files"]["properties"]["tags"]["x-settings"], false ); + let summarize = &plugins["summarize"]["properties"]; + assert_eq!(summarize["system_prompt"]["x-settings"], false); + assert!(summarize["system_prompt"]["default"] + .as_str() + .unwrap() + .starts_with("For each listed fold, rewrite that function body")); let keys: Vec<&String> = plugins.as_object().unwrap().keys().collect(); assert_eq!( keys, @@ -553,6 +564,7 @@ mod tests { "deleted-bodies", "test-bodies", "removed-runs", + "summarize", "group" ] ); @@ -561,20 +573,20 @@ mod tests { #[test] fn order_names_every_entry_exactly_once() { let order = |names: &str| Config::from_toml(&format!("[plugins]\norder = [{names}]")); - assert!(order("'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group'").is_ok()); + assert!(order("'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group', 'summarize'").is_ok()); let error = order( - "'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs'", + "'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'summarize'", ) .err() .unwrap() .to_string(); assert!(error.contains("\"group\" is not listed"), "{error}"); - let error = order("'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group', 'mine'") + let error = order("'context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'summarize', 'group', 'mine'") .err() .unwrap() .to_string(); assert!(error.contains("no plugin entry named \"mine\""), "{error}"); - let error = order("'context', 'hide-files', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group'") + let error = order("'context', 'hide-files', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'summarize', 'group'") .err() .unwrap() .to_string(); @@ -604,7 +616,7 @@ mod tests { "((block) @fold (#set! tag \"mine:block\"))\n", ) .unwrap(); - let order = "order = ['context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'group', 'mine']"; + let order = "order = ['context', 'hide-files', 'deleted-bodies', 'test-bodies', 'removed-runs', 'summarize', 'group', 'mine']"; let config = Config::from_toml_in( &format!("[plugins]\n{order}\n[plugins.mine]\npath = 'plugins/mine'\n"), dir.path(), @@ -667,6 +679,14 @@ mod tests { hide.options["tags"], serde_json::json!(["generated", "vendored", "test"]) ); + let summarize = &config.plugins.entries["summarize"]; + assert_eq!( + summarize.enabled, + Some(false), + "the summarizer needs a key, so it is off unless turned on" + ); + assert!(summarize.options.get("api_key").is_none()); + assert_eq!(summarize.options["request_timeout_ms"], 60_000); let error = |toml: &str| Config::from_toml(toml).err().unwrap().to_string(); let typo = error("[plugins.deleted-bodies]\ntypo = 1\n"); assert!( @@ -678,10 +698,12 @@ mod tests { mistyped.starts_with("plugins.deleted-bodies: min_lines: "), "{mistyped}" ); - let negative = error("[plugins.removed-runs]\nmin_lines = -1\n"); + let zero = error("[plugins.summarize]\nmax_concurrency = 0\n"); assert!( - negative.starts_with("plugins.removed-runs: min_lines: "), - "{negative}" + zero.starts_with("plugins.summarize: max_concurrency: "), + "{zero}" ); + assert!(error("[plugins.summarize]\nprovider = 'openai'\n") + .starts_with("plugins.summarize: provider: ")); } } diff --git a/src/plugin/native.rs b/src/plugin/native.rs index b9d2cb2ec..07eafe4ba 100644 --- a/src/plugin/native.rs +++ b/src/plugin/native.rs @@ -29,6 +29,7 @@ pub(crate) const REGISTRY: &[(&str, Constructor)] = &[ "removed-runs", native::<diffr_plugin_removed_runs::RemovedRuns>, ), + ("summarize", native::<diffr_plugin_summarize::Summarize>), ("group", native::<diffr_plugin_group::Group>), ]; diff --git a/src/plugin/queries.rs b/src/plugin/queries.rs index 279abed74..5f59b6dfa 100644 --- a/src/plugin/queries.rs +++ b/src/plugin/queries.rs @@ -249,13 +249,13 @@ mod tests { #[test] fn disabled_plugins_contribute_no_queries() { let config = Config::from_toml( - "[plugins.deleted-bodies]\nenabled = false\n[plugins.test-bodies]\nenabled = false\n", + "[plugins.deleted-bodies]\nenabled = false\n[plugins.summarize]\nenabled = false\n", ) .unwrap(); let assembled = assemble(&config.plugins.enabled_queries()).unwrap(); let names = names(&assembled["rust"]); assert!(!names.iter().any(|name| name.contains("deleted-bodies"))); - assert!(!names.iter().any(|name| name.contains("test-bodies"))); + assert!(!names.iter().any(|name| name.contains("summarize"))); } #[test] @@ -265,7 +265,7 @@ mod tests { let mine = write( dir.path(), "queries/rust/mine.scm", - "; inherits: ../shared.scm, builtin:shared/queries/rust.scm\n((function_item body: (block) @fold) (#set! tag \"removed-runs:function\"))\n", + "; inherits: ../shared.scm, builtin:shared/queries/rust.scm\n((function_item body: (block) @fold) (#set! tag \"summarize:function\"))\n", ); let plugins = [ ( @@ -276,7 +276,7 @@ mod tests { )]), ), ( - "removed-runs".to_owned(), + "summarize".to_owned(), Queries::from([("rust".to_owned(), mine)]), ), ]; @@ -313,27 +313,27 @@ mod tests { write(dir.path(), "b.scm", "; inherits: a.scm\n"); let queries = |path: &str| { [( - "removed-runs".to_owned(), + "summarize".to_owned(), Queries::from([("rust".to_owned(), path.to_owned())]), )] }; let error = assemble(&queries(&a)).err().expect("a cycle").to_string(); assert!(error.contains("cycle"), "{error}"); assert!( - error.starts_with("plugin removed-runs: queries.rust: "), + error.starts_with("plugin summarize: queries.rust: "), "{error}" ); let absent = dir.path().join("absent.scm").display().to_string(); assert!(assemble(&queries(&absent)).is_err()); - let error = assemble(&queries("builtin:removed-runs/queries/absent.scm")) + let error = assemble(&queries("builtin:summarize/queries/absent.scm")) .err() .expect("unknown builtin") .to_string(); assert!( - error.contains("no bundled file builtin:removed-runs/queries/absent.scm"), + error.contains("no bundled file builtin:summarize/queries/absent.scm"), "{error}" ); - let error = assemble(&queries("builtin:removed-runs/../../outside.scm")) + let error = assemble(&queries("builtin:summarize/../../outside.scm")) .err() .expect("a path out of the bundled plugins") .to_string(); diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs index 71c89ec7d..6d5213d43 100644 --- a/src/plugin/tests/mod.rs +++ b/src/plugin/tests/mod.rs @@ -6,6 +6,7 @@ mod deleted_bodies; mod group; mod hide_files; mod removed_runs; +mod summarize; mod test_bodies; use super::*; @@ -306,7 +307,8 @@ fn the_default_pipeline_makes_every_plugin_that_is_on() { "test-bodies", "removed-runs", "group" - ] + ], + "the summarizer is off until turned on" ); } @@ -447,6 +449,23 @@ fn a_move_that_cannot_be_carried_out_fails_naming_the_plugin() { assert_eq!(format!("{error:#}"), "mutation bad: no region 99999"); } +#[test] +fn a_plugin_that_cannot_be_made_is_a_setup_error() { + let config = Config::from_toml("[plugins.summarize]\nenabled = true\napi_key = ''\n").unwrap(); + // Only meaningful when the environment carries no key. + if std::env::var_os("GEMINI_API_KEY").is_some() || std::env::var_os("GOOGLE_API_KEY").is_some() + { + return; + } + let error = Pipeline::from_config(&config.plugins, Path::new(".")) + .err() + .expect("a summarizer without a key cannot be made"); + assert_eq!( + format!("{error:#}"), + "plugins.summarize: no API key: set plugins.summarize.api_key, or GEMINI_API_KEY or GOOGLE_API_KEY in the environment, or turn the summarizer off with plugins.summarize.enabled = false" + ); +} + #[test] fn options_that_do_not_deserialize_are_a_setup_error() { let mut pipeline = Pipeline::default(); diff --git a/src/plugin/tests/summarize.rs b/src/plugin/tests/summarize.rs new file mode 100644 index 000000000..e76af0fa5 --- /dev/null +++ b/src/plugin/tests/summarize.rs @@ -0,0 +1,401 @@ +use super::*; +use diffr_plugin_sdk::tree::walk_mut; +use diffr_plugin_summarize::{select, Options}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; + +const FUNCTION: &str = "summarize:function"; + +/// Project with the summarizer's queries, which run only when it is on. +fn project(path: &str, before: &str, after: &str) -> (FileChange, Pairing<protocol::Source>) { + project_with(path, before, after, DiffOptions::default()) +} + +fn project_with( + path: &str, + before: &str, + after: &str, + options: DiffOptions, +) -> (FileChange, Pairing<protocol::Source>) { + let params = Config::from_toml("[plugins.summarize]\nenabled = true\n") + .unwrap() + .compile() + .unwrap(); + project_compiled(path, before, after, ¶ms, options) +} + +const LARGE: &str = "def f():\n a()\n b()\n c()\n\ndef g(): d()\n"; + +/// Answer each request with the next canned response. +fn serve(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<Vec<String>>) { + 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 function body on the after side. The scope fold +/// the context queries wrap around it is not one. +fn fold_label(sides: &tree::Pairing<tree::Source>) -> String { + let mut labels = Vec::new(); + walk(&rhs(sides).regions, &mut |region| { + if is_fold(region) && has_tag(region, FUNCTION) { + 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) -> Pipeline { + summarizer_with(json!({ + "api_key": "test-key", + "endpoint": endpoint, + "retries": retries, + "request_timeout_ms": 5000, + "min_lines": 3, + })) +} + +/// A summarizer with the bundled defaults and `overrides`. +fn summarizer_with(overrides: serde_json::Value) -> Pipeline { + bundled("summarize", overrides) +} + +#[test] +fn selection_takes_new_bodies_of_at_least_min_lines() { + let (_, sides) = project("a.py", "", LARGE); + let selected = select(&trees(&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(&trees(&sides), 3).is_empty()); +} + +#[test] +fn selection_reads_newness_from_the_lines_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, + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ); + // Nothing matched, so no fold is paired; the lines still are. Only the + // added body, whose lines pair with nothing, is new. + let selected = select(&trees(&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(&trees(&sides), 3); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert_eq!((selected[0].1, selected[0].2), (3, 11)); + // Below the threshold, nothing. + assert!(select(&trees(&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(&trees(&sides), 3); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert_eq!((selected[0].1, selected[0].2), (9, 11)); + run("test-bodies", json!({"min_lines": 3}), &file, &mut sides); + let mut sides = trees(&sides); + let (tree::Pairing::Both { rhs, .. } | tree::Pairing::RightOnly { rhs }) = &mut sides else { + panic!("an after side"); + }; + walk_mut(&mut rhs.regions, &mut |region| { + if region.range.start.line == 8 { + 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(&trees(&sides), 3)[0].0; + let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "a()\nb()\nc()")]))]); + summarizer(&endpoint, 0).run(&file, &mut sides).unwrap(); + let sides = trees(&sides); + server.join().unwrap(); + let mut folds = Vec::new(); + walk(&rhs(&sides).regions, &mut |region| { + if is_fold(region) && has_tag(region, FUNCTION) { + folds.push((region.visibility.collapsed, region.visibility.label.clone())); + } + }); + // No plugin collapsed it, so it has no label. + assert_eq!(folds, vec![(false, String::new())]); +} + +#[test] +fn summaries_collapse_selected_folds_behind_pseudocode() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&trees(&sides), 3)[0].0; + let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "call a, b, c")]))]); + summarizer(&endpoint, 0).run(&file, &mut sides).unwrap(); + let sides = trees(&sides); + let bodies = server.join().unwrap(); + assert!(bodies[0].contains("thinkingBudget")); + assert!(bodies[0].contains(&format!("fold {id}: lines 2-4"))); + // `g` is a one-line function, whose body is not a region. + assert_eq!(fold_label(&sides), "call a, b, c"); + let mut collapsed = Vec::new(); + walk(&rhs(&sides).regions, &mut |region| { + if is_fold(region) && has_tag(region, FUNCTION) { + collapsed.push(region.visibility.collapsed); + } + }); + assert_eq!(collapsed, [true]); +} + +#[test] +fn a_docstring_is_sent_and_only_a_verbatim_sentence_from_it_is_kept() { + let after = "/// Sums three numbers.\n/// 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 answer = |id: u32, 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 body_label = |sides: &tree::Pairing<tree::Source>| { + let mut labels = Vec::new(); + walk(&rhs(sides).regions, &mut |region| { + if is_fold(region) && has_tag(region, FUNCTION) { + labels.push(region.visibility.label.clone()); + } + }); + assert_eq!(labels.len(), 1, "{labels:?}"); + labels.remove(0) + }; + let (file, mut sides) = project("a.rs", "", after); + let id = select(&trees(&sides), 3)[0].0; + let (endpoint, server) = serve(vec![(200, answer(id, "Sums three numbers."))]); + summarizer(&endpoint, 0).run(&file, &mut sides).unwrap(); + let sides = trees(&sides); + let bodies = server.join().unwrap(); + assert!( + bodies[0].contains("doc: Sums three numbers. Used by tests."), + "{}", + bodies[0] + ); + assert_eq!(body_label(&sides), "Sums three numbers.\nreturn a + b + c"); + assert_linked(&sides, id); + + // A sentence the docstring does not contain is dropped. + let (file, mut sides) = project("a.rs", "", after); + let (endpoint, server) = serve(vec![(200, answer(id, "Adds things up."))]); + summarizer(&endpoint, 0).run(&file, &mut sides).unwrap(); + let sides = trees(&sides); + server.join().unwrap(); + assert_eq!(body_label(&sides), "return a + b + c"); +} + +/// The after side's docstring shares the summarized body's fold state +/// and starts collapsed with an empty label. +fn assert_linked(sides: &tree::Pairing<tree::Source>, body: u32) { + let rhs = rhs(sides); + let mut state = None; + walk(&rhs.regions, &mut |region| { + if region.id == body { + state = Some(region.fold_state_id); + } + }); + let mut docstrings = Vec::new(); + walk(&rhs.regions, &mut |region| { + if has_tag(region, "summarize:docstring") { + docstrings.push(( + region.fold_state_id, + region.visibility.collapsed, + region.visibility.label.clone(), + )); + } + }); + assert_eq!(docstrings, [(state.unwrap(), true, String::new())]); +} + +#[test] +fn newness_is_the_lines_inside_the_body() { + // The docstring is unchanged and the signature line still pairs. A fold + // covers its body alone, so that line sits outside it: what decides is + // whether a line of the body itself pairs. + let head = "fn keep() {}\n\n/// Sums three numbers.\n/// Used by tests.\nfn total(a: u32, b: u32, c: u32) -> u32 "; + let one_liner = format!("{head}{{ a }}\n"); + let grown = format!("{head}{{\n let x = a;\n x\n}}\n"); + let after = + format!("{head}{{\n let x = a;\n let y = b;\n let z = c;\n x + y + z\n}}\n"); + let (_, sides) = project("a.rs", &one_liner, &after); + let states = |source: &tree::Source| { + let mut states = Vec::new(); + walk(&source.regions, &mut |region| { + if is_fold(region) && region.range.start.line == 2 { + states.push(region.fold_state_id); + } + }); + states + }; + let projected = trees(&sides); + let tree::Pairing::Both { lhs, rhs: replaced } = &projected else { + panic!("both sides"); + }; + assert_eq!( + states(lhs), + states(replaced), + "the docstring is matched across sides" + ); + // The one-liner it replaced had no body fold to match, and no line of + // the new body pairs: the body is new. + let mut body = None; + walk(&replaced.regions, &mut |region| { + if is_fold(region) && has_tag(region, FUNCTION) { + body = Some(region.fold_state_id); + } + }); + let mut lhs_states = Vec::new(); + walk(&lhs.regions, &mut |region| { + lhs_states.push(region.fold_state_id) + }); + assert!(!lhs_states.contains(&body.expect("a function body on the after side"))); + assert_eq!(select(&projected, 3).len(), 1); + // The same body grown from one that already had lines: `let x = a;` + // still pairs, so this is a rewrite rather than a new body. + let (_, sides) = project("a.rs", &grown, &after); + assert!(select(&trees(&sides), 3).is_empty()); +} + +#[test] +fn the_system_prompt_is_the_configured_one() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&trees(&sides), 3)[0].0; + let request = |overrides: serde_json::Value, sides: &mut Pairing<protocol::Source>| { + let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "call a, b, c")]))]); + let mut overrides = overrides; + overrides["api_key"] = json!("test-key"); + overrides["endpoint"] = json!(endpoint); + overrides["min_lines"] = json!(3); + summarizer_with(overrides).run(&file, sides).unwrap(); + let bodies = server.join().unwrap(); + let body: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap(); + ( + body["systemInstruction"]["parts"][0]["text"].clone(), + body["contents"][0]["parts"][0]["text"].clone(), + ) + }; + let (system, user) = request(json!({"system_prompt": "Answer in haiku."}), &mut sides); + assert_eq!(system, "Answer in haiku."); + let user = user.as_str().unwrap(); + assert!(user.starts_with("File a.py:\n"), "{user}"); + assert!(user.contains(&format!("fold {id}: lines 2-4")), "{user}"); + + let (_, mut sides) = project("a.py", "", LARGE); + let (system, _) = request(json!({}), &mut sides); + let default: Options = serde_json::from_value(serde_json::Value::Object( + builtin::manifest("summarize").unwrap().defaults(), + )) + .unwrap(); + assert_eq!(system, default.system_prompt.as_str()); + assert!(default.system_prompt.starts_with( + "For each listed fold, rewrite that function body as short python-flavored pseudocode. Keep the names." + )); + assert!(!default.system_prompt.contains('\n')); +} + +#[test] +fn transient_failures_are_retried_then_succeed() { + let (file, mut sides) = project("a.py", "", LARGE); + let id = select(&trees(&sides), 3)[0].0; + let (endpoint, server) = serve(vec![ + (503, "{}".to_owned()), + (429, "{}".to_owned()), + (200, gemini_answer(&[(id, "retry ok")])), + ]); + summarizer(&endpoint, 3).run(&file, &mut sides).unwrap(); + let sides = trees(&sides); + assert_eq!(server.join().unwrap().len(), 3); + let label = fold_label(&sides); + assert!(label.ends_with("retry ok"), "{label}"); +} + +#[test] +fn hard_failures_and_exhausted_retries_are_run_failures() { + let (file, sides) = project("a.py", "", LARGE); + let (endpoint, server) = serve(vec![(400, "{\"error\": \"bad key\"}".to_owned())]); + let error = summarizer(&endpoint, 3) + .run(&file, &mut sides.clone()) + .unwrap_err(); + server.join().unwrap(); + assert!(error.downcast_ref::<MutationFailed>().is_some()); + assert!(format!("{error:#}").starts_with("mutation summarize: summarizer: ")); + assert!(format!("{error:#}").contains("HTTP 400"), "{error:#}"); + let (endpoint, server) = serve(vec![(500, "{}".to_owned()), (500, "{}".to_owned())]); + let error = summarizer(&endpoint, 1) + .run(&file, &mut sides.clone()) + .unwrap_err(); + server.join().unwrap(); + assert!( + format!("{error:#}").contains("after 2 attempts"), + "{error:#}" + ); +} + +#[test] +fn small_files_never_call_the_model() { + let (file, sides) = project("a.py", "", "def h():\n e()\n"); + let pipeline = summarizer_with(json!({ + "api_key": "k", + "endpoint": "http://127.0.0.1:1", + "min_lines": 3, + })); + let moves = moves(&pipeline, &file, &sides).unwrap(); + assert!(moves.is_empty()); +} diff --git a/src/protocol/project.rs b/src/protocol/project.rs index bbc45d2ad..beac6d046 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -1068,7 +1068,11 @@ mod tests { .all(|leaf| !lhs_leaves.contains_key(&alignment(leaf)))); assert_eq!( new_fold.tags, - vec!["deleted-bodies:function", "removed-runs:function"] + vec![ + "deleted-bodies:function", + "removed-runs:function", + "summarize:function" + ] ); // A label is a plugin's to give; the projection leaves it empty. assert_eq!(new_fold.visibility.label, ""); diff --git a/tests/cli.rs b/tests/cli.rs index f722c4917..139d2ed50 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -292,3 +292,34 @@ fn git_unmerged_files() { let predicate_fn = predicate::str::contains("Unmerged path"); cmd.assert().stdout(predicate_fn); } + +#[test] +fn a_plugin_that_cannot_be_made_stops_diffr_before_any_record() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("diffr"); + std::fs::create_dir_all(&config).unwrap(); + std::fs::write( + config.join("config.toml"), + "[plugins.summarize]\nenabled = true\n", + ) + .unwrap(); + let mut cmd = Command::new(assert_cmd::cargo_bin!("diffr")); + + cmd.args([ + "--no-index", + "sample_files/simple_1.js", + "sample_files/simple_2.js", + "--format", + "ndjson", + ]) + .env("XDG_CONFIG_HOME", dir.path()) + .env_remove("GEMINI_API_KEY") + .env_remove("GOOGLE_API_KEY"); + cmd.assert() + .failure() + .code(2) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "plugins.summarize: no API key: set plugins.summarize.api_key", + )); +} diff --git a/tests/view.rs b/tests/view.rs index 20642df4a..d2b9e72e3 100644 --- a/tests/view.rs +++ b/tests/view.rs @@ -3,6 +3,7 @@ use git2::{IndexAddOption, Repository, Signature, Time}; use serde_json::Value; use std::fs; +use std::path::PathBuf; use std::process::{Command, Output}; use tempfile::TempDir; @@ -25,6 +26,18 @@ impl Fixture { fs::write(path, text).unwrap(); } + fn remove(&self, path: &str) { + fs::remove_file(self.dir.path().join("repo").join(path)).unwrap(); + } + + /// A file next to the global configuration file. + fn config_file(&self, path: &str, text: &str) -> PathBuf { + let path = self.dir.path().join("config/diffr").join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, text).unwrap(); + path + } + fn commit(&self) -> String { let mut index = self.repo.index().unwrap(); index.add_all(["*"], IndexAddOption::DEFAULT, None).unwrap(); @@ -162,3 +175,26 @@ fn the_default_view_hides_links_and_collapses() { assert_eq!(docstring["fold_state_id"], body["fold_state_id"]); assert_eq!(docstring["visibility"]["collapsed"], true); } + +#[test] +fn a_failing_plugin_aborts_the_run() { + let fixture = Fixture::new(); + fixture.config_file( + "config.toml", + "[plugins.summarize]\nenabled = true\napi_key = 'k'\nendpoint = 'http://127.0.0.1:1'\nretries = 0\nmin_lines = 1\n", + ); + fixture.write("keep.txt", "keep\n"); + let base = fixture.commit(); + fixture.write("new.py", "def f():\n a()\n b()\n"); + fixture.remove("keep.txt"); + let head = fixture.commit(); + let output = fixture.run(&base, &head); + assert_eq!(output.status.code(), Some(2)); + let records = records(&output); + let aborted = &records.last().unwrap()["aborted"]; + assert_eq!(aborted["code"], "mutation_failed", "{aborted}"); + assert!(aborted["message"] + .as_str() + .unwrap() + .starts_with("mutation summarize: summarizer: ")); +} From 048b87fa3cd8eddc5c667bd20ed7fa9c7593cc8a Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 08/20] Run WASM component plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin folder that holds `plugin.wasm` runs as a WASM component. The wasmtime runner (`src/plugin/wasm.rs`) compiles the component once, links the host functions, instantiates it once per plugin, keeps the resource handle and serialises calls through a mutex around its store. Nothing else changes: a component implements the same `Plugin` trait through the SDK's `export!`, is made from the same options string, and is called with the same records as the native code, so `Pipeline` treats the two the same. An entry may name a folder on disk with `path`, relative to the configuration file. The folder is shaped like a bundled plugin's — `plugin.toml`, its query files and `plugin.wasm` — and its options are checked against the schema its own `plugin.toml` declares, which `diffr config set` reads too. A bundled plugin's entry may set `path`, and that folder then runs in its place. `scripts/build-wasm-plugins.sh` builds each bundled plugin's crate as a component beside its `plugin.toml`, from the same source the native registry compiles in. `examples/plugins/fixtures` is a plugin that exercises every point of the contract, and `tests/wasm.rs` runs the bundled plugins as components and checks they shape files exactly as they do natively. docs/plugins.md describes the contract and how diffr loads a plugin. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 --- .gitignore | 6 +- Cargo.lock | 1212 ++++++++++++++++++++++++- Cargo.toml | 6 + README.md | 3 +- docs/config.md | 139 +-- docs/plugins.md | 298 ++++++ docs/streaming.md | 77 +- examples/plugins/fixtures/Cargo.toml | 12 + examples/plugins/fixtures/plugin.toml | 9 + examples/plugins/fixtures/src/lib.rs | 107 +++ scripts/build-wasm-plugins.sh | 25 + src/plugin/config.rs | 12 +- src/plugin/mod.rs | 27 +- src/plugin/wasm.rs | 294 ++++++ src/tags/mod.rs | 3 +- tests/wasm.rs | 392 ++++++++ 16 files changed, 2493 insertions(+), 129 deletions(-) create mode 100644 docs/plugins.md create mode 100644 examples/plugins/fixtures/Cargo.toml create mode 100644 examples/plugins/fixtures/plugin.toml create mode 100644 examples/plugins/fixtures/src/lib.rs create mode 100755 scripts/build-wasm-plugins.sh create mode 100644 src/plugin/wasm.rs create mode 100644 tests/wasm.rs diff --git a/.gitignore b/.gitignore index 9cf547af8..3773f1c39 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,11 @@ perf.data* flamegraph.svg .idea -sample_files/compare.result - notes.md perf_baseline_*.txt __pycache__/ .env + +# WASM plugin builds: scripts/build-wasm-plugins.sh +plugin.wasm +/crates/*/target diff --git a/Cargo.lock b/Cargo.lock index ed5a23c71..d15d3169d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + [[package]] name = "ahash" version = "0.8.12" @@ -27,9 +36,24 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.16" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] [[package]] name = "anstream" @@ -86,6 +110,21 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object 0.39.1", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "assert_cmd" version = "2.0.17" @@ -102,6 +141,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -170,6 +220,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -192,6 +251,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecount" @@ -205,6 +267,84 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cap-fs-ext" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f0d0003a760918ed4b1e039a59e11769030416f79c8222551d22785f7f70d" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150941cefd3df4de2fea24604ba4949371576f62e527410298333f7d431a1bc6" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.4", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec6a5b75f54547c579a6b117c6fdd5f04f4ab7598de747b9f440a53592b3a4a" +dependencies = [ + "ambient-authority", + "rand 0.8.8", +] + +[[package]] +name = "cap-std" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54c289326c70f1c697ebf0a31842a480932e5942b5fac92fcc46e87286b48e2" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + [[package]] name = "cc" version = "1.2.54" @@ -236,8 +376,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -277,6 +417,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -309,6 +458,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -318,6 +476,152 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce81edaca6167d1f78da026afa92d7ff957a80aa82a79076e11cd34cde20165" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d0d51e12f958551165969c6e8767e1e461729f6c1ccae923b0ba1d5cbcbbbf8" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41294c755094d2c8a514cea903039742474423f2e91601332eab5f4094f76333" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebb6f5d0df5bd0d02c63ec48e8f2e38a176b123f59e084f22caf89a0d0593e7e" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e543cdb278b7c15f739021cf880ee1808c68fa2402febb87edb9307f552c8fec" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.15.5", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-math", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f979c75cfd712dbc754799dfe4a4d0db7a51defc2e36d006b27a8a63e018eece" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f36e74ba4033490587a47952f74390cb7d4f1fc1fa28ace50564e491f1e38f" + +[[package]] +name = "cranelift-control" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6671962c7d65b9a7ad038cd92da6784744d8a9ecf8ded8bb9a1f7046dbe2ccf" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee832f8329fa87c5df6c1d64a8506a58031e6f8a190d9b21b1900272a4dbb47d" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f7bc17aa3277214eab4b63a03544b1b46962154012b751c9f14c2a5419c6471" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff02dcecae2e7e9c61b713f1fb46eabecdca9f55b49f99859ceb1a3e7f4a9cb" + +[[package]] +name = "cranelift-native" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f76fd681f35bdf17be9c3e516b9acc0c7bd61b81faf95496decd8e0000979c" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.121.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3d9071bc5ee5573e723d9d84a45b7025a29e8f2c5ad81b3b9d0293129541d9" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.5" @@ -343,6 +647,16 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -547,6 +861,28 @@ dependencies = [ "typed-arena", "unicode-width", "version_check", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", ] [[package]] @@ -570,6 +906,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -614,6 +961,18 @@ dependencies = [ "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -652,6 +1011,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fancy-regex" version = "0.19.2" @@ -669,6 +1034,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -681,6 +1057,14 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "fixtures-plugin" +version = "0.1.0" +dependencies = [ + "diffr-plugin-sdk", + "serde", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -738,12 +1122,37 @@ dependencies = [ "num-bigint", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -751,6 +1160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -759,6 +1169,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + [[package]] name = "futures-sink" version = "0.3.34" @@ -778,11 +1194,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-sink", "futures-task", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -820,12 +1247,23 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] [[package]] -name = "git2" +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "git2" version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" @@ -882,6 +1320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -1020,6 +1459,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1166,6 +1629,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "ipnet" version = "2.12.2" @@ -1189,6 +1668,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.10" @@ -1419,6 +1907,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "macro-string" version = "0.2.0" @@ -1430,12 +1927,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "micromap" version = "0.3.0" @@ -1546,6 +2058,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", + "memchr", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1628,6 +2161,18 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1637,6 +2182,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.3" @@ -1706,6 +2260,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulley-interpreter" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be14280b69a9cbb6ada02a7aa5f7b3f1b72d1043b5bc9336990b700525dea6e3" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-math", +] + +[[package]] +name = "pulley-macros" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076f1be746801280af4c96c4407b5fd1d09cfa53ab27ba0ac7dd8f207e7bbf83" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "quinn" version = "0.11.12" @@ -1736,7 +2323,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -1790,6 +2377,17 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ffec9df464013295b499298811e6a3de31bf8128092135826517db12dee601" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -1798,7 +2396,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1813,7 +2430,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1893,6 +2510,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "regalloc2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.2" @@ -2015,6 +2646,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.45" @@ -2179,6 +2820,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2268,6 +2913,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2301,6 +2957,9 @@ name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -2399,6 +3058,28 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.34", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -2681,9 +3362,21 @@ 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" @@ -2693,6 +3386,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trait-variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tree-sitter" version = "0.26.10" @@ -3316,6 +4020,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -3334,6 +4044,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3496,6 +4212,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.233.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9679ae3cf7cfa2ca3a327f7fab97f27f3294d402fd1a76ca8ab514e17973e4d3" +dependencies = [ + "leb128fmt", + "wasmparser 0.233.0", +] + [[package]] name = "wasm-encoder" version = "0.254.0" @@ -3503,7 +4229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.254.0", ] [[package]] @@ -3514,8 +4240,21 @@ checksum = "b01df5f3b4ca7881e843f3bc0fb8a3905d79c68692250dcb8e33e698705ccdb6" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.254.0", + "wasmparser 0.254.0", +] + +[[package]] +name = "wasmparser" +version = "0.233.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51cb03afce7964bbfce46602d6cb358726f36430b6ba084ac6020d8ce5bc102" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", ] [[package]] @@ -3530,6 +4269,294 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmprinter" +version = "0.233.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf8e5b732895c99b21aa615f1b73352e51bbe2b2cb6c87eae7f990d07c1ac18" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.233.0", +] + +[[package]] +name = "wasmtime" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec10e50038f22ab407fdd8708120b8feed3450a02618efcf26ca47e82122927d" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "hashbrown 0.15.5", + "indexmap", + "libc", + "log", + "mach2", + "memfd", + "object 0.36.7", + "once_cell", + "postcard", + "psm", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "trait-variant", + "wasmparser 0.233.0", + "wasmtime-asm-macros", + "wasmtime-cache", + "wasmtime-component-macro", + "wasmtime-component-util", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-icache-coherence", + "wasmtime-math", + "wasmtime-slab", + "wasmtime-versioned-export-macros", + "wasmtime-winch", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d379cda46d6fd18619e282a75fbb09b70b3d0f166b605f45b4059dfaf9dc6ce" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-cache" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f421723a7736c0767ceb422afef69b41526864bd0f026e0f49bb2bde7168f9a6" +dependencies = [ + "anyhow", + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2", + "toml", + "windows-sys 0.59.0", + "zstd", +] + +[[package]] +name = "wasmtime-component-macro" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b08be093e0a876da45f79070c2ada4656f2785eb77c01b86ce60be3153920a5" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser 0.233.0", +] + +[[package]] +name = "wasmtime-component-util" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0451ce0dd94a33d0dbd57934ce666a04c2753a5262ca2bc84cf6a67cf5303dc" + +[[package]] +name = "wasmtime-cranelift" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15aa836683d7398f13f2f26bbe74c404ceaba66b6bbb96700d6b7f91bec90e03" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object 0.36.7", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.20", + "wasmparser 0.233.0", + "wasmtime-environ", + "wasmtime-math", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317081a0cbbb1f749d348b262575608fc082d47ab11b6247bbe9163eeb955777" +dependencies = [ + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object 0.36.7", + "postcard", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.233.0", + "wasmparser 0.233.0", + "wasmprinter", + "wasmtime-component-util", +] + +[[package]] +name = "wasmtime-fiber" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6763b33eceefc443f6477d84dc8751df5f23d280d7e01f28339fa3ec4b00ff13" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-jit-icache-coherence" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea6b740d1a35f2cebfe88e013ac8a4a84ff8dabc3a392df920abf554e871cf2" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-math" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fa317691aedc64aae3a86b3d786e4b2b0007bc0b56e0b6098b8b5a85ab2134" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-slab" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a06819d24370273021054b50589e3078e7f5cfac15515e58b3fbbebf5e5b39" + +[[package]] +name = "wasmtime-versioned-export-macros" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca100ed168ffc9b37aefc07a5be440645eab612a2ff6e2ff884e8cc3740e666" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "wasmtime-wasi" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb691e92f81f339a215c6fb78f10fb9c03886a34995eba84f14d6aa846b72161" +dependencies = [ + "anyhow", + "async-trait", + "bitflags", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.20", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5703fe2988cf503431c11989bec85bda910077925c232aeaf8d52c408fec48c5" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "futures", + "wasmtime", +] + +[[package]] +name = "wasmtime-winch" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595f51430606a7b5578f34e0d7c73dca52a22ed24756f2ba9d4d0c1bde8631af" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object 0.36.7", + "target-lexicon", + "wasmparser 0.233.0", + "wasmtime-cranelift", + "wasmtime-environ", + "winch-codegen", +] + +[[package]] +name = "wasmtime-wit-bindgen" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233fdcb96f9097be697319ba647ef42bdbdb40e89f04c8ae3713103813b5b793" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "wit-parser 0.233.0", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -3559,6 +4586,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -3568,12 +4611,91 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winch-codegen" +version = "34.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdf007d7940f62127ce4f33a8aa92dadedfdc78c3860a057e06c8c24e26e180d" +dependencies = [ + "anyhow", + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.20", + "wasmparser 0.233.0", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-math", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3749,6 +4871,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -3773,7 +4905,7 @@ checksum = "48521bb96e56cbb9e031ad306c80dc20e89e69e49ada02781ee06f60fbcb545f" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.254.0", ] [[package]] @@ -3821,10 +4953,28 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.254.0", "wasm-metadata", - "wasmparser", - "wit-parser", + "wasmparser 0.254.0", + "wit-parser 0.254.0", +] + +[[package]] +name = "wit-parser" +version = "0.233.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f22f1cd55247a2e616870b619766e9522df36b7abafbb29bbeb34b7a9da7e9f0" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.233.0", ] [[package]] @@ -3843,7 +4993,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-ident", - "wasmparser", + "wasmparser 0.254.0", ] [[package]] @@ -3966,3 +5116,31 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index a88dbe812..b4f3b4a6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,6 +142,10 @@ dirs = "5" toml_edit = "0.25.15" serde_yaml_ng = "0.10.0" jsonschema = { version = "0.56.0", default-features = false } +# WASM component plugins. Wasmtime 34 is the newest release whose +# rust-version (1.85) matches ours. +wasmtime = { version = "34.0.2", default-features = false, features = ["runtime", "cranelift", "component-model", "cache", "parallel-compilation", "std"] } +wasmtime-wasi = { version = "34.0.2", default-features = false } # The plugin contract, and the bundled plugins: each a crate the native # registry calls, which also builds as a WASM component. diffr-plugin-sdk = { path = "crates/diffr-plugin-sdk" } @@ -180,6 +184,7 @@ members = [ "plugins/hide-files", "plugins/removed-runs", "plugins/summarize", + "examples/plugins/fixtures", "plugins/test-bodies", ] default-members = [ @@ -191,6 +196,7 @@ default-members = [ "plugins/hide-files", "plugins/removed-runs", "plugins/summarize", + "examples/plugins/fixtures", "plugins/test-bodies", ] diff --git a/README.md b/README.md index f2b4e466b..d9950f3e5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ diffr main HEAD --format ndjson ``` See the [CLI reference](docs/cli.md), [streaming API](docs/streaming.md) -including [configuration](docs/config.md) and [plugins](docs/streaming.md#plugins). +including [configuration](docs/config.md) and [plugins](docs/streaming.md#plugins), +and [writing plugins](docs/plugins.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/config.md b/docs/config.md index a00271f78..ce2aeca35 100644 --- a/docs/config.md +++ b/docs/config.md @@ -32,13 +32,15 @@ gives the key, and only that type: a string key takes the text as it is (`diffr config set theme.name 1234` writes `"1234"`), an integer key a TOML integer (`12`), a number key a TOML number (`1.5`), a boolean key `true` or `false`, an array key a TOML array (`'["a", "b"]'`), and an enum key one of -its choices. Anything else, an unknown key, or a value the configuration -rejects (such as a negative limit) is an error naming the key and what it -expected, for example `diff.graph_limit: expected an integer, got "abc"`; -diffr exits 2 and the file is not touched. Frontends drive their settings pages -through these three commands; the schema is the only contract. Each setting -in the schema carries a `title` and an `x-group` that settings screens show -in place of the dotted key; keys marked `"x-settings": false` are not settings. +its choices. A plugin option has the type its `plugin.toml` declares, a WASM +plugin's folder included, and an entry's `path` is a string. Anything else, an +unknown key, or a value the configuration rejects (such as a negative limit) +is an error naming the key and what it expected, for example +`diff.graph_limit: expected an integer, got "abc"`; diffr exits 2 and the file +is not touched. Frontends drive their settings pages through these three +commands; the schema is the only contract. Each setting in the schema carries +a `title` and an `x-group` that settings screens show in place of the dotted +key; keys marked `"x-settings": false` are not settings. ## Keys @@ -54,38 +56,6 @@ path = "/path/to/theme.toml" # or a Helix-style theme file [plugins] # see "Plugins" below order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "summarize", "group"] - -[plugins.hide-files] -enabled = true -tags = ["generated", "vendored", "test"] # the first listed tag a file carries names the reason -deleted = true # hide deleted files whatever their tags - -[plugins.deleted-bodies] # deleted function bodies, "12 lines removed" -enabled = true -min_lines = 12 - -[plugins.test-bodies] # test functions and test modules, on both sides -enabled = true -min_lines = 3 - -[plugins.removed-runs] # the middle of long removed stretches -enabled = true -min_lines = 5 - -[plugins.summarize] # pseudocode for large new function bodies -enabled = false # off by default: it needs an API key -provider = "gemini" -model = "gemini-3.8-flash" -min_lines = 20 -api_key = "…" # or GEMINI_API_KEY / GOOGLE_API_KEY -endpoint = "https://…" # optional base URL override -request_timeout_ms = 60000 -max_concurrency = 16 -retries = 3 -system_prompt = """…""" # the model's system instruction; defaults to diffr's own - -[plugins.group] # adjacent collapsed regions under one row -enabled = true ``` When a file exceeds a `[diff]` limit it falls back to a line diff: the file's @@ -155,13 +125,32 @@ plugin's options, checked against the options its `plugin.toml` declares: an unknown option or a value of the wrong type is an error naming the key, such as `plugins.deleted-bodies: min_lines: "many" is not of type "integer"`. +An entry that names no bundled plugin is an error unless it sets `path`: a +plugin folder, relative to the configuration file's directory (or absolute), +holding `plugin.toml`, its query files and `plugin.wasm`. The folder's +`plugin.toml` must be named after the entry, and the entry must be listed in +`order`. A bundled plugin's entry may set `path` too, and that folder runs in +its place. `path` is diffr's key like `enabled`, so no plugin option may be +named either. [plugins.md](plugins.md) describes plugins and how diffr loads +them. + Every plugin that is on is made (its `new` runs) when diffr starts, before any output, and one that cannot be made stops diffr with an error naming it. -The summarizer cannot be made without an API key, which is why it ships off: -turn it on with `enabled = true` once `api_key`, `GEMINI_API_KEY` or -`GOOGLE_API_KEY` holds a key. +The summarizer cannot be made without an API key, which is why it ships off: turn it on with +`enabled = true` once `api_key`, `GEMINI_API_KEY` or `GOOGLE_API_KEY` holds a +key. + +```toml +[plugins] +order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "summarize", "group", "fixtures"] + +[plugins.fixtures] +path = "plugins/fixtures" +``` -An entry that names no bundled plugin is an error. +`summarize`'s `system_prompt` is the system instruction sent with every +request. The per-file message (the file's numbered lines and the folds to +summarize) is built by diffr. `diffr config show` prints the default prompt. ### Plugin folders @@ -173,29 +162,30 @@ plugins/deleted-bodies/ plugin.toml # name, title, options, query files queries/rust.scm # one query file per language Cargo.toml, src/lib.rs # the plugin's code, a crate using the plugin SDK + plugin.wasm # the same code built as a WASM component (not committed) ``` diffr embeds a bundled plugin's `plugin.toml` and query files and compiles -its code in. +its code in. Any other plugin's folder holds `plugin.wasm`. `plugin.toml` is static: ```toml -name = "context" # the entry name, and the prefix of its tags -title = "Context" # the group settings screens list its settings under -description = "Collapse unchanged stretches far from any change, …" +name = "deleted-bodies" # the entry name, and the prefix of its tags +title = "Deleted function bodies" # the group settings screens list its settings under +description = "Collapse function bodies that were deleted: …" [enabled] # how settings screens show the switch -title = "Collapse unchanged lines" +title = "Collapse deleted function bodies" description = "…" default = true # whether the plugin is on unless an entry says otherwise -[options.lines] # each option is a JSON Schema, written in TOML +[options.min_lines] # each option is a JSON Schema, written in TOML type = "integer" minimum = 0 -title = "Context lines" -description = "Unchanged lines kept visible on either side of a change. …" -default = 3 +title = "Shortest body to collapse (lines)" +description = "Deleted bodies shorter than this stay open." +default = 12 [queries] # per language key, relative to the folder rust = "queries/rust.scm" @@ -211,13 +201,13 @@ settings schema in the order `plugin.toml` declares them. Tree-sitter queries decide which folds exist and what they are, as tags; plugins decide how they are shown. Each plugin that reads syntax (`context`, `deleted-bodies`, `test-bodies`, `removed-runs`, -`summarize`) -lists one query file per language +`summarize`) lists one query file per language key (`rust`, `python`, `go`, `javascript`, `javascriptjsx`, `typescript`, `typescripttsx`, or any other lowercase language name) under `[queries]` in its `plugin.toml`. Queries are not configured in `[plugins]`. -- A relative path is inside the plugin's folder. +- A relative path is inside the plugin's folder (a folder on disk, or a + bundled plugin's embedded one). - `builtin:<plugin>/<path>` names a file of a bundled plugin, embedded in diffr from `plugins/<plugin>/<path>`. - An absolute path is a file. @@ -227,15 +217,29 @@ plugin, in `order`, into one query and runs it during the diff. A file whose first line is `; inherits: shared.scm` (several paths separated by commas; relative to the importing file, or `builtin:` paths) includes those files first. Each file is included at most once per language, however many plugins -import it, and an import cycle is an error. No bundled plugin owns fold -queries yet: each language's query starts with the bundled fold rules in -`src/config/defaults.toml`, whose tags carry no plugin prefix. +import it, and an import cycle is an error. The structure every bundled +plugin shares (blocks, collections, imports, strings) lives in +`plugins/shared/queries/<language>.scm`, which is not a plugin: the bundled +query files import it as `builtin:shared/queries/<language>.scm`. + +Docstrings live beside it, in `plugins/shared/queries/<language>-docstrings.scm`: +comments directly above a function (Rust `///` and `//` runs and `/** */` +doc comments, Go comment groups, JavaScript and TypeScript comments and JSDoc) +and Python's leading string. The plugins that collapse function bodies +(`deleted-bodies`, `test-bodies`, `summarize`) import it, and each links a +body it collapses to its docstring, so the two open and close together. +Because a file is included once however many plugins import it, each +docstring pattern sets one tag per importer (`deleted-bodies:docstring`, +`test-bodies:docstring`, `summarize:docstring`), and a docstring carries all +three whenever any of those plugins is enabled. A docstring is tagged only +when its function's body spans lines: a comment above a one-line function or +a plain declaration still folds, untagged. `src/config/README.md` describes the capture conventions (`@fold`, `@fold.open`, `@fold.close` and `#set! tag`). Every `@fold` capture in one match forms one fold, so a quantified run such as `(comment)+ @fold` folds as one region spanning the run. Tags name a plugin and are written in the query: -`(#set! tag "<plugin>:function")`. A tag must have the form +`(#set! tag "deleted-bodies:function")`. A tag must have the form `<plugin>:<name>` with a plugin from `plugins.order`, or the configuration does not compile; a plugin reads only the tags its own queries set. A pattern may set no tag at all, to make a fold exist without meaning anything @@ -245,8 +249,9 @@ Patterns from different files that capture the same syntax node merge into one fold with the union of their tags when they agree on its range. When they capture it with different ranges, that file is not diffed: its record carries an error with code `query_conflict` naming both files, for example -`src/lib.rs:42: /path/to/a.scm and /path/to/b.scm capture the same -function_item with different fold ranges`. Other files proceed. +`src/lib.rs:42: builtin:deleted-bodies/queries/rust.scm and +builtin:summarize/queries/rust.scm capture the same function_item with +different fold ranges`. Other files proceed. A query file that does not compile (a syntax error, an unknown node, an unsupported capture or directive, a malformed tag) is an error naming the @@ -256,10 +261,14 @@ file, and diffr exits 2 before the stream starts. `diffr config schema` builds the `plugins` part of the schema from each plugin's `plugin.toml`. Every scalar setting under `plugins` has a `title` -and an `x-group`: the group is the plugin's `title` (`Context`), and each -`enabled` is titled after what that plugin does. `plugins.order` and every -list option are marked `"x-settings": false`: the schema describes them, a settings screen does not edit them, and -they are set in the file or with `diffr config set`. +and an `x-group`: the group is the plugin's `title` (`Context`, `Hidden files`, +`Deleted function bodies`, `Test bodies`, `Removed stretches`, +`Summaries`, `Groups`), and each `enabled` is titled after what that plugin +does. `plugins.order`, every list option such as `hide-files.tags`, and +`summarize.system_prompt` (a multi-line value) are marked `"x-settings": +false`: the schema describes them, a settings screen does not edit them, and +they are set in the file or with `diffr config set`. `diffr config show` redacts `plugins.summarize.api_key` unless +`--reveal` is given. ## File tags diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..3d719f325 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,298 @@ +# Plugins + +A plugin shapes how diffr shows a changed file. Every plugin implements the +same contract, [`wit/plugin.wit`](../wit/plugin.wit), and diffr loads and +runs every plugin the same way: the bundled ones (`plugins/<name>/`, compiled +into diffr) and WASM components in a folder the configuration points at. All +are configured the same way, in `[plugins]` ([config.md](config.md#plugins)), +and produce the same wire records ([streaming.md](streaming.md#plugins)). + +This is a research prototype: a WASM plugin runs with full access to the +machine (see [Access](#access)). Run only plugins you trust. + +## The plugin points + +A plugin is made once, then a file passes three points, each in +`plugins.order`: + +0. **New.** When diffr starts, before any output, it makes each enabled + plugin's one instance for the run: it deserializes the plugin's options + into the plugin's own options type and calls `new` with them. A plugin + that cannot be made (options that do not deserialize, a summarizer without + an API key, a component that does not compile or link) is a setup error: + diffr writes it to stderr, naming the plugin as `plugins.<name>`, and exits + 2 before any record, for native and component plugins alike. +1. **Pre-process: `classify`.** Before the stream starts, each plugin's + `classify` returns tags to add to the file's manifest entry. They join the + tags from the bundled rules and git attributes + ([config.md](config.md#file-tags)), so they decide how the file is diffed + (a `generated` file gets a line diff), how `--order` ranks it, and what + later plugins see: each plugin sees the tags the ones before it added. An + error here, or a tag that is not lowercase letters, digits, `-` and `_`, + is a setup error. +2. **During: queries.** The tree-sitter query files a plugin lists in its + `plugin.toml` join the per-language fold query the diff runs with + ([config.md](config.md#queries)). They decide which folds exist and tag + them `<plugin>:<name>`. They are data, not code, and tags are the only + syntax a plugin sees: the `context` plugin reads the scopes its queries + tag `context:scope`. +3. **Post-process: `mutate`.** After the file is diffed and its region trees + are built, each plugin's `mutate` reads the file and both sides and + returns moves, which diffr carries out before the next plugin runs. An + error, a trap, or a move that cannot be carried out aborts the run with + `mutation_failed` naming the plugin. + +## A plugin folder + +```text +examples/plugins/fixtures/ + plugin.toml # name, title, options schema, query files + plugin.wasm # the component + queries/rust.scm # optional: one query file per language +``` + +`plugin.toml` is the same file the bundled plugins carry +([config.md](config.md#plugin-folders)): `name` (the entry name in +`[plugins]`, and the prefix of every tag its queries set), `title`, +`description`, `[enabled]` (with `default`, whether the plugin is on unless +its entry says otherwise), `[options.<key>]` (each a JSON Schema with a +`title`), and `[queries]`. Query paths are relative to the folder, absolute, +or `builtin:<plugin>/<path>` for a bundled file such as +`builtin:shared/queries/rust.scm`. + +A plugin from disk is an entry with `path`, relative to the configuration +file's directory (or absolute), and must be listed in `order`: + +```toml +[plugins] +order = ["context", "hide-files", "deleted-bodies", "test-bodies", "removed-runs", "summarize", "group", "fixtures"] + +[plugins.fixtures] +path = "plugins/fixtures" # holds plugin.toml naming "fixtures" +fail = false # an option from its plugin.toml +``` + +`enabled` and `path` are diffr's keys; every other key is an option, +validated against the folder's `plugin.toml` and filled with its defaults, +and passed to the plugin as one JSON object. The folder's `plugin.toml` must +be named after the entry. + +### Loading + +diffr reads every entry's folder the same way: a bundled plugin's embedded +folder, or the folder `path` names. Then: + +- A folder holding `plugin.wasm` runs that component through wasmtime. +- Any other folder runs the native code diffr registers under the plugin's + name: the bundled plugins, compiled in from the same crates that build + their components. A folder with neither is a setup error. + +So a bundled plugin's entry can set `path` to a folder holding a component, +which then runs in the native plugin's place with the folder's queries. + +## The contract + +[`wit/plugin.wit`](../wit/plugin.wit) is the contract: the package +`diffr:plugin@0.1.0`, world `plugin`. A plugin exports the interface `guest`, +which holds one resource, the plugin itself (the component model has no +optional exports; a plugin that does not classify returns an empty list): + +```wit +resource plugin { + new: static func(options: string) -> result<plugin, string>; + classify: func(file: file-entry) -> result<list<string>, string>; + mutate: func(file: file-entry, lhs: option<source>, rhs: option<source>) -> result<list<move>, string>; +} +``` + +`new` is a static function rather than a constructor, because a constructor +cannot fail. + +- `options`: the plugin's entry as a JSON object, defaults filled in and + already validated against `plugin.toml`. Only `new` gets it; what the + plugin keeps from it lasts the run. +- `file-entry`: `path` (the after path, or the before path of a deleted + file), `old-path` (the before path, when there is one), `status` and `tags`. +- `source`: one side's `text` and its `regions`, the tree flattened in + preorder: each `region` carries its `parent` (a fold's `id`, or `0` for a + top-level region), `id`, `fold-state-id`, `range`, `tags`, `visibility`, and + `kind`: `leaf(alignment-id, changed)` or `fold`. The fields mean what they + mean on the wire ([streaming.md](streaming.md#regions)). A binary file's + sides have empty text and no regions. +- `move`: `cut({region, at})`, `join-folds(ids)`, `link-fold-state(ids)`, + `set-collapsed((id, collapsed))`, `set-label((id, label))` and + `set-tags((id, tags))`, with the semantics of the moves in + [streaming.md](streaming.md#plugins). `0` names the file where a move + allows it. + +diffr builds these records once per call, from the file's manifest entry and +its trees as the plugins before left them, and hands the same records to a +native plugin and to a component. Nothing else reaches a plugin. + +diffr makes one instance of each plugin per run and calls it for every file. +A native plugin's instance is called from several files' workers in parallel, +so it must be `Send + Sync`; interior mutability is the plugin's own +business. A component's instance lives in one wasmtime store, which runs one +call at a time: diffr calls a component plugin one file at a time. + +### Fresh ids + +A plugin often needs to name what its own moves create: the piece a cut +leaves, the fold a join adds. Ids are handed out deterministically, so a +plugin can predict them: + +- When a plugin's moves begin, the next fresh `id` is one above the largest + region `id` in the file (both sides), and the next fresh `alignment_id` one + above the largest leaf `alignment_id`. +- A **cut** takes one fresh `alignment_id`, which the second pieces share, + then one fresh `id` for the second piece on each side that holds the leaf + or the leaf paired with it, lhs first. Both second pieces take the lhs + piece's `id` (or the only piece's) as their `fold_state_id`. The first + piece keeps the leaf's ids. +- A **join** takes one fresh `id` for the new fold on each side that holds + the listed regions, lhs first; both share the lhs fold's `id` as their + `fold_state_id`. +- No other move takes an id. + +So cutting a paired leaf when the largest id is `n` names the lhs piece +`n + 1` and the rhs piece `n + 2`. diffr carries out every plugin's moves +with the SDK's applier (`crates/diffr-plugin-sdk/src/apply.rs`), the same +code a plugin predicts ids with. + +## Host imports + +Besides WASI, which only a component gets, diffr gives every plugin +`diffr:plugin/host`: + +- `read-head(side, max-bytes) -> option<list<u8>>`: the first bytes of one + side of the file. During `classify` it reads the blob (or the working-tree + file); it is `none` for a side the file does not have, or one that is not a + regular file. During `mutate` it reads the side's text as diffed. During + `new` it is `none`. +- `git(args) -> result<string, string>`: runs `git` with these arguments in + the repository's working directory (the current directory for + `--no-index`), returning stdout on success and stderr otherwise. +- `log(message)`: writes `diffr plugin <name>: <message>` to stderr. + +A host failure (git cannot be started, its output is not UTF-8, a blob cannot +be read) fails the call: it traps a component, and fails a native plugin's +call when it returns. + +### Access + +A component gets full access; diffr does not sandbox it: + +- WASI with the working directory preopened read-write as `.`. +- The environment inherited. +- The network open, with name lookup. +- Its stderr inherited; its stdout goes to diffr's stderr, because diffr's + stdout is the stream. + +## Writing a plugin in Rust + +`crates/diffr-plugin-sdk` is the contract in Rust, and every bundled plugin is +written with it: + +- `types`: the contract's records, as plain Rust. +- `Plugin`: the one trait, mirroring the `plugin` resource. A plugin is a + struct with an `Options` type (deserialized from the options JSON), and + implements `new` (make the plugin from its options; where it can fail), + `classify` and `mutate`. None has a default: a plugin that does not + classify returns `Ok(Vec::new())`. +- `export!(MyPlugin)`: built for `wasm32`, exports the plugin as the + component's `plugin` resource: its `new` deserializes the options string + into `Options` and calls `Plugin::new`, and its `classify` and `mutate` + call the instance, converting between the records and the generated guest + bindings. Built for anything else, it expands to nothing, and diffr's + native registry deserializes the options and calls the trait itself. The + same source builds both ways. +- `host::{read_head, git, log}`: the host functions, the same call natively + and in a component. +- `tree::sides(lhs, rhs)` rebuilds the records as region trees + (`Pairing<Source>`, each `Region` holding its children), and the helpers + the bundled plugins read trees with: `walk`, `OtherSide`, `one_sided`, + `docstring_of`, `before_and_after_ids`, and the rest. +- `Draft` carries moves out on a copy of the trees as the plugin makes them, + with diffr's applier, so `draft.cut_lines`, `draft.collapse`, `draft.link` + and `draft.group` can name what earlier moves created. + `apply::Fresh::of(&sides)` predicts ids without a draft. + +```toml +# Cargo.toml +[dependencies] +diffr-plugin-sdk = { path = "../../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } +``` + +```rust +use diffr_plugin_sdk::types::Source; +use diffr_plugin_sdk::{anyhow, export, FileEntry, Move, Plugin, ROOT}; +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options {} + +pub struct HideAll; + +impl Plugin for HideAll { + type Options = Options; + + fn new(_: Options) -> anyhow::Result<Self> { + Ok(Self) + } + + fn classify(&self, _: &FileEntry) -> anyhow::Result<Vec<String>> { + Ok(Vec::new()) + } + + fn mutate( + &self, + _: &FileEntry, + _: Option<&Source>, + _: Option<&Source>, + ) -> anyhow::Result<Vec<Move>> { + Ok(vec![Move::SetCollapsed { region: ROOT, collapsed: true }]) + } +} + +export!(HideAll); +``` + +Build it as a component with the pinned toolchain and the `wasm32-wasip2` +target, which emits a component directly: + +```sh +rustup target add wasm32-wasip2 +cargo rustc --release --target wasm32-wasip2 --crate-type cdylib +cp target/wasm32-wasip2/release/hide_all.wasm plugin.wasm +``` + +`scripts/build-wasm-plugins.sh` builds every plugin crate in this repository +that runs as a component into its folder's `plugin.wasm` (the outputs are not +committed). diffr compiles each component once per run, with wasmtime's +compilation cache on disk, so an unchanged component is not recompiled on the +next run. + +## Examples + +- `examples/plugins/fixtures`: `classify` tags a file `fixture` when it is + under a `fixtures/` directory or its first line is `// fixture` (read with + `read-head`); `mutate` hides a fixture behind the subject of the last + commit that touched it (from `git log -1 --format=%s -- <path>`), and + collapses all but the first line of its first leaf, naming the cut's piece + by predicting its id. Its `fail` option makes `mutate` return an error. +- `plugins/context`, `plugins/hide-files`, `plugins/deleted-bodies`, + `plugins/test-bodies`, `plugins/removed-runs` and `plugins/group`: the + bundled plugins, which build as components too. Pointing their entries at + the bundled folders once the script has built them runs the components in + place of the native code, and produces the same stream (`tests/wasm.rs` + checks it): + + ```toml + [plugins.deleted-bodies] + path = "/path/to/diffr/plugins/deleted-bodies" + ``` + + The summarizer's HTTP client does not build for `wasm32-wasip2`, so it runs + natively only. diff --git a/docs/streaming.md b/docs/streaming.md index 5d04216b2..fe9a1b9b7 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -14,6 +14,8 @@ 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`, the plugin host that shapes it is `src/plugin/`, and the bundled plugins are under `plugins/`. +WASM component plugins, and the three points where a plugin shapes a file, +are described in [plugins.md](plugins.md). ## Configuration and ordering @@ -109,17 +111,17 @@ One shape everywhere: `{"code": "<snake_case>", "message": "<prose>"}`. files), or `internal` for a failure diffr did not classify. - On `complete`, `aborted` reports a run-level failure in a plugin: `mutation_failed` when a plugin's `mutate` returns an error (the - summarizer's model call failing after its retries, say) or when a plugin - asks for a move that cannot be carried out. The message names the + summarizer's model call failing after its retries, say) or traps, or when a + plugin asks for a move that cannot be carried out. The message names the plugin. diffr stops pulling files, lets the ones in flight finish, and exits 2. Every `file` record already written stays valid; the file that failed has no record. - Setup failures (bad revision, unreadable config, a query file that does not compile, a malformed `diffr-tags` attribute, a plugin option that does not - match its schema, a plugin that cannot be made, such as a summarizer - without an API key, or a `classify` that fails or returns a malformed tag) - write to stderr + match its schema, a plugin that cannot be made, such as a WASM plugin that + does not compile or link or a summarizer without an API key, or a + `classify` that fails or returns a malformed tag) 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 @@ -157,8 +159,9 @@ the engine's own account of why, such as the size a file reached and the limit it exceeded. A fallback diff is aligned by a line diff and its `changed` spans are word-level, but the parse still stands: folds are present whenever the language parsed (`too_complex`, `parse_error`). A line diff has no matcher, so -its folds pair with nothing. Only `unsupported_language`, `too_large` and -`generated` produce leaves alone. +its folds pair with nothing; what is new inside them is still the leaves' +answer. Only `unsupported_language`, `too_large` and `generated` produce +leaves alone. ### Regions @@ -167,18 +170,24 @@ identities that never stand in for one another. `id` names the region: it is unique within the file, across both sides, and it is what plugins address. `fold_state_id` says what the region opens and closes with: regions sharing it open and close together, on the same side or across sides. Paired leaves and -matched folds share it across sides, and a plugin that links regions gives -several regions one +matched folds share it across sides, and a plugin that links regions (a +docstring with its function body, say) gives several regions one `fold_state_id` while each keeps its own `id`. Only a leaf has an `alignment_id`, and it is row alignment: the same value on the other side marks the leaf whose rows line up with this one, line for line. Consumers key the row zip by leaf `alignment_id`, collapse state by `fold_state_id`, and anything about the region itself by `id`. +Today the bundled link is a docstring: a plugin that collapses a function body +(`deleted-bodies`, `test-bodies`, `summarize`) links the body's docstring, a +fold tagged `deleted-bodies:docstring`, `test-bodies:docstring` and (when the +summarizer is on) `summarize:docstring`, to it. The docstring then carries the body's +`fold_state_id` and starts collapsed, with an empty label. A docstring whose +body no plugin collapses is not linked. ```jsonc {"id": 7, "fold_state_id": 7, "kind": "fold", "start": {"line": 18, "column": 0}, "end": {"line": 53, "column": 0}, - "tags": ["context:scope"], + "tags": ["deleted-bodies:function", "removed-runs:function"], "visibility": {"collapsed": false, "label": ""}, "children": [ {"id": 8, "fold_state_id": 8, "kind": "leaf", "alignment_id": 5, "start": {"line": 18, "column": 0}, "end": {"line": 30, "column": 0}}, @@ -223,12 +232,13 @@ spans a single line is not a region: it hides nothing. `tags` name what a region is. A leaf carries none. A fold carries the tags the fold queries set on it, each written `<plugin>:<name>` after the -plugin whose query set it (`context:scope`); a fold only some query made -exist without meaning carries none. Tags describe syntax, and are the only -syntax a plugin sees; how a region starts out is `visibility`, +plugin whose query set it (`deleted-bodies:function`, `test-bodies:test`, +`context:scope`); a fold only some query made exist without meaning +carries none. Tags describe syntax, and are the only syntax a plugin sees; +how a region starts out is `visibility`, and frontends need no knowledge of tags to render it. `visibility` is -`collapsed` and the `label` to show while collapsed: the count for a -collapsed stretch of unchanged lines, say. +`collapsed` and the `label` to show while collapsed: a line count, a +pseudocode summary, or the count for a collapsed stretch of unchanged lines. A label is the plugin's that collapsed the fold, so a fold no plugin collapsed has none, and the empty label shows the source. Absent means open. @@ -246,18 +256,6 @@ is at least three lines long collapses with a label such as `"142 unchanged lines"`; shorter stretches stay open because a row would save nothing. A file with no change collapses whole however short. - -**Groups** come from the `group` plugin: a run of two or more sibling -regions that start collapsed, whichever plugin collapsed them, is wrapped in -one new fold, with no tags. Between two collapsed regions of a run there may -be open leaves spanning at most two lines in all, such as a blank line and the -next function's header. The label counts the collapsed regions and the lines -the group spans: `"3 collapsed regions · 42 lines"`. It starts collapsed; -expanding it reveals each child's own row. The wrapped children are untouched. -When the other side pairs a region of the run, the run is grouped only if the -other side holds a run that matches it region for region, and then both are -grouped with one fold state. - Region edges cut a stretch. The part of it among one list of siblings collapses when it is at least three lines long or is the whole stretch, so a sliver at a fold edge stays open. A part inside one leaf is that leaf's lines, @@ -272,6 +270,17 @@ sharing a fold state; otherwise the regions collapse one by one. A stretch that crosses the end of a fold collapses on each side of that edge. +**Groups** come from the `group` plugin: a run of two or more sibling +regions that start collapsed, whichever plugin collapsed them, is wrapped in +one new fold, with no tags. Between two collapsed regions of a run there may +be open leaves spanning at most two lines in all, such as a blank line and the +next function's header. The label counts the collapsed regions and the lines +the group spans: `"3 collapsed regions · 42 lines"`. It starts collapsed; +expanding it reveals each child's own row. The wrapped children are untouched. +When the other side pairs a region of the run, the run is grouped only if the +other side holds a run that matches it region for region, and then both are +grouped with one fold state. + Ids are per file. The projection numbers the regions as it builds them, in lhs preorder then rhs preorder, from two counters: `id` starts at 1 and `alignment_id` at 0. Every region takes the next `id`, and every leaf takes @@ -307,7 +316,7 @@ Each file goes through one pipeline before its record is written: 1. Git lists the file and its tags ([config.md](config.md#file-tags)), and each enabled plugin's `classify` adds tags of its own, before the - `start` header is written. + `start` header is written ([plugins.md](plugins.md#the-plugin-points)). 2. The diff runs. Its fold query for the file's language is assembled from the query files of every enabled plugin ([config.md](config.md#queries)). 3. The projection builds the region trees above and pairs them. Nothing @@ -346,13 +355,7 @@ every region hangs from: the two new folds take their own `id`s and share one `fold_state_id`. - **Link fold state**: every region in the listed regions' fold states, on both sides, takes the first region's `fold_state_id` and whether it starts - collapsed, so they open and close together. Today the bundled link is a - docstring: a plugin that collapses a function body (`deleted-bodies`, - `test-bodies`, `summarize`) links the body's docstring, a fold tagged - `deleted-bodies:docstring`, `test-bodies:docstring` or (when the summarizer - is on) `summarize:docstring`, to it. The docstring - then carries the body's `fold_state_id` and starts collapsed, with an empty - label. A docstring whose body no plugin collapses is not linked. + collapsed, so they open and close together. - **Set collapsed** on a region: every region sharing its `fold_state_id` starts collapsed, or open. On the file, whether the file starts hidden: the record's `visibility`. @@ -383,9 +386,9 @@ The bundled plugins, in their default order, each a folder under `plugins/`: | `deleted-bodies` | A function body of at least 12 lines with nothing paired under it collapses: `"20 lines removed"`. Its docstring is linked to it. | | `test-bodies` | Test function bodies (`"test body"`) and Rust `#[cfg(test)]` modules (`"test module"`) collapse on both sides. A test body's docstring is linked to it. | | `removed-runs` | A one-sided leaf of at least 5 lines in removed (not rewritten) code keeps its first and last line open and collapses the rest: `"8 lines removed"`. | -| `summarize` | Off by default. A new function body of at least 20 lines collapses behind pseudocode from the configured model. The body is chosen as new before its docstring is linked to it; the pseudocode may quote the docstring. On without an API key, diffr stops before the stream starts. | +| `summarize` | Off by default. A new function body of at least 20 lines collapses behind plain pseudocode from the configured model. The body is chosen as new before its docstring is linked to it; the pseudocode may quote the docstring. On without an API key, diffr stops before the stream starts. | | `group` | Two or more adjacent collapsed regions are wrapped in one collapsed fold: `"3 collapsed regions · 42 lines"`. An open fold showing only collapsed regions and at most two open lines, such as a function's scope around its collapsed body and closing brace, counts as collapsed. | A binary diff has no regions, so only `hide-files` can change how it starts -out. Streaming is the only output mode that runs plugins. +out. diff --git a/examples/plugins/fixtures/Cargo.toml b/examples/plugins/fixtures/Cargo.toml new file mode 100644 index 000000000..d9ff70d20 --- /dev/null +++ b/examples/plugins/fixtures/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fixtures-plugin" +description = "An example diffr plugin: tag fixtures and hide them." +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +license = "MIT" +publish = false + +[dependencies] +diffr-plugin-sdk = { path = "../../../crates/diffr-plugin-sdk" } +serde = { version = "1.0", features = ["derive"] } diff --git a/examples/plugins/fixtures/plugin.toml b/examples/plugins/fixtures/plugin.toml new file mode 100644 index 000000000..1764c813a --- /dev/null +++ b/examples/plugins/fixtures/plugin.toml @@ -0,0 +1,9 @@ +name = "fixtures" +title = "Fixtures" +description = "Tag test fixtures (files under a fixtures/ directory, or starting with a `// fixture` line) and hide them behind the subject of the last commit that touched them." + +[options.fail] +type = "boolean" +title = "Fail on every file" +description = "Return an error from mutate, to see a plugin abort the run." +default = false diff --git a/examples/plugins/fixtures/src/lib.rs b/examples/plugins/fixtures/src/lib.rs new file mode 100644 index 000000000..ef9a4458d --- /dev/null +++ b/examples/plugins/fixtures/src/lib.rs @@ -0,0 +1,107 @@ +//! An example diffr plugin. `classify` tags a file `fixture` when it sits +//! under a `fixtures/` directory or its first line is `// fixture`, read with +//! the host's `read_head`. `mutate` hides a fixture behind the subject of the +//! last commit that touched it, asked of the host's `git`, and collapses all +//! but the first line of its first leaf, naming the piece its cut creates by +//! predicting the id. +use diffr_plugin_sdk::apply::Fresh; +use diffr_plugin_sdk::types::Source; +use diffr_plugin_sdk::{ + anyhow, export, host, line_count, tree, FileEntry, FileStatus, Move, Node, Pairing, Plugin, + Side, ROOT, +}; +use serde::Deserialize; + +const TAG: &str = "fixture"; +const MARKER: &[u8] = b"// fixture\n"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Options { + fail: bool, +} + +pub struct Fixtures { + options: Options, +} + +impl Plugin for Fixtures { + type Options = Options; + + fn new(options: Options) -> anyhow::Result<Self> { + Ok(Self { options }) + } + + fn classify(&self, file: &FileEntry) -> anyhow::Result<Vec<String>> { + if file.path.starts_with("fixtures/") || file.path.contains("/fixtures/") { + return Ok(vec![TAG.to_owned()]); + } + let side = match file.status { + FileStatus::Deleted => Side::Lhs, + _ => Side::Rhs, + }; + let head = host::read_head(side, MARKER.len() as u32); + Ok(match head.as_deref() == Some(MARKER) { + true => vec![TAG.to_owned()], + false => Vec::new(), + }) + } + + fn mutate( + &self, + file: &FileEntry, + lhs: Option<&Source>, + rhs: Option<&Source>, + ) -> anyhow::Result<Vec<Move>> { + anyhow::ensure!(!self.options.fail, "asked to fail on {}", file.path); + if !file.tags.iter().any(|tag| tag == TAG) { + return Ok(Vec::new()); + } + let args = ["log", "-1", "--format=%s", "--", &file.path].map(str::to_owned); + let subject = host::git(&args).map_err(|stderr| anyhow::anyhow!("git log: {stderr}"))?; + let subject = subject.trim(); + host::log(&format!("{}: last changed in {subject:?}", file.path)); + let mut moves = vec![ + Move::SetCollapsed { + region: ROOT, + collapsed: true, + }, + Move::SetLabel { + region: ROOT, + label: Some(format!("Fixture · {subject}")), + }, + ]; + // The first top-level leaf of two or more lines, on the before side + // when there is one. A cut hands out its piece ids lhs first, so the + // piece on this leaf's side takes the first fresh id either way. + let sides = tree::sides(lhs, rhs)?; + let side = match &sides { + Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => lhs, + Pairing::RightOnly { rhs } => rhs, + }; + let leaf = side + .regions + .iter() + .find(|region| matches!(region.node, Node::Leaf { .. }) && line_count(region) >= 2); + if let Some(leaf) = leaf { + let piece = Fresh::of(&sides).id(); + moves.extend([ + Move::Cut { + region: leaf.id, + at: 1, + }, + Move::SetCollapsed { + region: piece, + collapsed: true, + }, + Move::SetLabel { + region: piece, + label: Some(format!("{} fixture lines", line_count(leaf) - 1)), + }, + ]); + } + Ok(moves) + } +} + +export!(Fixtures); diff --git a/scripts/build-wasm-plugins.sh b/scripts/build-wasm-plugins.sh new file mode 100755 index 000000000..27a149b22 --- /dev/null +++ b/scripts/build-wasm-plugins.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Build plugin crates into their folders' plugin.wasm: the bundled plugins +# that also run as components (plugins/<name>, the same source diffr's native +# registry compiles in) and the examples (examples/plugins). Needs the +# wasm32-wasip2 target for the pinned toolchain: +# rustup target add wasm32-wasip2 +set -eu +root=$(cd "$(dirname "$0")/.." && pwd) +target="$root/target/wasm-plugins" + +# build <package> <plugin folder> <library name> +build() { + cargo rustc --quiet --release --target wasm32-wasip2 --crate-type cdylib \ + --manifest-path "$root/Cargo.toml" --package "$1" --target-dir "$target" + cp "$target/wasm32-wasip2/release/$3.wasm" "$root/$2/plugin.wasm" +} + +cd "$root" +build diffr-plugin-context plugins/context diffr_plugin_context +build diffr-plugin-hide-files plugins/hide-files diffr_plugin_hide_files +build diffr-plugin-deleted-bodies plugins/deleted-bodies diffr_plugin_deleted_bodies +build diffr-plugin-test-bodies plugins/test-bodies diffr_plugin_test_bodies +build diffr-plugin-removed-runs plugins/removed-runs diffr_plugin_removed_runs +build diffr-plugin-group plugins/group diffr_plugin_group +build fixtures-plugin examples/plugins/fixtures fixtures_plugin diff --git a/src/plugin/config.rs b/src/plugin/config.rs index 7d855682e..429421fcb 100644 --- a/src/plugin/config.rs +++ b/src/plugin/config.rs @@ -36,8 +36,9 @@ pub(crate) const PATH: &str = "path"; /// use them. pub(crate) const RESERVED: [&str; 2] = [ENABLED, PATH]; -/// A plugin folder's description. +/// A plugin folder's description, and its component when it has one. pub(crate) const MANIFEST_FILE: &str = "plugin.toml"; +pub(crate) const COMPONENT_FILE: &str = "plugin.wasm"; /// A plugin's `plugin.toml`. #[derive(Clone, Debug, Deserialize)] @@ -274,6 +275,15 @@ impl Folder { }) } + /// The folder's component, `plugin.wasm`, when it has one. An embedded + /// folder has none. + pub(crate) fn component(&self) -> Option<PathBuf> { + match &self.location { + Location::Bundled => None, + Location::Disk(dir) => Some(dir.join(COMPONENT_FILE)).filter(|path| path.is_file()), + } + } + /// The folder's query files per language key, each path resolved against /// the folder: `builtin:<name>/<path>` for an embedded folder, absolute /// for one on disk. A `builtin:` path stays as written. diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 869172678..54764586c 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod config; pub(crate) mod host; pub(crate) mod native; pub(crate) mod queries; +pub(crate) mod wasm; #[cfg(test)] mod tests; @@ -41,7 +42,7 @@ use crate::constants::Side; use crate::pairing::Pairing; use crate::protocol::{self, FileChange, FileStatus, SourcePos, SourceRange}; use anyhow::{anyhow, Context as _}; -use config::PluginsConfig; +use config::{PluginsConfig, COMPONENT_FILE}; use diffr_plugin_sdk::{apply, tree, types}; use host::Host; use serde_json::Value; @@ -126,11 +127,29 @@ impl Pipeline { plugins: Vec::new(), workdir: workdir.into(), }; + let mut engine = None; for (name, entry) in config.enabled() { + let folder = entry.folder(); let options = Value::Object(entry.options.clone()); - let create = native::lookup(name) - .ok_or_else(|| anyhow!("plugins.{name}: diffr has no plugin of that name"))?; - pipeline.push(name, options, &create)?; + match folder.component() { + Some(path) => { + let engine = match &engine { + Some(engine) => engine, + None => engine.insert(wasm::engine()?), + }; + pipeline.push(name, options, &|host, options| { + wasm::WasmPlugin::load(engine, &path)?.create(host, options) + })? + } + None => { + let create = native::lookup(name).ok_or_else(|| { + anyhow!( + "plugins.{name}: the plugin folder has no {COMPONENT_FILE}, and diffr has no native plugin of that name" + ) + })?; + pipeline.push(name, options, &create)? + } + } } Ok(pipeline) } diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs new file mode 100644 index 000000000..54ee0b8f5 --- /dev/null +++ b/src/plugin/wasm.rs @@ -0,0 +1,294 @@ +//! The WASM runner: a folder's `plugin.wasm`, a component of the `plugin` +//! world in `wit/plugin.wit`, run through wasmtime. +//! +//! Each component is compiled once, when the pipeline is built (with +//! wasmtime's disk cache, so an unchanged component is not recompiled on the +//! next run), linked against WASI and the `host` imports, and instantiated +//! once in its own [`Store`]. The plugin's `new` makes the one `plugin` +//! resource of the run in that instance, and every `classify` and `mutate` +//! calls that resource. A store runs one call at a time, so it sits behind a +//! mutex: the rayon workers call one component plugin one file at a time. +//! The records a call gets are the ones a native plugin gets, lowered field +//! for field into the generated bindings. +//! +//! A plugin runs with full access: WASI with the working directory preopened +//! read-write as `.`, the environment inherited, and the network open. Its +//! stdout goes to diffr's stderr, since diffr's stdout is the stream. +use super::host::Host; +use super::Runner; +use anyhow::Context as _; +use diffr_plugin_sdk::types as contract; +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::Instant; +use wasmtime::component::{Component, HasSelf, Linker, ResourceAny, ResourceTable}; +use wasmtime::{Cache, CacheConfig, Config, Engine, Store}; +use wasmtime_wasi::p2::{IoView, WasiCtx, WasiCtxBuilder, WasiView}; +use wasmtime_wasi::{DirPerms, FilePerms}; + +mod bindings { + wasmtime::component::bindgen!({ + path: "wit/plugin.wit", + world: "plugin", + trappable_imports: true, + }); +} + +use bindings::diffr::plugin::{host, types}; + +/// The engine every component of one pipeline compiles with. +pub(crate) fn engine() -> anyhow::Result<Engine> { + let mut config = Config::new(); + config.wasm_component_model(true); + config.cache(Some( + Cache::new(CacheConfig::new()).context("wasmtime's compilation cache")?, + )); + Engine::new(&config) +} + +/// What a plugin instance's store holds. `host` is the host of the call in +/// progress, set before each call. +struct State { + wasi: WasiCtx, + table: ResourceTable, + host: Host, +} + +impl IoView for State { + fn table(&mut self) -> &mut ResourceTable { + &mut self.table + } +} + +impl WasiView for State { + fn ctx(&mut self) -> &mut WasiCtx { + &mut self.wasi + } +} + +impl types::Host for State {} + +impl host::Host for State { + fn read_head( + &mut self, + side: types::Side, + max_bytes: u32, + ) -> wasmtime::Result<Option<Vec<u8>>> { + let side = match side { + types::Side::Lhs => contract::Side::Lhs, + types::Side::Rhs => contract::Side::Rhs, + }; + self.host.read_head(side, max_bytes) + } + + fn git(&mut self, args: Vec<String>) -> wasmtime::Result<Result<String, String>> { + self.host.git(&args) + } + + fn log(&mut self, message: String) -> wasmtime::Result<()> { + self.host.log(&message); + Ok(()) + } +} + +/// A compiled, linked component. +pub(crate) struct WasmPlugin { + engine: Engine, + pre: bindings::PluginPre<State>, +} + +impl WasmPlugin { + /// Compile and link the component at `path`. + pub(crate) fn load(engine: &Engine, path: &Path) -> anyhow::Result<Self> { + let started = Instant::now(); + let component = Component::from_file(engine, path) + .with_context(|| format!("compiling {}", path.display()))?; + let mut linker = Linker::<State>::new(engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; + bindings::Plugin::add_to_linker::<State, HasSelf<State>>(&mut linker, |state| state)?; + let pre = bindings::PluginPre::new( + linker + .instantiate_pre(&component) + .with_context(|| format!("linking {}", path.display()))?, + )?; + log::debug!( + "compiled and linked {} in {:?}", + path.display(), + started.elapsed() + ); + Ok(Self { + engine: engine.clone(), + pre, + }) + } + + /// Instantiate the component in a store of its own and make the plugin + /// with its `new`, from `options`. + pub(crate) fn create(&self, host: Host, options: &str) -> anyhow::Result<Box<dyn Runner>> { + let started = Instant::now(); + let mut wasi = WasiCtxBuilder::new(); + wasi.inherit_env() + .stdout(wasmtime_wasi::p2::stderr()) + .inherit_stderr() + .inherit_network() + .allow_ip_name_lookup(true) + .preopened_dir(&*host.workdir, ".", DirPerms::all(), FilePerms::all()) + .with_context(|| format!("preopening {}", host.workdir.display()))?; + let name = host.name.clone(); + let mut store = Store::new( + &self.engine, + State { + wasi: wasi.build(), + table: ResourceTable::new(), + host, + }, + ); + let exports = self.pre.instantiate(&mut store)?; + let plugin = exports + .diffr_plugin_guest() + .plugin() + .call_new(&mut store, options)? + .map_err(anyhow::Error::msg)?; + log::debug!( + "plugin {name}: instantiated and made in {:?}", + started.elapsed() + ); + Ok(Box::new(WasmInstance(Mutex::new(Instance { + store, + exports, + plugin, + })))) + } +} + +/// A component's instance and the `plugin` resource `new` made in it. +struct Instance { + store: Store<State>, + exports: bindings::Plugin, + plugin: ResourceAny, +} + +/// The instance, one call at a time. +struct WasmInstance(Mutex<Instance>); + +impl WasmInstance { + /// The instance, with `host` behind its imports for the call. + fn enter(&self, host: Host) -> MutexGuard<'_, Instance> { + let mut instance = self + .0 + .lock() + .expect("no call panics while it holds a plugin's store"); + instance.store.data_mut().host = host; + instance + } +} + +impl Runner for WasmInstance { + fn classify(&self, host: Host, file: &contract::FileEntry) -> anyhow::Result<Vec<String>> { + let instance = &mut *self.enter(host); + instance + .exports + .diffr_plugin_guest() + .plugin() + .call_classify(&mut instance.store, instance.plugin, &file_entry(file))? + .map_err(anyhow::Error::msg) + } + + fn mutate( + &self, + host: Host, + file: &contract::FileEntry, + lhs: Option<&contract::Source>, + rhs: Option<&contract::Source>, + ) -> anyhow::Result<Vec<contract::Move>> { + let (lhs, rhs) = (lhs.map(source), rhs.map(source)); + let instance = &mut *self.enter(host); + let started = Instant::now(); + let moves = instance + .exports + .diffr_plugin_guest() + .plugin() + .call_mutate( + &mut instance.store, + instance.plugin, + &file_entry(file), + lhs.as_ref(), + rhs.as_ref(), + )? + .map_err(anyhow::Error::msg)?; + log::debug!("called in {:?}", started.elapsed()); + Ok(moves.into_iter().map(lift).collect()) + } +} + +fn file_entry(file: &contract::FileEntry) -> types::FileEntry { + types::FileEntry { + path: file.path.clone(), + old_path: file.old_path.clone(), + status: match file.status { + contract::FileStatus::Added => types::FileStatus::Added, + contract::FileStatus::Deleted => types::FileStatus::Deleted, + contract::FileStatus::Modified => types::FileStatus::Modified, + contract::FileStatus::Renamed => types::FileStatus::Renamed, + contract::FileStatus::Copied => types::FileStatus::Copied, + contract::FileStatus::TypeChanged => types::FileStatus::TypeChanged, + }, + tags: file.tags.clone(), + } +} + +fn source(side: &contract::Source) -> types::Source { + let position = |position: contract::Position| types::Position { + line: position.line, + column: position.column, + }; + types::Source { + text: side.text.clone(), + regions: side + .regions + .iter() + .map(|region| types::Region { + id: region.id, + parent: region.parent, + fold_state_id: region.fold_state_id, + range: types::Range { + start: position(region.range.start), + end: position(region.range.end), + }, + tags: region.tags.clone(), + visibility: types::Visibility { + collapsed: region.visibility.collapsed, + label: region.visibility.label.clone(), + }, + kind: match ®ion.kind { + contract::Kind::Leaf(leaf) => types::Kind::Leaf(types::Leaf { + alignment_id: leaf.alignment_id, + changed: leaf + .changed + .iter() + .map(|span| types::Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), + }), + contract::Kind::Fold => types::Kind::Fold, + }, + }) + .collect(), + } +} + +fn lift(next: types::Move) -> contract::Move { + match next { + types::Move::Cut(types::Cut { region, at }) => contract::Move::Cut { region, at }, + types::Move::JoinFolds(regions) => contract::Move::JoinFolds { regions }, + types::Move::LinkFoldState(regions) => contract::Move::LinkFoldState { regions }, + types::Move::SetCollapsed((region, collapsed)) => { + contract::Move::SetCollapsed { region, collapsed } + } + types::Move::SetLabel((region, label)) => contract::Move::SetLabel { region, label }, + types::Move::SetTags((region, tags)) => contract::Move::SetTags { region, tags }, + } +} diff --git a/src/tags/mod.rs b/src/tags/mod.rs index f2142a6bd..6b012a72b 100644 --- a/src/tags/mod.rs +++ b/src/tags/mod.rs @@ -217,8 +217,7 @@ pub(crate) fn is_tag(tag: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') } -/// `a,b`: each tag is lowercase ASCII letters, digits, `-` and `_`, starting -/// with a letter or digit. +/// `a,b`: each one a tag. fn parse_tags(value: &str) -> Result<Vec<String>, String> { value .split(',') diff --git a/tests/wasm.rs b/tests/wasm.rs new file mode 100644 index 000000000..ac57f599c --- /dev/null +++ b/tests/wasm.rs @@ -0,0 +1,392 @@ +//! WASM component plugins: the bundled plugins built as components shape +//! files exactly as the same source compiled into diffr does, and the +//! fixtures example classifies, reads the head of a file, runs git and moves +//! regions. +use git2::{IndexAddOption, Repository, Signature, Time}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Once; +use tempfile::TempDir; + +/// Build every guest plugin into its folder's `plugin.wasm`, once per test +/// run. +fn build_plugins() { + static BUILD: Once = Once::new(); + BUILD.call_once(|| { + let output = Command::new("sh") + .arg(root().join("scripts/build-wasm-plugins.sh")) + .output() + .unwrap(); + assert!( + output.status.success(), + "building the WASM plugins failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + }); +} + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// A repository with a working tree, a home directory, and a config +/// directory. +struct Fixture { + dir: TempDir, + repo: Repository, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path().join("repo")).unwrap(); + fs::create_dir_all(dir.path().join("home/.config")).unwrap(); + Self { dir, repo } + } + + fn write(&self, path: &str, text: &str) { + let path = self.dir.path().join("repo").join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + + fn remove(&self, path: &str) { + fs::remove_file(self.dir.path().join("repo").join(path)).unwrap(); + } + + fn commit(&self, message: &str) -> String { + let mut index = self.repo.index().unwrap(); + index.add_all(["*"], IndexAddOption::DEFAULT, None).unwrap(); + index.update_all(["*"], None).unwrap(); + index.write().unwrap(); + let tree = self.repo.find_tree(index.write_tree().unwrap()).unwrap(); + let signature = Signature::new( + "Fixture", + "fixture@example.invalid", + &Time::new(946684800, 0), + ) + .unwrap(); + let parent = self + .repo + .head() + .ok() + .map(|head| head.peel_to_commit().unwrap()); + let parents: Vec<_> = parent.iter().collect(); + self.repo + .commit( + Some("HEAD"), + &signature, + &signature, + message, + &tree, + &parents, + ) + .unwrap() + .to_string() + } + + /// A config file holding `text`, in a directory of its own. + fn config(&self, name: &str, text: &str) -> PathBuf { + let path = self.dir.path().join(name).join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, text).unwrap(); + path + } + + fn run(&self, config: &Path, base: &str, head: &str) -> Output { + let home = self.dir.path().join("home"); + Command::new(assert_cmd::cargo_bin!("diffr")) + .arg("--repo") + .arg(self.dir.path().join("repo")) + .arg("--config") + .arg(config) + .args([base, head, "--format", "ndjson"]) + .env("HOME", &home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env_remove("GIT_DIR") + .env_remove("GEMINI_API_KEY") + .env_remove("GOOGLE_API_KEY") + .output() + .unwrap() + } +} + +fn records(output: &Output) -> Vec<Value> { + String::from_utf8(output.stdout.clone()) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +fn path_of(file: &Value) -> &str { + file.get("rhs").or_else(|| file.get("lhs")).unwrap()["path"] + .as_str() + .unwrap() +} + +/// The start record, the file records sorted by path (they arrive in +/// completion order), and the complete record. +fn sorted(output: &Output) -> (Value, Vec<Value>, Value) { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let mut records = records(output); + let complete = records.pop().unwrap(); + let start = records.remove(0); + records.sort_by(|a, b| path_of(&a["file"]).cmp(path_of(&b["file"]))); + (start, records, complete) +} + +fn walk<'a>(regions: &'a Value, out: &mut Vec<&'a Value>) { + for region in regions.as_array().into_iter().flatten() { + out.push(region); + walk(®ion["children"], out); + } +} + +fn file_record<'a>(records: &'a [Value], path: &str) -> &'a Value { + records + .iter() + .find(|record| record["type"] == "file" && path_of(&record["file"]) == path) + .unwrap_or_else(|| panic!("{path} has no file record")) +} + +#[test] +fn bundled_plugins_as_components_shape_files_exactly_as_natively() { + build_plugins(); + let fixture = Fixture::new(); + let body = |name: &str| { + format!("def {name}():\n a = 1\n b = 2\n c = 3\n return a + b + c\n\n") + }; + let rust_test = |name: &str| { + format!(" /// Checks {name}.\n /// Really.\n #[test]\n fn {name}() {{\n let a = 1;\n let b = 2;\n assert_eq!(a + b, 3);\n }}\n\n") + }; + fixture.write( + "src/removed.py", + &format!("{}{}{}keep = 1\n", body("a"), body("b"), body("c")), + ); + fixture.write( + "src/lib.rs", + &format!( + "/// Adds.\n/// Twice.\npub fn add(a: u32) -> u32 {{\n let b = a;\n let c = b;\n c + c\n}}\n\npub fn keep() -> u32 {{\n 1\n}}\n\n#[cfg(test)]\nmod tests {{\n{}{}}}\n", + rust_test("one"), + rust_test("two") + ), + ); + fixture.write( + "tests/test_things.py", + &format!( + "def test_one():\n assert 1\n assert 2\n assert 3\n\n\ndef test_two():\n assert 1\n assert 2\n assert 3\n\n{}", + body("helper") + ), + ); + fixture.write( + "web/a.test.ts", + "it('adds', () => {\n expect(1).toBe(1);\n expect(2).toBe(2);\n});\n\nit('subtracts', () => {\n expect(1).toBe(1);\n expect(2).toBe(2);\n});\n", + ); + fixture.write("src/gone.go", "package a\n\n// Gone.\n// Really.\nfunc Gone() int {\n\ta := 1\n\tb := 2\n\treturn a + b\n}\n"); + let base = fixture.commit("base\n"); + fixture.write("src/removed.py", "keep = 1\n"); + fixture.write( + "src/lib.rs", + &format!( + "pub fn keep() -> u32 {{\n 2\n}}\n\n#[cfg(test)]\nmod tests {{\n{}{}}}\n", + rust_test("one").replace("a + b", "b + a"), + rust_test("three") + ), + ); + fixture.write( + "tests/test_things.py", + "def test_one():\n assert 1\n assert 2\n assert 4\n\n\ndef test_three():\n assert 1\n assert 2\n assert 3\n", + ); + fixture.write( + "web/a.test.ts", + "it('adds', () => {\n expect(1).toBe(1);\n expect(3).toBe(3);\n});\n", + ); + fixture.remove("src/gone.go"); + fixture.write( + "src/new.rs", + "#[test]\nfn new() {\n a();\n b();\n c();\n}\n", + ); + let head = fixture.commit("head\n"); + + let options = "[plugins.deleted-bodies]\nmin_lines = 3\n[plugins.test-bodies]\nmin_lines = 2\n"; + let native = fixture.config("native", options); + let plugin = |name: &str| root().join("plugins").join(name).display().to_string(); + // Every bundled plugin that builds as a component; the summarizer is off. + let wasm = fixture.config( + "wasm", + &format!( + "[plugins.context]\npath = {:?}\n[plugins.hide-files]\npath = {:?}\n[plugins.deleted-bodies]\npath = {:?}\nmin_lines = 3\n[plugins.test-bodies]\npath = {:?}\nmin_lines = 2\n[plugins.removed-runs]\npath = {:?}\n[plugins.group]\npath = {:?}\n", + plugin("context"), + plugin("hide-files"), + plugin("deleted-bodies"), + plugin("test-bodies"), + plugin("removed-runs"), + plugin("group") + ), + ); + + let native = sorted(&fixture.run(&native, &base, &head)); + let wasm = sorted(&fixture.run(&wasm, &base, &head)); + assert_eq!(native.1.len(), 6); + // The runs shape something, so that equal streams mean something. + let mut collapsed = Vec::new(); + for record in &native.1 { + for side in ["lhs", "rhs"] { + let mut regions = Vec::new(); + walk(&record["diff"][side]["regions"], &mut regions); + collapsed.extend( + regions + .into_iter() + .filter(|region| region["visibility"]["collapsed"] == true) + .filter_map(|region| region["visibility"]["label"].as_str()), + ); + } + } + for label in [ + "2 collapsed regions · 7 lines", + "test body", + "test module", + "4 lines removed", + ] { + assert!(collapsed.contains(&label), "{label}: {collapsed:?}"); + } + assert_eq!(native, wasm); +} + +/// The example's config: every bundled plugin, then `fixtures`, whose folder +/// is written relative to the config file. +fn fixtures_config(fixture: &Fixture, extra: &str) -> PathBuf { + let config = fixture.config("fixtures", ""); + let relative = pathdiff( + &root().join("examples/plugins/fixtures"), + config.parent().unwrap(), + ); + fs::write( + &config, + format!( + "[plugins]\norder = [\"context\", \"hide-files\", \"deleted-bodies\", \"test-bodies\", \"removed-runs\", \"summarize\", \"group\", \"fixtures\"]\n[plugins.fixtures]\npath = {:?}\n{extra}", + relative.display().to_string() + ), + ) + .unwrap(); + config +} + +/// `path` relative to `base`, both absolute. +fn pathdiff(path: &Path, base: &Path) -> PathBuf { + let path = fs::canonicalize(path).unwrap(); + let base = fs::canonicalize(base).unwrap(); + let common = path + .components() + .zip(base.components()) + .take_while(|(a, b)| a == b) + .count(); + let mut relative = PathBuf::new(); + for _ in base.components().skip(common) { + relative.push(".."); + } + for component in path.components().skip(common) { + relative.push(component); + } + relative +} + +fn fixtures_repo() -> (Fixture, String, String) { + let fixture = Fixture::new(); + let data = |last: &str| format!("header\none\ntwo\nthree\nfour\n{last}\n"); + fixture.write("fixtures/data.txt", &data("five")); + fixture.write("src/marked.txt", &format!("// fixture\n{}", data("five"))); + fixture.write("src/plain.txt", &data("five")); + let base = fixture.commit("Add fixtures\n"); + fixture.write("fixtures/data.txt", &data("six")); + fixture.write("src/marked.txt", &format!("// fixture\n{}", data("six"))); + fixture.write("src/plain.txt", &data("six")); + let head = fixture.commit("Update fixtures\n"); + (fixture, base, head) +} + +#[test] +fn the_fixtures_example_classifies_reads_heads_runs_git_and_moves_regions() { + build_plugins(); + let (fixture, base, head) = fixtures_repo(); + let output = fixture.run(&fixtures_config(&fixture, ""), &base, &head); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let records = records(&output); + let tags = |path: &str| { + records[0]["files"] + .as_array() + .unwrap() + .iter() + .find(|entry| path_of(&entry["file"]) == path) + .unwrap() + .get("tags") + .cloned() + }; + assert_eq!( + tags("fixtures/data.txt"), + Some(serde_json::json!(["fixture"])) + ); + assert_eq!( + tags("src/marked.txt"), + Some(serde_json::json!(["fixture"])), + "read-head finds the marker" + ); + assert_eq!(tags("src/plain.txt"), None); + + for path in ["fixtures/data.txt", "src/marked.txt"] { + let record = file_record(&records, path); + assert_eq!( + record["visibility"], + serde_json::json!({"collapsed": true, "label": "Fixture · Update fixtures"}), + "{path}" + ); + let mut regions = Vec::new(); + walk(&record["diff"]["lhs"]["regions"], &mut regions); + let pieces: Vec<&Value> = regions + .into_iter() + .filter(|region| { + region["visibility"]["label"] + .as_str() + .is_some_and(|label| label.ends_with(" fixture lines")) + }) + .collect(); + assert_eq!(pieces.len(), 1, "{path}: {record}"); + assert_eq!(pieces[0]["visibility"]["collapsed"], true); + } + let plain = file_record(&records, "src/plain.txt"); + assert!(plain.get("visibility").is_none(), "{plain}"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains( + "diffr plugin fixtures: fixtures/data.txt: last changed in \"Update fixtures\"" + ), + "{stderr}" + ); +} + +#[test] +fn a_guest_error_aborts_the_run_with_mutation_failed() { + build_plugins(); + let (fixture, base, head) = fixtures_repo(); + let output = fixture.run(&fixtures_config(&fixture, "fail = true\n"), &base, &head); + assert_eq!(output.status.code(), Some(2)); + let records = records(&output); + let aborted = &records.last().unwrap()["aborted"]; + assert_eq!(aborted["code"], "mutation_failed", "{aborted}"); + let message = aborted["message"].as_str().unwrap(); + assert!( + message.starts_with("mutation fixtures: asked to fail on "), + "{message}" + ); +} From 114c5e6062f5aec4e19ebf88da6da0e680043e7b Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 09/20] Read wire v3 in the terminal frontend The TUI reads per-side region trees: rows zip by leaf `alignment_id`, folds open and close by `fold_state_id`, collapsed regions show their header then their label, and hidden files show a "Load diff" placeholder with the reason. A collapsed fold is a row of its own, between the rows around it, so the line that opens a construct is an ordinary row and nothing a fold covers stays on screen; an open fold marks the first line it covers so it can still be collapsed from there. Counts come from `stats.visible`; context gaps are derived from the wire as collapsed, untagged, paired regions holding no change. Folds sharing a header line show the outermost collapsed one. Colours come from tree-sitter capture names, which `--syntax` adds to the stream as each side's `syntax` spans for structurally parsed files, through bundled Helix-style themes. Bare `diffr config [query]` opens the frontend's settings screen, driven by `diffr config schema`, `show` and `set`. The frontend trusts diffr's wire: it parses each record's shape and the protocol version, and rejects malformed JSON and a stream that ends before `complete`, without re-checking invariants across records. The frontend's previous fold model (folds.ts) and its launch PTY tests are deleted. With the terminal UI in place, difftastic's terminal renderer goes too. A comparison without `--format` opens the terminal UI, and fails with exit 2 when stdin or stdout is not a terminal; `--format` takes only `ndjson`; `--display`, `--color` and `--no-color` are gone, and `--width` only sizes `--stat`. Deleted: src/display/ (side by side and inline printing, hunks and their context padding, styles, the `--display json` output), `DisplayOptions`, `DiffResult`'s `hunks`, `extra_info`, `display_path`, `has_byte_changes` and `has_syntactic_changes`, `--check-only` and CR stripping, and the difftastic file, directory and conflict-marker modes of `diffr debug` with the helpers only they used (conflicts.rs, directory walking, file permissions, stdin arguments, line-number formatting). `diffr debug` keeps the syntax dumps and `--list-languages`. The renderer's output regression script (sample_files/compare_all.sh, compare.expected, its CI job and just recipe) goes with it. Line alignment, which the projection still builds leaves from, moves out of src/display/ to src/line_layout.rs, with the line matching it needs in src/line_layout/matched_lines.rs. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 --- .github/workflows/test.yml | 12 - Cargo.lock | 80 +- Cargo.toml | 13 +- diffr.1 | 5 +- diffr.1.md | 5 +- docs/cli.md | 23 +- docs/config.md | 2 + docs/streaming.md | 12 +- examples/review/README.md | 2 +- justfile | 4 - plugins/summarize/src/lib.rs | 3 +- sample_files/compare.expected | 327 ------- sample_files/compare_all.sh | 43 - src/cli.rs | 269 +++--- src/conflicts.rs | 149 --- src/display/hunks.rs | 865 ------------------ src/display/inline.rs | 149 --- src/display/json.rs | 341 ------- src/display/mod.rs | 8 - src/display/prepare.rs | 90 -- src/display/side_by_side.rs | 839 ----------------- src/display/style.rs | 730 --------------- src/engine.rs | 160 +--- src/exit_codes.rs | 8 - src/files.rs | 109 +-- src/git.rs | 10 +- src/{display => }/line_layout.rs | 7 +- .../matched_lines.rs} | 313 +------ src/lines.rs | 62 -- src/main.rs | 539 +---------- src/options.rs | 789 +--------------- src/parse/fold_tests.rs | 9 +- src/parse/syntax.rs | 46 - src/parse/tree_sitter_parser.rs | 27 + src/plugin/tests/mod.rs | 9 +- src/plugin/tests/summarize.rs | 6 +- src/protocol/mod.rs | 24 +- src/protocol/project.rs | 279 +++--- src/protocol/stream.rs | 59 +- src/summary.rs | 25 - tests/cli.rs | 287 +----- tests/view.rs | 25 + tui/README.md | 167 +++- tui/package.json | 4 +- tui/packages/hunk/src/diffr/config.test.ts | 109 +++ tui/packages/hunk/src/diffr/config.ts | 180 ++++ tui/packages/hunk/src/diffr/counts.test.ts | 17 + tui/packages/hunk/src/diffr/counts.ts | 45 + tui/packages/hunk/src/diffr/fileTree.test.ts | 7 +- tui/packages/hunk/src/diffr/fileTree.ts | 18 +- tui/packages/hunk/src/diffr/fixture.ts | 111 ++- tui/packages/hunk/src/diffr/folds.test.ts | 88 -- tui/packages/hunk/src/diffr/folds.ts | 112 --- tui/packages/hunk/src/diffr/regions.test.ts | 285 ++++++ tui/packages/hunk/src/diffr/regions.ts | 238 +++++ tui/packages/hunk/src/diffr/rows.test.ts | 236 ++--- tui/packages/hunk/src/diffr/rows.ts | 431 ++++----- tui/packages/hunk/src/diffr/selection.ts | 20 +- tui/packages/hunk/src/diffr/store.ts | 36 +- tui/packages/hunk/src/diffr/stream.test.ts | 123 ++- tui/packages/hunk/src/diffr/stream.ts | 43 +- tui/packages/hunk/src/diffr/theme.test.ts | 67 ++ tui/packages/hunk/src/diffr/theme.ts | 174 ++++ tui/packages/hunk/src/diffr/wire.ts | 216 ++--- tui/packages/hunk/src/main.tsx | 44 +- tui/packages/hunk/src/ui/App.test.tsx | 299 ++++-- tui/packages/hunk/src/ui/App.tsx | 210 +++-- tui/packages/hunk/src/ui/Settings.test.tsx | 134 +++ tui/packages/hunk/src/ui/Settings.tsx | 249 +++++ tui/packages/hunk/src/ui/diff/CodeRowView.tsx | 26 +- tui/packages/hunk/src/ui/diff/diffRowModel.ts | 11 +- tui/test/fixtures/after.ts | 4 - tui/test/fixtures/before.ts | 3 - tui/test/fixtures/comparison.ndjson | 7 +- tui/test/pty/launch-integration.test.ts | 83 -- tui/test/pty/launch.py | 66 -- tui/themes/LICENSE | 373 ++++++++ tui/themes/README.md | 14 + tui/themes/gruvbox.toml | 153 ++++ tui/themes/onedark.toml | 118 +++ tui/themes/onelight.toml | 201 ++++ tui/themes/solarized_light.toml | 155 ++++ 82 files changed, 4229 insertions(+), 7412 deletions(-) delete mode 100644 sample_files/compare.expected delete mode 100755 sample_files/compare_all.sh delete mode 100644 src/conflicts.rs delete mode 100644 src/display/hunks.rs delete mode 100644 src/display/inline.rs delete mode 100644 src/display/json.rs delete mode 100644 src/display/mod.rs delete mode 100644 src/display/prepare.rs delete mode 100644 src/display/side_by_side.rs delete mode 100644 src/display/style.rs rename src/{display => }/line_layout.rs (97%) rename src/{display/context.rs => line_layout/matched_lines.rs} (69%) create mode 100644 tui/packages/hunk/src/diffr/config.test.ts create mode 100644 tui/packages/hunk/src/diffr/config.ts create mode 100644 tui/packages/hunk/src/diffr/counts.test.ts create mode 100644 tui/packages/hunk/src/diffr/counts.ts delete mode 100644 tui/packages/hunk/src/diffr/folds.test.ts delete mode 100644 tui/packages/hunk/src/diffr/folds.ts create mode 100644 tui/packages/hunk/src/diffr/regions.test.ts create mode 100644 tui/packages/hunk/src/diffr/regions.ts create mode 100644 tui/packages/hunk/src/diffr/theme.test.ts create mode 100644 tui/packages/hunk/src/diffr/theme.ts create mode 100644 tui/packages/hunk/src/ui/Settings.test.tsx create mode 100644 tui/packages/hunk/src/ui/Settings.tsx delete mode 100644 tui/test/fixtures/after.ts delete mode 100644 tui/test/fixtures/before.ts delete mode 100644 tui/test/pty/launch-integration.test.ts delete mode 100644 tui/test/pty/launch.py create mode 100644 tui/themes/LICENSE create mode 100644 tui/themes/README.md create mode 100644 tui/themes/gruvbox.toml create mode 100644 tui/themes/onedark.toml create mode 100644 tui/themes/onelight.toml create mode 100644 tui/themes/solarized_light.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d73b9874..cf1f3e2f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,18 +69,6 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: cargo +stable test - regression_test: - name: Output Regression Test - runs-on: ubuntu-22.04 - steps: - # v4.2.2 - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: dtolnay/rust-toolchain@1.85.0 - - name: Generate output for all sample files - run: ./sample_files/compare_all.sh - - name: Verify output is unchanged - run: diff -C2 sample_files/compare.result sample_files/compare.expected - package: name: Check Linux Packaging runs-on: ubuntu-22.04 diff --git a/Cargo.lock b/Cargo.lock index d15d3169d..8d05dbdf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,7 +376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -469,9 +469,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] @@ -767,20 +767,19 @@ dependencies = [ "glob", "hashbrown 0.17.1", "humansize", - "ignore", "imara-diff", "jsonschema", "lazy_static", "libc", "line-numbers", "log", - "owo-colors", "predicates", "pretty_assertions", "pretty_env_logger", "radix-heap", "rayon", "regex", + "reqwest", "rustc-hash", "schemars", "serde", @@ -859,7 +858,6 @@ dependencies = [ "tree_magic_mini", "ts-parser-perl", "typed-arena", - "unicode-width", "version_check", "wasmtime", "wasmtime-wasi", @@ -1281,19 +1279,6 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" -[[package]] -name = "globset" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - [[package]] name = "h2" version = "0.4.19" @@ -1591,22 +1576,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "ignore" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "imara-diff" version = "0.2.0" @@ -1903,9 +1872,9 @@ checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" [[package]] name = "lru-slab" -version = "0.1.3" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mach2" @@ -2103,12 +2072,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "owo-colors" -version = "4.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" - [[package]] name = "parking_lot" version = "0.12.5" @@ -2295,9 +2258,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.12" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -2315,9 +2278,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -2658,9 +2621,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "aws-lc-rs", "once_cell", @@ -3190,9 +3153,18 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +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" @@ -4038,12 +4010,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index b4f3b4a6c..b8b7e8c0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,18 +44,12 @@ log = "0.4.14" pretty_env_logger = "0.5.0" radix-heap = "0.4.2" -# ignore does not declare a MSRV, but it's part of ripgrep, which has -# a slightly more aggressive MSRV than difftastic. Ignore 0.4.30 -# requires rust 1.88. -ignore = ">= 0.4, < 0.4.30" -owo-colors = "4.2.3" imara-diff = { version = "0.2.0", default-features = false } rayon = "1.7.0" -tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time"] } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time", "sync"] } tree_magic_mini = "3.2.0" bumpalo = "3.18.0" -unicode-width = "0.2.0" terminal_size = "0.4.1" glob = "0.3.2" strum = { version = "0.28", features = ["derive"] } @@ -140,6 +134,7 @@ serde_path_to_error = "0.1.20" schemars = { version = "1.2.2", features = ["preserve_order"] } dirs = "5" toml_edit = "0.25.15" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "http2"] } serde_yaml_ng = "0.10.0" jsonschema = { version = "0.56.0", default-features = false } # WASM component plugins. Wasmtime 34 is the newest release whose @@ -184,8 +179,8 @@ members = [ "plugins/hide-files", "plugins/removed-runs", "plugins/summarize", - "examples/plugins/fixtures", "plugins/test-bodies", + "examples/plugins/fixtures", ] default-members = [ ".", @@ -196,8 +191,8 @@ default-members = [ "plugins/hide-files", "plugins/removed-runs", "plugins/summarize", - "examples/plugins/fixtures", "plugins/test-bodies", + "examples/plugins/fixtures", ] [profile.release] diff --git a/diffr.1 b/diffr.1 index d16b876bc..37cab73c1 100644 --- a/diffr.1 +++ b/diffr.1 @@ -31,8 +31,9 @@ Print metadata without syntax matching. Stat counts are textual. .B --quiet, --exit-code Exit 1 for changes. Quiet also suppresses output. Errors exit 2. .TP -.B --format text|ndjson -Terminal rendering, or the event stream described under STREAMING. +.B --format ndjson +Write the event stream described under STREAMING instead of opening the +terminal UI, which needs a terminal. .TP .B -U N Ordinary context padding (default 3). diff --git a/diffr.1.md b/diffr.1.md index 286e3d7b0..4970d39c1 100644 --- a/diffr.1.md +++ b/diffr.1.md @@ -36,8 +36,9 @@ compare their trees. A...B compares merge-base(A,B) to B; A..B compares A to B. **--quiet**, **--exit-code** : Exit 1 for changes. Quiet also suppresses output. Errors exit 2. -**--format** text|ndjson -: Terminal rendering, or the event stream described under STREAMING. +**--format** ndjson +: Write the event stream described under STREAMING instead of opening the + terminal UI, which needs a terminal. **-U** N : Select ordinary context padding (default 3). diff --git a/docs/cli.md b/docs/cli.md index c735fb3c0..533e5388a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,19 +27,22 @@ Text output does not implement Git's path quoting or compact rename formatting. for changes; ordinary output exits 0. Errors exit 2. `--no-index` supports two files, implies change exit status, and does not yet support metadata options. -Structural output uses the existing terminal renderer. `--format 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. +A comparison opens the terminal UI (`tui/`), which needs a terminal on stdin +and stdout; without one diffr exits 2. `--format ndjson` instead writes the +event stream described in [streaming.md](streaming.md), diffing `--jobs N` +files at once (default 16) and emitting each as it finishes; the terminal UI +reads the same stream. `ndjson` is the only format. `-U N` sets the unchanged lines kept around each change, overriding -`plugins.context.lines` (default 3). Matching limits, `--ignore-comments`, -color, width and inline/split display remain configurable; see `--help`. +`plugins.context.lines` (default 3). Matching limits and `--ignore-comments` +remain configurable; `--width` sets the columns of `--stat`. See `--help`. Configuration comes from the global file, command-line flags and git -attributes; `--config PATH` replaces the global file. `diffr config schema`, -`show` and `set` are the commands frontends use. See [config.md](config.md). +attributes; `--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. -The old file/external-diff/debug parser is available under `debug` for diagnostic -use; it is not the default argument syntax. `review` is replaced by the normal -CLI's `--format` option. The stdout event contract is in [streaming.md](streaming.md). +`diffr debug` keeps difftastic's syntax dumps (`--dump-ts`, `--dump-syntax`, +`--dump-syntax-dot`) and `--list-languages` for diagnostics. The stdout event +contract is in [streaming.md](streaming.md). diff --git a/docs/config.md b/docs/config.md index ce2aeca35..532880a7f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -19,6 +19,8 @@ an error that names the file and the key's dotted path, for example ## Commands ```sh +diffr config # settings screen in the terminal frontend +diffr config theme # the same, searching for "theme" diffr config schema # JSON Schema: title, group, description and default per key, plugins included diffr config show [--json] # the resolved configuration diffr config set diff.graph_limit 5000000 diff --git a/docs/streaming.md b/docs/streaming.md index fe9a1b9b7..f270a108a 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -2,7 +2,7 @@ ```sh diffr main HEAD --format ndjson -diffr --cached --format ndjson --order test,docs -- src/ +diffr --cached --format ndjson --syntax --order test,docs -- src/ diffr --no-index --format ndjson -- before.rs after.rs ``` @@ -131,8 +131,8 @@ any file failed or the run aborted. ```jsonc {"type": "text", - "lhs": {"text": "…", "regions": [...]}, - "rhs": {"text": "…", "regions": [...]}, + "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 ``` @@ -141,7 +141,11 @@ 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. +`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 diff --git a/examples/review/README.md b/examples/review/README.md index 30bb0f04b..f6328ac89 100644 --- a/examples/review/README.md +++ b/examples/review/README.md @@ -75,7 +75,7 @@ Each example deliberately specifies a small selected set, not every possible fol - `before.*` / `after.*`: exact pinned sources. An absent side is an empty fixture file; its provenance path and blob SHA are null. - `provenance.json`: PR URL, merge time, comparison commits, paths, local filenames, original Git blob SHAs and SHA-256 hashes. This is the source identity record. -- `expected.json`: the original flat annotation oracle (`folds` and `context`); this is not the serialized live domain. Generated context now belongs to each `diff.hunks[i]`. +- `expected.json`: the original flat annotation oracle (`folds` and `context`); this is not the serialized live domain. - `case.json`: purpose, exact reviewed excerpts selected by the annotations, and source ranges/text that folds must leave exposed. These excerpt assertions are fixture oracles, not domain fields. - `change.patch`: ordinary Git/Myers diff with three context lines, for comparison. Difftastic may choose different hunks; when integrating, context must be deduplicated against its actual visible rows. - `README.md`: links and selected ranges with the actual source excerpts. diff --git a/justfile b/justfile index 5cefab145..ec4bd6105 100644 --- a/justfile +++ b/justfile @@ -5,10 +5,6 @@ default: doc: cd manual && mdbook serve --open -# Run the output regression test. -compare: - sample_files/compare_all.sh - # Create a git tag and push it, to trigger a release on GitHub actions. release: #!/bin/bash diff --git a/plugins/summarize/src/lib.rs b/plugins/summarize/src/lib.rs index ac2529328..d5efa928a 100644 --- a/plugins/summarize/src/lib.rs +++ b/plugins/summarize/src/lib.rs @@ -1,4 +1,5 @@ -//! The summarizer: large new function bodies become short pseudocode, shown +//! The summarizer: large new function bodies become short python-flavored +//! pseudocode, shown //! in place of the collapsed body. //! //! It needs an API key: `new` fails without one, naming how to set it or diff --git a/sample_files/compare.expected b/sample_files/compare.expected deleted file mode 100644 index a7a103973..000000000 --- a/sample_files/compare.expected +++ /dev/null @@ -1,327 +0,0 @@ -sample_files/Session_1.kt sample_files/Session_2.kt -1e5de641d7a8f1429fe1dca749f13ade - - -sample_files/ada_1.adb sample_files/ada_2.adb -2c5a7ca4fe0a9b837b3055cf17b6b8d2 - - -sample_files/added_line_1.txt sample_files/added_line_2.txt -8a1587e6b5fc53f2ec2ac665a5d00372 - - -sample_files/align_footer_1.txt sample_files/align_footer_2.txt -49b8e5156047150594abbab22c402efa - - -sample_files/all_changed_1.js sample_files/all_changed_2.js -8b18e5155d4754e8e6d6319be6f58301 - - -sample_files/apex_1.trigger sample_files/apex_2.trigger -155030769509703a2cad74f1bc3c44f6 - - -sample_files/asm_1.s sample_files/asm_2.s -869f6057e259526eaf913e9505f3d473 - - -sample_files/b2_math_1.h sample_files/b2_math_2.h -2d8f9619e0c39d04b1a6ecbe14696b38 - - -sample_files/bad_combine_1.rs sample_files/bad_combine_2.rs -a6d674bd88d54f085c22c70c2d5a4c80 - - -sample_files/big_text_hunk_1.txt sample_files/big_text_hunk_2.txt -fc26d41a5ff771670e04033b177973d2 - - -sample_files/change_outer_1.el sample_files/change_outer_2.el -1c5611ec618ce8f1ea8623057bffd544 - - -sample_files/chinese_1.po sample_files/chinese_2.po -eac26d58f1266d339d60608656a6eb8b - - -sample_files/clojure_1.clj sample_files/clojure_2.clj -9b88515ca9daf5d3cf4de7c61915fc10 - - -sample_files/comma_1.js sample_files/comma_2.js -dfd18ec953d29545380fa656e54ff70e - - -sample_files/comma_and_comment_1.js sample_files/comma_and_comment_2.js -6f7d50d421502aa6a143502643d51ae7 - - -sample_files/comments_1.rs sample_files/comments_2.rs -d8c3c256de454bc23944cf1a4300c66e - - -sample_files/context_1.rs sample_files/context_2.rs -9b84c492d07d125912de77002bcaad9a - - -sample_files/contiguous_1.js sample_files/contiguous_2.js -7ac6081dee02e3529a88defca194bc00 - - -sample_files/css_1.css sample_files/css_2.css -43a2bbc8dc2f4b4733ecb5a888d10dfc - - -sample_files/dart_1.dart sample_files/dart_2.dart -2cacc19ded574d97416eed1a3da513e6 - - -sample_files/devicetree_1.dts sample_files/devicetree_2.dts -36a87e82032c54d0d1a630f895ea6f1b - - -sample_files/elisp_1.el sample_files/elisp_2.el -3e8c8abcb65a5141a2fb54bae4a9ca06 - - -sample_files/elisp_contiguous_1.el sample_files/elisp_contiguous_2.el -f10afb2f1f60651b3dc384ea3ce8b1e4 - - -sample_files/elixir_1.ex sample_files/elixir_2.ex -26e982d8972475312b350ce1e0bfdcc9 - - -sample_files/elm_1.elm sample_files/elm_2.elm -8a67269f2dd2e4913f885b3b6e6d6a07 - - -sample_files/erlang_1.erl sample_files/erlang_2.erl -77c259baf4751716db4da1503088e742 - - -sample_files/f_sharp_1.fs sample_files/f_sharp_2.fs -63defe290bfc6754b51457a6c374838b - - -sample_files/fish_1.fish sample_files/fish_2.fish -707c9e00520b380813342c33a9d0f2d3 - - -sample_files/fortran_1.f90 sample_files/fortran_2.f90 -94351de50c5e6eae2ce5f165e83d93da - - -sample_files/hack_1.php sample_files/hack_2.php -fd23895fca3cb3e70b5ffd364f8c30fe - - -sample_files/haskell_1.hs sample_files/haskell_2.hs -3cace65f14319c339fcbd30e8e9ebdcc - - -sample_files/hcl_1.hcl sample_files/hcl_2.hcl -b4de7a8269018e05c0eba247d8097111 - - -sample_files/hello_world_1.smali sample_files/hello_world_2.smali -4a754c9c7d84d5fd4e3fbb16f060afbb - - -sample_files/helpful_1.el sample_files/helpful_2.el -a26c7b0e3758b387237d9932002211f8 - - -sample_files/html_1.html sample_files/html_2.html -40cf5a085ac58331afdf8cc7fb366cae - - -sample_files/html_simple_1.html sample_files/html_simple_2.html -e31d600c98f9607c6f56c3967c000055 - - -sample_files/huge_cpp_1.cpp sample_files/huge_cpp_2.cpp -12f666ec2e48233d7a46308d0a3ce4f6 - - -sample_files/hyphen_subwords_1.json sample_files/hyphen_subwords_2.json -8315e8d21c7c8a22c48ff46c3931eda9 - - -sample_files/identical_1.scala sample_files/identical_2.scala -15c5a789e644348cb7e0de051ff4b63e - - -sample_files/if_1.py sample_files/if_2.py -afcc7b60dd5c267ae183a496e0af511b - - -sample_files/insert_blank_1.txt sample_files/insert_blank_2.txt -a5fd75afcc99aa7b2b285f1f9ced8607 - - -sample_files/janet_1.janet sample_files/janet_2.janet -7dc0f1a3ce49f489fa4b64edefebb3c5 - - -sample_files/java_1.java sample_files/java_2.java -f4b15ddf3283e6c14d200efd06c0e85a - - -sample_files/javascript_1.js sample_files/javascript_2.js -549ed686b3ce0271a38c9087387afdd8 - - -sample_files/javascript_simple_1.js sample_files/javascript_simple_2.js -1a2fdfb7210a93c80a7ce7c0768920c3 - - -sample_files/json_1.json sample_files/json_2.json -2be569c35a1e90edbbdea641db73f9e7 - - -sample_files/jsx_1.jsx sample_files/jsx_2.jsx -89832bde34f9c54c99a1bfb0f68291a9 - - -sample_files/julia_1.jl sample_files/julia_2.jl -10ac32ea1bd1d48b20386f1deeca40a0 - - -sample_files/load_1.js sample_files/load_2.js -8d6d58c44e3c24488fcb4f7f6ce60746 - - -sample_files/long_line_1.txt sample_files/long_line_2.txt -e8b5cc4b04030c79150aa70aaaa364b1 - - -sample_files/lua_1.lua sample_files/lua_2.lua -11693bb34168541f2ac9f4154d246038 - - -sample_files/makefile_1.mk sample_files/makefile_2.mk -8c5cc39b6dab819fcc0f472698e21b35 - - -sample_files/many_newlines_1.txt sample_files/many_newlines_2.txt -52ca05855e520876479e6f608c5e7831 - - -sample_files/metadata_1.clj sample_files/metadata_2.clj -8762c3de7cdb95eb320d14c1c5440e37 - - -sample_files/modules_1.ml sample_files/modules_2.ml -07e1872e32f8c11e4c3bb7ae3b3fe22f - - -sample_files/multibyte_1.py sample_files/multibyte_2.py -eb1eef8be0f08b07ad79bd8ca328e2e5 - - -sample_files/multiline_string_1.ml sample_files/multiline_string_2.ml -3bfaea34db8a65d370e19b2e902d7cef - - -sample_files/multiline_string_eof_1.yml sample_files/multiline_string_eof_2.yml -4d80f5907b7f360999ec51a70818fd97 - - -sample_files/nest_1.rs sample_files/nest_2.rs -9d808755cff9af64cdc861bbef966cb4 - - -sample_files/nested_slider_1.el sample_files/nested_slider_2.el -2a4e41f16b7ce9cb6692ef72754b9076 - - -sample_files/nested_slider_1.rs sample_files/nested_slider_2.rs -342df46db959e8c90925ff4400c0fa63 - - -sample_files/nesting_1.el sample_files/nesting_2.el -bbbb35cea6d1de31ff40db1f51ebf9c0 - - -sample_files/newick_1.nwk sample_files/newick_2.nwk -e3e7336d993c7ac8c3140d64f057c248 - - -sample_files/nix_1.nix sample_files/nix_2.nix -5f591ced7633a07f13cdda9766110715 - - -sample_files/nullable_1.kt sample_files/nullable_2.kt -382063175940a489c581e24209399a7a - - -sample_files/objc_header_1.h sample_files/objc_header_2.h -42a55ca60ffcbdfbe22884f9eee38468 - - -sample_files/objc_module_1.m sample_files/objc_module_2.m -5c63beac1ce04ff870096e9352ef7c84 - - -sample_files/ocaml_1.ml sample_files/ocaml_2.ml -e99bb9f1f789bce0e9a1d4e5205b8139 - - -sample_files/outer_delimiter_1.el sample_files/outer_delimiter_2.el -971c3ddebfae222ded24f7a97ec7b5d7 - - -sample_files/pascal_1.pascal sample_files/pascal_2.pascal -de04f7fdb7af7bd70ca62f1868504f11 - - -sample_files/perl_1.pl sample_files/perl_2.pl -c840d4e116ff2d963c111239ae2bc895 - - -sample_files/prefer_outer_1.el sample_files/prefer_outer_2.el -991038c9988cccc2c824652e33f772a2 - - -sample_files/preprocessor_1.h sample_files/preprocessor_2.h -d94f452bbf7cb3accc8d538fdd7ab5e4 - - -sample_files/qml_1.qml sample_files/qml_2.qml -ec69003436e7780633b6224861c3cccf - - -sample_files/r_1.R sample_files/r_2.R -3c3f47ddb1e05732e8d9f3bd78cf5f7b - - -sample_files/racket_1.rkt sample_files/racket_2.rkt -4debb7e7af49678aa19726acb5c55d7d - - -sample_files/repeated_line_no_eol_1.txt sample_files/repeated_line_no_eol_2.txt -b63c743f2133480de3ba42ecdec5eb93 - - -sample_files/ruby_1.rb sample_files/ruby_2.rb -a0a7121421e3d57acadc08519275dcce - - -sample_files/scala_1.scala sample_files/scala_2.scala -2304bea82a6dae573964afdaeb47f845 - - -sample_files/scheme_1.scm sample_files/scheme_2.scm -53c43d0dd0aea0f3e49385cc6d520963 - - -sample_files/simple_1.js sample_files/simple_2.js -f73ae56c0467bac101bb5f7a435f89f3 - - -sample_files/simple_1.txt sample_files/simple_2.txt -b0a13567bfdd36c854d9d2f537c704de - - -sample_files/slider_1.rs sample_files/slider_2.rs -094b0ef7fc4a565f993ffb077192edb4 - - -sample_files/slider_at_end_1.json sample_files/slider_at_end_2.json -6c994cd99226f6584f74f531bb27d54f - - -sample_files/slow_1.rs sample_files/slow_2.rs -302a9e9ae2af1ecc800d906ea67c74dd - - -sample_files/small_1.js sample_files/small_2.js -86b1132b6c17fcc2cbec65b1c248baa9 - - -sample_files/sql_1.sql sample_files/sql_2.sql -ee54ee3f974aa54c8bdfc2b9075824f2 - - -sample_files/string_subwords_1.el sample_files/string_subwords_2.el -25c9e7cc72bf1a0ed2211762ad3bd7bc - - -sample_files/strings_1.el sample_files/strings_2.el -4d39b32b53a056e1e770b8e5ab04790c - - -sample_files/swift_1.swift sample_files/swift_2.swift -3a90686a3e172ae19a5fd7cef2f1b944 - - -sample_files/syntax_error_1.js sample_files/syntax_error_2.js -32c72c64f399bbd26fdffbfed1066789 - - -sample_files/tab_1.c sample_files/tab_2.c -fd896b8e53b183f0d1d5cfeb6a1dfab4 - - -sample_files/tab_1.txt sample_files/tab_2.txt -10e9a853917dc9d92d2867e3453269b2 - - -sample_files/tailwind_1.css sample_files/tailwind_2.css -9fdb506f97542d0edce0b14e99116617 - - -sample_files/text_1.txt sample_files/text_2.txt -ceb707a51501ca63ade198e7ebf92659 - - -sample_files/todomvc_1.gleam sample_files/todomvc_2.gleam -725bb793f219e13cee199f18ffaed872 - - -sample_files/toml_1.toml sample_files/toml_2.toml -061ffdc7a60051a1185b742672b1050a - - -sample_files/trailing_commas_1.js sample_files/trailing_commas_2.js -9ca72e199466c75f85d00f4d521e85ff - - -sample_files/trailling_newline_1.yaml sample_files/trailling_newline_2.yaml -763e6f9720a1f4e448d547c361f22c68 - - -sample_files/typescript_1.ts sample_files/typescript_2.ts -e15ceae53da7cfb308960f8c3f30b891 - - -sample_files/typing_1.ml sample_files/typing_2.ml -ae41c3f5aa1ba13c2a424d3afa7ece6c - - -sample_files/utf16_1.py sample_files/utf16_2.py -bd9a1d79fd78185c94ce89ea7d4ac581 - - -sample_files/verilog_1.sv sample_files/verilog_2.sv -0fbb9bd8ff99b8ee852741860d2c25d0 - - -sample_files/vhdl_1.vhd sample_files/vhdl_2.vhd -267dcc013be29dd84728870fadfe9504 - - -sample_files/whitespace_1.tsx sample_files/whitespace_2.tsx -789689b170b80ef280a322b3a6c7363b - - -sample_files/windows1251_1.txt sample_files/windows1251_2.txt -3f648c2b6fc626022e3e591fbfd7ec81 - - -sample_files/xml_1.xml sample_files/xml_2.xml -38600945878fdea4b06c5da026e1fb78 - - -sample_files/yaml_1.yaml sample_files/yaml_2.yaml -3a4a35bf75297de64796eafb1feb4f97 - - -sample_files/zig_1.zig sample_files/zig_2.zig -ee5a604e604d310066deeff30e28f9d7 - - diff --git a/sample_files/compare_all.sh b/sample_files/compare_all.sh deleted file mode 100755 index 90c68f090..000000000 --- a/sample_files/compare_all.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash - -# Run difftastic on all the sample files, so we can see if any output -# has been changed. -# -# # Generate a new compare.result. -# $ sample_files/compare_all.sh -# -# # Compare it with the previous one. -# $ difft sample_files/compare.expected sample_files/compare.result -# -# # If changes were expected, update the .expected file. -# $ cp sample_files/compare.result sample_files/compare.expected - -set -e - -echo "==> Building difftastic" -cargo build --release -difft_bin=$(cargo metadata --format-version 1 | jq -r '.target_directory')/release/difft - -# Set language so we expand globs in a consistent order regardless of -# locale (e.g. on GitHub actions). -LANG=en_US.UTF-8 -LC_ALL=C -LC_COLLATE=C - -echo "==> Check outputs" -for before_f in sample_files/*_1.*; do - after_f=${before_f/_1/_2} - echo "$before_f $after_f" - - difft_out=$(DFT_WIDTH=180 $difft_bin --color=always "$before_f" "$after_f" | md5sum) - echo "$difft_out" - echo -done | tee sample_files/compare.result - -echo "==> Result" -if cmp sample_files/compare.result sample_files/compare.expected >/dev/null 2>&1; then - echo "No changes." -else - echo "Output changed. If this was expected, update with:" - echo " cp sample_files/compare.result sample_files/compare.expected" -fi diff --git a/src/cli.rs b/src/cli.rs index f3d2db03b..3e3f6ba54 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. +//! Git-style CLI input: the terminal UI, the NDJSON stream, and Git metadata. use crate::config::{self, Config}; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; -use crate::options::{DiffOptions, DisplayMode, DisplayOptions}; +use crate::options::DiffOptions; use crate::plugin::Pipeline; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use git2::{DiffStatsFormat, Repository}; @@ -60,11 +60,9 @@ pub(crate) fn run() -> Result<i32> { .arg(flag("no-renames")) .arg(flag("find-renames").short('M').conflicts_with("no-renames")) .arg(Arg::new("unified").short('U').long("unified").value_parser(clap::value_parser!(u32)).help("Unchanged lines kept around each change; defaults to plugins.context.lines")) - .arg(Arg::new("format").long("format").value_parser(["text", "ndjson"]).default_value("text")) - .arg(Arg::new("display").long("display").value_parser(["inline", "side-by-side", "side-by-side-show-both"]).default_value("side-by-side")) - .arg(Arg::new("color").long("color").num_args(0..=1).require_equals(true).default_missing_value("always").default_value("auto").value_parser(["auto", "always", "never"])) - .arg(flag("no-color")) - .arg(Arg::new("width").long("width").value_parser(clap::value_parser!(usize))) + .arg(Arg::new("format").long("format").value_parser(["ndjson"]).help("Write the event stream to stdout instead of opening the terminal UI")) + .arg(flag("syntax").help("Include every token's tree-sitter capture name in --format ndjson output")) + .arg(Arg::new("width").long("width").value_parser(clap::value_parser!(usize)).help("Columns for --stat; defaults to the terminal's width")) .arg(flag("ignore-comments")) .arg(Arg::new("byte-limit").long("byte-limit").value_parser(clap::value_parser!(usize))) .arg(Arg::new("graph-limit").long("graph-limit").value_parser(clap::value_parser!(usize))) @@ -72,8 +70,8 @@ pub(crate) fn run() -> Result<i32> { .arg(Arg::new("items").num_args(0..).value_parser(clap::value_parser!(OsString))) .subcommand( Command::new("config") - .about("Show or edit diffr's configuration") - .subcommand_required(true) + .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") @@ -93,50 +91,31 @@ pub(crate) fn run() -> Result<i32> { 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); + let streaming = args.contains_id("format"); + let metadata_or_quiet = args.get_flag("quiet") || args.contains_id("metadata"); + if opens_tui(streaming, metadata_or_quiet) { + if !(io::stdin().is_terminal() && io::stdout().is_terminal()) { + return Err("diffr shows diffs in its terminal UI, which needs a terminal; use --format ndjson for the event stream".into()); + } + return launch_tui(&frontend_args, true); } - let streaming = args.get_one::<String>("format").unwrap() == "ndjson"; - if streaming && (args.get_flag("quiet") || args.contains_id("metadata")) { + if streaming && metadata_or_quiet { 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<OsString> = args .get_many::<OsString>("items") .into_iter() .flatten() .cloned() .collect(); - let display = DisplayOptions { - // Set once the configuration is read: `-U`, or the context plugin's lines. - num_context_lines: 0, - terminal_width: args - .get_one::<usize>("width") - .copied() - .unwrap_or_else(crate::options::detect_terminal_width), - use_color: !args.get_flag("no-color") - && crate::options::should_use_color( - match args.get_one::<String>("color").unwrap().as_str() { - "always" => crate::options::ColorOutput::Always, - "never" => crate::options::ColorOutput::Never, - _ => crate::options::ColorOutput::Auto, - }, - ), - display_mode: match args.get_one::<String>("display").unwrap().as_str() { - "inline" => DisplayMode::Inline, - "side-by-side-show-both" => DisplayMode::SideBySideShowBoth, - _ => DisplayMode::SideBySide, - }, - ..DisplayOptions::default() - }; if args.get_flag("no-index") { return no_index( &args, items.into_iter().chain(explicit_paths).collect(), - &display, + stream_options, ); } if args.get_flag("null") && !args.get_flag("name-only") && !args.get_flag("name-status") { @@ -163,59 +142,52 @@ pub(crate) fn run() -> Result<i32> { .cloned() .collect(), }; - let has_changes = if args.get_flag("quiet") || args.contains_id("metadata") { + if metadata_or_quiet { let diff = comparison.resolve(&repo)?.diff(&repo, &files)?; let changed = diff.deltas().len() > 0; if !args.get_flag("quiet") { - print_metadata(&diff, &args, display.terminal_width)?; + let width = args + .get_one::<usize>("width") + .copied() + .unwrap_or_else(crate::options::detect_terminal_width); + print_metadata(&diff, &args, width)?; } - changed + return Ok(i32::from( + changed && (args.get_flag("exit-code") || args.get_flag("quiet")), + )); + } + let mut config = load_config(&args)?; + apply_unified(&args, &mut config); + let params = Arc::new(config.compile()?); + let diff_options = diff_options(&args, ¶ms); + // The whole chain: `plugins.<name>` and why the plugin cannot be made. + let pipeline = + Pipeline::from_config(¶ms.plugins, workspace).map_err(|error| format!("{error:#}"))?; + let mut session = DiffSession::open( + workspace, + comparison, + Arc::clone(¶ms), + &files, + &pipeline, + )?; + session.diff_options = diff_options; + let changed = session.remaining() > 0; + let jobs = *args.get_one::<usize>("jobs").unwrap(); + if jobs == 0 { + return Err("--jobs must be at least 1".into()); + } + let ended = crate::protocol::stream::write( + session, + jobs, + Arc::new(pipeline), + stream_options, + &mut io::stdout().lock(), + )?; + Ok(if ended.failed || ended.aborted { + 2 } else { - let mut config = load_config(&args)?; - let display = DisplayOptions { - num_context_lines: context_lines(&args, &mut config), - ..display - }; - let params = Arc::new(config.compile()?); - let diff_options = diff_options(&args, ¶ms); - let pipeline = if streaming { - // The whole chain: `plugins.<name>` and why the plugin cannot be made. - Pipeline::from_config(¶ms.plugins, workspace).map_err(|error| format!("{error:#}"))? - } else { - Pipeline::default() - }; - let mut session = - DiffSession::open(workspace, comparison, Arc::clone(¶ms), &files, &pipeline)?; - session.diff_options = diff_options; - let changed = session.remaining() > 0; - if streaming { - let jobs = *args.get_one::<usize>("jobs").unwrap(); - if jobs == 0 { - return Err("--jobs must be at least 1".into()); - } - let ended = crate::protocol::stream::write( - session, - jobs, - Arc::new(pipeline), - &mut io::stdout().lock(), - )?; - return Ok(if ended.failed || ended.aborted { - 2 - } else { - i32::from(changed && args.get_flag("exit-code")) - }); - } - while let Some((file, loaded)) = session.load() { - let result = loaded - .and_then(|loaded| loaded.diff(&display)) - .map_err(|error| format!("{}: {error:#}", file.path()))?; - crate::print_diff_result(&display, &result); - } - changed - }; - Ok(i32::from( - has_changes && (args.get_flag("exit-code") || args.get_flag("quiet")), - )) + i32::from(changed && args.get_flag("exit-code")) + }) } fn select( @@ -416,7 +388,11 @@ fn print_metadata(diff: &git2::Diff<'_>, args: &ArgMatches, width: usize) -> Res Ok(()) } -fn no_index(args: &ArgMatches, paths: Vec<OsString>, display: &DisplayOptions) -> Result<i32> { +fn no_index( + args: &ArgMatches, + paths: Vec<OsString>, + stream_options: crate::protocol::stream::Options, +) -> Result<i32> { if paths.len() != 2 { return Err("--no-index requires two file paths".into()); } @@ -444,10 +420,7 @@ fn no_index(args: &ArgMatches, paths: Vec<OsString>, display: &DisplayOptions) - return Ok(i32::from(changed)); } let mut config = load_config(args)?; - let display = &DisplayOptions { - num_context_lines: context_lines(args, &mut config), - ..display.clone() - }; + apply_unified(args, &mut config); let config = config.compile()?; let options = &diff_options(args, &config); let lhs = crate::options::FileArgument::from_path_argument(&paths[0]); @@ -456,38 +429,31 @@ fn no_index(args: &ArgMatches, paths: Vec<OsString>, display: &DisplayOptions) - crate::diff_file( &config, &paths[1].to_string_lossy(), - None, &lhs, &rhs, - lhs.permissions().as_ref(), - rhs.permissions().as_ref(), - display, options, false, &[], &[], ) }; - if args.get_one::<String>("format").map(String::as_str) == Some("ndjson") { - let pipeline = Pipeline::from_config(&config.plugins, &std::env::current_dir()?) - .map_err(|error| format!("{error:#}"))?; - 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, - &pipeline, - &mut io::stdout().lock(), - )?; - Ok(if ended.failed || ended.aborted { - 2 - } else { - i32::from(changed && args.get_flag("exit-code")) - }) + let pipeline = Pipeline::from_config(&config.plugins, &std::env::current_dir()?) + .map_err(|error| format!("{error:#}"))?; + 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, + &pipeline, + stream_options, + &mut io::stdout().lock(), + )?; + Ok(if ended.failed || ended.aborted { + 2 } else { - crate::print_diff_result(display, &compute()?); - Ok(i32::from(changed)) - } + i32::from(changed && args.get_flag("exit-code")) + }) } /// The engine limits: the configured `[diff]` table, then the command-line @@ -506,9 +472,11 @@ fn diff_options(args: &ArgMatches, params: &config::Params) -> DiffOptions { options } -/// Unchanged lines kept around each change: `-U`, which also overrides the -/// context plugin's `lines` for this run, or that setting. -fn context_lines(args: &ArgMatches, config: &mut Config) -> u32 { +/// `-U` overrides the context plugin's `lines` for this run. +fn apply_unified(args: &ArgMatches, config: &mut Config) { + let Some(unified) = args.get_one::<u32>("unified") else { + return; + }; let lines = config .plugins .entries @@ -517,13 +485,7 @@ fn context_lines(args: &ArgMatches, config: &mut Config) -> u32 { .options .get_mut("lines") .expect("plugins.context.lines has a default"); - if let Some(unified) = args.get_one::<u32>("unified") { - *lines = serde_json::Value::from(*unified); - } - lines - .as_u64() - .and_then(|lines| u32::try_from(lines).ok()) - .expect("plugins.context.lines is validated as an integer in u32's range") + *lines = serde_json::Value::from(*unified); } /// The global file, or the `--config` file in its place. @@ -533,7 +495,7 @@ fn load_config(args: &ArgMatches) -> Result<Config> { )?) } -/// `diffr config schema`, `show` or `set`. +/// `diffr config`: the settings screen, or one of `schema`, `show`, `set`. fn run_config(args: &ArgMatches, sub: &ArgMatches) -> Result<i32> { let mut stdout = io::stdout().lock(); match sub.subcommand() { @@ -564,17 +526,27 @@ fn run_config(args: &ArgMatches, sub: &ArgMatches) -> Result<i32> { set.get_one::<String>("value").unwrap(), )?; } - _ => unreachable!("clap requires a config command"), + 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::<String>("query") { + frontend.push(query.into()); + } + return launch_tui(&frontend, false); + } } Ok(0) } -/// Explicit machine/text modes and redirected output must never enter the alternate screen. -fn opens_tui(explicit_format: bool, metadata_or_quiet: bool, terminal: bool) -> bool { - terminal && !explicit_format && !metadata_or_quiet +/// Without `--format`, a comparison opens the terminal UI; metadata and +/// `--quiet` print, or exit, without it. +fn opens_tui(explicit_format: bool, metadata_or_quiet: bool) -> bool { + !explicit_format && !metadata_or_quiet } -fn launch_tui(args: &[OsString]) -> Result<i32> { +/// `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<i32> { let entry = std::env::var_os("DIFFR_TUI_ENTRY") .map(PathBuf::from) .unwrap_or_else(|| { @@ -582,15 +554,29 @@ fn launch_tui(args: &[OsString]) -> Result<i32> { }); if !entry.is_file() { return Err( - "Terminal frontend is unavailable; install the tui dependencies or use --format text" + "Terminal frontend is unavailable; install the tui dependencies or use --format ndjson" .into(), ); } 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() - .map_err(|error| format!("Could not launch terminal frontend: {error}. Install Bun and run bun install in tui/, or use --format text."))?; + 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 <exe> [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 ndjson."))?; Ok(status.code().unwrap_or(2)) } @@ -598,10 +584,9 @@ fn launch_tui(args: &[OsString]) -> Result<i32> { mod tui_launch_tests { use super::opens_tui; #[test] - fn only_implicit_interactive_output_opens_the_viewer() { - assert!(opens_tui(false, false, true)); - assert!(!opens_tui(true, false, true)); - assert!(!opens_tui(false, true, true)); - assert!(!opens_tui(false, false, false)); + fn only_a_comparison_without_format_opens_the_viewer() { + assert!(opens_tui(false, false)); + assert!(!opens_tui(true, false)); + assert!(!opens_tui(false, true)); } } diff --git a/src/conflicts.rs b/src/conflicts.rs deleted file mode 100644 index 466021245..000000000 --- a/src/conflicts.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Apply conflict markers to obtain the original file contents. -//! -//! <https://git-scm.com/docs/git-merge#Documentation/git-merge.txt-mergeconflictStyle> - -use ConflictState::*; - -#[derive(Debug, Clone, Copy)] -enum ConflictState { - NoConflict, - Left, - Base, - Right, -} - -pub(crate) const START_LHS_MARKER: &str = "<<<<<<<"; -const START_BASE_MARKER: &str = "|||||||"; -const START_RHS_MARKER: &str = "======="; -const END_RHS_MARKER: &str = ">>>>>>>"; - -pub(crate) struct ConflictFiles { - pub(crate) lhs_name: Option<String>, - pub(crate) lhs_content: String, - pub(crate) rhs_name: Option<String>, - pub(crate) rhs_content: String, - pub(crate) num_conflicts: usize, -} - -/// Convert a string with conflict markers into the two conflicting -/// file contents. -pub(crate) fn apply_conflict_markers(s: &str) -> Result<ConflictFiles, String> { - let mut lhs_name: Option<String> = None; - let mut rhs_name: Option<String> = None; - - let mut lhs_content = String::with_capacity(s.len()); - let mut rhs_content = String::with_capacity(s.len()); - let mut num_conflicts = 0; - - let mut state = NoConflict; - let mut conflict_start_line = None; - for (i, line) in s.split_inclusive('\n').enumerate() { - if let Some(hunk_lhs_name) = line.strip_prefix(START_LHS_MARKER) { - state = Left; - num_conflicts += 1; - conflict_start_line = Some(i); - - let hunk_lhs_name = hunk_lhs_name.trim(); - if !hunk_lhs_name.is_empty() { - let should_replace = match &lhs_name { - Some(prev_name) => hunk_lhs_name.len() > prev_name.len(), - None => true, - }; - if should_replace { - lhs_name = Some(hunk_lhs_name.to_owned()); - } - } - - continue; - } - if line.starts_with(START_BASE_MARKER) { - state = Base; - continue; - } - if line.starts_with(START_RHS_MARKER) { - state = Right; - continue; - } - if let Some(hunk_rhs_name) = line.strip_prefix(END_RHS_MARKER) { - state = NoConflict; - - let hunk_rhs_name = hunk_rhs_name.trim(); - if !hunk_rhs_name.is_empty() { - let should_replace = match &rhs_name { - Some(prev_name) => hunk_rhs_name.len() > prev_name.len(), - None => true, - }; - if should_replace { - rhs_name = Some(hunk_rhs_name.to_owned()); - } - } - continue; - } - - match state { - NoConflict => { - lhs_content.push_str(line); - rhs_content.push_str(line); - } - Left => { - lhs_content.push_str(line); - } - Right => { - rhs_content.push_str(line); - } - Base => {} - } - } - - if matches!(state, NoConflict) { - Ok(ConflictFiles { - lhs_name, - lhs_content, - rhs_name, - rhs_content, - num_conflicts, - }) - } else { - let message = match conflict_start_line { - Some(line_i) => format!( - "Could not parse conflict markers, line {} has no matching {}.", - line_i, END_RHS_MARKER - ), - None => "Could not parse conflict markers.".to_owned(), - }; - Err(message) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_with_base() { - // Deliberately avoid a multiline string literal to avoid - // confusing text editors when we open this file. - let s = "before\n<<<<<<< Temporary merge branch 1\nnew in left\n||||||| merged common ancestors\noriginal\n=======\nnew in right\n>>>>>>> Temporary merge branch 2\nafter"; - - let conflict_files = apply_conflict_markers(s).unwrap(); - assert_eq!(conflict_files.lhs_content, "before\nnew in left\nafter"); - assert_eq!(conflict_files.rhs_content, "before\nnew in right\nafter"); - - assert_eq!(conflict_files.lhs_name.unwrap(), "Temporary merge branch 1"); - assert_eq!(conflict_files.rhs_name.unwrap(), "Temporary merge branch 2"); - } - - #[test] - fn test_without_base() { - // Deliberately avoid a multiline string literal to avoid - // confusing text editors when we open this file. - let s = "before\n<<<<<<< Temporary merge branch 1\nnew in left\n=======\nnew in right\n>>>>>>> Temporary merge branch 2\nafter"; - - let conflict_files = apply_conflict_markers(s).unwrap(); - assert_eq!(conflict_files.lhs_content, "before\nnew in left\nafter"); - assert_eq!(conflict_files.rhs_content, "before\nnew in right\nafter"); - - assert_eq!(conflict_files.lhs_name.unwrap(), "Temporary merge branch 1"); - assert_eq!(conflict_files.rhs_name.unwrap(), "Temporary merge branch 2"); - } -} diff --git a/src/display/hunks.rs b/src/display/hunks.rs deleted file mode 100644 index 63f3a228d..000000000 --- a/src/display/hunks.rs +++ /dev/null @@ -1,865 +0,0 @@ -//! Calculating which modified lines should be displayed together. - -/// The maximum number of lines that may occur between changed lines in a hunk. -/// -/// If we exceed this, the lines are stored in separate hunks. -const MAX_DISTANCE: u32 = 4; - -use line_numbers::LineNumber; - -use crate::constants::Side; -use crate::display::context::{add_context, opposite_positions}; -use crate::display::side_by_side::lines_with_novel; -use crate::hash::{DftHashMap, DftHashSet}; -use crate::parse::syntax::{zip_pad_shorter, MatchKind, MatchedPos}; - -/// A hunk represents a series of modified lines that are displayed -/// together. -#[derive(Debug, Clone)] -pub(crate) struct Hunk { - /// The LHS line numbers that contain novel content. - pub(crate) novel_lhs: DftHashSet<LineNumber>, - /// The RHS line numbers that contain novel content. - pub(crate) novel_rhs: DftHashSet<LineNumber>, - /// Aligned source rows. In DiffResult these include ordinary padding and - /// enclosing syntax context. Discontinuities represent hidden gaps. - pub(crate) lines: Vec<(Option<LineNumber>, Option<LineNumber>)>, -} - -impl Hunk { - pub(crate) fn merge(self, other: &Self) -> Self { - let mut lines = self.lines; - lines.extend(other.lines.iter()); - - let mut lhs_seen: DftHashSet<LineNumber> = DftHashSet::default(); - let mut rhs_seen: DftHashSet<LineNumber> = DftHashSet::default(); - - let mut deduped_lines = vec![]; - for (lhs_line, rhs_line) in lines { - let lhs_is_dupe = match lhs_line { - Some(lhs_line) => { - let seen_already = lhs_seen.contains(&lhs_line); - lhs_seen.insert(lhs_line); - seen_already - } - None => false, - }; - let rhs_is_dupe = match rhs_line { - Some(rhs_line) => { - let seen_already = rhs_seen.contains(&rhs_line); - rhs_seen.insert(rhs_line); - seen_already - } - None => false, - }; - if lhs_is_dupe && rhs_is_dupe { - continue; - } - - deduped_lines.push(( - if lhs_is_dupe { None } else { lhs_line }, - if rhs_is_dupe { None } else { rhs_line }, - )); - } - - Self { - novel_lhs: self.novel_lhs.union(&other.novel_lhs).copied().collect(), - novel_rhs: self.novel_rhs.union(&other.novel_rhs).copied().collect(), - lines: deduped_lines, - } - } -} - -fn fill_between( - prev_lhs: Option<LineNumber>, - next_lhs: Option<LineNumber>, - prev_rhs: Option<LineNumber>, - next_rhs: Option<LineNumber>, -) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - let mut lhs_lines = vec![]; - if let (Some(prev_lhs), Some(next_lhs)) = (prev_lhs, next_lhs) { - if prev_lhs.0 + 1 < next_lhs.0 { - lhs_lines = (prev_lhs.0 + 1..next_lhs.0).map(LineNumber::from).collect(); - } - } - - let mut rhs_lines = vec![]; - if let (Some(prev_rhs), Some(next_rhs)) = (prev_rhs, next_rhs) { - if prev_rhs.0 + 1 < next_rhs.0 { - rhs_lines = (prev_rhs.0 + 1..next_rhs.0).map(LineNumber::from).collect(); - } - } - - zip_pad_shorter(&lhs_lines, &rhs_lines) -} - -fn extract_lines(hunk: &Hunk) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - let mut min_lhs = None; - let mut min_rhs = None; - let mut max_lhs = None; - let mut max_rhs = None; - - // Relevant lines. - let mut relevant = vec![]; - for (left_line, right_line) in &hunk.lines { - // Fill in the gap. - let mut inbetween = fill_between(max_lhs, *left_line, max_rhs, *right_line); - relevant.append(&mut inbetween); - - if let Some(l) = left_line { - if min_lhs.is_none() { - min_lhs = Some(*l); - } - max_lhs = Some(*l); - } - - if let Some(r) = right_line { - if min_rhs.is_none() { - min_rhs = Some(*r); - } - max_rhs = Some(*r); - } - - relevant.push((*left_line, *right_line)); - } - - relevant -} - -/// If any hunks are sufficiently close that they have overlapping -/// lines, merge them. -/// -/// For example, given a hunk from lines 8-10 and a hunk from lines -/// 12-15 with 5 context lines, combine the two hunks. -pub(crate) fn merge_adjacent( - hunks: &[Hunk], - opposite_to_lhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - opposite_to_rhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - max_lhs_src_line: LineNumber, - max_rhs_src_line: LineNumber, - num_context_lines: usize, -) -> Vec<Hunk> { - let mut merged_hunks: Vec<Hunk> = vec![]; - let mut prev_hunk: Option<Hunk> = None; - - let mut prev_lhs_lines: DftHashSet<LineNumber> = DftHashSet::default(); - let mut prev_rhs_lines: DftHashSet<LineNumber> = DftHashSet::default(); - - for hunk in hunks { - let mut lhs_lines: DftHashSet<LineNumber> = DftHashSet::default(); - let mut rhs_lines: DftHashSet<LineNumber> = DftHashSet::default(); - - let lines = extract_lines(hunk); - let contextual_lines = add_context( - &lines, - opposite_to_lhs, - opposite_to_rhs, - max_lhs_src_line, - max_rhs_src_line, - num_context_lines, - ); - for (lhs_line, rhs_line) in contextual_lines { - if let Some(lhs_line) = lhs_line { - lhs_lines.insert(lhs_line); - } - if let Some(rhs_line) = rhs_line { - rhs_lines.insert(rhs_line); - } - } - - match prev_hunk { - Some(hunk_so_far) => { - if lhs_lines.is_disjoint(&prev_lhs_lines) && rhs_lines.is_disjoint(&prev_rhs_lines) - { - // No overlaps, start a new hunk. - merged_hunks.push(hunk_so_far.clone()); - prev_hunk = Some(hunk.clone()); - - prev_lhs_lines = lhs_lines; - prev_rhs_lines = rhs_lines; - } else { - // Adjacent hunks, merge. - prev_hunk = Some(hunk_so_far.merge(hunk)); - prev_lhs_lines.extend(lhs_lines.iter()); - prev_rhs_lines.extend(rhs_lines.iter()); - } - } - None => { - // The very first hunk. - prev_hunk = Some(hunk.clone()); - prev_lhs_lines = lhs_lines; - prev_rhs_lines = rhs_lines; - } - } - } - - if let Some(current_hunk) = prev_hunk { - merged_hunks.push(current_hunk); - } - - merged_hunks -} - -fn lines_are_close( - max_lhs: Option<LineNumber>, - max_rhs: Option<LineNumber>, - line: (Option<LineNumber>, Option<LineNumber>), -) -> bool { - let (lhs, rhs) = line; - - if let (Some(max_lhs_number), Some(lhs_number)) = (max_lhs, lhs) { - if lhs_number.0 <= max_lhs_number.0 + MAX_DISTANCE { - return true; - } - } - if let (Some(max_rhs_number), Some(rhs_number)) = (max_rhs, rhs) { - if rhs_number.0 <= max_rhs_number.0 + MAX_DISTANCE { - return true; - } - } - - false -} - -/// Given a slice of line pairs that are ordered as far as possible, -/// but may refer to earlier lines, return line pairs that are -/// monotonically increasing. If an earlier line occurs after a later -/// line, replace that line with None. -fn enforce_increasing( - lines: &[(Option<LineNumber>, Option<LineNumber>)], -) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - let mut ordered_lines = vec![]; - - let mut max_lhs_line: Option<LineNumber> = None; - let mut max_rhs_line: Option<LineNumber> = None; - - for (mut lhs_line, mut rhs_line) in lines { - // If this LHS line is before the max line we've seen, discard it. - match max_lhs_line { - Some(max_lhs_line_number) => match lhs_line { - Some(lhs_line_number) if lhs_line_number > max_lhs_line_number => { - max_lhs_line = lhs_line; - } - _ => { - lhs_line = None; - } - }, - None => { - max_lhs_line = lhs_line; - } - } - - // If this RHS line is before the max line we've seen, discard it. - match max_rhs_line { - Some(max_rhs_line_number) => match rhs_line { - Some(rhs_line_number) if rhs_line_number > max_rhs_line_number => { - max_rhs_line = rhs_line; - } - _ => { - rhs_line = None; - } - }, - None => { - max_rhs_line = rhs_line; - } - } - - if lhs_line.is_some() || rhs_line.is_some() { - ordered_lines.push((lhs_line, rhs_line)); - } - } - - ordered_lines -} - -fn find_novel_lines( - lines: &[(Option<LineNumber>, Option<LineNumber>)], - all_lhs_novel: &DftHashSet<LineNumber>, - all_rhs_novel: &DftHashSet<LineNumber>, -) -> (DftHashSet<LineNumber>, DftHashSet<LineNumber>) { - let mut lhs_novel = DftHashSet::default(); - let mut rhs_novel = DftHashSet::default(); - - for (lhs_line, rhs_line) in lines { - if let Some(lhs_line) = lhs_line { - if all_lhs_novel.contains(lhs_line) { - lhs_novel.insert(*lhs_line); - } - } - if let Some(rhs_line) = rhs_line { - if all_rhs_novel.contains(rhs_line) { - rhs_novel.insert(*rhs_line); - } - } - } - - (lhs_novel, rhs_novel) -} - -/// Split lines into hunks. -fn lines_to_hunks( - lines: &[(Option<LineNumber>, Option<LineNumber>)], - lhs_mps: &[MatchedPos], - rhs_mps: &[MatchedPos], -) -> Vec<Hunk> { - let (all_lhs_novel, all_rhs_novel) = lines_with_novel(lhs_mps, rhs_mps); - - let mut hunks = vec![]; - let mut current_hunk_lines = vec![]; - let mut max_lhs_line: Option<LineNumber> = None; - let mut max_rhs_line: Option<LineNumber> = None; - - for line in enforce_increasing(lines) { - let (lhs_line, rhs_line) = line; - - if current_hunk_lines.is_empty() || lines_are_close(max_lhs_line, max_rhs_line, line) { - current_hunk_lines.push(line); - } else { - let (novel_lhs, novel_rhs) = - find_novel_lines(¤t_hunk_lines, &all_lhs_novel, &all_rhs_novel); - hunks.push(Hunk { - novel_lhs, - novel_rhs, - lines: current_hunk_lines, - }); - current_hunk_lines = vec![line]; - } - - if let Some(lhs_line) = lhs_line { - max_lhs_line = Some(lhs_line); - } - if let Some(rhs_line) = rhs_line { - max_rhs_line = Some(rhs_line); - } - } - - if !current_hunk_lines.is_empty() { - let (novel_lhs, novel_rhs) = - find_novel_lines(¤t_hunk_lines, &all_lhs_novel, &all_rhs_novel); - hunks.push(Hunk { - novel_lhs, - novel_rhs, - lines: current_hunk_lines, - }); - } - - hunks -} - -/// Given a sequence of novel [`MatchedPos`] values in a section between -/// two unchanged [`MatchedPos`] values, return them in an order suited -/// for displaying. -/// -/// ```text -/// unchanged-before novel-1 -/// novel-2 -/// novel-3 -/// novel-4 unchanged-after -/// ``` -/// -/// We need novel-1 to occur before novel-2/3, and novel-2/3 to occur -/// before novel-4, so we can safely interleave LHS and RHS whilst -/// still being monotonically increasing. -fn novel_section_in_order( - lhs_novel_mps: &[&MatchedPos], - rhs_novel_mps: &[&MatchedPos], - lhs_prev_matched_line: Option<LineNumber>, - rhs_prev_matched_line: Option<LineNumber>, - opposite_to_lhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - opposite_to_rhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, -) -> Vec<(Side, MatchedPos)> { - let mut res: Vec<(Side, MatchedPos)> = vec![]; - - let mut lhs_iter = lhs_novel_mps.iter().peekable(); - let mut rhs_iter = rhs_novel_mps.iter().peekable(); - - // Novel MatchedPos values that occur on the same line as the - // previous unchanged MatchedPos must occur first. - while let Some(lhs_mp) = lhs_iter.peek() { - let same_line_as_prev = if let Some(lhs_prev_matched_line) = lhs_prev_matched_line { - lhs_mp.pos.line == lhs_prev_matched_line - } else { - false - }; - if same_line_as_prev { - res.push((Side::Left, (**lhs_mp).clone())); - lhs_iter.next(); - } else { - break; - } - } - while let Some(rhs_mp) = rhs_iter.peek() { - let same_line_as_prev = if let Some(rhs_prev_matched_line) = rhs_prev_matched_line { - rhs_mp.pos.line == rhs_prev_matched_line - } else { - false - }; - if same_line_as_prev { - res.push((Side::Right, (**rhs_mp).clone())); - rhs_iter.next(); - } else { - break; - } - } - - // Next, we want novel MatchedPos values that occur on lines - // without any unchanged MatchedPos values. - while let Some(lhs_mp) = lhs_iter.peek() { - if opposite_to_lhs.contains_key(&lhs_mp.pos.line) { - break; - } - res.push((Side::Left, (**lhs_mp).clone())); - lhs_iter.next(); - } - while let Some(rhs_mp) = rhs_iter.peek() { - if opposite_to_rhs.contains_key(&rhs_mp.pos.line) { - break; - } - res.push((Side::Right, (**rhs_mp).clone())); - rhs_iter.next(); - } - - // Finally, the remainder of the novel MatchedPos values will be - // on the same line as the following unchanged MatchedPos value. - for lhs_mp in lhs_iter { - res.push((Side::Left, (*lhs_mp).clone())); - } - for rhs_mp in rhs_iter { - res.push((Side::Right, (*rhs_mp).clone())); - } - - res -} - -/// Return a vec of novel [`MatchedPos`] values in an order suited for -/// displaying. -/// -/// Since novel positions don't have a corresponding opposite -/// position, use the last opposite matched position to decide -/// ordering. -fn sorted_novel_positions( - lhs_mps: &[MatchedPos], - rhs_mps: &[MatchedPos], - opposite_to_lhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - opposite_to_rhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, -) -> Vec<(Side, MatchedPos)> { - let mut lhs_mps: Vec<MatchedPos> = lhs_mps.to_vec(); - lhs_mps.sort_unstable_by_key(|mp| mp.pos); - - let mut rhs_mps: Vec<MatchedPos> = rhs_mps.to_vec(); - rhs_mps.sort_unstable_by_key(|mp| mp.pos); - - let mut res: Vec<(Side, MatchedPos)> = vec![]; - - let mut lhs_prev_matched_line = None; - let mut rhs_prev_matched_line = None; - let mut lhs_novel_section = vec![]; - let mut rhs_novel_section = vec![]; - - let mut lhs_iter = lhs_mps.iter().peekable(); - let mut rhs_iter = rhs_mps.iter().peekable(); - loop { - match (lhs_iter.peek(), rhs_iter.peek()) { - ( - Some(MatchedPos { - kind: MatchKind::Ignored { .. }, - .. - }), - _, - ) => { - // Ignored nodes shouldn't be treated as novel, - // because we don't want to create hunks if we see - // them. However, they don't have corresponding - // positions on the other side, so just skip. - lhs_iter.next(); - } - ( - _, - Some(MatchedPos { - kind: MatchKind::Ignored { .. }, - .. - }), - ) => { - // Ignored nodes shouldn't be treated as novel, - // because we don't want to create hunks if we see - // them. However, they don't have corresponding - // positions on the other side, so just skip. - rhs_iter.next(); - } - (Some(lhs_mp), Some(rhs_mp)) if !lhs_mp.kind.is_novel() && !rhs_mp.kind.is_novel() => { - res.append(&mut novel_section_in_order( - &lhs_novel_section, - &rhs_novel_section, - lhs_prev_matched_line, - rhs_prev_matched_line, - opposite_to_lhs, - opposite_to_rhs, - )); - lhs_novel_section = vec![]; - rhs_novel_section = vec![]; - - lhs_prev_matched_line = Some(lhs_mp.pos.line); - rhs_prev_matched_line = Some(rhs_mp.pos.line); - lhs_iter.next(); - rhs_iter.next(); - } - (Some(lhs_mp), _) if lhs_mp.kind.is_novel() => { - lhs_novel_section.push(lhs_mp); - lhs_iter.next(); - } - (_, Some(rhs_mp)) if rhs_mp.kind.is_novel() => { - rhs_novel_section.push(rhs_mp); - rhs_iter.next(); - } - (None, None) => { - break; - } - (lhs_mp, rhs_mp) => { - unreachable!("Should be impossible: every LHS Unchanged MatchedPos should have a corresponding RHS Unchanged MatchedPos\n {:?}\n {:?}", lhs_mp, rhs_mp); - } - } - } - - res.append(&mut novel_section_in_order( - &lhs_novel_section, - &rhs_novel_section, - lhs_prev_matched_line, - rhs_prev_matched_line, - opposite_to_lhs, - opposite_to_rhs, - )); - - res -} - -fn next_opposite( - line: LineNumber, - opposites: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - prev_opposite: Option<LineNumber>, -) -> Option<LineNumber> { - opposites.get(&line).and_then(|lines_set| { - let mut lines: Vec<LineNumber> = lines_set.iter().copied().collect(); - lines.sort_unstable(); - - lines.into_iter().find(|ln| { - if let Some(prev_opposite) = prev_opposite { - *ln > prev_opposite - } else { - true - } - }) - }) -} - -fn matched_novel_lines( - lhs_mps: &[MatchedPos], - rhs_mps: &[MatchedPos], -) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - let mut highest_lhs: Option<LineNumber> = None; - let mut highest_rhs: Option<LineNumber> = None; - - let opposite_to_lhs = opposite_positions(lhs_mps); - let opposite_to_rhs = opposite_positions(rhs_mps); - - let mut lines: Vec<(Option<LineNumber>, Option<LineNumber>)> = vec![]; - for (side, mp) in sorted_novel_positions(lhs_mps, rhs_mps, &opposite_to_lhs, &opposite_to_rhs) { - let self_line = mp.pos.line; - - match side { - Side::Left => { - let should_append = if let Some(highest_lhs) = highest_lhs { - self_line > highest_lhs - } else { - true - }; - if should_append { - lines.push(( - Some(self_line), - next_opposite(self_line, &opposite_to_lhs, highest_rhs), - )); - highest_lhs = Some(self_line); - } - } - Side::Right => { - let should_append = if let Some(highest_rhs) = highest_rhs { - self_line > highest_rhs - } else { - true - }; - if should_append { - lines.push(( - next_opposite(self_line, &opposite_to_rhs, highest_rhs), - Some(self_line), - )); - highest_rhs = Some(self_line); - } - } - } - } - - lines -} - -pub(crate) fn matched_pos_to_hunks(lhs_mps: &[MatchedPos], rhs_mps: &[MatchedPos]) -> Vec<Hunk> { - lines_to_hunks(&matched_novel_lines(lhs_mps, rhs_mps), lhs_mps, rhs_mps) -} - -fn either_side_equal( - x: &(Option<LineNumber>, Option<LineNumber>), - y: &(Option<LineNumber>, Option<LineNumber>), -) -> bool { - let (lhs_x, rhs_x) = x; - let (lhs_y, rhs_y) = y; - if let (Some(lhs_x), Some(lhs_y)) = (lhs_x, lhs_y) { - if lhs_x == lhs_y { - return true; - } - } - if let (Some(rhs_x), Some(rhs_y)) = (rhs_x, rhs_y) { - if rhs_x == rhs_y { - return true; - } - } - - false -} - -/// Given a set of matched lines between the LHS and RHS, return the -/// start and end indexes in `matched_lines` that should be displayed -/// for `hunk`. -pub(crate) fn matched_lines_indexes_for_hunk( - matched_lines: &[(Option<LineNumber>, Option<LineNumber>)], - hunk: &Hunk, - num_context_lines: usize, -) -> (usize, usize) { - let mut hunk_lhs_novel = hunk.novel_lhs.iter().copied().collect::<Vec<_>>(); - hunk_lhs_novel.sort(); - - let mut hunk_rhs_novel = hunk.novel_rhs.iter().copied().collect::<Vec<_>>(); - hunk_rhs_novel.sort(); - - let hunk_smallest = ( - hunk_lhs_novel.first().copied(), - hunk_rhs_novel.first().copied(), - ); - let hunk_largest = ( - hunk_lhs_novel.last().copied(), - hunk_rhs_novel.last().copied(), - ); - - // TODO: Use binary search instead. - let mut start_i = None; - for (i, matched_line) in matched_lines.iter().enumerate() { - if either_side_equal(matched_line, &hunk_smallest) { - start_i = Some(i); - break; - } - } - - let mut end_i = None; - for (i, matched_line) in matched_lines.iter().enumerate().rev() { - if either_side_equal(matched_line, &hunk_largest) { - end_i = Some(i + 1); - break; - } - } - - let mut start_i = start_i.expect("Hunk lines should be present in matched lines"); - let mut end_i = end_i.expect("Hunk lines should be present in matched lines"); - if start_i >= num_context_lines { - start_i -= num_context_lines; - } else { - start_i = 0; - } - - if end_i + num_context_lines < matched_lines.len() { - end_i += num_context_lines - } else { - end_i = matched_lines.len(); - } - - (start_i, end_i) -} - -#[cfg(test)] -mod tests { - use std::iter::FromIterator; - - use line_numbers::SingleLineSpan; - use pretty_assertions::assert_eq; - - use super::*; - use crate::hash::DftHashMap; - use crate::syntax::{MatchKind, TokenKind}; - - #[test] - fn test_sorted_novel_positions_simple() { - let novel_mp = MatchedPos { - kind: MatchKind::Novel { - highlight: TokenKind::Delimiter, - }, - pos: SingleLineSpan { - line: 0.into(), - start_col: 1, - end_col: 2, - }, - }; - let matched_mp = MatchedPos { - kind: MatchKind::UnchangedToken { - highlight: TokenKind::Delimiter, - self_pos: vec![SingleLineSpan { - line: 1.into(), - start_col: 1, - end_col: 2, - }], - opposite_pos: vec![SingleLineSpan { - line: 2.into(), - start_col: 1, - end_col: 2, - }], - }, - pos: SingleLineSpan { - line: 1.into(), - start_col: 1, - end_col: 2, - }, - }; - - let lhs_mps = vec![novel_mp.clone(), matched_mp.clone()]; - let res = sorted_novel_positions( - &lhs_mps, - &[matched_mp], - &DftHashMap::default(), - &DftHashMap::default(), - ); - - assert_eq!(res, vec![(Side::Left, novel_mp)]); - } - - #[test] - fn test_matched_pos_to_hunks() { - let matched_pos = SingleLineSpan { - line: 0.into(), - start_col: 2, - end_col: 3, - }; - - let lhs_mps = [ - MatchedPos { - kind: MatchKind::Novel { - highlight: TokenKind::Delimiter, - }, - pos: SingleLineSpan { - line: 0.into(), - start_col: 1, - end_col: 2, - }, - }, - MatchedPos { - kind: MatchKind::UnchangedToken { - highlight: TokenKind::Delimiter, - self_pos: vec![matched_pos], - opposite_pos: vec![matched_pos], - }, - pos: matched_pos, - }, - ]; - - let rhs_mps = [ - MatchedPos { - kind: MatchKind::Novel { - highlight: TokenKind::Delimiter, - }, - pos: SingleLineSpan { - line: 0.into(), - start_col: 1, - end_col: 2, - }, - }, - MatchedPos { - kind: MatchKind::UnchangedToken { - highlight: TokenKind::Delimiter, - self_pos: vec![matched_pos], - opposite_pos: vec![matched_pos], - }, - pos: matched_pos, - }, - ]; - - let hunks = matched_pos_to_hunks(&lhs_mps, &rhs_mps); - assert_eq!(hunks.len(), 1); - assert_eq!(hunks[0].lines, vec![(Some(0.into()), Some(0.into()))]); - } - - fn matched_lines_for_hunk<'a>( - matched_lines: &'a [(Option<LineNumber>, Option<LineNumber>)], - hunk: &Hunk, - ) -> &'a [(Option<LineNumber>, Option<LineNumber>)] { - let (start_i, end_i) = matched_lines_indexes_for_hunk(matched_lines, hunk, 3); - &matched_lines[start_i..end_i] - } - - #[test] - fn test_matched_lines_for_hunk() { - let matched_lines = &[ - (Some(0.into()), Some(0.into())), - (Some(1.into()), Some(1.into())), - (Some(2.into()), Some(2.into())), - ]; - - let novel_lhs = DftHashSet::from_iter([1.into()]); - let novel_rhs = DftHashSet::from_iter([1.into()]); - let hunk = Hunk { - novel_lhs, - novel_rhs, - lines: vec![(Some(1.into()), Some(1.into()))], - }; - - let res = matched_lines_for_hunk(matched_lines, &hunk); - assert_eq!( - res, - vec![ - (Some(0.into()), Some(0.into())), - (Some(1.into()), Some(1.into())), - (Some(2.into()), Some(2.into())), - ] - ); - } - - #[test] - fn test_matched_lines_for_hunk_misaligned() { - let matched_lines = &[ - (Some(0.into()), Some(0.into())), - (Some(1.into()), Some(1.into())), - (Some(2.into()), Some(2.into())), - (Some(3.into()), Some(3.into())), - (Some(4.into()), Some(4.into())), - (Some(5.into()), Some(5.into())), - ]; - - let novel_lhs = DftHashSet::from_iter([1.into()]); - let novel_rhs = DftHashSet::from_iter([2.into()]); - let hunk = Hunk { - novel_lhs, - novel_rhs, - // LHS and RHS are misaligned - lines: vec![(Some(1.into()), Some(2.into()))], - }; - - let res = matched_lines_for_hunk(matched_lines, &hunk); - assert_eq!( - res, - vec![ - (Some(0.into()), Some(0.into())), - (Some(1.into()), Some(1.into())), - (Some(2.into()), Some(2.into())), - // We want to show the full 3 lines of padding after - // the lower of the two lines, so up to line 5 - // inclusive. - (Some(3.into()), Some(3.into())), - (Some(4.into()), Some(4.into())), - (Some(5.into()), Some(5.into())), - ] - ); - } -} diff --git a/src/display/inline.rs b/src/display/inline.rs deleted file mode 100644 index 70e161a08..000000000 --- a/src/display/inline.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Inline, or "unified" diff display. - -use line_numbers::LineNumber; - -use crate::constants::Side; -use crate::display::hunks::Hunk; -use crate::display::style::{self, apply_colors, apply_line_number_color}; -use crate::lines::{format_line_num, format_line_num_padded, split_on_newlines, MaxLine}; -use crate::options::DisplayOptions; -use crate::parse::syntax::MatchedPos; -use crate::summary::FileFormat; - -pub(crate) fn print( - lhs_src: &str, - rhs_src: &str, - display_options: &DisplayOptions, - lhs_mps: &[MatchedPos], - rhs_mps: &[MatchedPos], - hunks: &[Hunk], - display_path: &str, - extra_info: &Option<String>, - file_format: &FileFormat, -) { - let (lhs_colored_lines, rhs_colored_lines) = if display_options.use_color { - ( - apply_colors( - lhs_src, - Side::Left, - display_options.syntax_highlight, - file_format, - display_options.background_color, - lhs_mps, - ), - apply_colors( - rhs_src, - Side::Right, - display_options.syntax_highlight, - file_format, - display_options.background_color, - rhs_mps, - ), - ) - } else { - ( - split_on_newlines(lhs_src) - .map(|s| format!("{}\n", s)) - .collect(), - split_on_newlines(rhs_src) - .map(|s| format!("{}\n", s)) - .collect(), - ) - }; - - let lhs_colored_lines: Vec<_> = lhs_colored_lines - .into_iter() - .map(|line| style::replace_tabs(&line, display_options.tab_width)) - .collect(); - let rhs_colored_lines: Vec<_> = rhs_colored_lines - .into_iter() - .map(|line| style::replace_tabs(&line, display_options.tab_width)) - .collect(); - - // Calculate the maximum line number width for alignment - let lhs_line_nums_width = format_line_num(lhs_src.max_line()).len(); - let rhs_line_nums_width = format_line_num(rhs_src.max_line()).len(); - - let lhs_lines: Vec<_> = split_on_newlines(lhs_src).collect(); - let rhs_lines: Vec<_> = split_on_newlines(rhs_src).collect(); - for (i, hunk) in hunks.iter().enumerate() { - println!( - "{}", - style::header( - display_path, - extra_info.as_ref(), - i + 1, - hunks.len(), - file_format, - display_options - ) - ); - - let mut removed = String::new(); - let mut added = String::new(); - let mut previous: (Option<LineNumber>, Option<LineNumber>) = (None, None); - for &(lhs, rhs) in &hunk.lines { - let has_gap = lhs - .zip(previous.0) - .is_some_and(|(line, prev)| line.0 > prev.0 + 1) - || rhs - .zip(previous.1) - .is_some_and(|(line, prev)| line.0 > prev.0 + 1); - if has_gap { - println!("{}{} ...", removed, added); - removed.clear(); - added.clear(); - } - if lhs.is_some() { - previous.0 = lhs; - } - if rhs.is_some() { - previous.1 = rhs; - } - if let (Some(left), Some(right)) = (lhs, rhs) { - if lhs_lines[left.as_usize()] == rhs_lines[right.as_usize()] { - print!("{}{}", removed, added); - removed.clear(); - added.clear(); - print!( - "{} {}", - apply_line_number_color( - &format_line_num_padded(left, lhs_line_nums_width), - false, - Side::Left, - display_options - ), - lhs_colored_lines[left.as_usize()] - ); - continue; - } - } - if let Some(line) = lhs { - removed.push_str(&format!( - "{} {}", - apply_line_number_color( - &format_line_num_padded(line, lhs_line_nums_width), - hunk.novel_lhs.contains(&line), - Side::Left, - display_options - ), - lhs_colored_lines[line.as_usize()] - )); - } - if let Some(line) = rhs { - added.push_str(&format!( - " {}{}", - apply_line_number_color( - &format_line_num_padded(line, rhs_line_nums_width), - hunk.novel_rhs.contains(&line), - Side::Right, - display_options - ), - rhs_colored_lines[line.as_usize()] - )); - } - } - print!("{}{}", removed, added); - println!(); - } -} diff --git a/src/display/json.rs b/src/display/json.rs deleted file mode 100644 index 76c20fcb2..000000000 --- a/src/display/json.rs +++ /dev/null @@ -1,341 +0,0 @@ -use std::collections::BTreeMap; - -use line_numbers::LineNumber; -use serde::ser::SerializeStruct; -use serde::{Serialize, Serializer}; - -use crate::display::context::{all_matched_lines_filled, opposite_positions}; -use crate::display::hunks::{matched_lines_indexes_for_hunk, matched_pos_to_hunks, merge_adjacent}; -use crate::display::side_by_side::lines_with_novel; -use crate::lines::MaxLine; -use crate::parse::syntax::{self, MatchedPos, StringKind}; -use crate::summary::{DiffResult, FileContent, FileFormat}; - -#[derive(Debug, Serialize, PartialEq)] -#[serde(rename_all = "lowercase")] -enum Status { - Unchanged, - Changed, - Created, - Deleted, -} - -#[derive(Debug)] -struct File<'f> { - language: &'f FileFormat, - path: &'f str, - aligned_lines: Vec<(Option<u32>, Option<u32>)>, - chunks: Vec<Vec<Line<'f>>>, - status: Status, -} - -impl<'f> File<'f> { - fn with_sections( - language: &'f FileFormat, - path: &'f str, - aligned_lines: Vec<(Option<u32>, Option<u32>)>, - chunks: Vec<Vec<Line<'f>>>, - ) -> Self { - File { - language, - path, - aligned_lines, - chunks, - status: Status::Changed, - } - } - - fn with_status(language: &'f FileFormat, path: &'f str, status: Status) -> Self { - File { - language, - path, - aligned_lines: Vec::new(), - chunks: Vec::new(), - status, - } - } -} - -impl<'f> From<&'f DiffResult> for File<'f> { - fn from(summary: &'f DiffResult) -> Self { - match (&summary.lhs_src, &summary.rhs_src) { - (FileContent::Text(lhs_src), FileContent::Text(rhs_src)) => { - // TODO: move into function as it effectively duplicates the hunk computation in main::diff_file_content. - let opposite_to_lhs = opposite_positions(&summary.lhs_positions); - let opposite_to_rhs = opposite_positions(&summary.rhs_positions); - - let hunks = matched_pos_to_hunks(&summary.lhs_positions, &summary.rhs_positions); - let hunks = merge_adjacent( - &hunks, - &opposite_to_lhs, - &opposite_to_rhs, - lhs_src.max_line(), - rhs_src.max_line(), - 0, - ); - - if hunks.is_empty() { - return File::with_status( - &summary.file_format, - &summary.display_path, - Status::Unchanged, - ); - } - - if lhs_src.is_empty() { - return File::with_status( - &summary.file_format, - &summary.display_path, - Status::Created, - ); - } - if rhs_src.is_empty() { - return File::with_status( - &summary.file_format, - &summary.display_path, - Status::Deleted, - ); - } - - let lhs_lines = lhs_src.split('\n').collect::<Vec<&str>>(); - let rhs_lines = rhs_src.split('\n').collect::<Vec<&str>>(); - - let (lhs_lines_with_novel, rhs_lines_with_novel) = - lines_with_novel(&summary.lhs_positions, &summary.rhs_positions); - - let matched_lines = all_matched_lines_filled( - &summary.lhs_positions, - &summary.rhs_positions, - &lhs_lines, - &rhs_lines, - ); - - // Convert alignment to serializable format (Option<u32>, Option<u32>) - let aligned_lines: Vec<(Option<u32>, Option<u32>)> = matched_lines - .iter() - .map(|(lhs, rhs)| (lhs.map(|l| l.0), rhs.map(|l| l.0))) - .collect(); - - let mut matched_lines = &matched_lines[..]; - - let mut chunks = Vec::with_capacity(hunks.len()); - for hunk in &hunks { - let mut lines = BTreeMap::new(); - - let (start_i, end_i) = matched_lines_indexes_for_hunk(matched_lines, hunk, 0); - let aligned_lines = &matched_lines[start_i..end_i]; - matched_lines = &matched_lines[start_i..]; - - for (lhs_line_num, rhs_line_num) in aligned_lines { - if !lhs_lines_with_novel.contains(&lhs_line_num.unwrap_or(LineNumber(0))) - && !rhs_lines_with_novel - .contains(&rhs_line_num.unwrap_or(LineNumber(0))) - { - continue; - } - - let line = lines - .entry((lhs_line_num.map(|l| l.0), rhs_line_num.map(|l| l.0))) - .or_insert_with(|| { - Line::new(lhs_line_num.map(|l| l.0), rhs_line_num.map(|l| l.0)) - }); - - if let Some(line_num) = lhs_line_num { - add_changes_to_side( - line.lhs.as_mut().unwrap(), - *line_num, - &lhs_lines, - &summary.lhs_positions, - ); - } - if let Some(line_num) = rhs_line_num { - add_changes_to_side( - line.rhs.as_mut().unwrap(), - *line_num, - &rhs_lines, - &summary.rhs_positions, - ); - } - } - - chunks.push(lines.into_values().collect()); - } - - File::with_sections( - &summary.file_format, - &summary.display_path, - aligned_lines, - chunks, - ) - } - (FileContent::Binary, FileContent::Binary) => { - let status = if summary.has_byte_changes.is_some() { - Status::Changed - } else { - Status::Unchanged - }; - File::with_status(&FileFormat::Binary, &summary.display_path, status) - } - (_, FileContent::Binary) | (FileContent::Binary, _) => { - File::with_status(&FileFormat::Binary, &summary.display_path, Status::Changed) - } - } - } -} - -impl Serialize for File<'_> { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - // Count fields: language, path, status are always present (3) - // + aligned_lines if not empty (1) - // + chunks if not empty (1) - let field_count = - 3 + (!self.aligned_lines.is_empty()) as usize + (!self.chunks.is_empty()) as usize; - - let mut file = serializer.serialize_struct("File", field_count)?; - - if !self.aligned_lines.is_empty() { - file.serialize_field("aligned_lines", &self.aligned_lines)?; - } - if !self.chunks.is_empty() { - file.serialize_field("chunks", &self.chunks)?; - } - - file.serialize_field("language", &format!("{}", self.language))?; - file.serialize_field("path", &self.path)?; - file.serialize_field("status", &self.status)?; - - file.end() - } -} - -#[derive(Debug, Serialize)] -struct Line<'l> { - #[serde(skip_serializing_if = "Option::is_none")] - lhs: Option<Side<'l>>, - #[serde(skip_serializing_if = "Option::is_none")] - rhs: Option<Side<'l>>, -} - -impl<'l> Line<'l> { - fn new(lhs_number: Option<u32>, rhs_number: Option<u32>) -> Self { - Line { - lhs: lhs_number.map(Side::new), - rhs: rhs_number.map(Side::new), - } - } -} - -#[derive(Debug, Serialize)] -struct Side<'s> { - line_number: u32, - changes: Vec<Change<'s>>, -} - -impl<'s> Side<'s> { - fn new(line_number: u32) -> Self { - Side { - line_number, - changes: Vec::new(), - } - } -} - -#[derive(Debug, Serialize)] -struct Change<'c> { - start: u32, - end: u32, - content: &'c str, - highlight: Highlight, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -// TODO: use syntax::TokenKind and syntax::AtomKind instead of this merged enum, -// blocked by https://github.com/serde-rs/serde/issues/1402 -enum Highlight { - Delimiter, - Normal, - String, - Type, - Comment, - Keyword, - TreeSitterError, -} - -impl Highlight { - fn from_match(kind: &syntax::MatchKind) -> Self { - use syntax::{AtomKind, MatchKind, TokenKind}; - - let highlight = match kind { - MatchKind::Ignored { highlight, .. } => highlight, - MatchKind::UnchangedToken { highlight, .. } => highlight, - MatchKind::Novel { highlight, .. } => highlight, - MatchKind::NovelWord { highlight, .. } => highlight, - MatchKind::UnchangedPartOfNovelItem { highlight, .. } => highlight, - }; - - match highlight { - TokenKind::Delimiter => Self::Delimiter, - TokenKind::Atom(atom) => match atom { - AtomKind::String(StringKind::StringLiteral) => Self::String, - AtomKind::String(StringKind::Text) => Self::Normal, - AtomKind::Keyword => Self::Keyword, - AtomKind::Comment => Self::Comment, - AtomKind::Type => Self::Type, - AtomKind::Normal => Self::Normal, - AtomKind::CanIgnore => Self::Normal, - AtomKind::TreeSitterError => Self::TreeSitterError, - }, - } - } -} - -pub(crate) fn print_directory(diffs: Vec<DiffResult>, print_unchanged: bool) { - let files = diffs - .iter() - .map(File::from) - .filter(|f| print_unchanged || f.status != Status::Unchanged) - .collect::<Vec<File>>(); - println!( - "{}", - serde_json::to_string(&files).expect("failed to serialize files") - ); -} - -pub(crate) fn print(diff: &DiffResult) { - let file = File::from(diff); - println!( - "{}", - serde_json::to_string(&file).expect("failed to serialize file") - ) -} - -fn add_changes_to_side<'s>( - side: &mut Side<'s>, - line_num: LineNumber, - src_lines: &[&'s str], - all_matches: &[MatchedPos], -) { - let src_line = src_lines[line_num.0 as usize]; - - let matches = matches_for_line(all_matches, line_num); - for m in matches { - side.changes.push(Change { - start: m.pos.start_col, - end: m.pos.end_col, - content: &src_line[(m.pos.start_col as usize)..(m.pos.end_col as usize)], - highlight: Highlight::from_match(&m.kind), - }) - } -} - -fn matches_for_line(matches: &[MatchedPos], line_num: LineNumber) -> Vec<&MatchedPos> { - matches - .iter() - .filter(|m| m.pos.line == line_num) - .filter(|m| m.kind.is_novel()) - .collect() -} diff --git a/src/display/mod.rs b/src/display/mod.rs deleted file mode 100644 index 6f4b7ec55..000000000 --- a/src/display/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub(crate) mod context; -pub(crate) mod hunks; -pub(crate) mod inline; -pub(crate) mod json; -pub(crate) mod line_layout; -pub(crate) mod prepare; -pub(crate) mod side_by_side; -pub(crate) mod style; diff --git a/src/display/prepare.rs b/src/display/prepare.rs deleted file mode 100644 index 433e992ac..000000000 --- a/src/display/prepare.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Complete the selected rows before publishing a DiffResult. -use super::hunks::Hunk; -use super::line_layout; -use crate::parse::syntax::MatchedPos; -use line_numbers::LineNumber; -use std::collections::{BTreeMap, BTreeSet}; - -pub(crate) fn prepare( - hunks: &[Hunk], - sources: (&str, &str), - positions: (&[MatchedPos], &[MatchedPos]), - padding: usize, -) -> Vec<Hunk> { - let rows = line_layout::aligned_rows(sources, positions); - let lhs_novel = line_layout::novel_lines(positions.0); - let rhs_novel = line_layout::novel_lines(positions.1); - let mut lhs_index = BTreeMap::new(); - let mut rhs_index = BTreeMap::new(); - for (index, (lhs, rhs)) in rows.iter().enumerate() { - lhs_index.extend(lhs.map(|line| (line, index))); - rhs_index.extend(rhs.map(|line| (line, index))); - } - - let mut selections = Vec::new(); - for hunk in hunks { - let mut seeds = BTreeSet::new(); - for (lhs, rhs) in &hunk.lines { - seeds.extend( - lhs.and_then(|line| lhs_index.get(&line.as_usize())) - .copied(), - ); - seeds.extend( - rhs.and_then(|line| rhs_index.get(&line.as_usize())) - .copied(), - ); - } - let mut selected = BTreeSet::new(); - for index in seeds { - selected.extend(index.saturating_sub(padding)..(index + padding + 1).min(rows.len())); - } - selections.push(selected); - } - - selections.sort_by_key(|selection| selection.first().copied()); - let mut groups: Vec<BTreeSet<usize>> = Vec::new(); - for selection in selections { - if let Some(previous) = groups.last_mut() { - // Touching or interleaving windows share a hunk, without exposing gaps. - if selection - .first() - .zip(previous.last()) - .is_some_and(|(first, last)| *first <= last.saturating_add(1)) - { - previous.extend(selection); - continue; - } - } - groups.push(selection); - } - groups - .into_iter() - .map(|selected| { - let lines: Vec<_> = selected - .into_iter() - .map(|index| { - let (lhs, rhs) = rows[index]; - ( - lhs.map(|line| LineNumber(line as u32)), - rhs.map(|line| LineNumber(line as u32)), - ) - }) - .collect(); - let novel_lhs = lines - .iter() - .filter_map(|(lhs, _)| *lhs) - .filter(|line| lhs_novel.contains(&line.as_usize())) - .collect(); - let novel_rhs = lines - .iter() - .filter_map(|(_, rhs)| *rhs) - .filter(|line| rhs_novel.contains(&line.as_usize())) - .collect(); - Hunk { - lines, - novel_lhs, - novel_rhs, - } - }) - .collect() -} diff --git a/src/display/side_by_side.rs b/src/display/side_by_side.rs deleted file mode 100644 index c774d2a15..000000000 --- a/src/display/side_by_side.rs +++ /dev/null @@ -1,839 +0,0 @@ -//! Side-by-side (two column) display of diffs. - -use std::cmp::{max, min}; - -use line_numbers::{LineNumber, SingleLineSpan}; -use owo_colors::{OwoColorize, Style}; - -use crate::constants::Side; -use crate::display::hunks::Hunk; -use crate::display::style::{ - self, apply_colors, apply_line_number_color, color_positions, novel_style, replace_tabs, - split_and_apply, width_respecting_tabs, BackgroundColor, -}; -use crate::hash::{DftHashMap, DftHashSet}; -use crate::lines::{format_line_num, format_line_num_padded, split_on_newlines}; -use crate::options::{DisplayMode, DisplayOptions}; -use crate::parse::syntax::{zip_pad_shorter, MatchedPos}; -use crate::summary::FileFormat; - -/// The space shown between LHS and RHS columns. -const SPACER: &str = " "; - -fn format_missing_line_num( - prev_num: LineNumber, - source_dims: &SourceDimensions, - side: Side, - is_continuation: bool, - use_color: bool, -) -> String { - let column_width = match side { - Side::Left => source_dims.lhs_line_nums_width, - Side::Right => source_dims.rhs_line_nums_width, - }; - - let after_end = match side { - Side::Left => prev_num >= source_dims.lhs_max_line_in_file, - Side::Right => prev_num >= source_dims.rhs_max_line_in_file, - }; - - let mut style = Style::new(); - if use_color { - style = style.dimmed(); - } - - let c = if is_continuation { - // Always show dots when this line is too long so we had to - // split it over several lines when displaying. - "." - } else if after_end { - // The file on this side has fewer lines, and we've already - // shown the last ones. - " " - } else { - // There are more lines in this file. - "." - }; - - let num_digits = prev_num.display().len(); - format!( - "{:>width$} ", - c.repeat(num_digits), - width = column_width - 1 - ) - .style(style) - .to_string() -} - -/// Display `src` in a single column (e.g. a file removal or addition). -fn display_single_column( - display_path: &str, - old_path: Option<&String>, - file_format: &FileFormat, - src_lines: &[String], - side: Side, - display_options: &DisplayOptions, -) -> Vec<String> { - let column_width = format_line_num((src_lines.len() as u32).into()).len(); - - let mut formatted_lines = Vec::with_capacity(src_lines.len()); - - let mut header_line = String::new(); - header_line.push_str(&style::header( - display_path, - old_path, - 1, - 1, - file_format, - display_options, - )); - header_line.push('\n'); - formatted_lines.push(header_line); - - let mut style = Style::new(); - if display_options.use_color { - style = novel_style(Style::new(), side, display_options.background_color); - } - - for (i, line) in src_lines.iter().enumerate() { - let mut formatted_line = String::with_capacity(line.len()); - formatted_line.push_str( - &format_line_num_padded((i as u32).into(), column_width) - .style(style) - .to_string(), - ); - formatted_line.push_str(line); - formatted_lines.push(formatted_line); - } - - formatted_lines -} - -fn display_line_nums( - lhs_line_num: Option<LineNumber>, - rhs_line_num: Option<LineNumber>, - source_dims: &SourceDimensions, - display_options: &DisplayOptions, - lhs_has_novel: bool, - rhs_has_novel: bool, - prev_lhs_line_num: Option<LineNumber>, - prev_rhs_line_num: Option<LineNumber>, -) -> (String, String) { - let display_lhs_line_num: String = match lhs_line_num { - Some(line_num) => { - let s = format_line_num_padded(line_num, source_dims.lhs_line_nums_width); - apply_line_number_color(&s, lhs_has_novel, Side::Left, display_options) - } - None => format_missing_line_num( - prev_lhs_line_num.unwrap_or_else(|| 1.into()), - source_dims, - Side::Left, - false, - display_options.use_color, - ), - }; - let display_rhs_line_num: String = match rhs_line_num { - Some(line_num) => { - let s = format_line_num_padded(line_num, source_dims.rhs_line_nums_width); - apply_line_number_color(&s, rhs_has_novel, Side::Right, display_options) - } - None => format_missing_line_num( - prev_rhs_line_num.unwrap_or_else(|| 1.into()), - source_dims, - Side::Right, - false, - display_options.use_color, - ), - }; - - (display_lhs_line_num, display_rhs_line_num) -} - -// Sizes used when displaying a hunk. -#[derive(Debug)] -struct SourceDimensions { - /// The number of characters used to display LHS source lines. Any - /// line that exceeds this length will be wrapped. - lhs_content_display_width: usize, - /// The number of characters used to display RHS source lines. Any - /// line that exceeds this length will be wrapped. - rhs_content_display_width: usize, - /// The number of characters required to display line numbers on - /// the LHS. - lhs_line_nums_width: usize, - /// The number of characters required to display line numbers on - /// the RHS. - rhs_line_nums_width: usize, - lhs_max_line_in_file: LineNumber, - rhs_max_line_in_file: LineNumber, -} - -impl SourceDimensions { - fn new( - terminal_width: usize, - lhs_max_line_visible: LineNumber, - rhs_max_line_visible: LineNumber, - lhs_max_line_in_file: LineNumber, - rhs_max_line_in_file: LineNumber, - lhs_content_max_width: usize, - rhs_content_max_width: usize, - ) -> Self { - let lhs_line_nums_width = format_line_num(lhs_max_line_visible).len(); - let rhs_line_nums_width = format_line_num(rhs_max_line_visible).len(); - - let content_max_width = max(lhs_content_max_width, rhs_content_max_width); - - // If the file lines are extremely short, treat them as if - // they have a line of 25 characters. - let content_max_width = max(content_max_width, 25); - - // If the terminal is very wide, we don't want to use the full - // 50% for the LHS column, we end up with too much space - // between LHS and RHS. - // - // Instead, cap the display width based on the maximum length - // of visible lines within the file. - // - // This naively assumes that byte length is the same as - // display length, which is generally OK because byte length - // will tend to be larger than the display length. Tab - // characters are an exception, where display width exceeds - // byte length, so we account for tab expansion explicitly. - let width_without_truncation = lhs_line_nums_width - + content_max_width - + SPACER.len() - + rhs_line_nums_width - + content_max_width; - let display_width = min(terminal_width, width_without_truncation); - - assert!( - display_width > SPACER.len(), - "Terminal total width should not overflow" - ); - let lhs_total_width = (display_width - SPACER.len()) / 2; - - let lhs_content_width = if lhs_line_nums_width < lhs_total_width { - lhs_total_width - lhs_line_nums_width - } else { - // The terminal is so narrow that even the column numbers - // display doesn't fit. Ensure we show a non-zero number - // of content columns anyway. - 1 - }; - - let rhs_content_width = max( - 1, - display_width as isize - - lhs_total_width as isize - - SPACER.len() as isize - - rhs_line_nums_width as isize, - ) as usize; - - // We want the content width to be the same on both - // sides. This ensures that line wrapping splits lines at the - // same point on both sides. - let content_width = min(lhs_content_width, rhs_content_width); - - Self { - lhs_content_display_width: content_width, - rhs_content_display_width: content_width, - lhs_line_nums_width, - rhs_line_nums_width, - lhs_max_line_in_file, - rhs_max_line_in_file, - } - } -} - -pub(crate) fn lines_with_novel( - lhs_mps: &[MatchedPos], - rhs_mps: &[MatchedPos], -) -> (DftHashSet<LineNumber>, DftHashSet<LineNumber>) { - let lhs_lines_with_novel: DftHashSet<LineNumber> = lhs_mps - .iter() - .filter(|mp| mp.kind.is_novel()) - .map(|mp| mp.pos.line) - .collect(); - let rhs_lines_with_novel: DftHashSet<LineNumber> = rhs_mps - .iter() - .filter(|mp| mp.kind.is_novel()) - .map(|mp| mp.pos.line) - .collect(); - - (lhs_lines_with_novel, rhs_lines_with_novel) -} - -/// Calculate positions of highlights on both sides. This includes -/// both syntax highlighting and added/removed content highlighting. -fn highlight_positions( - background: BackgroundColor, - syntax_highlight: bool, - file_format: &FileFormat, - lhs_mps: &[MatchedPos], - rhs_mps: &[MatchedPos], -) -> ( - DftHashMap<LineNumber, Vec<(SingleLineSpan, Style)>>, - DftHashMap<LineNumber, Vec<(SingleLineSpan, Style)>>, -) { - let lhs_positions = color_positions( - Side::Left, - background, - syntax_highlight, - file_format, - lhs_mps, - ); - let mut lhs_styles: DftHashMap<LineNumber, Vec<(SingleLineSpan, Style)>> = - DftHashMap::default(); - for (span, style) in lhs_positions { - let styles = lhs_styles.entry(span.line).or_insert_with(Vec::new); - styles.push((span, style)); - } - - let rhs_positions = color_positions( - Side::Right, - background, - syntax_highlight, - file_format, - rhs_mps, - ); - let mut rhs_styles: DftHashMap<LineNumber, Vec<(SingleLineSpan, Style)>> = - DftHashMap::default(); - for (span, style) in rhs_positions { - let styles = rhs_styles.entry(span.line).or_insert_with(Vec::new); - styles.push((span, style)); - } - - (lhs_styles, rhs_styles) -} - -fn highlight_as_novel( - line_num: Option<LineNumber>, - lines: &[&str], - opposite_line_num: Option<LineNumber>, - lines_with_novel: &DftHashSet<LineNumber>, -) -> bool { - if let Some(line_num) = line_num { - // If this line contains any novel tokens, highlight it. - if lines_with_novel.contains(&line_num) { - return true; - } - - let line_content = lines.get(line_num.as_usize()).map(|s| str::trim(s)); - // If this is a blank line without a corresponding line on the - // other side, highlight it too. This helps highlight novel - // blank lines. - if line_content == Some("") && opposite_line_num.is_none() { - return true; - } - } - - false -} - -/// Find the longest line in `lhs_src` and `rhs_src` that will be -/// displayed. Return the length of that line for both LHS and RHS. -fn visible_content_max_display_width( - lhs_src: &str, - rhs_src: &str, - hunks: &[Hunk], - tab_width: usize, -) -> (usize, usize) { - let mut lhs_displayed_lines: DftHashSet<usize> = DftHashSet::default(); - let mut rhs_displayed_lines: DftHashSet<usize> = DftHashSet::default(); - - for hunk in hunks { - for (lhs, rhs) in &hunk.lines { - lhs_displayed_lines.extend(lhs.map(|line| line.as_usize())); - rhs_displayed_lines.extend(rhs.map(|line| line.as_usize())); - } - } - - let mut lhs_content_max_width: usize = 0; - let mut rhs_content_max_width: usize = 0; - - for (lhs_i, lhs_line) in lhs_src.lines().enumerate() { - if lhs_displayed_lines.contains(&lhs_i) { - lhs_content_max_width = max( - lhs_content_max_width, - width_respecting_tabs(lhs_line, tab_width), - ); - } - } - for (rhs_i, rhs_line) in rhs_src.lines().enumerate() { - if rhs_displayed_lines.contains(&rhs_i) { - rhs_content_max_width = max( - rhs_content_max_width, - width_respecting_tabs(rhs_line, tab_width), - ); - } - } - - (lhs_content_max_width, rhs_content_max_width) -} - -pub(crate) fn print( - hunks: &[Hunk], - display_options: &DisplayOptions, - display_path: &str, - old_path: Option<&String>, - file_format: &FileFormat, - lhs_src: &str, - rhs_src: &str, - lhs_mps: &[MatchedPos], - rhs_mps: &[MatchedPos], -) { - let (lhs_content_max_width, rhs_content_max_width) = - visible_content_max_display_width(lhs_src, rhs_src, hunks, display_options.tab_width); - - let (lhs_colored_lines, rhs_colored_lines) = if display_options.use_color { - ( - apply_colors( - lhs_src, - Side::Left, - display_options.syntax_highlight, - file_format, - display_options.background_color, - lhs_mps, - ), - apply_colors( - rhs_src, - Side::Right, - display_options.syntax_highlight, - file_format, - display_options.background_color, - rhs_mps, - ), - ) - } else { - ( - split_on_newlines(lhs_src) - .map(|s| format!("{}\n", s)) - .collect(), - split_on_newlines(rhs_src) - .map(|s| format!("{}\n", s)) - .collect(), - ) - }; - - // Style positions are relative to the source code offsets. Now - // that we've applied styling, we can replace tabs. - let lhs_colored_lines: Vec<_> = lhs_colored_lines - .iter() - .map(|l| replace_tabs(l, display_options.tab_width)) - .collect(); - let rhs_colored_lines: Vec<_> = rhs_colored_lines - .iter() - .map(|l| replace_tabs(l, display_options.tab_width)) - .collect(); - - if lhs_src.is_empty() - && !matches!( - display_options.display_mode, - DisplayMode::SideBySideShowBoth - ) - { - for line in display_single_column( - display_path, - old_path, - file_format, - &rhs_colored_lines, - Side::Right, - display_options, - ) { - print!("{}", line); - } - println!(); - return; - } - if rhs_src.is_empty() - && !matches!( - display_options.display_mode, - DisplayMode::SideBySideShowBoth - ) - { - for line in display_single_column( - display_path, - old_path, - file_format, - &lhs_colored_lines, - Side::Left, - display_options, - ) { - print!("{}", line); - } - println!(); - return; - } - - // TODO: this is largely duplicating the `apply_colors` logic. - let (lhs_highlights, rhs_highlights) = if display_options.use_color { - highlight_positions( - display_options.background_color, - display_options.syntax_highlight, - file_format, - lhs_mps, - rhs_mps, - ) - } else { - (DftHashMap::default(), DftHashMap::default()) - }; - - let (lhs_lines_with_novel, rhs_lines_with_novel) = lines_with_novel(lhs_mps, rhs_mps); - - let mut prev_lhs_line_num = None; - let mut prev_rhs_line_num = None; - - let mut lhs_lines = split_on_newlines(lhs_src).collect::<Vec<_>>(); - let mut rhs_lines = split_on_newlines(rhs_src).collect::<Vec<_>>(); - - if lhs_lines.last() == Some(&"") && lhs_lines.len() > 1 { - lhs_lines.pop(); - } - if rhs_lines.last() == Some(&"") && rhs_lines.len() > 1 { - rhs_lines.pop(); - } - - let mut lhs_max_visible_line = 1.into(); - let mut rhs_max_visible_line = 1.into(); - - if let Some(hunk) = hunks.last() { - let aligned_lines = &hunk.lines; - - for (lhs_line_num, rhs_line_num) in aligned_lines.iter().rev() { - if let Some(lhs_line_num) = *lhs_line_num { - lhs_max_visible_line = max(lhs_max_visible_line, lhs_line_num); - } - if let Some(rhs_line_num) = *rhs_line_num { - rhs_max_visible_line = max(rhs_max_visible_line, rhs_line_num); - } - - if lhs_max_visible_line > 1.into() && rhs_max_visible_line > 1.into() { - break; - } - } - } - - let lhs_max_line_in_file = LineNumber(lhs_lines.len().saturating_sub(1) as u32); - let rhs_max_line_in_file = LineNumber(rhs_lines.len().saturating_sub(1) as u32); - - let source_dims = SourceDimensions::new( - display_options.terminal_width, - lhs_max_visible_line, - rhs_max_visible_line, - lhs_max_line_in_file, - rhs_max_line_in_file, - lhs_content_max_width, - rhs_content_max_width, - ); - - for (i, hunk) in hunks.iter().enumerate() { - println!( - "{}", - style::header( - display_path, - old_path, - i + 1, - hunks.len(), - file_format, - display_options - ) - ); - - let aligned_lines = &hunk.lines; - - let no_lhs_changes = hunk.novel_lhs.is_empty(); - let no_rhs_changes = hunk.novel_rhs.is_empty(); - let same_lines = aligned_lines.iter().all(|(l, r)| l == r); - - for (lhs_line_num, rhs_line_num) in aligned_lines { - let skips_lines = |before: Option<LineNumber>, after: Option<LineNumber>| matches!((before, after), (Some(before), Some(after)) if after.0 > before.0 + 1); - if skips_lines(prev_lhs_line_num, *lhs_line_num) - || skips_lines(prev_rhs_line_num, *rhs_line_num) - { - println!("..."); - } - let lhs_line_novel = highlight_as_novel( - *lhs_line_num, - &lhs_lines, - *rhs_line_num, - &lhs_lines_with_novel, - ); - let rhs_line_novel = highlight_as_novel( - *rhs_line_num, - &rhs_lines, - *lhs_line_num, - &rhs_lines_with_novel, - ); - - let (display_lhs_line_num, display_rhs_line_num) = display_line_nums( - *lhs_line_num, - *rhs_line_num, - &source_dims, - display_options, - lhs_line_novel, - rhs_line_novel, - prev_lhs_line_num, - prev_rhs_line_num, - ); - - let show_both = matches!( - display_options.display_mode, - DisplayMode::SideBySideShowBoth - ); - if no_lhs_changes && !show_both { - match rhs_line_num { - Some(rhs_line_num) => { - let rhs_line = &rhs_colored_lines[rhs_line_num.as_usize()]; - if same_lines { - print!("{}{}", display_rhs_line_num, rhs_line); - } else { - print!( - "{}{}{}", - display_lhs_line_num, display_rhs_line_num, rhs_line - ); - } - } - None => { - // We didn't have any changed RHS lines in the - // hunk, but we had some contextual lines that - // only occurred on the LHS (e.g. extra newlines). - println!("{}{}", display_lhs_line_num, display_rhs_line_num); - } - } - } else if no_rhs_changes && !show_both { - match lhs_line_num { - Some(lhs_line_num) => { - let lhs_line = &lhs_colored_lines[lhs_line_num.as_usize()]; - if same_lines { - print!("{}{}", display_lhs_line_num, lhs_line); - } else { - print!( - "{}{}{}", - display_lhs_line_num, display_rhs_line_num, lhs_line - ); - } - } - None => { - println!("{}{}", display_lhs_line_num, display_rhs_line_num); - } - } - } else { - let lhs_line = match lhs_line_num { - Some(lhs_line_num) => split_and_apply( - lhs_lines[lhs_line_num.as_usize()], - source_dims.lhs_content_display_width, - display_options.tab_width, - lhs_highlights.get(lhs_line_num).unwrap_or(&vec![]), - Side::Left, - ), - None => vec![" ".repeat(source_dims.lhs_content_display_width)], - }; - let rhs_line = match rhs_line_num { - Some(rhs_line_num) => split_and_apply( - rhs_lines[rhs_line_num.as_usize()], - source_dims.rhs_content_display_width, - display_options.tab_width, - rhs_highlights.get(rhs_line_num).unwrap_or(&vec![]), - Side::Right, - ), - None => vec!["".into()], - }; - - for (i, (lhs_line, rhs_line)) in zip_pad_shorter(&lhs_line, &rhs_line) - .into_iter() - .enumerate() - { - let lhs_line = lhs_line - .unwrap_or_else(|| " ".repeat(source_dims.lhs_content_display_width)); - let rhs_line = rhs_line.unwrap_or_else(|| "".into()); - let lhs_num: String = if i == 0 { - display_lhs_line_num.clone() - } else { - let mut s = format_missing_line_num( - lhs_line_num - .unwrap_or_else(|| prev_lhs_line_num.unwrap_or_else(|| 10.into())), - &source_dims, - Side::Left, - true, - display_options.use_color, - ); - if let Some(line_num) = lhs_line_num { - s = apply_line_number_color( - &s, - lhs_lines_with_novel.contains(line_num), - Side::Left, - display_options, - ); - } - s - }; - let rhs_num: String = if i == 0 { - display_rhs_line_num.clone() - } else { - let mut s = format_missing_line_num( - rhs_line_num - .unwrap_or_else(|| prev_rhs_line_num.unwrap_or_else(|| 10.into())), - &source_dims, - Side::Right, - true, - display_options.use_color, - ); - if let Some(line_num) = rhs_line_num { - s = apply_line_number_color( - &s, - rhs_lines_with_novel.contains(line_num), - Side::Right, - display_options, - ); - } - s - }; - - println!("{}{}{}{}{}", lhs_num, lhs_line, SPACER, rhs_num, rhs_line); - } - } - - if lhs_line_num.is_some() { - prev_lhs_line_num = *lhs_line_num; - } - if rhs_line_num.is_some() { - prev_rhs_line_num = *rhs_line_num; - } - } - println!(); - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use super::*; - use crate::options::DEFAULT_TERMINAL_WIDTH; - use crate::parse::guess_language::Language; - use crate::syntax::{AtomKind, MatchKind, TokenKind}; - - #[test] - fn test_width_calculations() { - let source_dims = SourceDimensions::new( - DEFAULT_TERMINAL_WIDTH, - 1.into(), - 10.into(), - 1.into(), - 10.into(), - 9999, - 9999, - ); - - assert_eq!(source_dims.lhs_line_nums_width, 2); - assert_eq!(source_dims.rhs_line_nums_width, 3); - } - - #[test] - fn test_format_missing_line_num() { - let source_dims = SourceDimensions::new( - DEFAULT_TERMINAL_WIDTH, - 1.into(), - 1.into(), - 1.into(), - 1.into(), - 9999, - 9999, - ); - - assert_eq!( - format_missing_line_num(0.into(), &source_dims, Side::Left, false, true), - ". ".dimmed().to_string() - ); - assert_eq!( - format_missing_line_num(0.into(), &source_dims, Side::Left, false, false), - ". ".to_owned() - ); - } - - #[test] - fn test_format_missing_line_num_at_end() { - let source_dims = - SourceDimensions::new(80, 1.into(), 1.into(), 1.into(), 1.into(), 9999, 9999); - - assert_eq!( - format_missing_line_num(1.into(), &source_dims, Side::Left, false, true), - " ".dimmed().to_string() - ); - assert_eq!( - format_missing_line_num(1.into(), &source_dims, Side::Left, false, false), - " ".to_owned() - ); - } - - #[test] - fn test_display_single_column() { - // Basic smoke test. - let res_lines = display_single_column( - "foo.py", - None, - &FileFormat::SupportedLanguage(Language::Python), - &["print(123)\n".to_owned()], - Side::Right, - &DisplayOptions::default(), - ); - let res = res_lines.join(""); - assert!(res.len() > 10); - } - - #[test] - fn test_display_hunks() { - // Simulate diffing: - // - // Old: - // foo - // - // New: - // bar - let lhs_mps = [MatchedPos { - kind: MatchKind::Novel { - highlight: TokenKind::Atom(AtomKind::Normal), - }, - pos: SingleLineSpan { - line: 0.into(), - start_col: 0, - end_col: 3, - }, - }]; - - let rhs_mps = [MatchedPos { - kind: MatchKind::Novel { - highlight: TokenKind::Atom(AtomKind::Normal), - }, - pos: SingleLineSpan { - line: 0.into(), - start_col: 0, - end_col: 3, - }, - }]; - - let mut novel_lhs = DftHashSet::default(); - novel_lhs.insert(0.into()); - let mut novel_rhs = DftHashSet::default(); - novel_rhs.insert(0.into()); - - let hunks = [Hunk { - novel_lhs, - novel_rhs, - lines: vec![(Some(0.into()), Some(0.into()))], - }]; - - // Simple smoke test. - print( - &hunks, - &DisplayOptions::default(), - "foo-new.el", - None, - &FileFormat::SupportedLanguage(Language::EmacsLisp), - "foo", - "bar", - &lhs_mps, - &rhs_mps, - ); - } -} diff --git a/src/display/style.rs b/src/display/style.rs deleted file mode 100644 index 56576e810..000000000 --- a/src/display/style.rs +++ /dev/null @@ -1,730 +0,0 @@ -//! Apply colours and styling to strings. - -use std::cmp::{max, min}; - -use line_numbers::{LineNumber, SingleLineSpan}; -use owo_colors::{OwoColorize, Style}; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -use crate::constants::Side; -use crate::hash::DftHashMap; -use crate::lines::{byte_len, split_on_newlines}; -use crate::options::DisplayOptions; -use crate::parse::syntax::{AtomKind, MatchKind, MatchedPos, StringKind, TokenKind}; -use crate::summary::FileFormat; - -#[derive(Clone, Copy, Debug)] -pub(crate) enum BackgroundColor { - Dark, - Light, -} - -impl BackgroundColor { - pub(crate) fn is_dark(self) -> bool { - matches!(self, Self::Dark) - } -} - -/// Find the largest byte offset in `s` that gives the longest -/// starting substring whose display width does not exceed `width`. -/// -/// If `s` contains full-width Unicode characters, or emoji, or tabs, -/// its display width may be less than `width`. -fn byte_offset_for_width(s: &str, width: usize, tab_width: usize) -> usize { - let mut current_offset = 0; - let mut current_width = 0; - - for (offset, ch) in s.char_indices() { - current_offset = offset; - - let char_width = if ch == '\t' { - tab_width - } else { - ch.width().unwrap_or(0) - }; - current_width += char_width; - - if current_width > width { - break; - } - } - - current_offset -} - -fn substring_by_byte(s: &str, start: usize, end: usize) -> &str { - &s[start..end] -} - -fn substring_by_byte_replace_tabs(s: &str, start: usize, end: usize, tab_width: usize) -> String { - let s = s[start..end].to_string(); - s.replace('\t', &" ".repeat(tab_width)) -} - -pub(crate) fn width_respecting_tabs(s: &str, tab_width: usize) -> usize { - let display_width = s.width(); - - let tab_count = s.matches('\t').count(); - // "\t".width() is 1 on 0.1.13 and 0.2.x of unicode-width, but - // "\t".width() is 0 on <=0.1.12 and 0.1.14 of unicode-width. Be - // defensive against both cases. - let tab_display_width_extra = tab_count * (tab_width - "\t".width()); - - display_width + tab_display_width_extra -} - -/// Split a string into parts whose display length does not -/// exceed `max_width`. -/// -/// If any part has a display width less than `max_width`, also -/// specify the number of spaces required to pad the part to reach the -/// desired width. -/// -/// ``` -/// split_string_by_width("fooba", 3, TAB_WIDTH) // vec![("foo", 0), ("ba", 1)] -/// ``` -fn split_string_by_width(s: &str, max_width: usize, tab_width: usize) -> Vec<(&str, usize)> { - let mut parts: Vec<(&str, usize)> = vec![]; - let mut s = s; - - // Optimisation: width_respecting_tabs() walks the whole string, - // which is slow when we have files with massive lines. - // - // A single character (grapheme) in UTF-8 can be 1, 2, 3 or 4 - // bytes. A character's display width can be 0 (control - // characters), 1 (the typical case), 2 (e.g. fullwidth characters - // in Chinese, Japanese and Korean) or 4 (the default width for - // tabs in difftastic). - // - // Ignoring control characters, this means an n-byte UTF-8 string - // has a display width of at least n/4 characters. Check that case - // first, because it's a cheap conservative calculation. - while s.len() / 4 > max_width || width_respecting_tabs(s, tab_width) > max_width { - let offset = byte_offset_for_width(s, max_width, tab_width); - - let part = substring_by_byte(s, 0, offset); - s = substring_by_byte(s, offset, s.len()); - - let part_width = width_respecting_tabs(part, tab_width); - let padding = if part_width < max_width { - max_width - part_width - } else { - 0 - }; - parts.push((part, padding)); - } - - if parts.is_empty() || !s.is_empty() { - parts.push((s, max_width - width_respecting_tabs(s, tab_width))); - } - - parts -} - -/// Return a copy of `src` with all the tab characters replaced by -/// `tab_width` strings. -pub(crate) fn replace_tabs(src: &str, tab_width: usize) -> String { - let tab_as_spaces = " ".repeat(tab_width); - src.replace('\t', &tab_as_spaces) -} - -/// Split `line` (from the source code) into multiple lines of -/// `max_len` (i.e. word wrapping), and apply `styles` to each part -/// according to its original position in `line`. -pub(crate) fn split_and_apply( - line: &str, - max_len: usize, - tab_width: usize, - styles: &[(SingleLineSpan, Style)], - side: Side, -) -> Vec<String> { - assert!( - max_len > 0, - "Splitting lines into pieces of length 0 will never terminate" - ); - assert!( - max_len > tab_width, - "Parts must be big enough to hold at least one tab (max_len = {} tab_width = {})", - max_len, - tab_width - ); - - if styles.is_empty() && !line.trim().is_empty() { - return split_string_by_width(line, max_len, tab_width) - .into_iter() - .map(|(part, pad)| { - let part = replace_tabs(part, tab_width); - - let mut parts = String::with_capacity(part.len() + pad); - parts.push_str(&part); - - if matches!(side, Side::Left) { - parts.push_str(&" ".repeat(pad)); - } - parts - }) - .collect(); - } - - let mut styled_parts = vec![]; - let mut part_start = 0; - - for (line_part, pad) in split_string_by_width(line, max_len, tab_width) { - let mut res = String::with_capacity(line_part.len() + pad); - let mut prev_style_end = 0; - for (span, style) in styles { - let start_col = span.start_col as usize; - let end_col = span.end_col as usize; - - // The remaining spans are beyond the end of this line_part. - if start_col >= part_start + byte_len(line_part) { - break; - } - - // If there's an unstyled gap before the next span. - if start_col > part_start && prev_style_end < start_col { - // Then append that text without styling. - let unstyled_start = max(prev_style_end, part_start); - res.push_str(&substring_by_byte_replace_tabs( - line_part, - unstyled_start - part_start, - start_col - part_start, - tab_width, - )); - } - - // Apply style to the substring in this span. - if end_col > part_start { - let span_s = substring_by_byte_replace_tabs( - line_part, - max(0, span.start_col as isize - part_start as isize) as usize, - min(byte_len(line_part), end_col - part_start), - tab_width, - ); - res.push_str(&span_s.style(*style).to_string()); - } - prev_style_end = end_col; - } - - // Ensure that prev_style_end is at least at the start of this - // line_part. - if prev_style_end < part_start { - prev_style_end = part_start; - } - - // Unstyled text after the last span. - if prev_style_end < part_start + byte_len(line_part) { - let span_s = &substring_by_byte_replace_tabs( - line_part, - prev_style_end - part_start, - byte_len(line_part), - tab_width, - ); - res.push_str(span_s); - } - - if matches!(side, Side::Left) { - res.push_str(&" ".repeat(pad)); - } - - styled_parts.push(res); - part_start += byte_len(line_part); - } - - styled_parts -} - -/// Return a copy of `line` with styles applied to all the spans -/// specified. -fn apply_line(line: &str, styles: &[(SingleLineSpan, Style)]) -> String { - let line_bytes = byte_len(line); - let mut styled_line = String::with_capacity(line.len()); - let mut i = 0; - for (span, style) in styles { - let start_col = span.start_col as usize; - let end_col = span.end_col as usize; - - // The remaining spans are beyond the end of this line. This - // occurs when we truncate the line to fit on the display. - if start_col >= line_bytes { - break; - } - - // Unstyled text before the next span. - if i < start_col { - styled_line.push_str(substring_by_byte(line, i, start_col)); - } - - // Apply style to the substring in this span. - let span_s = substring_by_byte(line, start_col, min(line_bytes, end_col)); - styled_line.push_str(&span_s.style(*style).to_string()); - i = end_col; - } - - // Unstyled text after the last span. - if i < line_bytes { - let span_s = substring_by_byte(line, i, line_bytes); - styled_line.push_str(span_s); - } - styled_line -} - -fn group_by_line( - ranges: &[(SingleLineSpan, Style)], -) -> DftHashMap<LineNumber, Vec<(SingleLineSpan, Style)>> { - let mut ranges_by_line: DftHashMap<_, Vec<_>> = DftHashMap::default(); - for range in ranges { - if let Some(matching_ranges) = ranges_by_line.get_mut(&range.0.line) { - (*matching_ranges).push(*range); - } else { - ranges_by_line.insert(range.0.line, vec![*range]); - } - } - - ranges_by_line -} - -/// Apply the `Style`s to the spans specified. Return a vec of the -/// styled strings, including trailing newlines. -/// -/// Tolerant against lines in `s` being shorter than the spans. -fn style_lines(lines: &[&str], styles: &[(SingleLineSpan, Style)]) -> Vec<String> { - let mut ranges_by_line = group_by_line(styles); - - let mut styled_lines = Vec::with_capacity(lines.len()); - for (i, line) in lines.iter().enumerate() { - let mut styled_line = String::with_capacity(line.len()); - let ranges = ranges_by_line - .remove::<LineNumber>(&(i as u32).into()) - .unwrap_or_default(); - - styled_line.push_str(&apply_line(line, &ranges)); - styled_line.push('\n'); - styled_lines.push(styled_line); - } - styled_lines -} - -pub(crate) fn novel_style(style: Style, side: Side, background: BackgroundColor) -> Style { - if background.is_dark() { - match side { - Side::Left => style.bright_red(), - Side::Right => style.bright_green(), - } - } else { - match side { - Side::Left => style.red(), - Side::Right => style.green(), - } - } -} - -/// Merge spans where the end of one span matches the start of the -/// next span. -/// -/// This reduces the number of ANSI character codes in the -/// output. This is negligible for performance, but makes regression -/// testing easier for difftastic. -/// -/// The file compare.expected contains hashes of the output, so it -/// considers `<green>ab</green>` to be distinct from -/// `<green>a</green><green>b</green>`. Merging the spans normalises -/// the output to `<green>ab</green>`. -fn merge_adjacent(items: &[(SingleLineSpan, Style)]) -> Vec<(SingleLineSpan, Style)> { - let mut merged: Vec<(SingleLineSpan, Style)> = vec![]; - let mut prev_item: Option<(SingleLineSpan, Style)> = None; - - for (span, style) in items.iter().copied() { - match prev_item.take() { - Some((mut prev_span, prev_style)) => { - if prev_style == style - && prev_span.line == span.line - && prev_span.end_col == span.start_col - { - prev_span.end_col = span.end_col; - prev_item = Some((prev_span, style)); - } else { - merged.push((prev_span, prev_style)); - prev_item = Some((span, style)); - } - } - None => { - prev_item = Some((span, style)); - } - } - } - - if let Some(last_item) = prev_item { - merged.push(last_item); - } - - merged -} - -pub(crate) fn color_positions( - side: Side, - background: BackgroundColor, - syntax_highlight: bool, - file_format: &FileFormat, - mps: &[MatchedPos], -) -> Vec<(SingleLineSpan, Style)> { - let mut styles = vec![]; - for mp in mps { - let mut style = Style::new(); - match mp.kind { - MatchKind::UnchangedToken { highlight, .. } | MatchKind::Ignored { highlight } => { - if syntax_highlight { - if let TokenKind::Atom(atom_kind) = highlight { - match atom_kind { - AtomKind::String(StringKind::StringLiteral) => { - style = if background.is_dark() { - style.bright_magenta() - } else { - style.magenta() - }; - } - AtomKind::String(StringKind::Text) => {} - AtomKind::Comment => { - style = style.italic(); - style = if background.is_dark() { - style.bright_blue() - } else { - style.blue() - }; - } - AtomKind::Keyword | AtomKind::Type => { - style = style.bold(); - } - AtomKind::TreeSitterError => style = style.purple(), - AtomKind::Normal | AtomKind::CanIgnore => {} - } - } - } - } - MatchKind::Novel { highlight, .. } => { - style = novel_style(style, side, background); - if syntax_highlight - && matches!( - highlight, - TokenKind::Delimiter - | TokenKind::Atom(AtomKind::Keyword) - | TokenKind::Atom(AtomKind::Type) - ) - { - style = style.bold(); - } - if matches!(highlight, TokenKind::Atom(AtomKind::Comment)) { - style = style.italic(); - } - } - MatchKind::NovelWord { highlight } => { - style = novel_style(style, side, background).bold(); - - // Underline novel words inside comments in code, but - // don't apply it to every single line in plaintext. - if matches!(file_format, FileFormat::SupportedLanguage(_)) { - style = style.underline(); - } - - if syntax_highlight && matches!(highlight, TokenKind::Atom(AtomKind::Comment)) { - style = style.italic(); - } - } - MatchKind::UnchangedPartOfNovelItem { highlight, .. } => { - style = novel_style(style, side, background); - if syntax_highlight && matches!(highlight, TokenKind::Atom(AtomKind::Comment)) { - style = style.italic(); - } - } - }; - styles.push((mp.pos, style)); - } - - merge_adjacent(&styles) -} - -pub(crate) fn apply_colors( - s: &str, - side: Side, - syntax_highlight: bool, - file_format: &FileFormat, - background: BackgroundColor, - mps: &[MatchedPos], -) -> Vec<String> { - let styles = color_positions(side, background, syntax_highlight, file_format, mps); - let lines = split_on_newlines(s).collect::<Vec<_>>(); - style_lines(&lines, &styles) -} - -fn apply_header_color( - s: &str, - use_color: bool, - background: BackgroundColor, - hunk_num: usize, -) -> String { - if use_color { - if hunk_num != 1 { - s.to_owned() - } else if background.is_dark() { - s.bright_yellow().to_string() - } else { - s.yellow().to_string() - } - .bold() - .to_string() - } else { - s.to_owned() - } -} - -/// Style `s` as a warning and write to stderr. -pub(crate) fn print_warning(s: &str, display_options: &DisplayOptions) { - let prefix = if display_options.use_color { - if display_options.background_color.is_dark() { - "warning: ".bright_yellow().to_string() - } else { - "warning: ".yellow().to_string() - } - .bold() - .to_string() - } else { - "warning: ".to_owned() - }; - - eprint!("{}", prefix); - eprint!("{}\n\n", s); -} - -/// Style `s` as an error and write to stderr. -pub(crate) fn print_error(s: &str, use_color: bool) { - // TODO: this is inconsistent with print_warning regarding - // arguments and trailing whitespace. - let prefix = if use_color { - "error: ".red().bold().to_string() - } else { - "error: ".to_owned() - }; - - eprintln!("{}{}", prefix, s); -} - -pub(crate) fn apply_line_number_color( - s: &str, - is_novel: bool, - side: Side, - display_options: &DisplayOptions, -) -> String { - if display_options.use_color { - let mut style = Style::new(); - - // The goal here is to choose a style for line numbers that is - // visually distinct from content. - if is_novel { - // For changed lines, show the line number as red/green - // and bold. This works well for syntactic diffs, where - // most content is not bold. - style = novel_style(style, side, display_options.background_color).bold(); - } else { - // For unchanged lines, dim the line numbers so it's - // clearly separate from the content. - style = style.dimmed() - } - - s.style(style).to_string() - } else { - s.to_owned() - } -} - -pub(crate) fn header( - display_path: &str, - extra_info: Option<&String>, - hunk_num: usize, - hunk_total: usize, - file_format: &FileFormat, - display_options: &DisplayOptions, -) -> String { - let divider = if hunk_total == 1 { - "".to_owned() - } else { - format!("{}/{} --- ", hunk_num, hunk_total) - }; - - let display_path_pretty = apply_header_color( - display_path, - display_options.use_color, - display_options.background_color, - hunk_num, - ); - - let mut trailer = format!(" --- {}{}", divider, file_format); - if display_options.use_color { - trailer = trailer.dimmed().to_string(); - } - - match extra_info { - Some(extra_info) if hunk_num == 1 => { - let mut extra_info = extra_info.clone(); - if display_options.use_color { - extra_info = extra_info.dimmed().to_string(); - } - - format!("{}{}\n{}", display_path_pretty, trailer, extra_info) - } - _ => { - format!("{}{}", display_path_pretty, trailer) - } - } -} - -#[cfg(test)] -mod tests { - const TAB_WIDTH: usize = 2; - - use pretty_assertions::assert_eq; - - use super::*; - - #[test] - fn split_string_simple() { - assert_eq!( - split_string_by_width("fooba", 3, TAB_WIDTH), - vec![("foo", 0), ("ba", 1)] - ); - } - - /// Widths must match the columns occupied once tabs are expanded, - /// otherwise side-by-side display pads the left column wrongly. - #[test] - fn test_width_respecting_tabs() { - for tab_width in [1, 2, 4, 8] { - for s in ["", "x", "\t", "\tx", "\t\tx", "x\ty\tz", "\t一个\t"] { - assert_eq!( - width_respecting_tabs(s, tab_width), - replace_tabs(s, tab_width).width(), - "width of {:?} with tab width {}", - s, - tab_width - ); - } - } - } - - #[test] - fn split_string_unicode() { - assert_eq!( - split_string_by_width("ab📦def", 4, TAB_WIDTH), - vec![("ab📦", 0), ("def", 1)] - ); - } - - #[test] - fn test_combining_char() { - assert_eq!( - split_string_by_width("aabbcc\u{300}x", 6, TAB_WIDTH), - vec![("aabbcc\u{300}", 0), ("x", 5)], - ); - } - - #[test] - fn split_string_cjk() { - assert_eq!( - split_string_by_width("一个汉字两列宽", 8, TAB_WIDTH), - vec![("一个汉字", 0), ("两列宽", 2)] - ); - } - - #[test] - fn split_string_cjk2() { - assert_eq!( - split_string_by_width("你好啊", 5, TAB_WIDTH), - vec![("你好", 1), ("啊", 3)] - ); - } - - #[test] - fn test_split_and_apply() { - let res = split_and_apply( - "foo", - 3, - TAB_WIDTH, - &[( - SingleLineSpan { - line: 0.into(), - start_col: 0, - end_col: 3, - }, - Style::new(), - )], - Side::Left, - ); - assert_eq!(res, vec!["foo"]) - } - - #[test] - fn test_split_and_apply_trailing_text() { - let res = split_and_apply( - "foobar", - 6, - TAB_WIDTH, - &[( - SingleLineSpan { - line: 0.into(), - start_col: 0, - end_col: 3, - }, - Style::new(), - )], - Side::Left, - ); - assert_eq!(res, vec!["foobar"]) - } - - #[test] - fn test_split_and_apply_gap_between_styles_on_wrap_boundary() { - let res = split_and_apply( - "foobar", - 3, - TAB_WIDTH, - &[ - ( - SingleLineSpan { - line: 0.into(), - start_col: 0, - end_col: 2, - }, - Style::new(), - ), - ( - SingleLineSpan { - line: 0.into(), - start_col: 4, - end_col: 6, - }, - Style::new(), - ), - ], - Side::Left, - ); - assert_eq!(res, vec!["foo", "bar"]) - } - - #[test] - fn test_split_and_apply_trailing_text_newline() { - let res = split_and_apply( - "foobar ", - 6, - TAB_WIDTH, - &[( - SingleLineSpan { - line: 0.into(), - start_col: 0, - end_col: 3, - }, - Style::new(), - )], - Side::Left, - ); - assert_eq!(res, vec!["foobar", " "]) - } -} diff --git a/src/engine.rs b/src/engine.rs index 904c77a4d..ef4d8eb02 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -7,12 +7,8 @@ use crate::diff::changes::ChangeMap; use crate::diff::shortest_path::{mark_syntax, ExceededGraphLimit}; use crate::diff::sliders::fix_all_sliders; 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_parser; -use crate::lines::MaxLine; -use crate::options::{DiffOptions, DisplayOptions, FileArgument}; +use crate::options::{DiffOptions, FileArgument}; use crate::parse::folds; use crate::parse::guess_language::{guess, language_name, LanguageOverride}; use crate::parse::syntax::{self, init_next_prev}; @@ -82,14 +78,7 @@ impl DiffResult { rhs: &str, params: &Params, ) -> Result<Self, QueryConflict> { - Self::from_sources_with_options( - path, - lhs, - rhs, - params, - &DisplayOptions::default(), - &DiffOptions::default(), - ) + Self::from_sources_with_options(path, lhs, rhs, params, &DiffOptions::default()) } pub(crate) fn from_sources_with_options( @@ -97,62 +86,19 @@ impl DiffResult { lhs: &str, rhs: &str, params: &Params, - display: &DisplayOptions, options: &DiffOptions, ) -> Result<Self, QueryConflict> { let file = crate::options::FileArgument::NamedPath(path.into()); - diff_file_content( - params, - path, - None, - &file, - &file, - lhs, - rhs, - display, - options, - &[], - ) - } -} -fn check_only_text( - file_format: &FileFormat, - display_path: &str, - extra_info: Option<String>, - lhs_src: &str, - rhs_src: &str, -) -> DiffResult { - let has_byte_changes = if lhs_src == rhs_src { - None - } else { - Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len())) - }; - - DiffResult { - display_path: display_path.to_owned(), - extra_info, - file_format: file_format.clone(), - lhs_src: FileContent::Text(lhs_src.into()), - rhs_src: FileContent::Text(rhs_src.into()), - lhs_positions: vec![], - rhs_positions: vec![], - hunks: vec![], - lhs_folds: vec![], - rhs_folds: vec![], - has_byte_changes, - has_syntactic_changes: lhs_src != rhs_src, + diff_file_content(params, path, &file, &file, lhs, rhs, options, &[]) } } - pub(crate) fn diff_file_content( params: &Params, display_path: &str, - extra_info: Option<String>, _lhs_path: &FileArgument, rhs_path: &FileArgument, lhs_src: &str, rhs_src: &str, - display_options: &DisplayOptions, diff_options: &DiffOptions, overrides: &[(LanguageOverride, Vec<glob::Pattern>)], ) -> Result<DiffResult, QueryConflict> { @@ -173,18 +119,13 @@ pub(crate) fn diff_file_content( // If the two files are byte-for-byte identical, return early // rather than doing any more work. return Ok(DiffResult { - extra_info, - display_path: display_path.to_owned(), file_format, lhs_src: FileContent::Text(lhs_src.into()), rhs_src: FileContent::Text(rhs_src.into()), lhs_positions: vec![], rhs_positions: vec![], - hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], - has_byte_changes: None, - has_syntactic_changes: false, }); } @@ -196,30 +137,11 @@ pub(crate) fn diff_file_content( cause: FallbackCause::Generated, reason: GENERATED_FALLBACK.to_owned(), }; - if diff_options.check_only { - return Ok(check_only_text( - &file_format, - display_path, - extra_info, - lhs_src, - rhs_src, - )); - } let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); (file_format, lhs_positions, rhs_positions) } None => { let file_format = FileFormat::PlainText; - if diff_options.check_only { - return Ok(check_only_text( - &file_format, - display_path, - extra_info, - lhs_src, - rhs_src, - )); - } - let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); (file_format, lhs_positions, rhs_positions) } @@ -237,31 +159,6 @@ pub(crate) fn diff_file_content( diff_options, ) { Ok((lhs, rhs)) => { - if diff_options.check_only { - let has_syntactic_changes = lhs != rhs; - - let has_byte_changes = if lhs_src == rhs_src { - None - } else { - Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len())) - }; - - return Ok(DiffResult { - extra_info, - display_path: display_path.to_owned(), - file_format: FileFormat::SupportedLanguage(language), - lhs_src: FileContent::Text(lhs_src.to_owned()), - rhs_src: FileContent::Text(rhs_src.to_owned()), - lhs_positions: vec![], - rhs_positions: vec![], - hunks: vec![], - lhs_folds: vec![], - rhs_folds: vec![], - has_byte_changes, - has_syntactic_changes, - }); - } - let mut change_map = ChangeMap::default(); let possibly_changed = if env::var("DFT_DBG_KEEP_UNCHANGED").is_ok() { vec![(lhs.clone(), rhs.clone())] @@ -374,16 +271,6 @@ pub(crate) fn diff_file_content( ), }; - if diff_options.check_only { - return Ok(check_only_text( - &file_format, - display_path, - extra_info, - lhs_src, - rhs_src, - )); - } - // The trees parsed, only with too many errors to // match on. Folds and context still come from them. let conflict = |side| { @@ -432,16 +319,6 @@ pub(crate) fn diff_file_content( ), }; - if diff_options.check_only { - return Ok(check_only_text( - &file_format, - display_path, - extra_info, - lhs_src, - rhs_src, - )); - } - let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src); (file_format, lhs_positions, rhs_positions) @@ -461,44 +338,13 @@ pub(crate) fn diff_file_content( &rhs_src.split_terminator('\n').collect::<Vec<_>>(), ); - let opposite_to_lhs = opposite_positions(&lhs_positions); - let opposite_to_rhs = opposite_positions(&rhs_positions); - - let hunks = matched_pos_to_hunks(&lhs_positions, &rhs_positions); - let hunks = merge_adjacent( - &hunks, - &opposite_to_lhs, - &opposite_to_rhs, - lhs_src.max_line(), - rhs_src.max_line(), - display_options.num_context_lines as usize, - ); - let has_syntactic_changes = !hunks.is_empty(); - let hunks = display::prepare::prepare( - &hunks, - (lhs_src, rhs_src), - (&lhs_positions, &rhs_positions), - display_options.num_context_lines as usize, - ); - - let has_byte_changes = if lhs_src == rhs_src { - None - } else { - Some((lhs_src.as_bytes().len(), rhs_src.as_bytes().len())) - }; - Ok(DiffResult { - extra_info, - display_path: display_path.to_owned(), file_format, lhs_src: FileContent::Text(lhs_src.to_owned()), rhs_src: FileContent::Text(rhs_src.to_owned()), lhs_positions, rhs_positions, - hunks, lhs_folds, rhs_folds, - has_byte_changes, - has_syntactic_changes, }) } diff --git a/src/exit_codes.rs b/src/exit_codes.rs index d6d3ede23..373383dd6 100644 --- a/src/exit_codes.rs +++ b/src/exit_codes.rs @@ -1,11 +1,3 @@ -/// Successfully ran a diff, found no syntactic changes in text files -/// or byte changes in binary files. -pub(crate) const EXIT_SUCCESS: i32 = 0; - -/// Successfully ran a diff, found syntactic changes in text files or -/// byte changes in binary files. -pub(crate) const EXIT_FOUND_CHANGES: i32 = 1; - /// Invalid arguments given to difftastic. This could be usage errors /// (e.g. invalid numbers of arguments) or invalid paths (e.g. files /// we don't have permission to read). diff --git a/src/files.rs b/src/files.rs index 1dddd065e..e2f55e684 100644 --- a/src/files.rs +++ b/src/files.rs @@ -2,25 +2,11 @@ use std::fs; use std::io::ErrorKind::*; -use std::io::Read; -use std::path::{Path, PathBuf}; - -use ignore::WalkBuilder; +use std::path::Path; use crate::exit_codes::EXIT_BAD_ARGUMENTS; -use crate::hash::DftHashSet; use crate::options::FileArgument; -pub(crate) fn read_file_or_die(path: &FileArgument) -> Vec<u8> { - match read_file_arg(path) { - Ok(src) => src, - Err(e) => { - eprint_read_error(path, &e); - std::process::exit(EXIT_BAD_ARGUMENTS); - } - } -} - pub(crate) fn read_files_or_die( lhs_path: &FileArgument, rhs_path: &FileArgument, @@ -58,14 +44,6 @@ pub(crate) fn read_files_or_die( fn read_file_arg(file_arg: &FileArgument) -> std::io::Result<Vec<u8>> { match file_arg { FileArgument::NamedPath(path) => fs::read(path), - FileArgument::Stdin => { - let stdin = std::io::stdin(); - let mut handle = stdin.lock(); - - let mut bytes = vec![]; - handle.read_to_end(&mut bytes)?; - Ok(bytes) - } FileArgument::DevNull => { // Treat /dev/null as an empty file, even on platforms like // Windows where this path doesn't exist. Git uses /dev/null @@ -271,95 +249,12 @@ pub(crate) fn guess_content( ProbableFileKind::Binary } -/// All the files in `dir`, including subdirectories. -fn relative_file_paths_in_dir(dir: &Path) -> Vec<PathBuf> { - // Walk all the files in `dir`, excluding those mentioned in .git. - let walker = WalkBuilder::new(dir) - // Include files whose name starts with a dot. - .hidden(false) - // Exclude the .git directory. - .filter_entry(|e| { - !(e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) && e.file_name() == ".git") - }) - .build(); - - walker - .filter_map(Result::ok) - .map(|entry| Path::new(entry.path()).to_owned()) - .filter(|path| !path.is_dir()) - .map(|path| path.strip_prefix(dir).unwrap().to_path_buf()) - .collect() -} - -/// Walk `lhs_dir` and `rhs_dir`, and return relative paths of files -/// that occur in at least one directory. -/// -/// Attempts to preserve the ordering of files in both directories. -pub(crate) fn relative_paths_in_either(lhs_dir: &Path, rhs_dir: &Path) -> Vec<PathBuf> { - let lhs_paths = relative_file_paths_in_dir(lhs_dir); - let rhs_paths = relative_file_paths_in_dir(rhs_dir); - - let mut seen = DftHashSet::default(); - let mut paths: Vec<PathBuf> = vec![]; - - let mut i = 0; - let mut j = 0; - - loop { - match (lhs_paths.get(i), rhs_paths.get(j)) { - (Some(lhs_path), Some(rhs_path)) if lhs_path == rhs_path => { - if !seen.contains(lhs_path) { - // It should be impossible to get duplicates, but - // be defensive. - paths.push(lhs_path.clone()); - seen.insert(lhs_path); - } - - i += 1; - j += 1; - } - (Some(lhs_path), Some(rhs_path)) => { - if seen.contains(lhs_path) { - i += 1; - } else if seen.contains(rhs_path) { - j += 1; - } else { - paths.push(lhs_path.clone()); - paths.push(rhs_path.clone()); - - seen.insert(lhs_path); - seen.insert(rhs_path); - - i += 1; - j += 1; - } - } - _ => break, - } - } - - paths.extend( - lhs_paths[i..] - .iter() - .filter(|&path| !seen.contains(path)) - .cloned(), - ); - paths.extend( - rhs_paths[j..] - .iter() - .filter(|&path| !seen.contains(path)) - .cloned(), - ); - - paths -} - #[cfg(test)] mod tests { use super::*; fn guess_content(bytes: &[u8]) -> ProbableFileKind { - super::guess_content(bytes, &FileArgument::Stdin, &[]) + super::guess_content(bytes, &FileArgument::DevNull, &[]) } #[test] diff --git a/src/git.rs b/src/git.rs index dda3bdbb0..75e6b5e9b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -586,13 +586,8 @@ impl LoadedFile { (self.before.len() as u64, self.after.len() as u64) } - /// A fold query conflict fails this file alone. `display` sets the - /// unchanged lines around each of difftastic's terminal hunks; nothing - /// else in the result depends on it. - pub(crate) fn diff( - &self, - display: &crate::options::DisplayOptions, - ) -> anyhow::Result<DiffResult> { + /// A fold query conflict fails this file alone. + pub(crate) fn diff(&self) -> anyhow::Result<DiffResult> { let options = crate::options::DiffOptions { generated: self.file.tags.iter().any(|tag| tag == tags::GENERATED), ..self.diff_options.clone() @@ -602,7 +597,6 @@ impl LoadedFile { &self.before, &self.after, &self.params, - display, &options, )?) } diff --git a/src/display/line_layout.rs b/src/line_layout.rs similarity index 97% rename from src/display/line_layout.rs rename to src/line_layout.rs index 78e5511a7..62855779c 100644 --- a/src/display/line_layout.rs +++ b/src/line_layout.rs @@ -1,7 +1,10 @@ -//! Line alignment and ordinary context padding. -use crate::display::context::all_matched_lines_filled; +//! Full-file line alignment, and the runs of rows the projection builds +//! leaves from. +mod matched_lines; + use crate::pairing::Pairing; use crate::parse::syntax::MatchedPos; +use matched_lines::all_matched_lines_filled; use std::collections::BTreeSet; pub(crate) type Row = (Option<usize>, Option<usize>); diff --git a/src/display/context.rs b/src/line_layout/matched_lines.rs similarity index 69% rename from src/display/context.rs rename to src/line_layout/matched_lines.rs index 41f0dd1f9..e51547953 100644 --- a/src/display/context.rs +++ b/src/line_layout/matched_lines.rs @@ -1,11 +1,11 @@ -//! Calculate which nearby lines should also be displayed. +//! Pair up the lines of both sides from the matched positions. use std::cmp::Ordering; use line_numbers::LineNumber; -use crate::hash::{DftHashMap, DftHashSet}; -use crate::parse::syntax::{zip_repeat_shorter, MatchKind, MatchedPos}; +use crate::hash::DftHashSet; +use crate::parse::syntax::{MatchKind, MatchedPos}; pub(crate) fn all_matched_lines_filled( lhs_mps: &[MatchedPos], @@ -324,135 +324,6 @@ fn match_preceding_blanks( res } -pub(crate) fn opposite_positions( - mps: &[MatchedPos], -) -> DftHashMap<LineNumber, DftHashSet<LineNumber>> { - let mut res: DftHashMap<LineNumber, DftHashSet<LineNumber>> = DftHashMap::default(); - - for mp in mps { - match &mp.kind { - MatchKind::UnchangedToken { - self_pos, - opposite_pos, - .. - } => { - for (self_span, opposite_span) in zip_repeat_shorter(self_pos, opposite_pos) { - let opposite_lines = res - .entry(self_span.line) - .or_insert_with(DftHashSet::default); - opposite_lines.insert(opposite_span.line); - } - } - MatchKind::UnchangedPartOfNovelItem { - opposite_pos, - self_pos, - .. - } => { - let opposite_lines = res.entry(self_pos.line).or_insert_with(DftHashSet::default); - for opposite_span in opposite_pos { - opposite_lines.insert(opposite_span.line); - } - } - MatchKind::Novel { .. } | MatchKind::NovelWord { .. } | MatchKind::Ignored { .. } => {} - } - } - - res -} - -/// Before: -/// -/// ```text -/// 118 -- -/// 119 -- -/// 120 -- (novel) -/// ``` -/// -/// After: -/// -/// ```text -/// 118 88 (expanded from closest) -/// 119 89 (closest match) -/// 120 -- (novel) -/// ``` -fn before_with_opposites( - before_lines: &[LineNumber], - opposite_lines: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, -) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - let mut lines = before_lines.to_vec(); - lines.reverse(); - - let mut prev_opposite: Option<LineNumber> = None; - let mut res = vec![]; - - for line in lines { - let current_opposite: Option<LineNumber> = match prev_opposite { - Some(prev_opposite) => { - if prev_opposite.0 > 0 { - Some((prev_opposite.0 - 1).into()) - } else { - None - } - } - None => match opposite_lines.get(&line) { - Some(all_opposites) => { - let mut all_opposites: Vec<LineNumber> = - all_opposites.iter().copied().collect(); - all_opposites.sort(); - - all_opposites.last().copied() - } - None => None, - }, - }; - - res.push((Some(line), current_opposite)); - if current_opposite.is_some() { - prev_opposite = current_opposite; - } - } - - res.reverse(); - res -} - -fn pad_before(ln: LineNumber, num_context_lines: usize) -> Vec<LineNumber> { - let mut line_nums = vec![]; - - let mut current = ln; - // Use one more line than num_context_lines so we merge - // immediately adjacent hunks. - for _ in 0..num_context_lines + 1 { - if current.0 == 0 { - break; - } - - current = (current.0 - 1).into(); - line_nums.push(current); - } - - line_nums.reverse(); - line_nums -} - -fn pad_after(ln: LineNumber, max_line: LineNumber, num_context_lines: usize) -> Vec<LineNumber> { - let mut line_nums = vec![]; - - let mut current = ln; - // Use one more line than num_context_lines so we merge - // immediately adjacent hunks. - for _ in 0..num_context_lines + 1 { - if current >= max_line { - break; - } - - current = (current.0 + 1).into(); - line_nums.push(current); - } - - line_nums -} - pub(crate) fn flip_tuple<Tx: Copy, Ty: Copy>(pair: (Tx, Ty)) -> (Ty, Tx) { let (x, y) = pair; (y, x) @@ -462,156 +333,6 @@ pub(crate) fn flip_tuples<Tx: Copy, Ty: Copy>(items: &[(Tx, Ty)]) -> Vec<(Ty, Tx items.iter().copied().map(flip_tuple).collect() } -/// Before: -/// 120 -- (novel) -/// 121 -- -/// 122 -- -/// -/// After: -/// 120 90 (novel) -/// 121 -- -/// 122 91 (closest match) -fn after_with_opposites( - after_lines: &[LineNumber], - opposite_lines: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - prev_max_opposite: Option<LineNumber>, - max_opposite: LineNumber, -) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - let mut prev_opposite: Option<LineNumber> = None; - let mut res: Vec<(Option<LineNumber>, Option<LineNumber>)> = vec![]; - - for line in after_lines { - let current_opposite: Option<LineNumber> = match prev_opposite { - Some(prev_opposite) => { - if prev_opposite < max_opposite { - Some((prev_opposite.0 + 1).into()) - } else { - None - } - } - None => match opposite_lines.get(line) { - Some(all_opposites) => { - let mut all_opposites: Vec<LineNumber> = - all_opposites.iter().copied().collect(); - all_opposites.sort(); - - if let Some(prev_max_opposite) = prev_max_opposite { - all_opposites.retain(|x| *x > prev_max_opposite); - } - - all_opposites.first().copied() - } - None => None, - }, - }; - - res.push((Some(*line), current_opposite)); - if current_opposite.is_some() { - prev_opposite = current_opposite; - } - } - - res -} - -pub(crate) fn calculate_before_context( - lines: &[(Option<LineNumber>, Option<LineNumber>)], - opposite_to_lhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - opposite_to_rhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - num_context_lines: usize, -) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - match lines.first() { - Some(first_line) => match *first_line { - (Some(lhs_line), _) => { - let padded_lines = pad_before(lhs_line, num_context_lines); - before_with_opposites(&padded_lines, opposite_to_lhs) - } - (_, Some(rhs_line)) => { - let padded_lines = pad_before(rhs_line, num_context_lines); - flip_tuples(&before_with_opposites(&padded_lines, opposite_to_rhs)) - } - (None, None) => vec![], - }, - None => vec![], - } -} - -pub(crate) fn calculate_after_context( - lines: &[(Option<LineNumber>, Option<LineNumber>)], - opposite_to_lhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - opposite_to_rhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - max_lhs_src_line: LineNumber, - max_rhs_src_line: LineNumber, - num_context_lines: usize, -) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - match lines.last() { - Some(last_line) => match *last_line { - (Some(lhs_line), _) => { - let mut max_opposite = None; - // TODO: It would be simpler to do one loop over all - // the lines and take the last non-None on each side. - for (_, rhs_line) in lines { - if let Some(rhs_line) = rhs_line { - max_opposite = Some(*rhs_line); - } - } - - let padded_lines = pad_after(lhs_line, max_lhs_src_line, num_context_lines); - after_with_opposites( - &padded_lines, - opposite_to_lhs, - max_opposite, - max_rhs_src_line, - ) - } - (_, Some(rhs_line)) => { - let mut max_opposite = None; - for (lhs_line, _) in lines { - if let Some(lhs_line) = lhs_line { - max_opposite = Some(*lhs_line); - } - } - - let padded_lines = pad_after(rhs_line, max_rhs_src_line, num_context_lines); - flip_tuples(&after_with_opposites( - &padded_lines, - opposite_to_rhs, - max_opposite, - max_lhs_src_line, - )) - } - (None, None) => vec![], - }, - None => vec![], - } -} - -pub(crate) fn add_context( - lines: &[(Option<LineNumber>, Option<LineNumber>)], - opposite_to_lhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - opposite_to_rhs: &DftHashMap<LineNumber, DftHashSet<LineNumber>>, - max_lhs_src_line: LineNumber, - max_rhs_src_line: LineNumber, - num_context_lines: usize, -) -> Vec<(Option<LineNumber>, Option<LineNumber>)> { - let before_lines = - calculate_before_context(lines, opposite_to_lhs, opposite_to_rhs, num_context_lines); - let after_lines = calculate_after_context( - &[&before_lines, lines].concat(), - opposite_to_lhs, - opposite_to_rhs, - max_lhs_src_line, - max_rhs_src_line, - num_context_lines, - ); - - before_lines - .into_iter() - .chain(lines.iter().copied()) - .chain(after_lines) - .collect() -} - /// Ensure that we don't miss any intermediate values. /// /// Before: @@ -728,34 +449,12 @@ fn compact_gaps( #[cfg(test)] mod tests { - use std::iter::FromIterator; - use line_numbers::SingleLineSpan; use pretty_assertions::assert_eq; use super::*; use crate::syntax::TokenKind; - #[test] - fn test_calculate_before_context() { - let num_context_lines = 3; - let lines = vec![(Some(1.into()), Some(1.into()))]; - - let mut opposite_to_lhs = DftHashMap::default(); - opposite_to_lhs.insert(0.into(), DftHashSet::from_iter([0.into()])); - - let mut opposite_to_rhs = DftHashMap::default(); - opposite_to_rhs.insert(0.into(), DftHashSet::from_iter([0.into()])); - - let res = calculate_before_context( - &lines, - &opposite_to_lhs, - &opposite_to_rhs, - num_context_lines, - ); - assert_eq!(res, vec![(Some(0.into()), Some(0.into()))]); - } - #[test] fn test_all_matched_lines() { let matched_pos = SingleLineSpan { @@ -1037,10 +736,4 @@ mod tests { ] ); } - - #[test] - fn test_pad_after_when_line_exceeds_max() { - let res = pad_after(2.into(), 1.into(), 5); - assert_eq!(res, vec![]); - } } diff --git a/src/lines.rs b/src/lines.rs index a5fce0246..0251ad9f9 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -1,45 +1,7 @@ //! Manipulate lines of text and groups of lines. -use std::ops::Sub; - use line_numbers::LineNumber; -pub(crate) fn format_line_num(line_num: LineNumber) -> String { - format!("{} ", line_num.display()) -} - -pub(crate) fn format_line_num_padded(line_num: LineNumber, column_width: usize) -> String { - format!( - "{:width$} ", - line_num.as_usize() + 1, - width = column_width - 1 - ) -} - -/// Return the length of `s` in bytes. -/// -/// This is a trivial wrapper to make it clear when we want bytes not -/// codepoints. -pub(crate) fn byte_len(s: &str) -> usize { - s.len() -} - -pub(crate) trait MaxLine { - fn max_line(&self) -> LineNumber; -} - -impl<S: AsRef<str>> MaxLine for S { - fn max_line(&self) -> LineNumber { - (self - .as_ref() - .trim_end() // Remove extra trailing whitespaces. - .split('\n') // Split by `\n` to calculate lines. - .count() as u32) - .sub(1) // Sub 1 to make zero-indexed LineNumber - .into() - } -} - /// Split `s` on \n or \r\n. Always yields at least one item. Each line /// does not include the trailing newline. /// @@ -80,30 +42,6 @@ mod tests { use super::*; - #[test] - fn str_max_line() { - let line: String = "foo\nbar".into(); - assert_eq!(line.max_line().0, 1); - } - - #[test] - fn empty_str_max_line() { - let line: String = "".into(); - assert_eq!(line.max_line().0, 0); - } - - #[test] - fn str_max_line_trailing_newline() { - let line: String = "foo\nbar\n".into(); - assert_eq!(line.max_line().0, 1); - } - - #[test] - fn str_max_line_extra_trailing_newline() { - let line: String = "foo\nbar\n\n".into(); - assert_eq!(line.max_line().0, 1); - } - #[test] fn test_split_line_empty() { assert_eq!(split_on_newlines("").collect::<Vec<_>>(), vec![""]); diff --git a/src/main.rs b/src/main.rs index e008eb491..6dd75d718 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,10 +43,8 @@ mod cli; mod config; -mod conflicts; mod constants; mod diff; -mod display; mod engine; mod exit_codes; use engine::diff_file_content; @@ -54,6 +52,7 @@ mod files; mod git; mod gitattributes; mod hash; +mod line_layout; mod line_parser; mod lines; mod options; @@ -70,17 +69,9 @@ mod words; extern crate log; use crate::config::Params; -use display::style::print_warning; -use log::info; -use options::{FilePermissions, USAGE}; -use crate::conflicts::{apply_conflict_markers, START_LHS_MARKER}; -use crate::display::style::print_error; -use crate::exit_codes::{EXIT_BAD_ARGUMENTS, EXIT_FOUND_CHANGES, EXIT_SUCCESS}; -use crate::files::{ - guess_content, read_file_or_die, read_files_or_die, read_or_die, relative_paths_in_either, - ProbableFileKind, -}; +use crate::exit_codes::EXIT_BAD_ARGUMENTS; +use crate::files::{guess_content, read_files_or_die, read_or_die, ProbableFileKind}; use crate::gitattributes::{check_diff_attr, DiffAttribute}; use crate::parse::guess_language::{ guess, language_globs, language_name, Language, LanguageOverride, @@ -110,17 +101,13 @@ use tikv_jemallocator::Jemalloc; #[global_allocator] static GLOBAL: Jemalloc = Jemalloc; -use humansize::{format_size, FormatSizeOptions, BINARY}; use std::path::Path; -use std::thread; -use owo_colors::OwoColorize; -use rayon::prelude::*; use strum::IntoEnumIterator; use typed_arena::Arena; use crate::engine::QueryConflict; -use crate::options::{DiffOptions, DisplayMode, DisplayOptions, FileArgument, Mode}; +use crate::options::{DiffOptions, FileArgument, Mode}; use crate::parse::folds::Conflict; use crate::parse::syntax::init_all_info; use crate::parse::tree_sitter_parser as tsp; @@ -233,19 +220,12 @@ fn run_debug() { } } } - Mode::ListLanguages { - use_color, - language_overrides, - } => { + Mode::ListLanguages { language_overrides } => { for (lang_override, globs) in language_overrides { - let mut name = match lang_override { + let name = match lang_override { LanguageOverride::Language(lang) => language_name(lang), LanguageOverride::PlainText => "Text", - } - .to_owned(); - if use_color { - name = name.bold().to_string(); - } + }; println!("{} (from override)", name); for glob in globs { print!(" {}", glob.as_str()); @@ -254,11 +234,7 @@ fn run_debug() { } for language in Language::iter() { - let mut name = language_name(language).to_owned(); - if use_color { - name = name.bold().to_string(); - } - println!("{}", name); + println!("{}", language_name(language)); for glob in language_globs(language) { print!(" {}", glob.as_str()); @@ -266,181 +242,15 @@ fn run_debug() { println!(); } } - Mode::DiffFromConflicts { - display_path, - path, - diff_options, - display_options, - set_exit_code, - language_overrides, - binary_overrides, - } => { - let diff_result = diff_or_die( - diff_conflicts_file( - params, - &display_path, - &path, - &display_options, - &diff_options, - &language_overrides, - &binary_overrides, - ), - display_options.use_color, - ); - - print_diff_result(&display_options, &diff_result); - - let exit_code = if set_exit_code && diff_result.has_reportable_change() { - EXIT_FOUND_CHANGES - } else { - EXIT_SUCCESS - }; - std::process::exit(exit_code); - } - Mode::Diff { - diff_options, - display_options, - set_exit_code, - language_overrides, - binary_overrides, - lhs_path, - rhs_path, - lhs_permissions, - rhs_permissions, - display_path, - renamed, - } => { - if lhs_path == rhs_path { - let is_dir = match &lhs_path { - FileArgument::NamedPath(path) => path.is_dir(), - _ => false, - }; - - print_warning( - &format!( - "You've specified the same {} twice.", - if is_dir { "directory" } else { "file" } - ), - &display_options, - ); - } - - let mut encountered_changes = false; - match (&lhs_path, &rhs_path) { - ( - options::FileArgument::NamedPath(lhs_path), - options::FileArgument::NamedPath(rhs_path), - ) if lhs_path.is_dir() && rhs_path.is_dir() => { - // Diffs in parallel when iterating this iterator. - let diff_iter = diff_directories( - params, - lhs_path, - rhs_path, - &display_options, - &diff_options, - &language_overrides, - &binary_overrides, - ); - - if matches!(display_options.display_mode, DisplayMode::Json) { - let results: Vec<_> = diff_iter.collect(); - encountered_changes = results - .iter() - .any(|diff_result| diff_result.has_reportable_change()); - display::json::print_directory(results, display_options.print_unchanged); - } else if display_options.sort_paths { - let mut result: Vec<DiffResult> = diff_iter.collect(); - result.sort_unstable_by(|a, b| a.display_path.cmp(&b.display_path)); - for diff_result in result { - print_diff_result(&display_options, &diff_result); - - if diff_result.has_reportable_change() { - encountered_changes = true; - } - } - } else { - // We want to diff files in the directory in - // parallel, but print the results serially - // (to prevent display interleaving). - // https://github.com/rayon-rs/rayon/issues/210#issuecomment-551319338 - thread::scope(|s| { - let (send, recv) = std::sync::mpsc::sync_channel(1); - - // I don't see a nice way of fixing this, and difftastic is regularly benchmarked - // for performance issues and this hasn't been a problem. - #[allow(clippy::result_large_err)] - s.spawn(move || { - diff_iter - .try_for_each_with(send, |s, diff_result| s.send(diff_result)) - .expect("Receiver should be connected") - }); - - for diff_result in recv.into_iter() { - print_diff_result(&display_options, &diff_result); - - if diff_result.has_reportable_change() { - encountered_changes = true; - } - } - }); - } - } - _ => { - let diff_result = diff_or_die( - diff_file( - params, - &display_path, - renamed, - &lhs_path, - &rhs_path, - lhs_permissions.as_ref(), - rhs_permissions.as_ref(), - &display_options, - &diff_options, - false, - &language_overrides, - &binary_overrides, - ), - display_options.use_color, - ); - if diff_result.has_reportable_change() { - encountered_changes = true; - } - - match display_options.display_mode { - DisplayMode::Inline - | DisplayMode::SideBySide - | DisplayMode::SideBySideShowBoth => { - print_diff_result(&display_options, &diff_result); - } - DisplayMode::Json => display::json::print(&diff_result), - } - } - } - - let exit_code = if set_exit_code && encountered_changes { - EXIT_FOUND_CHANGES - } else { - EXIT_SUCCESS - }; - std::process::exit(exit_code); - } - Mode::GitHasUnmergedFile { display_path } => { - println!("Unmerged path: {display_path}"); - } }; } -/// Print a diff between two files. +/// Diff two files: `--no-index`. fn diff_file( params: &Params, display_path: &str, - renamed: Option<String>, lhs_path: &FileArgument, rhs_path: &FileArgument, - lhs_permissions: Option<&FilePermissions>, - rhs_permissions: Option<&FilePermissions>, - display_options: &DisplayOptions, diff_options: &DiffOptions, missing_as_empty: bool, overrides: &[(LanguageOverride, Vec<glob::Pattern>)], @@ -456,34 +266,19 @@ fn diff_file( (ProbableFileKind::Binary, _, _) | (_, ProbableFileKind::Binary, _) | (_, _, Some(DiffAttribute::AssumeBinary)) => { - let has_byte_changes = if lhs_bytes == rhs_bytes { - None - } else { - Some((lhs_bytes.len(), rhs_bytes.len())) - }; return Ok(DiffResult { - extra_info: renamed, - display_path: display_path.to_owned(), file_format: FileFormat::Binary, lhs_src: FileContent::Binary, rhs_src: FileContent::Binary, lhs_positions: vec![], rhs_positions: vec![], - hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], - has_byte_changes, - has_syntactic_changes: false, }); } (ProbableFileKind::Text(lhs_src), ProbableFileKind::Text(rhs_src), _) => (lhs_src, rhs_src), }; - if diff_options.strip_cr { - lhs_src.retain(|c| c != '\r'); - rhs_src.retain(|c| c != '\r'); - } - // Ensure that lhs_src and rhs_src both have trailing // newlines. // @@ -501,179 +296,18 @@ fn diff_file( rhs_src.push('\n'); } - let mut extra_info = renamed; - if let (Some(lhs_perms), Some(rhs_perms)) = (lhs_permissions, rhs_permissions) { - if lhs_perms != rhs_perms { - let msg = format!( - "File permissions changed from {} to {}.", - lhs_perms, rhs_perms - ); - - if let Some(extra_info) = &mut extra_info { - extra_info.push('\n'); - extra_info.push_str(&msg); - } else { - extra_info = Some(msg); - } - } - } - diff_file_content( params, display_path, - extra_info, lhs_path, rhs_path, &lhs_src, &rhs_src, - display_options, diff_options, overrides, ) } -fn diff_conflicts_file( - params: &Params, - display_path: &str, - path: &FileArgument, - display_options: &DisplayOptions, - diff_options: &DiffOptions, - overrides: &[(LanguageOverride, Vec<glob::Pattern>)], - binary_overrides: &[glob::Pattern], -) -> Result<DiffResult, QueryConflict> { - let bytes = read_file_or_die(path); - let mut src = match guess_content(&bytes, path, binary_overrides) { - ProbableFileKind::Text(src) => src, - ProbableFileKind::Binary => { - print_error( - "Expected a text file with conflict markers, got a binary file.", - display_options.use_color, - ); - std::process::exit(EXIT_BAD_ARGUMENTS); - } - }; - - if diff_options.strip_cr { - src.retain(|c| c != '\r'); - } - - let conflict_files = match apply_conflict_markers(&src) { - Ok(cf) => cf, - Err(msg) => { - print_error(&msg, display_options.use_color); - std::process::exit(EXIT_BAD_ARGUMENTS); - } - }; - - if conflict_files.num_conflicts == 0 { - print_error( - &format!( - "Difftastic requires two paths, or a single file with conflict markers {}.\n", - if display_options.use_color { - START_LHS_MARKER.bold().to_string() - } else { - START_LHS_MARKER.to_owned() - } - ), - display_options.use_color, - ); - - eprintln!("USAGE:\n\n {}\n", USAGE); - eprintln!("For more information try --help"); - std::process::exit(EXIT_BAD_ARGUMENTS); - } - - let lhs_name = match conflict_files.lhs_name { - Some(name) => format!("'{}'", name), - None => "the left file".to_owned(), - }; - let rhs_name = match conflict_files.rhs_name { - Some(name) => format!("'{}'", name), - None => "the right file".to_owned(), - }; - - let extra_info = format!( - "Showing the result of replacing every conflict in {} with {}.", - lhs_name, rhs_name - ); - - diff_file_content( - params, - display_path, - Some(extra_info), - path, - path, - &conflict_files.lhs_content, - &conflict_files.rhs_content, - display_options, - diff_options, - overrides, - ) -} - -/// Given two directories that contain the files, compare them -/// pairwise. Returns an iterator, so we can print results -/// incrementally. -/// -/// When more than one file is modified, the hg extdiff extension passes directory -/// paths with all the modified files. -fn diff_directories<'a>( - params: &'a Params, - lhs_dir: &'a Path, - rhs_dir: &'a Path, - display_options: &DisplayOptions, - diff_options: &DiffOptions, - overrides: &[(LanguageOverride, Vec<glob::Pattern>)], - binary_overrides: &[glob::Pattern], -) -> impl ParallelIterator<Item = DiffResult> + 'a { - let diff_options = diff_options.clone(); - let display_options = display_options.clone(); - let overrides: Vec<_> = overrides.into(); - let binary_overrides: Vec<_> = binary_overrides.into(); - - // We greedily list all files in the directory, and then diff them - // in parallel. This is assuming that diffing is slower than - // enumerating files, so it benefits more from parallelism. - let paths = relative_paths_in_either(lhs_dir, rhs_dir); - - paths.into_par_iter().map(move |rel_path| { - info!("Relative path is {:?} inside {:?}", rel_path, lhs_dir); - - let lhs_path = FileArgument::NamedPath(Path::new(lhs_dir).join(&rel_path)); - let rhs_path = FileArgument::NamedPath(Path::new(rhs_dir).join(&rel_path)); - - diff_or_die( - diff_file( - params, - &rel_path.display().to_string(), - None, - &lhs_path, - &rhs_path, - lhs_path.permissions().as_ref(), - rhs_path.permissions().as_ref(), - &display_options, - &diff_options, - true, - &overrides, - &binary_overrides, - ), - display_options.use_color, - ) - }) -} - -/// The difftastic-style modes stop at a query conflict, as they do at an -/// unreadable file. -fn diff_or_die(result: Result<DiffResult, QueryConflict>, use_color: bool) -> DiffResult { - match result { - Ok(diff) => diff, - Err(conflict) => { - print_error(&conflict.to_string(), use_color); - std::process::exit(EXIT_BAD_ARGUMENTS); - } - } -} - /// The syntax dumps stop at a fold query conflict. fn conflict_or_die<T>(result: Result<T, Conflict>) -> T { match result { @@ -691,159 +325,6 @@ fn conflict_or_die<T>(result: Result<T, Conflict>) -> T { } } -fn print_diff_result(display_options: &DisplayOptions, summary: &DiffResult) { - match (&summary.lhs_src, &summary.rhs_src) { - (FileContent::Text(lhs_src), FileContent::Text(rhs_src)) => { - let hunks = &summary.hunks; - - if !summary.has_syntactic_changes { - if display_options.print_unchanged { - println!( - "{}", - display::style::header( - &summary.display_path, - summary.extra_info.as_ref(), - 1, - 1, - &summary.file_format, - display_options - ) - ); - match summary.file_format { - _ if summary.lhs_src == summary.rhs_src => { - println!("No changes.\n"); - } - FileFormat::SupportedLanguage(_) => { - println!("No syntactic changes.\n"); - } - _ => { - println!("No changes.\n"); - } - } - } - return; - } - - if summary.has_syntactic_changes && hunks.is_empty() { - println!( - "{}", - display::style::header( - &summary.display_path, - summary.extra_info.as_ref(), - 1, - 1, - &summary.file_format, - display_options - ) - ); - match summary.file_format { - FileFormat::SupportedLanguage(_) => { - println!("Has syntactic changes.\n"); - } - _ => { - println!("Has changes.\n"); - } - } - - return; - } - - match display_options.display_mode { - DisplayMode::Inline => { - display::inline::print( - lhs_src, - rhs_src, - display_options, - &summary.lhs_positions, - &summary.rhs_positions, - hunks, - &summary.display_path, - &summary.extra_info, - &summary.file_format, - ); - } - DisplayMode::SideBySide | DisplayMode::SideBySideShowBoth => { - display::side_by_side::print( - hunks, - display_options, - &summary.display_path, - summary.extra_info.as_ref(), - &summary.file_format, - lhs_src, - rhs_src, - &summary.lhs_positions, - &summary.rhs_positions, - ); - } - DisplayMode::Json => unreachable!(), - } - } - (FileContent::Binary, FileContent::Binary) => { - if display_options.print_unchanged || summary.has_byte_changes.is_some() { - println!( - "{}", - display::style::header( - &summary.display_path, - summary.extra_info.as_ref(), - 1, - 1, - &FileFormat::Binary, - display_options - ) - ); - - match summary.has_byte_changes { - Some((lhs_len, rhs_len)) => { - let format_options = FormatSizeOptions::from(BINARY).decimal_places(1); - - if lhs_len == 0 { - // Strictly speaking this is wrong: - // previously we may have had an empty but - // existent file. In that case, it's a - // file modification instead of a file - // creation. - // - // TODO: Fix this pedantic case. - println!( - "Binary file added ({}).\n", - format_size(rhs_len, format_options), - ) - } else if rhs_len == 0 { - println!( - "Binary file removed ({}).\n", - format_size(lhs_len, format_options), - ) - } else { - println!( - "Binary file modified (old: {}, new: {}).\n", - format_size(lhs_len, format_options), - format_size(rhs_len, format_options), - ) - } - } - None => println!("No changes.\n"), - } - } - } - (FileContent::Text(_), FileContent::Binary) - | (FileContent::Binary, FileContent::Text(_)) => { - // We're diffing a binary file against a text file. - println!( - "{}", - display::style::header( - &summary.display_path, - summary.extra_info.as_ref(), - 1, - 1, - &FileFormat::Binary, - display_options - ) - ); - println!("Binary contents changed.\n"); - } - } -} - #[cfg(test)] mod tests { use std::ffi::OsStr; @@ -856,12 +337,10 @@ mod tests { let res = diff_file_content( &Params::default(), "foo.el", - None, &FileArgument::from_path_argument(OsStr::new("foo.el")), &FileArgument::from_path_argument(OsStr::new("foo.el")), s, s, - &DisplayOptions::default(), &DiffOptions::default(), &[], ) diff --git a/src/options.rs b/src/options.rs index 25cd83eb8..2186a78c8 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,15 +1,12 @@ //! CLI option parsing. use std::env; -use std::ffi::{OsStr, OsString}; +use std::ffi::OsStr; use std::fmt::Display; -use std::io::IsTerminal as _; use std::path::{Path, PathBuf}; -use clap::{crate_authors, crate_description, value_parser, Arg, ArgAction, Command}; -use owo_colors::OwoColorize as _; +use clap::{crate_authors, crate_description, Arg, ArgAction, Command}; -use crate::display::style::{print_error, BackgroundColor}; use crate::exit_codes::EXIT_BAD_ARGUMENTS; use crate::parse::guess_language::{language_override_from_name, LanguageOverride}; use crate::version::VERSION; @@ -21,56 +18,16 @@ pub(crate) const DEFAULT_BYTE_LIMIT: usize = 1_000_000; pub(crate) const DEFAULT_GRAPH_LIMIT: usize = 3_000_000; pub(crate) const DEFAULT_PARSE_ERROR_LIMIT: usize = 0; -pub(crate) const DEFAULT_TAB_WIDTH: usize = 4; - -pub(crate) const USAGE: &str = concat!(env!("CARGO_BIN_NAME"), " [OPTIONS] OLD-PATH NEW-PATH"); - -#[derive(Debug, Clone, Copy)] -pub(crate) enum ColorOutput { - Always, - Auto, - Never, -} - -#[derive(Debug, Clone)] -pub(crate) struct DisplayOptions { - pub(crate) background_color: BackgroundColor, - pub(crate) use_color: bool, - pub(crate) display_mode: DisplayMode, - pub(crate) print_unchanged: bool, - pub(crate) tab_width: usize, - pub(crate) terminal_width: usize, - pub(crate) num_context_lines: u32, - pub(crate) syntax_highlight: bool, - pub(crate) sort_paths: bool, -} +pub(crate) const USAGE: &str = concat!(env!("CARGO_BIN_NAME"), " debug [OPTIONS]"); pub(crate) const DEFAULT_TERMINAL_WIDTH: usize = 80; -impl Default for DisplayOptions { - fn default() -> Self { - Self { - background_color: BackgroundColor::Dark, - use_color: false, - display_mode: DisplayMode::SideBySide, - print_unchanged: true, - tab_width: 8, - terminal_width: DEFAULT_TERMINAL_WIDTH, - num_context_lines: 3, - syntax_highlight: true, - sort_paths: false, - } - } -} - #[derive(Debug, Clone)] pub(crate) struct DiffOptions { pub(crate) graph_limit: usize, pub(crate) byte_limit: usize, pub(crate) parse_error_limit: usize, - pub(crate) check_only: bool, pub(crate) ignore_comments: bool, - pub(crate) strip_cr: bool, /// The file is tagged `generated`: diff it by line without parsing. pub(crate) generated: bool, } @@ -81,66 +38,13 @@ impl Default for DiffOptions { graph_limit: DEFAULT_GRAPH_LIMIT, byte_limit: DEFAULT_BYTE_LIMIT, parse_error_limit: DEFAULT_PARSE_ERROR_LIMIT, - check_only: false, ignore_comments: false, - strip_cr: false, generated: false, } } } fn app() -> clap::Command { - let bin_name = env!("CARGO_BIN_NAME"); - - let mut after_help = String::new(); - after_help - .push_str("You can compare two files with difftastic by specifying them as arguments.\n\n"); - after_help.push_str(&format!("$ {} old.js new.js", bin_name).bold().to_string()); - - after_help.push_str("\n\nYou can also use directories as arguments. Difftastic will walk both directories and compare files with matching names.\n\n"); - after_help.push_str(&format!("$ {} old/ new/", bin_name).bold().to_string()); - - after_help.push_str("\n\nIf you have a file with conflict markers, you can pass it as a single argument. Difftastic will diff the two conflicting file states.\n\n"); - after_help.push_str( - &format!("$ {} file_with_conflicts.js", bin_name) - .bold() - .to_string(), - ); - - // For some reason clap will hard wrap these invocations weirdly - // (with extra blank lines) if we use bold. Since these are - // showing CLI formats rather than concrete values, compromise by - // not using bold. - after_help.push_str("\n\nDifftastic can also be invoked with 7 or 9 arguments in the format that GIT_EXTERNAL_DIFF expects.\n\n"); - after_help.push_str(&format!( - "$ {} DISPLAY-PATH OLD-FILE OLD-HEX OLD-MODE NEW-FILE NEW-HEX NEW-MODE", - bin_name - )); - - after_help.push('\n'); - after_help.push_str(&format!( - "$ {} OLD-NAME OLD-FILE OLD-HEX OLD-MODE NEW-FILE NEW-HEX NEW-MODE NEW-NAME METADATA", - bin_name - )); - - after_help.push_str("\n\nSee the full manual at "); - if std::io::stdout().is_terminal() { - // Make the link to the manual clickable in terminals that - // support OSC 8, the ANSI escape code for hyperlinks. - // - // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda - // https://github.com/Alhadis/OSC8-Adoption - // - // There isn't any way of detecting whether the terminal - // supports OSC 8 specifically, but we can limit usage to when - // there's a TTY. This is similar to how we detect whether to - // use colour. - after_help.push_str("\x1b]8;;https://difftastic.wilfred.me.uk/\x1b\\https://difftastic.wilfred.me.uk/\x1b]8;;\x1b\\"); - } else { - after_help.push_str("https://difftastic.wilfred.me.uk/"); - } - after_help.push('.'); - Command::new("Difftastic") // Show options in alphabetical order, rather than in // declaration order. @@ -150,7 +54,6 @@ fn app() -> clap::Command { .long_version(VERSION.as_str()) .about(crate_description!()) .author(crate_authors!()) - .after_long_help(after_help) .arg( Arg::new("dump-syntax") .long("dump-syntax") @@ -178,129 +81,25 @@ fn app() -> clap::Command { "Parse a single file with tree-sitter and display the tree-sitter parse tree.", ).help_heading("DEBUG OPTIONS"), ) - .arg( - Arg::new("context") - .long("context") - .short('c') - .value_name("LINES") - .action(ArgAction::Set) - .long_help("The number of contextual lines to show around changed lines.") - .default_value("3") - .env("DFT_CONTEXT") - .value_parser(clap::value_parser!(u32)) - .required(false), - ) - .arg( - Arg::new("width") - .long("width") - .value_name("COLUMNS") - .action(ArgAction::Set) - .long_help("Use this many columns when calculating line wrapping. If not specified, difftastic will detect the terminal width.") - .env("DFT_WIDTH") - .value_parser(clap::value_parser!(usize)) - .required(false), - ) - .arg( - Arg::new("tab-width") - .long("tab-width") - .value_name("NUM_SPACES") - .action(ArgAction::Set) - .long_help("Treat a tab as this many spaces.") - .env("DFT_TAB_WIDTH") - .default_value(format!("{}", DEFAULT_TAB_WIDTH)) - .value_parser(clap::value_parser!(usize)) - .required(false), - ) - .arg( - Arg::new("display").long("display") - .value_parser(["side-by-side", "side-by-side-show-both", "inline", "json"]) - .default_value("side-by-side") - .value_name("MODE") - .action(ArgAction::Set) - .env("DFT_DISPLAY") - .help("Display mode for showing results. - -side-by-side: Display the before file and the after file in two separate columns, with line numbers aligned according to unchanged content. If a change is exclusively additions or exclusively removals, use a single column. - -side-by-side-show-both: The same as side-by-side, but always uses two columns. - -inline: A single column display, closer to traditional diff display. - -json: Output the results as a machine-readable JSON array with an element per file.") - ) - .arg( - Arg::new("color").long("color") - .value_parser(["always", "auto", "never"]) - .default_value("auto") - .env("DFT_COLOR") - .value_name("WHEN") - .action(ArgAction::Set) - .help("When to use color output.") - ) - .arg( - Arg::new("background").long("background") - .value_name("BACKGROUND") - .env("DFT_BACKGROUND") - .value_parser(["dark", "light"]) - .default_value("dark") - .action(ArgAction::Set) - .help("Set the background brightness. Difftastic will prefer brighter colours on dark backgrounds.") - ) - .arg( - Arg::new("syntax-highlight").long("syntax-highlight") - .value_name("on/off") - .env("DFT_SYNTAX_HIGHLIGHT") - .value_parser(["on", "off"]) - .default_value("on") - .action(ArgAction::Set) - .help("Enable or disable syntax highlighting.") - ) - .arg( - Arg::new("exit-code").long("exit-code") - .action(ArgAction::SetTrue) - .env("DFT_EXIT_CODE") - .help("Set the exit code to 1 if there are syntactic changes in any files. For files where there is no detected language (e.g. unsupported language or binary files), sets the exit code if there are any byte changes.") - ) - .arg( - Arg::new("strip-cr").long("strip-cr") - .value_name("on/off") - .env("DFT_STRIP_CR") - .value_parser(["on", "off"]) - .default_value("on") - .action(ArgAction::Set) - .help("Remove any carriage return characters before diffing. This can be helpful when dealing with files on Windows that contain CRLF, i.e. `\\r\\n`.\n\nWhen disabled, difftastic will consider multiline string literals (in code) or multiline text (e.g. in HTML) to differ if the two input files have different line endings.") - ) - .arg( - Arg::new("check-only").long("check-only") - .action(ArgAction::SetTrue) - .env("DFT_CHECK_ONLY") - .help("Report whether there are any changes, but don't calculate them. Much faster.") - ) .arg( Arg::new("ignore-comments").long("ignore-comments") .action(ArgAction::SetTrue) .env("DFT_IGNORE_COMMENTS") .help("Don't consider comments when diffing.") ) - .arg( - Arg::new("skip-unchanged").long("skip-unchanged") - .action(ArgAction::SetTrue) - .env("DFT_SKIP_UNCHANGED") - .help("Don't display anything if a file is unchanged. This is useful when comparing directories of files.") - ) .arg( Arg::new("override").long("override") .value_name("GLOB:NAME") .action(ArgAction::Append) .help(concat!("Associate this glob pattern with this language, overriding normal language detection. For example: -$ ", env!("CARGO_BIN_NAME"), " --override='*.c:C++' old.c new.c +$ ", env!("CARGO_BIN_NAME"), " debug --override='*.c:C++' --dump-syntax file.c See --list-languages for the list of language names. Language names are matched case insensitively. Overrides may also specify the language \"text\" to treat a file as plain text. This argument may be given more than once. For example: -$ ", env!("CARGO_BIN_NAME"), " --override='CustomFile:json' --override='*.c:text' old.c new.c +$ ", env!("CARGO_BIN_NAME"), " debug --override='CustomFile:json' --override='*.c:text' --dump-syntax file.c To configure multiple overrides using environment variables, difftastic also accepts DFT_OVERRIDE_1 up to DFT_OVERRIDE_9. @@ -311,162 +110,20 @@ $ export DFT_OVERRIDE_2='*.js:javascript jsx' When multiple overrides are specified, the first matching override wins.")) .env("DFT_OVERRIDE") ) - .arg( - Arg::new("override-binary").long("override-binary") - .value_name("GLOB") - .action(ArgAction::Append) - .help(concat!("Always treat file names matching this glob as binary files, ignoring the default heuristics for binary detection. For example: - -$ ", env!("CARGO_BIN_NAME"), " --override-binary='*.gz' old.gz new.gz - -This argument may be given more than once. For example: - -$ ", env!("CARGO_BIN_NAME"), " --override-binary='*.gz' --override-binary='foo.pickle' old.gz new.gz - -To configure multiple overrides using environment variables, difftastic also accepts DFT_OVERRIDE_BINARY_1 up to DFT_OVERRIDE_BINARY_9. - -$ export DFT_OVERRIDE_BINARY='*.gz' -$ export DFT_OVERRIDE_BINARY_1='*.bz2' -$ export DFT_OVERRIDE_BINARY_2='foo.pickle'")) - .env("DFT_OVERRIDE_BINARY") - ) .arg( Arg::new("list-languages").long("list-languages") .action(ArgAction::SetTrue) .help("Print all the languages supported by difftastic, along with their recognised extensions.") ) - .arg( - Arg::new("byte-limit").long("byte-limit") - .value_name("LIMIT") - .action(ArgAction::Set) - .help("Use a line-oriented diff if either input file exceeds this size.") - .default_value(format!("{}", DEFAULT_BYTE_LIMIT)) - .env("DFT_BYTE_LIMIT") - .value_parser(clap::value_parser!(usize)) - .required(false), - ) - .arg( - Arg::new("graph-limit").long("graph-limit") - .value_name("LIMIT") - .help("Use a line-oriented diff if the internal graph exceeds this number of vertices. This limit controls the worst case runtime and memory usage for difftastic. - -Higher values will allow difftastic to perform a structural diff in more cases. Higher values will also increase the time before difftastic gives up on structural diffing, and increase peak memory usage.") - .default_value(format!("{}", DEFAULT_GRAPH_LIMIT)) - .action(ArgAction::Set) - .env("DFT_GRAPH_LIMIT") - .value_parser(clap::value_parser!(usize)) - .required(false), - ) - .arg( - Arg::new("parse-error-limit").long("parse-error-limit") - .value_name("LIMIT") - .action(ArgAction::Set) - .help("Use a line-oriented diff if the number of parse errors exceeds this value. - -A value of 0 means that any parse error will make difftastic use a line-oriented diff.") - .default_value(format!("{}", DEFAULT_PARSE_ERROR_LIMIT)) - .env("DFT_PARSE_ERROR_LIMIT") - .value_parser(clap::value_parser!(usize)) - .required(false), - ) - .arg( - Arg::new("paths") - .value_name("PATHS") - .action(ArgAction::Append) - .hide(true) - .value_parser(value_parser!(OsString)), - ) - .arg( - Arg::new("sort-paths").long("sort-paths") - .action(ArgAction::SetTrue) - .env("DFT_SORT_PATHS") - .help("When diffing a directory, output the results sorted by path. This is slower.") - ) .arg_required_else_help(true) } -#[derive(Debug, Copy, Clone)] -pub(crate) enum DisplayMode { - Inline, - SideBySide, - SideBySideShowBoth, - Json, -} - #[derive(Eq, PartialEq, Debug)] pub(crate) enum FileArgument { NamedPath(std::path::PathBuf), - Stdin, DevNull, } -impl FileArgument { - pub(crate) fn permissions(&self) -> Option<FilePermissions> { - match self { - Self::NamedPath(path) => { - // When used with `git difftool`, the first argument - // is a temporary file that always has the same - // permissions. That doesn't mean the file permissions - // have changed, so we shouldn't compare. - if is_git_tmpfile(path) { - return None; - } - - let metadata = std::fs::metadata(path).ok()?; - Some(metadata.permissions().into()) - } - Self::Stdin => None, - Self::DevNull => None, - } - } -} - -/// A cross-platform representation of file permissions. -/// -/// We're only interested in whether two permissions are the same, and -/// how to display them, so internally this is just a human-friendly -/// string. -#[derive(Debug, Eq, PartialEq)] -pub(crate) struct FilePermissions(String); - -impl Display for FilePermissions { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl TryFrom<&OsStr> for FilePermissions { - type Error = (); - - fn try_from(s: &OsStr) -> Result<Self, Self::Error> { - if s == "." { - Err(()) - } else { - Ok(Self(s.to_string_lossy().into_owned())) - } - } -} - -#[cfg(unix)] -impl From<std::fs::Permissions> for FilePermissions { - fn from(perms: std::fs::Permissions) -> Self { - use std::os::unix::fs::PermissionsExt; - Self(format!("{:o}", perms.mode())) - } -} - -#[cfg(not(unix))] -impl From<std::fs::Permissions> for FilePermissions { - fn from(perms: std::fs::Permissions) -> Self { - let s = if perms.readonly() { - "readonly" - } else { - "read-write" - }; - Self(s.to_owned()) - } -} - fn try_canonicalize(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.into()) } @@ -485,18 +142,6 @@ fn relative_to_current(path: &Path) -> PathBuf { } impl FileArgument { - /// Return a `FileArgument` representing this command line - /// argument. - pub(crate) fn from_cli_argument(arg: &OsStr) -> Self { - if arg == "/dev/null" { - Self::DevNull - } else if arg == "-" { - Self::Stdin - } else { - Self::NamedPath(PathBuf::from(arg)) - } - } - /// Return a `FileArgument` that always represents a path that /// exists, with the exception of `/dev/null`, which is turned into [FileArgument::DevNull]. pub(crate) fn from_path_argument(arg: &OsStr) -> Self { @@ -515,47 +160,13 @@ impl Display for FileArgument { Self::NamedPath(path) => { write!(f, "{}", relative_to_current(path).display()) } - Self::Stdin => write!(f, "(stdin)"), Self::DevNull => write!(f, "/dev/null"), } } } pub(crate) enum Mode { - Diff { - diff_options: DiffOptions, - display_options: DisplayOptions, - set_exit_code: bool, - language_overrides: Vec<(LanguageOverride, Vec<glob::Pattern>)>, - binary_overrides: Vec<glob::Pattern>, - /// The path where we can read the LHS file. This is often a - /// temporary file generated by source control. - lhs_path: FileArgument, - /// The path where we can read the RHS file. This is often a - /// temporary file generated by source control. - rhs_path: FileArgument, - lhs_permissions: Option<FilePermissions>, - rhs_permissions: Option<FilePermissions>, - /// The path that we show to the user. - display_path: String, - /// If this file has been renamed, a description of the change. - renamed: Option<String>, - }, - DiffFromConflicts { - diff_options: DiffOptions, - display_options: DisplayOptions, - set_exit_code: bool, - language_overrides: Vec<(LanguageOverride, Vec<glob::Pattern>)>, - binary_overrides: Vec<glob::Pattern>, - path: FileArgument, - /// The path that we show to the user. - display_path: String, - }, - GitHasUnmergedFile { - display_path: String, - }, ListLanguages { - use_color: bool, language_overrides: Vec<(LanguageOverride, Vec<glob::Pattern>)>, }, DumpTreeSitter { @@ -574,83 +185,6 @@ pub(crate) enum Mode { }, } -/// If `lhs_path` and `rhs_path` have a common ending, return that. -/// -/// For example, given `dir1/foo/bar.py` and `dir2/foo/bar.py`, return `foo/bar.py`. -fn common_path_suffix(lhs_path: &Path, rhs_path: &Path) -> Option<String> { - let lhs_rev_components = lhs_path - .components() - .map(|c| c.as_os_str()) - .rev() - .collect::<Vec<_>>(); - let rhs_rev_components = rhs_path - .components() - .map(|c| c.as_os_str()) - .rev() - .collect::<Vec<_>>(); - - let mut common_components = vec![]; - for (lhs_component, rhs_component) in lhs_rev_components.iter().zip(rhs_rev_components.iter()) { - if lhs_component == rhs_component { - common_components.push(lhs_component.to_string_lossy()); - } else { - break; - } - } - - if common_components.is_empty() { - None - } else { - common_components.reverse(); - Some(common_components.join(std::path::MAIN_SEPARATOR_STR)) - } -} - -/// Does `path` look like "/tmp/git-blob-abcdef/modified_field.txt"? -fn is_git_tmpfile(path: &Path) -> bool { - let Ok(rel_path) = path.strip_prefix(std::env::temp_dir()) else { - return false; - }; - - let components: Vec<_> = rel_path.components().collect(); - if components.len() != 2 { - return false; - } - - components[0] - .as_os_str() - .to_string_lossy() - .starts_with("git-blob-") -} - -fn build_display_path(lhs_path: &FileArgument, rhs_path: &FileArgument) -> String { - match (lhs_path, rhs_path) { - (FileArgument::NamedPath(lhs), FileArgument::NamedPath(rhs)) => { - if is_git_tmpfile(lhs) { - // git-difftool calls us with `/tmp/git-blob-abc/bar.txt foo/bar.txt`. - return rhs.display().to_string(); - } - - match common_path_suffix(lhs, rhs) { - Some(common_suffix) => { - // Handle arguments `/tmp/vcs-abc/foo/bar.txt /home/wilfred/foo/bar.txt` - // as `foo/bar.txt` - common_suffix - } - None => { - // Given `old.txt new.txt` show `new.txt`. - rhs.display().to_string() - } - } - } - (FileArgument::NamedPath(p), _) | (_, FileArgument::NamedPath(p)) => { - p.display().to_string() - } - (FileArgument::DevNull, _) | (_, FileArgument::DevNull) => "/dev/null".into(), - (FileArgument::Stdin, FileArgument::Stdin) => "-".into(), - } -} - fn parse_overrides_or_die(raw_overrides: &[String]) -> Vec<(LanguageOverride, Vec<glob::Pattern>)> { let mut overrides: Vec<(LanguageOverride, Vec<glob::Pattern>)> = vec![]; let mut invalid_syntax = false; @@ -700,62 +234,12 @@ fn parse_overrides_or_die(raw_overrides: &[String]) -> Vec<(LanguageOverride, Ve combined_overrides } -fn parse_binary_overrides_or_die(glob_strs: &[String]) -> Vec<glob::Pattern> { - let mut overrides: Vec<glob::Pattern> = vec![]; - let mut invalid_syntax = false; - - for glob_str in glob_strs { - match glob::Pattern::new(glob_str) { - Ok(pattern) => { - overrides.push(pattern); - } - Err(e) => { - eprintln!("Invalid glob syntax '{}'", glob_str); - eprintln!("Glob parsing error: {}", e.msg); - invalid_syntax = true; - } - } - } - - if invalid_syntax { - std::process::exit(EXIT_BAD_ARGUMENTS); - } - - overrides -} - /// Parse CLI arguments passed to the binary. pub(crate) fn parse_args() -> Mode { let matches = app().get_matches_from(std::env::args_os().skip(1)); - let color_output = match matches - .get_one::<String>("color") - .map(|s| s.as_str()) - .expect("color has a default") - { - "always" => ColorOutput::Always, - "never" => ColorOutput::Never, - "auto" => ColorOutput::Auto, - _ => { - unreachable!("clap has already validated color") - } - }; - let use_color = should_use_color(color_output); - let ignore_comments = matches.get_flag("ignore-comments"); - let mut raw_binary_overrides: Vec<String> = vec![]; - if let Some(binary_overrides) = matches.get_many("override-binary") { - raw_binary_overrides = binary_overrides.cloned().collect(); - } - for i in 1..=9 { - if let Ok(value) = env::var(format!("DFT_OVERRIDE_BINARY_{}", i)) { - raw_binary_overrides.push(value); - } - } - - let binary_overrides = parse_binary_overrides_or_die(&raw_binary_overrides); - let mut raw_overrides: Vec<String> = vec![]; if let Some(overrides) = matches.get_many("override") { raw_overrides = overrides.cloned().collect(); @@ -769,10 +253,7 @@ pub(crate) fn parse_args() -> Mode { let language_overrides = parse_overrides_or_die(&raw_overrides); if matches.get_flag("list-languages") { - return Mode::ListLanguages { - use_color, - language_overrides, - }; + return Mode::ListLanguages { language_overrides }; } if let Some(path) = matches.get_one::<String>("dump-syntax") { @@ -798,247 +279,8 @@ pub(crate) fn parse_args() -> Mode { }; } - let terminal_width = if let Some(arg_width) = matches.get_one::<usize>("width") { - *arg_width - } else { - detect_terminal_width() - }; - - let display_mode = match matches - .get_one::<String>("display") - .map(|s| s.as_str()) - .expect("display has a default") - { - "side-by-side" => DisplayMode::SideBySide, - "side-by-side-show-both" => DisplayMode::SideBySideShowBoth, - "inline" => DisplayMode::Inline, - "json" => { - if env::var("DFT_UNSTABLE").is_err() { - eprintln!("JSON output is an unstable feature and its format may change in future. To enable JSON output, set the environment variable DFT_UNSTABLE=yes."); - std::process::exit(EXIT_BAD_ARGUMENTS); - } - - DisplayMode::Json - } - _ => { - unreachable!("clap has already validated display") - } - }; - - let background_color = match matches - .get_one::<String>("background") - .map(|s| s.as_str()) - .expect("Always present as we've given clap a default") - { - "dark" => BackgroundColor::Dark, - "light" => BackgroundColor::Light, - _ => unreachable!("clap has already validated the values"), - }; - - let syntax_highlight = matches - .get_one::<String>("syntax-highlight") - .map(|s| s.as_str()) - == Some("on"); - - let sort_paths = matches.get_flag("sort-paths"); - - let graph_limit = *matches - .get_one("graph-limit") - .expect("Always present as we've given clap a default"); - - let byte_limit = *matches - .get_one("byte-limit") - .expect("Always present as we've given clap a default"); - - let parse_error_limit = *matches - .get_one("parse-error-limit") - .expect("Always present as we've given clap a default"); - - let tab_width = *matches - .get_one("tab-width") - .expect("Always present as we've given clap a default"); - - let num_context_lines = *matches - .get_one("context") - .expect("Always present as we've given clap a default"); - - let print_unchanged = !matches.get_flag("skip-unchanged"); - - let set_exit_code = matches.get_flag("exit-code"); - - let strip_cr = matches.get_one::<String>("strip-cr").map(|s| s.as_str()) == Some("on"); - - let check_only = matches.get_flag("check-only"); - - let diff_options = DiffOptions { - graph_limit, - byte_limit, - parse_error_limit, - check_only, - ignore_comments, - strip_cr, - generated: false, - }; - - let args = matches - .get_raw("paths") - .unwrap_or_default() - .collect::<Vec<_>>(); - info!("CLI arguments: {:?}", args); - - // When there's a single path that hasn't been merged, git invokes - // the external diff tool with a only single argument. There's - // nothing to diff against. - // - // In this case, we just inform the user that there's an unmerged - // file, matching the builtin git-diff behaviour. - if args.len() == 1 - && (env::var_os("GIT_EXEC_PATH").is_some() - || env::var_os("GIT_CONFIG_PARAMETERS").is_some() - || env::var_os("GIT_DIFF_PATH_TOTAL").is_some()) - { - return Mode::GitHasUnmergedFile { - display_path: args[0].to_string_lossy().to_string(), - }; - } - - // Print git environment variables so we can see the additional - // variable set when git invokes us. - for (env_var, value) in env::vars() { - if env_var.starts_with("GIT_") { - trace!("{}: {}", env_var, value); - } - } - - let (display_path, lhs_path, rhs_path, lhs_permissions, rhs_permissions, renamed) = match &args - [..] - { - [lhs_path, rhs_path] => { - let lhs_arg = FileArgument::from_cli_argument(lhs_path); - let rhs_arg = FileArgument::from_cli_argument(rhs_path); - let display_path = build_display_path(&lhs_arg, &rhs_arg); - - let lhs_permissions = lhs_arg.permissions(); - let rhs_permissions = rhs_arg.permissions(); - - ( - display_path, - lhs_arg, - rhs_arg, - lhs_permissions, - rhs_permissions, - None, - ) - } - [display_path, lhs_tmp_file, _lhs_hash, lhs_mode, rhs_tmp_file, _rhs_hash, rhs_mode] => { - // 7 arguments, per https://git-scm.com/docs/git#Documentation/git.txt-codeGITEXTERNALDIFFcode - ( - display_path.to_string_lossy().to_string(), - FileArgument::from_path_argument(lhs_tmp_file), - FileArgument::from_path_argument(rhs_tmp_file), - FilePermissions::try_from(*lhs_mode).ok(), - FilePermissions::try_from(*rhs_mode).ok(), - None, - ) - } - [old_name, lhs_tmp_file, _lhs_hash, lhs_mode, rhs_tmp_file, _rhs_hash, rhs_mode, new_name, _metainfo] => - { - // Rename file. - // TODO: where does git document these 9 arguments? - // (See run_external_diff() in diff.c in git source code.) - - let old_name = old_name.to_string_lossy().to_string(); - let new_name = new_name.to_string_lossy().to_string(); - let renamed = format!("Renamed from {} to {}", old_name, new_name); - - ( - new_name, - FileArgument::from_path_argument(lhs_tmp_file), - FileArgument::from_path_argument(rhs_tmp_file), - FilePermissions::try_from(*lhs_mode).ok(), - FilePermissions::try_from(*rhs_mode).ok(), - Some(renamed), - ) - } - [path] => { - let display_options = DisplayOptions { - background_color, - use_color, - print_unchanged, - tab_width, - display_mode, - terminal_width, - num_context_lines, - syntax_highlight, - sort_paths, - }; - - let display_path = path.to_string_lossy().to_string(); - let path = FileArgument::from_path_argument(path); - return Mode::DiffFromConflicts { - display_path, - path, - diff_options, - display_options, - set_exit_code, - language_overrides, - binary_overrides, - }; - } - _ => { - if !args.is_empty() { - let formatted_args = args - .iter() - .map(|arg| arg.to_string_lossy()) - .collect::<Vec<_>>(); - - let bin_name = if let Some(first_arg) = std::env::args_os().next() { - first_arg.to_string_lossy().to_string() - } else { - env!("CARGO_BIN_NAME").to_owned() - }; - - print_error( - &format!( - "Difftastic does not support being called with {} argument{}.\n\nYou can pass 2 arguments, or arguments in the form used by GIT_EXTERNAL_DIFF (7 or 9 arguments). See --help for more details. \n\nFor reference, difftastic was invoked as `{} {}`.\n", - args.len(), - if args.len() == 1 { "" } else { "s" }, - bin_name, - formatted_args.join(" "), - ), - use_color, - ); - } - - std::process::exit(EXIT_BAD_ARGUMENTS); - } - }; - - let display_options = DisplayOptions { - background_color, - use_color, - print_unchanged, - tab_width, - display_mode, - terminal_width, - num_context_lines, - syntax_highlight, - sort_paths, - }; - - Mode::Diff { - diff_options, - display_options, - set_exit_code, - language_overrides, - binary_overrides, - lhs_path, - rhs_path, - lhs_permissions, - rhs_permissions, - display_path, - renamed, - } + eprintln!("Pass one of --dump-syntax, --dump-syntax-dot, --dump-ts or --list-languages."); + std::process::exit(EXIT_BAD_ARGUMENTS); } /// Try to work out the width of the terminal we're on, or fall back @@ -1066,21 +308,6 @@ pub(crate) fn detect_terminal_width() -> usize { DEFAULT_TERMINAL_WIDTH } -pub(crate) fn should_use_color(color_output: ColorOutput) -> bool { - match color_output { - ColorOutput::Always => true, - ColorOutput::Auto => detect_color_support(), - ColorOutput::Never => false, - } -} - -/// Always enable colour if stdout is a TTY or if the git pager is active. -fn detect_color_support() -> bool { - // TODO: consider following the env parsing logic in git_config_bool - // in config.c. - std::io::stdout().is_terminal() || env::var("GIT_PAGER_IN_USE").is_ok() -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/parse/fold_tests.rs b/src/parse/fold_tests.rs index a31ef629e..89aafb33e 100644 --- a/src/parse/fold_tests.rs +++ b/src/parse/fold_tests.rs @@ -333,7 +333,7 @@ mod matcher { mod results { use super::text; - use crate::display::line_layout::aligned_rows; + use crate::line_layout::aligned_rows; use crate::parse::folds::FoldMatch; use crate::summary::{DiffResult, FileContent, FileFormat}; @@ -359,10 +359,7 @@ mod results { fn identical_files_keep_alignment_for_showing_hidden_source() { let source = "fn unchanged() {\n work();\n}\n"; let diff = DiffResult::from_sources("a.rs", source, source); - let rows = aligned_rows( - (source, source), - (&diff.lhs_positions, &diff.rhs_positions), - ); + let rows = aligned_rows((source, source), (&diff.lhs_positions, &diff.rhs_positions)); assert_eq!( rows, [(Some(0), Some(0)), (Some(1), Some(1)), (Some(2), Some(2))] @@ -435,12 +432,10 @@ mod results { let diff = crate::diff_file_content( &crate::config::Params::default(), "a.py", - None, &file, &file, "x = 1\n", "x = 2\n", - &Default::default(), &options, &[], ) diff --git a/src/parse/syntax.rs b/src/parse/syntax.rs index ec73dfc44..3934a9044 100644 --- a/src/parse/syntax.rs +++ b/src/parse/syntax.rs @@ -1134,52 +1134,6 @@ fn change_positions_<'a>( } } -pub(crate) fn zip_pad_shorter<Tx: Clone, Ty: Clone>( - lhs: &[Tx], - rhs: &[Ty], -) -> Vec<(Option<Tx>, Option<Ty>)> { - let mut res = vec![]; - - let mut lhs_iter = lhs.iter(); - let mut rhs_iter = rhs.iter(); - loop { - match (lhs_iter.next(), rhs_iter.next()) { - (None, None) => break, - (x, y) => res.push((x.cloned(), y.cloned())), - } - } - - res -} - -/// Zip `lhs` with `rhs`, but repeat the last item from the shorter -/// slice. -pub(crate) fn zip_repeat_shorter<Tx: Clone, Ty: Clone>(lhs: &[Tx], rhs: &[Ty]) -> Vec<(Tx, Ty)> { - let lhs_last: Tx = match lhs.last() { - Some(last) => last.clone(), - None => return vec![], - }; - let rhs_last: Ty = match rhs.last() { - Some(last) => last.clone(), - None => return vec![], - }; - - let mut res = vec![]; - let mut lhs_iter = lhs.iter(); - let mut rhs_iter = rhs.iter(); - loop { - match (lhs_iter.next(), rhs_iter.next()) { - (None, None) => break, - (x, y) => res.push(( - x.cloned().unwrap_or_else(|| lhs_last.clone()), - y.cloned().unwrap_or_else(|| rhs_last.clone()), - )), - } - } - - res -} - #[cfg(test)] mod tests { use pretty_assertions::assert_eq; diff --git a/src/parse/tree_sitter_parser.rs b/src/parse/tree_sitter_parser.rs index 151060a75..39eb5f18f 100644 --- a/src/parse/tree_sitter_parser.rs +++ b/src/parse/tree_sitter_parser.rs @@ -1470,6 +1470,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/plugin/tests/mod.rs b/src/plugin/tests/mod.rs index 6d5213d43..f8f398e32 100644 --- a/src/plugin/tests/mod.rs +++ b/src/plugin/tests/mod.rs @@ -46,12 +46,7 @@ pub(crate) fn project_compiled( options: DiffOptions, ) -> (FileChange, Pairing<protocol::Source>) { let result = crate::summary::DiffResult::from_sources_with_options( - path, - before, - after, - params, - &crate::options::DisplayOptions::default(), - &options, + path, before, after, params, &options, ) .unwrap(); let file_ref = FileRef { @@ -72,6 +67,7 @@ pub(crate) fn project_compiled( project::Inputs { file: &file.file, sizes: (before.len() as u64, after.len() as u64), + syntax: (Vec::new(), Vec::new()), }, ); let Diff::Text { sides, .. } = diff else { @@ -117,6 +113,7 @@ pub(crate) fn trees(sides: &Pairing<protocol::Source>) -> tree::Pairing<tree::So pub(crate) fn wire(sides: tree::Pairing<tree::Source>) -> Pairing<protocol::Source> { let source = |side: tree::Source| protocol::Source { text: side.text, + syntax: Vec::new(), regions: from_tree(side.regions), }; match sides { diff --git a/src/plugin/tests/summarize.rs b/src/plugin/tests/summarize.rs index e76af0fa5..f0a7c73eb 100644 --- a/src/plugin/tests/summarize.rs +++ b/src/plugin/tests/summarize.rs @@ -344,9 +344,9 @@ fn the_system_prompt_is_the_configured_one() { )) .unwrap(); assert_eq!(system, default.system_prompt.as_str()); - assert!(default.system_prompt.starts_with( - "For each listed fold, rewrite that function body as short python-flavored pseudocode. Keep the names." - )); + assert!(default + .system_prompt + .starts_with("For each listed fold, rewrite that function body as short python-flavored pseudocode. Keep the names.")); assert!(!default.system_prompt.contains('\n')); } diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index bd8370918..b3ff1bc04 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -130,7 +130,7 @@ pub struct FileRef { } /// How a file or region starts out. `label` is shown while collapsed: a -/// reason for a file, a placeholder for a region. +/// reason for a file, a placeholder or pseudocode summary for a region. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct Visibility { #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -163,10 +163,14 @@ pub enum Diff { }, } -/// One side's text and regions. +/// One side's text, colors, and regions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Source { pub text: String, + /// Every token with its tree-sitter capture name. Per line, sorted, + /// non-overlapping. Empty unless the run asked for syntax. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub syntax: Vec<SyntaxSpan>, /// The largest regions, in order. Leaves tile the file. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub regions: Vec<Region>, @@ -177,6 +181,15 @@ pub struct BinaryRef { pub size: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyntaxSpan { + pub line: u32, + pub start_column: u32, + pub end_column: u32, + /// A tree-sitter capture name such as `keyword` or `function.method`. + pub capture: String, +} + /// The `id` that names the file itself rather than a region. No region /// carries it: region ids start above it. A plugin addresses the file's own /// `visibility` through it, as the root every region hangs from. @@ -265,7 +278,8 @@ pub struct SourcePos { /// match did not run and the alignment is a line diff, carrying why: /// `too_complex`, `too_large`, `unsupported_language`, `parse_error`, /// `generated`. -/// Folds are still present on a fallback whenever the language parsed. +/// Folds are still present on a fallback whenever the language parsed, +/// paired through that alignment. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Stats { /// Lines with any byte change. @@ -323,6 +337,7 @@ mod tests { }; let side = |first: u32, text: &str, changed: Vec<Span>| Source { text: text.to_owned(), + syntax: vec![], regions: vec![Region { id: first, fold_state_id: 1, @@ -479,7 +494,7 @@ mod tests { failed: 1, aborted: Some(Problem { code: "mutation_failed".to_owned(), - message: "mutation context: no region 99999".to_owned(), + message: "mutation summarize: summarizer: gemini-3.8-flash: HTTP 503 Service Unavailable after 4 attempts".to_owned(), }), }, ]; @@ -500,6 +515,7 @@ mod tests { "null", "visibility", "collapsed", + "syntax", "fallback", "\"changed\":[]", ] { diff --git a/src/protocol/project.rs b/src/protocol/project.rs index beac6d046..8df7200aa 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -29,14 +29,15 @@ //! which unchanged lines to hide is the `context` plugin's. use super::{ BinaryRef, Diff, FileRef, LineCounts, Node, Problem, Region, Source, SourcePos, SourceRange, - Span, Stats, Visibility, ROOT, + Span, Stats, SyntaxSpan, Visibility, ROOT, }; -use crate::display::line_layout::{aligned_rows, novel_lines, runs, Run}; use crate::hash::DftHashMap; +use crate::line_layout::{aligned_rows, novel_lines, runs, Run}; use crate::line_parser; use crate::pairing::Pairing; use crate::parse::folds::{self, Fold, FoldMatch}; use crate::parse::syntax::{MatchKind, MatchedPos, SyntaxId}; +use crate::parse::tree_sitter_parser::{highlight_captures, TreeSitterConfig}; use crate::summary::{DiffResult, FallbackCause, FileContent, FileFormat}; use std::collections::{BTreeMap, BTreeSet}; @@ -46,6 +47,8 @@ pub(crate) struct Inputs<'a> { pub(crate) file: &'a Pairing<FileRef>, /// Byte length of each side's content, for binary files. pub(crate) sizes: (u64, u64), + /// Highlight spans per side; empty when the run did not ask for syntax. + pub(crate) syntax: (Vec<SyntaxSpan>, Vec<SyntaxSpan>), } pub(crate) fn diff(result: &DiffResult, inputs: Inputs<'_>) -> Diff { @@ -65,14 +68,17 @@ pub(crate) fn diff(result: &DiffResult, inputs: Inputs<'_>) -> Diff { } }; let (lhs_regions, rhs_regions) = regions(result, lhs_src, rhs_src); + let (lhs_syntax, rhs_syntax) = inputs.syntax; let sides = pair( inputs.file, Source { text: lhs_src.to_owned(), + syntax: lhs_syntax, regions: lhs_regions, }, Source { text: rhs_src.to_owned(), + syntax: rhs_syntax, regions: rhs_regions, }, ); @@ -128,6 +134,47 @@ fn fallback_code(cause: FallbackCause) -> &'static str { } } +/// Highlight spans for one side, per line, sorted, non-overlapping. Where +/// captures nest the innermost wins. +pub(crate) fn syntax_spans(src: &str, parser: &'static TreeSitterConfig) -> Vec<SyntaxSpan> { + let mut captures = highlight_captures(src, parser); + // Paint larger captures first so smaller (inner) ones overwrite them. + captures.sort_by_key(|(start, end, _)| std::cmp::Reverse(end - start)); + let mut owner: Vec<Option<&'static str>> = vec![None; src.len()]; + for (start, end, name) in captures { + for slot in &mut owner[start..end] { + *slot = Some(name); + } + } + let mut spans = Vec::new(); + let mut line_start = 0; + for (line, text) in src.split_inclusive('\n').enumerate() { + let content_len = text.trim_end_matches('\n').len(); + let mut run: Option<(usize, &'static str)> = None; + for column in 0..=content_len { + let current = (column < content_len) + .then(|| owner[line_start + column]) + .flatten(); + match (run, current) { + (Some((_, name)), Some(now)) if now == name => {} + (Some((start, name)), _) => { + spans.push(SyntaxSpan { + line: line as u32, + start_column: start as u32, + end_column: column as u32, + capture: name.to_owned(), + }); + run = current.map(|name| (column, name)); + } + (None, Some(name)) => run = Some((column, name)), + (None, None) => {} + } + } + line_start += text.len(); + } + spans +} + // ── regions ─────────────────────────────────────────────────────────────── /// A leaf after splitting. `key` identifies its counterpart on the other @@ -546,7 +593,7 @@ fn positions_by_line(positions: &[MatchedPos]) -> BTreeMap<usize, Vec<&MatchedPo mod tests { use super::*; use crate::config::body_params; - use crate::options::{DiffOptions, DisplayOptions}; + use crate::options::DiffOptions; fn refs(lhs: bool, rhs: bool) -> Pairing<FileRef> { let file_ref = FileRef { @@ -571,20 +618,15 @@ mod tests { /// `graph_limit: 1` forces the text-diff fallback for any real change. fn project_with(path: &str, lhs: &str, rhs: &str, options: DiffOptions) -> Diff { - let result = DiffResult::from_sources_with_options( - path, - lhs, - rhs, - &body_params(), - &DisplayOptions::default(), - &options, - ) - .unwrap(); + let result = + DiffResult::from_sources_with_options(path, lhs, rhs, &body_params(), &options) + .unwrap(); diff( &result, Inputs { file: &refs(!lhs.is_empty(), !rhs.is_empty()), sizes: (lhs.len() as u64, rhs.len() as u64), + syntax: (Vec::new(), Vec::new()), }, ) } @@ -720,6 +762,98 @@ mod tests { } } + #[test] + fn a_parse_error_fallback_numbers_its_folds() { + // Both sides hold a stray `)`, so the parse-error limit of zero sends + // the file to a line diff; the folds still come from the parse. + let lhs = format!("{RUST_LHS})\n"); + let rhs = format!("{RUST_RHS})\n"); + let diff = project_with( + "a.rs", + &lhs, + &rhs, + DiffOptions { + parse_error_limit: 0, + ..DiffOptions::default() + }, + ); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.fallback.as_ref().unwrap().code, "parse_error"); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + let (lhs_folds, rhs_folds) = (fold_ids(lhs), fold_ids(rhs)); + let lhs_ids: BTreeSet<u32> = lhs_folds.values().copied().collect(); + let rhs_ids: BTreeSet<u32> = rhs_folds.values().copied().collect(); + assert_eq!( + lhs_ids.len(), + lhs_folds.len(), + "lhs folds have distinct ids" + ); + assert_eq!( + rhs_ids.len(), + rhs_folds.len(), + "rhs folds have distinct ids" + ); + let (lhs_states, rhs_states) = (fold_states(lhs), fold_states(rhs)); + // Nothing matched the nodes these folds belong to, so no fold pairs. + let lhs_state_ids: BTreeSet<u32> = lhs_states.values().copied().collect(); + assert!( + !rhs_states + .values() + .any(|state| lhs_state_ids.contains(state)), + "a fallback's folds are unpaired" + ); + } + + #[test] + fn folds_pair_only_where_the_matcher_paired_their_nodes() { + for options in [ + DiffOptions::default(), + DiffOptions { + graph_limit: 1, + ..DiffOptions::default() + }, + ] { + let structural = options.graph_limit != 1; + let diff = project_with("a.rs", RUST_LHS, RUST_RHS, options); + let Diff::Text { stats, .. } = &diff else { + panic!("text diff"); + }; + assert_eq!(stats.fallback.is_none(), structural); + let (lhs, rhs) = sources(&diff); + let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); + assert_tiles(lhs); + assert_tiles(rhs); + let (lhs_folds, rhs_folds) = (fold_states(lhs), fold_states(rhs)); + assert_eq!(lhs_folds.len(), 2); + assert_eq!(rhs_folds.len(), 3); + let shared: BTreeSet<u32> = lhs_folds + .values() + .filter(|state| rhs_folds.values().any(|other| other == *state)) + .copied() + .collect(); + if structural { + // `f` changed its signature and stays paired through the + // matcher; `keep` is untouched. `added` is rhs-only. + assert_eq!( + lhs_folds[&1], rhs_folds[&1], + "f pairs across a changed header" + ); + assert_eq!(lhs_folds[&7], rhs_folds[&7], "keep pairs"); + assert_eq!(shared.len(), 2); + } else { + // The matcher never ran, so every fold is on its own. + assert!(shared.is_empty(), "a fallback's folds are unpaired"); + } + assert!( + !lhs_folds.values().any(|id| *id == rhs_folds[&13]), + "added is rhs-only" + ); + } + } + #[test] fn structural_folds_pair_exactly_as_the_matcher_recorded() { let result = DiffResult::from_sources_with_options( @@ -727,7 +861,6 @@ mod tests { RUST_LHS, RUST_RHS, &body_params(), - &DisplayOptions::default(), &DiffOptions::default(), ) .unwrap(); @@ -831,6 +964,7 @@ mod tests { Inputs { file: &refs(true, true), sizes: (lhs.len() as u64, rhs.len() as u64), + syntax: (Vec::new(), Vec::new()), }, ); let (lhs_src, rhs_src) = sources(&diff); @@ -881,98 +1015,6 @@ mod tests { } } - #[test] - fn a_parse_error_fallback_numbers_its_folds() { - // Both sides hold a stray `)`, so the parse-error limit of zero sends - // the file to a line diff; the folds still come from the parse. - let lhs = format!("{RUST_LHS})\n"); - let rhs = format!("{RUST_RHS})\n"); - let diff = project_with( - "a.rs", - &lhs, - &rhs, - DiffOptions { - parse_error_limit: 0, - ..DiffOptions::default() - }, - ); - let Diff::Text { stats, .. } = &diff else { - panic!("text diff"); - }; - assert_eq!(stats.fallback.as_ref().unwrap().code, "parse_error"); - let (lhs, rhs) = sources(&diff); - let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); - let (lhs_folds, rhs_folds) = (fold_ids(lhs), fold_ids(rhs)); - let lhs_ids: BTreeSet<u32> = lhs_folds.values().copied().collect(); - let rhs_ids: BTreeSet<u32> = rhs_folds.values().copied().collect(); - assert_eq!( - lhs_ids.len(), - lhs_folds.len(), - "lhs folds have distinct ids" - ); - assert_eq!( - rhs_ids.len(), - rhs_folds.len(), - "rhs folds have distinct ids" - ); - let (lhs_states, rhs_states) = (fold_states(lhs), fold_states(rhs)); - // Nothing matched the nodes these folds belong to, so no fold pairs. - let lhs_state_ids: BTreeSet<u32> = lhs_states.values().copied().collect(); - assert!( - !rhs_states - .values() - .any(|state| lhs_state_ids.contains(state)), - "a fallback's folds are unpaired" - ); - } - - #[test] - fn folds_pair_only_where_the_matcher_paired_their_nodes() { - for options in [ - DiffOptions::default(), - DiffOptions { - graph_limit: 1, - ..DiffOptions::default() - }, - ] { - let structural = options.graph_limit != 1; - let diff = project_with("a.rs", RUST_LHS, RUST_RHS, options); - let Diff::Text { stats, .. } = &diff else { - panic!("text diff"); - }; - assert_eq!(stats.fallback.is_none(), structural); - let (lhs, rhs) = sources(&diff); - let (lhs, rhs) = (lhs.unwrap(), rhs.unwrap()); - assert_tiles(lhs); - assert_tiles(rhs); - let (lhs_folds, rhs_folds) = (fold_states(lhs), fold_states(rhs)); - assert_eq!(lhs_folds.len(), 2); - assert_eq!(rhs_folds.len(), 3); - let shared: BTreeSet<u32> = lhs_folds - .values() - .filter(|state| rhs_folds.values().any(|other| other == *state)) - .copied() - .collect(); - if structural { - // `f` changed its signature and stays paired through the - // matcher; `keep` is untouched. `added` is rhs-only. - assert_eq!( - lhs_folds[&1], rhs_folds[&1], - "f pairs across a changed header" - ); - assert_eq!(lhs_folds[&7], rhs_folds[&7], "keep pairs"); - assert_eq!(shared.len(), 2); - } else { - // The matcher never ran, so every fold is on its own. - assert!(shared.is_empty(), "a fallback's folds are unpaired"); - } - assert!( - !lhs_folds.values().any(|id| *id == rhs_folds[&13]), - "added is rhs-only" - ); - } - } - #[test] fn the_fallback_keeps_folds() { let diff = project_with( @@ -1247,24 +1289,20 @@ mod tests { #[test] fn binary_sides_carry_sizes() { let result = DiffResult { - display_path: "a.bin".to_owned(), - extra_info: None, file_format: FileFormat::Binary, lhs_src: FileContent::Binary, rhs_src: FileContent::Binary, - hunks: vec![], lhs_folds: vec![], rhs_folds: vec![], lhs_positions: vec![], rhs_positions: vec![], - has_byte_changes: Some((3, 5)), - has_syntactic_changes: false, }; let diff = diff( &result, Inputs { file: &refs(true, true), sizes: (3, 5), + syntax: (Vec::new(), Vec::new()), }, ); let Diff::Binary { @@ -1275,4 +1313,25 @@ mod tests { }; assert_eq!((lhs.size, rhs.size), (3, 5)); } + + #[test] + fn syntax_spans_are_per_line_sorted_and_innermost() { + let parser = crate::parse::tree_sitter_parser::from_language( + crate::parse::guess_language::Language::Python, + ); + let spans = syntax_spans("def f(x):\n return \"a\"\n", parser); + for pair in spans.windows(2) { + assert!( + pair[0].line < pair[1].line + || (pair[0].line == pair[1].line && pair[0].end_column <= pair[1].start_column), + "{pair:?}" + ); + } + assert!(spans + .iter() + .any(|span| span.capture == "keyword" && span.line == 0)); + assert!(spans + .iter() + .any(|span| span.capture.starts_with("string") && span.line == 1)); + } } diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index 7210ac835..e7382a4a4 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -5,15 +5,14 @@ use super::project::{self, Inputs}; use super::{ Diff, Event, FileChange, LineCounts, Node, Outcome, Problem, Region, Snapshot, Source, - Visibility, VERSION, + SyntaxSpan, Visibility, VERSION, }; use crate::engine::QueryConflict; use crate::git::{DiffSession, FileError, LoadedFile}; use crate::hash::DftHashSet; -use crate::options::DisplayOptions; use crate::pairing::Pairing; use crate::plugin::{MutationFailed, Pipeline}; -use crate::summary::DiffResult; +use crate::summary::{DiffResult, FileContent, FileFormat}; use anyhow::anyhow; use rayon::iter::{ParallelBridge, ParallelIterator}; use std::io::{BufWriter, Write}; @@ -22,6 +21,13 @@ use std::sync::mpsc::{sync_channel, SyncSender}; use std::sync::{Arc, Mutex}; use std::thread; +/// Runtime choices that shape every file record. +#[derive(Clone, Copy)] +pub(crate) struct Options { + /// Emit every token's capture name (`--syntax`). + pub(crate) syntax: bool, +} + /// What the stream ended with: whether any file failed, and whether a /// run-level failure cut it short. pub(crate) struct Ended { @@ -36,6 +42,7 @@ pub(crate) fn write( session: DiffSession, jobs: usize, pipeline: Arc<Pipeline>, + options: Options, output: &mut impl Write, ) -> anyhow::Result<Ended> { let manifest = manifest(&session); @@ -46,7 +53,7 @@ pub(crate) fn write( .build()?; let worker = thread::spawn(move || { // A disconnected consumer cancels production after the files in flight. - let _ = produce(session, manifest, &pool, &pipeline, sender); + let _ = produce(session, manifest, &pool, &pipeline, options, sender); }); let mut output = BufWriter::new(output); let result: anyhow::Result<Ended> = (|| { @@ -92,6 +99,7 @@ fn produce( manifest: Vec<FileChange>, pool: &rayon::ThreadPool, pipeline: &Pipeline, + options: Options, sender: SyncSender<Event>, ) -> Result<(), Disconnected> { let send = |event: Event| sender.send(event).map_err(|_| Disconnected); @@ -112,7 +120,7 @@ fn produce( }; pool.install(|| { loader.par_bridge().for_each(|(file, loaded)| { - let (visibility, outcome) = match loaded.and_then(|loaded| diffed(&loaded)) { + let (visibility, outcome) = match loaded.and_then(|loaded| diffed(&loaded, options)) { Ok((entry, diff)) => match shape(pipeline, &entry, diff) { Ok((visibility, diff)) => (visibility, Outcome::Diff { diff }), Err(error) => { @@ -174,16 +182,37 @@ fn wire_error(error: &anyhow::Error) -> Problem { /// The projected diff of one loaded file and its manifest entry. `Err` is /// this file's failure. -fn diffed(loaded: &LoadedFile) -> anyhow::Result<(FileChange, Diff)> { - // The stream reads no terminal hunks, so their context is moot. - let result = loaded.diff(&DisplayOptions::default())?; +fn diffed(loaded: &LoadedFile, options: Options) -> anyhow::Result<(FileChange, Diff)> { + let result = loaded.diff()?; + let syntax = match options.syntax { + true => syntax_spans(&result, &loaded.params), + false => (Vec::new(), Vec::new()), + }; let inputs = Inputs { file: &loaded.file.sides, sizes: loaded.sizes(), + syntax, }; Ok((loaded.file.manifest_entry(), project::diff(&result, inputs))) } +/// Highlight spans for both sides of a structurally parsed file. A file that +/// fell back to a line diff has none. +fn syntax_spans( + diff: &DiffResult, + params: &crate::config::Params, +) -> (Vec<SyntaxSpan>, Vec<SyntaxSpan>) { + let FileFormat::SupportedLanguage(language) = &diff.file_format else { + return (Vec::new(), Vec::new()); + }; + let parser = params.language(*language).parser; + let spans = |content: &FileContent| match content { + FileContent::Text(src) => project::syntax_spans(src, parser), + FileContent::Binary => Vec::new(), + }; + (spans(&diff.lhs_src), spans(&diff.rhs_src)) +} + /// Run the plugins on a diff and recount what stays visible. A binary diff has /// no text and no regions, so only moves on the file apply to it. `Err` /// is a run-level failure. @@ -204,6 +233,7 @@ fn shape( Diff::Binary { sides } => { let mut empty = sides.clone().map(|_| Source { text: String::new(), + syntax: Vec::new(), regions: Vec::new(), }); let visibility = pipeline.run(entry, &mut empty)?; @@ -235,7 +265,9 @@ pub(crate) fn write_file( after: &str, sizes: (u64, u64), compute: impl FnOnce() -> Result<DiffResult, QueryConflict>, + params: &crate::config::Params, pipeline: &Pipeline, + options: Options, output: &mut impl Write, ) -> anyhow::Result<Ended> { let file = crate::git::FileChange::standalone(before, after); @@ -272,6 +304,10 @@ pub(crate) fn write_file( Inputs { file: &file.sides, sizes, + syntax: match options.syntax { + true => syntax_spans(&result, params), + false => (Vec::new(), Vec::new()), + }, }, ); match shape(pipeline, &entry, projected) { @@ -438,6 +474,7 @@ mod visible_tests { fn source(regions: Vec<Region>) -> Source { Source { text: String::new(), + syntax: vec![], regions, } } @@ -491,8 +528,8 @@ mod conflict_tests { fn a_query_conflict_is_that_files_error_and_the_run_completes() { let params = try_with_queries(&[( "rust", - "((block) @fold (#set! tag \"context:whole\"))\n\ - ((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"context:inside\"))\n", + "((block) @fold (#set! tag \"removed-runs:whole\"))\n\ + ((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"removed-runs:inside\"))\n", )]) .unwrap(); let mut output = Vec::new(); @@ -508,7 +545,9 @@ mod conflict_tests { ¶ms, ) }, + ¶ms, &Pipeline::default(), + Options { syntax: false }, &mut output, ) .unwrap(); diff --git a/src/summary.rs b/src/summary.rs index 165f0e7ef..c961536f1 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -2,7 +2,6 @@ use std::fmt::Display; -use crate::display::hunks::Hunk; use crate::parse::folds::Fold; use crate::parse::guess_language::{self, language_name}; use crate::parse::syntax::MatchedPos; @@ -52,36 +51,12 @@ impl Display for FileFormat { #[derive(Debug)] pub(crate) struct DiffResult { - pub(crate) display_path: String, - /// Additional information to display about this file, such as - /// "Renamed from x.js to y.js". - pub(crate) extra_info: Option<String>, - pub(crate) file_format: FileFormat, pub(crate) lhs_src: FileContent, pub(crate) rhs_src: FileContent, - /// Complete selected rows, including ordinary padding and syntax context. - pub(crate) hunks: Vec<Hunk>, pub(crate) lhs_folds: Vec<Fold>, pub(crate) rhs_folds: Vec<Fold>, pub(crate) lhs_positions: Vec<MatchedPos>, pub(crate) rhs_positions: Vec<MatchedPos>, - - /// If the two files do not have exactly the same bytes, the - /// number of bytes in each file. - pub(crate) has_byte_changes: Option<(usize, usize)>, - pub(crate) has_syntactic_changes: bool, -} - -impl DiffResult { - pub(crate) fn has_reportable_change(&self) -> bool { - if matches!(self.lhs_src, FileContent::Binary) - || matches!(self.rhs_src, FileContent::Binary) - { - return self.has_byte_changes.is_some(); - } - - self.has_syntactic_changes - } } diff --git a/tests/cli.rs b/tests/cli.rs index 139d2ed50..85df00b15 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -28,141 +28,18 @@ fn get_base_command() -> Command { } else { cmd = Command::new(path); } - cmd.arg("debug"); cmd } -#[test] -fn no_such_files() { - let mut cmd = get_base_command(); - - cmd.arg("no_such_file").arg("no_such_file_either"); - cmd.assert().failure().code(2); -} - -#[test] -fn inline() { - let mut cmd = get_base_command(); - - cmd.arg("--display=inline") - .arg("sample_files/simple_1.js") - .arg("sample_files/simple_2.js"); - cmd.assert().success(); -} - -#[test] -fn binary_changed() { - let mut cmd = get_base_command(); - - cmd.arg("--display=inline") - .arg("img/logo.png") - .arg("/dev/null"); - - let predicate_fn = predicate::str::contains("Binary file removed"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn binary_override() { - let mut cmd = get_base_command(); - - cmd.arg("--override-binary=*.js") - .arg("sample_files/simple_1.js") - .arg("sample_files/simple_2.js"); - let predicate_fn = predicate::str::contains("Binary file modified"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn has_changes_default_exit_code() { - let mut cmd = get_base_command(); - - cmd.arg("sample_files/simple_1.js") - .arg("sample_files/simple_2.js"); - cmd.assert().success(); -} - -#[test] -fn has_changes_requested_exit_code() { +fn debug_command() -> Command { let mut cmd = get_base_command(); - - cmd.arg("--exit-code") - .arg("sample_files/simple_1.js") - .arg("sample_files/simple_2.js"); - cmd.assert().failure().code(1); -} - -#[test] -fn ignore_comments() { - let mut cmd = get_base_command(); - - cmd.arg("--ignore-comments") - .arg("sample_files/comma_and_comment_1.js") - .arg("sample_files/comma_and_comment_2.js"); - - let predicate_fn = predicate::str::contains("No syntactic changes"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn check_only() { - let mut cmd = get_base_command(); - - cmd.arg("--check-only") - .arg("sample_files/simple_1.js") - .arg("sample_files/simple_2.js"); - - let predicate_fn = predicate::str::contains("Has syntactic changes"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn check_only_text_file() { - let mut cmd = get_base_command(); - - cmd.arg("--check-only") - .arg("sample_files/text_1.txt") - .arg("sample_files/text_2.txt"); - - let predicate_fn = predicate::str::contains("Has changes"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn text_changes_at_end_doesnt_crash() { - let mut cmd = get_base_command(); - - cmd.arg("sample_files/cli_tests/changes_at_end_1.txt") - .arg("sample_files/cli_tests/changes_at_end_2.txt"); - - cmd.assert().success(); -} - -#[test] -fn makefile_text_as_atom() { - let mut cmd = get_base_command(); - - cmd.arg("sample_files/cli_tests/makefile_1.mk") - .arg("sample_files/cli_tests/makefile_2.mk"); - - let predicate_fn = predicate::str::contains("CCFLAGS"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn yaml_parse_errors() { - let mut cmd = get_base_command(); - - cmd.arg("sample_files/cli_tests/bad_yaml_1.yml") - .arg("sample_files/cli_tests/bad_yaml_2.yml"); - - let predicate_fn = predicate::str::contains("exceeded diff.parse_error_limit (0)"); - cmd.assert().stdout(predicate_fn); + cmd.arg("debug"); + cmd } #[test] fn list_languages() { - let mut cmd = get_base_command(); + let mut cmd = debug_command(); cmd.arg("--list-languages"); @@ -173,148 +50,70 @@ fn list_languages() { cmd.assert().stdout(predicate_fn); } -#[test] -fn test_mime_type_false_positive() { - let mut cmd = get_base_command(); - - cmd.arg("sample_files/cli_tests/x_mod_false_positive.js") - .arg("sample_files/cli_tests/empty.js"); - - let predicate_fn = predicate::str::contains("JavaScript"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn slightly_invalid_utf8() { - let mut cmd = get_base_command(); - - cmd.arg("sample_files/cli_tests/MainWindowViewModel.cs") - .arg("/dev/null"); - - let predicate_fn = predicate::str::contains("using"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn directory_arguments() { - let mut cmd = get_base_command(); - - cmd.arg("sample_files/dir_1").arg("sample_files/dir_2"); - - let predicate_fn = - predicate::str::contains("only_in_1.c").and(predicate::str::contains("only_in_2.rs")); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn git_style_arguments_rename() { - let mut cmd = get_base_command(); - - cmd.arg("elisp_oldname.el") - .arg("sample_files/elisp_1.el") - .arg("lhs_hash_placeholder") - .arg("lhs_mode_placeholder") - .arg("sample_files/elisp_2.el") - .arg("rhs_hash_placeholder") - .arg("rhs_mode_placeholder") - .arg("elisp_newname.el") - .arg("similarity_placeholder"); - let predicate_fn = predicate::str::contains("Renamed"); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn git_style_arguments_new_file() { - let mut cmd = get_base_command(); - - cmd.arg("simple.txt") - .arg("/dev/null") - .arg(".") - .arg(".") - .arg("sample_files/simple_1.txt") - .arg("abcdef1234") - .arg("100644"); - let predicate_fn = predicate::str::contains("File permissions changed").not(); - cmd.assert().stdout(predicate_fn); -} - -#[test] -fn drop_different_path_starts() { - let mut cmd = get_base_command(); - - cmd.arg("sample_files/dir_1/clojure.clj") - .arg("sample_files/dir_2/clojure.clj"); - let predicate_fn = predicate::str::contains("dir_2/clojure.clj").not(); - cmd.assert().stdout(predicate_fn); -} - #[test] fn dump_tree_sitter() { - let mut cmd = get_base_command(); + let mut cmd = debug_command(); - cmd.arg("--dump-ts") - .arg("sample_files/simple_1.js") - .arg("sample_files/simple_2.js"); + cmd.arg("--dump-ts").arg("sample_files/simple_1.js"); cmd.assert().success(); } #[test] fn dump_syntax() { - let mut cmd = get_base_command(); + let mut cmd = debug_command(); - cmd.arg("--dump-syntax") - .arg("sample_files/simple_1.js") - .arg("sample_files/simple_2.js"); + cmd.arg("--dump-syntax").arg("sample_files/simple_1.js"); cmd.assert().success(); } #[test] -fn walk_hidden_items() { +fn a_comparison_without_format_needs_a_terminal() { let mut cmd = get_base_command(); - cmd.args(["sample_files/hidden_1", "sample_files/hidden_2"]); - - let predicate_fn = - predicate::str::contains(format!(".hidden{}doc.txt", std::path::MAIN_SEPARATOR)) - .and(predicate::str::contains(".hidden.txt")) - .and(predicate::str::contains("before")) - .and(predicate::str::contains("after")); - cmd.assert().stdout(predicate_fn); + cmd.args([ + "--no-index", + "sample_files/simple_1.js", + "sample_files/simple_2.js", + ]); + cmd.assert() + .failure() + .code(2) + .stderr(predicate::str::contains("--format ndjson")); } #[test] -fn git_unmerged_files() { +fn text_output_is_gone() { let mut cmd = get_base_command(); - cmd.args(["sample_files/simple_1.js"]); - cmd.env("GIT_EXEC_PATH", "/usr/lib/git-core"); - - let predicate_fn = predicate::str::contains("Unmerged path"); - cmd.assert().stdout(predicate_fn); + cmd.args([ + "--format", + "text", + "--no-index", + "sample_files/simple_1.js", + "sample_files/simple_2.js", + ]); + cmd.assert().failure(); } #[test] fn a_plugin_that_cannot_be_made_stops_diffr_before_any_record() { let dir = tempfile::tempdir().unwrap(); - let config = dir.path().join("diffr"); - std::fs::create_dir_all(&config).unwrap(); - std::fs::write( - config.join("config.toml"), - "[plugins.summarize]\nenabled = true\n", - ) - .unwrap(); - let mut cmd = Command::new(assert_cmd::cargo_bin!("diffr")); - - cmd.args([ - "--no-index", - "sample_files/simple_1.js", - "sample_files/simple_2.js", - "--format", - "ndjson", - ]) - .env("XDG_CONFIG_HOME", dir.path()) - .env_remove("GEMINI_API_KEY") - .env_remove("GOOGLE_API_KEY"); + let config = dir.path().join("config.toml"); + std::fs::write(&config, "[plugins.summarize]\nenabled = true\n").unwrap(); + let mut cmd = get_base_command(); + + cmd.arg("--config") + .arg(&config) + .args([ + "--no-index", + "sample_files/simple_1.js", + "sample_files/simple_2.js", + "--format", + "ndjson", + ]) + .env("XDG_CONFIG_HOME", dir.path()) + .env_remove("GEMINI_API_KEY") + .env_remove("GOOGLE_API_KEY"); cmd.assert() .failure() .code(2) diff --git a/tests/view.rs b/tests/view.rs index d2b9e72e3..dbabd4f6e 100644 --- a/tests/view.rs +++ b/tests/view.rs @@ -198,3 +198,28 @@ fn a_failing_plugin_aborts_the_run() { .unwrap() .starts_with("mutation summarize: summarizer: ")); } + +#[test] +fn syntax_spans_come_only_with_the_flag() { + let fixture = Fixture::new(); + fixture.write("a.rs", "fn a() {}\n"); + let base = fixture.commit(); + fixture.write("a.rs", "fn b() {}\n"); + let head = fixture.commit(); + let plain = records(&fixture.run(&base, &head)); + assert!(record(&plain, "a.rs")["diff"]["rhs"] + .get("syntax") + .is_none()); + let output = fixture.diffr(&[&base, &head, "--format", "ndjson", "--syntax"]); + let records = records(&output); + let syntax = record(&records, "a.rs")["diff"]["rhs"]["syntax"] + .as_array() + .unwrap() + .clone(); + assert!( + syntax + .iter() + .any(|span| span["capture"] == "keyword" && span["start_column"] == 0), + "{syntax:?}" + ); +} diff --git a/tui/README.md b/tui/README.md index f102419d6..d47a7783d 100644 --- a/tui/README.md +++ b/tui/README.md @@ -14,10 +14,9 @@ cargo build --bin diffr ./target/debug/diffr --no-index -- before.ts after.ts ``` -With terminal stdin and stdout, omitting `--format` opens the viewer. Redirected output -stays text. Explicit `--format text`, `json`, `ndjson`, or `snapshot`, and metadata/quiet -modes bypass the frontend. `DIFFR_BUN` can specify a Bun executable; `DIFFR_TUI_ENTRY` -can specify a frontend entry file. The default entry is in the checkout used to build +Omitting `--format` opens the viewer, which needs terminal stdin and stdout. +`--format ndjson` and metadata/quiet modes bypass the frontend. `DIFFR_BUN` can +specify a Bun executable; `DIFFR_TUI_ENTRY` can specify a frontend entry file. The default entry is in the checkout used to build Rust. A standalone distribution/installer is deferred. Saved streams use the same reader: @@ -29,6 +28,59 @@ Saved streams use the same reader: `--input -` reads a pipe and opens the controlling terminal for keyboard input. +The frontend launches Rust with `--format ndjson --syntax`: the wire is diffr's v3 +protocol (`docs/streaming.md`, `src/protocol/mod.rs`) and `--syntax` adds a tree-sitter +capture name per token, which is the only source of syntax colour here. A file that fell +back to a line diff has no syntax and renders uncoloured. + +## Themes + +Colours come from Helix theme files: TOML keyed by tree-sitter capture names such as +`keyword`, `function.method`, `string`, `comment`, `type`, `variable.parameter`, with +a `[palette]` section and `ui.*` keys for chrome. A capture falls back to its parent +scope (`keyword.return` → `keyword`). Four themes are bundled under `themes/` (Helix's +own, MPL-2.0): `default-dark` (onedark), `default-light` (onelight), `gruvbox`, and +`solarized_light`. + +The frontend reads `diffr config show --json` at startup and uses `theme.path` when set, +else `theme.name` from the bundled index. An unknown name is an error, not a fallback. +To use any other Helix theme, download it from +https://github.com/helix-editor/helix/tree/master/runtime/themes and point at it: + +```sh +diffr config set theme.path ~/.config/helix/themes/dracula.toml +``` + +`t` toggles between `default-dark` and `default-light`. Change tints (line and word +backgrounds) are mixed from the theme's background and its `diff.plus`/`diff.minus` +colours. Recordings played with `--input` take `--theme <name|file.toml>` and default +to `default-dark`. + +## Settings screen + +`diffr config` opens a searchable settings screen in this frontend. The CLI contract it +relies on: + +```sh +bun run packages/hunk/src/main.tsx --settings --diffr /path/to/diffr [initial query] +diffr config schema # JSON Schema; each setting carries `title`, `x-group`, `description` and `default` +diffr config show --json # resolved values, nested like the schema +diffr config set <key> <value> # dotted key, value as typed: true, 12, gemini +``` + +Every scalar setting is listed under its `x-group` by its `title`, including each plugin's +`enabled` switch. Keys diffr marks `"x-settings": false` (`plugins.order`, +`plugins.hide-files.tags`, `plugins.summarize.system_prompt`, and any list or table option) are left to the file; any other key the screen cannot edit is an error +naming it, not a silently missing row. + +The list view: type to filter (fuzzy over titles and keys, substring over group names and +descriptions), `↑`/`↓` to move, Esc to quit. The selected setting's description, key and +default show below the list; a value that differs from its default is drawn brighter, and +an unset one shows as `not set`. Enter or Space flips a boolean or cycles an enum in place; +any other setting opens a prompt with its title and description, masked for +credential-looking keys such as `api_key`. Every change is written through +`diffr config set`, and the row changes only once the write succeeded. + ## Controls - Wheel, arrows, j/k, Page Up/Down, Home/End: scroll. @@ -36,22 +88,48 @@ Saved streams use the same reader: - File header click or Enter: collapse/expand the file. - `\` / Cmd-B (when forwarded by the terminal): toggle the file tree. - Click folders to expand/collapse them; click files to navigate. The active file is highlighted and revealed as the diff scrolls. -- File headers stay pinned while scrolling and show unique novel-line counts (`+added −removed`) from Rust hunks. +- A summary strip under the menubar shows the comparison (`main…HEAD`, `index…working + tree`), the file count, the totals `+N −M` in the theme's diff colours, and GitHub's + five-block bar. Totals and file-header counts are diffr's `stats.visible`, shown verbatim + and summed over loaded files: folding changes what is on screen, never the numbers. + Files still pending add nothing, and the total ends in `…` until the stream completes. +- `i` (or clicking the totals) opens a breakdown for the whole comparison and the current + file, straight from the wire: `visible` and `textual`, plus a `line diff: <code>` line + when tree-sitter fell back to a line diff for that file. Esc closes it. +- Matched folds: every region has its own `id`, and two folds diffr matched across + sides share a `fold_state_id`, so they open and close together wherever each sits. A fold is + paired when a region on the other side shares its `fold_state_id`, and its collapsed row then + stays neutral; a one-sided fold takes its side's change tint. +- A file diffr marks hidden by default (generated, test) opens collapsed with a GitHub-style + placeholder: `Load diff` and the reason line. Click it or press Enter to reveal. - File, View, Navigate, Theme and Help menus expose the supported controls. - `[` / `]`: previous/next hunk. - Folds follow VS Code with controls always shown: a foldable row shows `▾` in the - gutter, and a collapsed fold shows `▸` plus a `⋯ Placeholder` after the header line. - Click either to toggle. Alt-click also folds or unfolds every nested region. Vim chords - act on the fold whose header is the top row: `za` toggle, `zo` open, `zc` close, with - `zA` / `zO` / `zC` recursive; `zM` / `zR` (and View > Fold all / Unfold all) fold or - unfold every visible fold; `zj` / `zk` scroll to the next or previous fold header. - The header and closing delimiter stay visible; paired unchanged folds - collapse on both sides. A fold on one side only blanks that side's cells, keeping the - other side's lines in Rust's alignment. -- `c`: toggle compact/all context. Compact uses Rust-selected nearby and enclosing syntax context, with an ellipsis for each omitted stretch. + gutter, and a collapsed fold shows `▸` plus `⋯ Label` after the header line. A label + with several lines (pseudocode from a summarizer) shows only `⋯` on the header and hangs + the label under it, indented one level, inside the fold tint. Click either to toggle. + Alt-click also folds or unfolds every nested region. Vim chords act on the fold whose + header is the top row: `za` toggle, `zo` open, `zc` close, with `zA` / `zO` / `zC` + recursive; `zM` / `zR` (and View > Fold all / Unfold all) fold or unfold every fold; + `zj` / `zk` scroll to the next or previous fold header. The header and closing + delimiter stay visible; regions that share a `fold_state_id` collapse together, on both sides + and within one side (a docstring bundled with its function folds and unfolds with it; an + empty label shows as a bare `⋯`). Folds that share a header line, such as a group of + collapsed regions and the first of them, show the outermost collapsed one there, so + opening a group reveals each member's own collapsed row. A fold on one + side only blanks that side's cells, keeping the other side's lines in Rust's alignment. +- Context gaps are folds too: an unchanged stretch diffr's `context` plugin collapsed + arrives as a collapsed leaf, or an untagged collapsed fold around several siblings, and + renders as one fold row with its label (`142 unchanged lines`) that toggles like any + other. The wire has no gap kind, so `c` finds gaps by what makes one: collapsed, + untagged, paired across sides, and holding no change on either side. It opens or + closes every such gap at once. Folds and gaps start in the state diffr's `visibility` + asks for. +- A file whose `file` record carries `visibility` (a generated or test file hidden by + the `hide-files` plugin, say) shows its header, a "Load diff" row and the reason. - `s`: split/unified; initial mode is responsive to width. - `w`: wrap; Left/Right: horizontal scrolling when unwrapped. -- `t`: dark/light theme. +- `t`: toggle between the bundled dark and light defaults (see Themes). - Drag code rows: select original source lines on the starting side; `y` or Copy copies via OSC 52. Escape clears selection. Character-level selection and drag autoscroll are not implemented in this first pass. @@ -61,33 +139,39 @@ Saved streams use the same reader: ```text Rust CLI -- implicit interactive output --> Bun frontend -Bun frontend -- same comparison arguments + --format ndjson --> Rust subprocess -Rust stdout --> validated events --> file store -file source + token spans + full-file alignment --> split/unified rows +Bun frontend -- same comparison arguments + --format ndjson --syntax --> Rust subprocess +Rust stdout --> parsed events --> file store +per-side text + syntax spans + region trees --> leaves zipped by alignment_id --> split/unified rows rows + width + wrapping --> measured row bounds row bounds + viewport --> mounted OpenTUI rows -mouse/keyboard --> viewer state --> updated projection +mouse/keyboard --> viewer state (collapsed ids, closed files) --> updated projection ``` - `src/cli.rs` owns argument interpretation and selecting interactive versus explicit output. The frontend does not resolve revisions or invoke Git. -- `src/stream.rs` owns serialization. Repository comparisons retain their existing - wire format. Standalone comparisons add start-event operands `{kind: "file", path}`. -- `packages/hunk/src/diffr/wire.ts` validates the Rust field shapes. Source positions - remain zero-based UTF-8 byte offsets. Fold metadata, tags, pairings and placeholders - are retained intact. -- `diffr/folds.ts` turns fold ranges into VS Code-style regions: one region per header - line (the outermost wins), unchanged pairs share one id, and a trailing line hides only - when nothing follows the range on it. Collapsed ids live in viewer state; folded lines - are masked per side before row building, so no realignment happens. -- `diffr/stream.ts` validates event ordering, versions and completion counts, handles - arbitrary chunk boundaries, and rejects truncated streams. +- `src/protocol/mod.rs` is the wire contract: a `start` manifest, one `file` record per file + with `diff` or `error`, and a `complete` footer. Sides are `lhs`/`rhs` by presence. + Each text side carries its full text, optional `syntax` spans, and a `regions` tree + whose leaves tile the file; every region has an `id` of its own, a leaf's `alignment_id` on both sides means its rows line up (folds have none), and `fold_state_id` groups what toggles together, across sides too. +- `packages/hunk/src/diffr/wire.ts` parses those shapes with Zod and fills omitted + defaults. Invariants across records (ordering, counts, which files the manifest lists, + which lines a region references) are diffr's, and the frontend trusts them. Columns remain zero-based UTF-8 byte offsets. +- `diffr/regions.ts` flattens each side's tree into leaves and folds, computes which lines + a collapsed fold hides (a trailing line hides only when nothing follows the range on + it), and seeds the default collapsed set from `visibility`. Collapsed ids live in + viewer state, keyed by `fold_state_id`, so regions sharing it (matched folds on both sides included) toggle together. +- `diffr/stream.ts` handles arbitrary chunk boundaries, and rejects malformed JSON, an + unknown protocol version and a stream that ends before `complete`. - `diffr/store.ts` holds completed files and errors. The UI can display files while subsequent results are arriving. Quit terminates the comparison subprocess. -- `diffr/rows.ts` consumes the exact full-file line pairs. It adds empty split cells where - Rust supplies null, and groups unified removals before additions between shared - context lines. Syntax colors and novelty emphasis come from Rust token spans. - There is no patch parser, second diff algorithm or frontend syntax highlighter. +- `diffr/rows.ts` zips the two leaf lists on their ids: paired leaves pair rows line for + line and come in the same order on both sides, + and unpaired leaves get blank cells opposite. Folding masks lines per side; nothing is + realigned. Line tint comes from a leaf's `changed` spans, word emphasis from the spans + themselves, and foreground colour from `syntax` capture names through a small theme + table. There is no patch parser, second diff algorithm or frontend tokenizer. +- `diffr/config.ts` and `ui/Settings.tsx` implement the settings screen over the + `diffr config` commands. - `diffr/geometry.ts` measures wrapping and equal-height split rows. Retained Hunk `styledSpanLayout.ts`, `ui/lib/text.ts` and `rowWindowing.ts` handle styled text slicing, terminal column measurement and binary-search viewport selection. @@ -115,20 +199,18 @@ retains Hunk's MIT notice. The parent Rust project keeps its existing license. ```sh cargo build --bin diffr cargo test --bin diffr -python3 tests/streaming/check.py cd tui bun run typecheck bun test packages -bun run test:integration ``` -The integration tests include real Rust wire output, redirected CLI behavior, and a -Unix PTY test for interactive launch, layout switching, mouse file toggles and clean -shutdown. They require Python 3 and the debug Rust binary. Set `DIFFR_TEST_BIN` to test -another binary. OpenTUI tests verify drag-copy, split/unified rendering, and bounded +OpenTUI tests verify drag-copy, split/unified rendering, and bounded mounted widgets while scrolling a 5,000-line file. -Full-file alignment is supplied by Rust as `aligned_rows` (zero-based line pairs, null for padding). Hunks supply the default context selection and navigation; omitted stretches become ellipsis rows without changing alignment. Press `c` to reveal all source. Older saved streams must be regenerated. +Alignment is supplied by Rust as per-side region trees whose leaves share `alignment_id`s +across sides. Context selection arrives as collapsed unchanged regions; `[` and `]` jump +between runs of changed rows. Saved streams from earlier wire versions must be +regenerated; `test/fixtures/comparison.ndjson` is a v3 recording the stream tests parse. @@ -140,7 +222,8 @@ d/u or Ctrl-D/Ctrl-U scroll half pages; f/b, PageDown/PageUp, or Ctrl-F/Ctrl-B scroll full pages; Space/Shift-Space also page; g (or gg)/G go to start/end. h/l or arrows pan horizontally. Cmd-B toggles the tree; backslash is its fallback. -The first NDJSON event includes `files: FileChange[]` in comparison order. +The first NDJSON event includes `files` in comparison order, each with its status and +tags. Whether a file starts hidden arrives later, on its `file` record. The tree renders this manifest immediately; pending files are marked ◌ and failures !. Diffs appear in tree order as they arrive, retaining the visible source row when an earlier file loads. Result arrival order need not match manifest order; each diff --git a/tui/package.json b/tui/package.json index ba8e4490d..fc6ef39bf 100644 --- a/tui/package.json +++ b/tui/package.json @@ -5,9 +5,7 @@ "scripts": { "start": "bun run packages/hunk/src/main.tsx", "typecheck": "tsc --noEmit", - "test": "bun test packages", - "test:integration": "bun test test/pty", - "test:tty-smoke": "bun test test/pty" + "test": "bun test packages" }, "dependencies": { "@opentui/core": "0.5.6", diff --git a/tui/packages/hunk/src/diffr/config.test.ts b/tui/packages/hunk/src/diffr/config.test.ts new file mode 100644 index 000000000..636efe8b3 --- /dev/null +++ b/tui/packages/hunk/src/diffr/config.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; +import { filterSettings, flattenSchema, fuzzyScore, isDefault, parseValue } from "./config"; +/** The shape of `diffr config schema`: `plugins` is inline, one entry per plugin; lists and multi-line + * values are marked `x-settings: false`. */ +export const schemaFixture = { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "Config", + type: "object", + properties: { + plugins: { + type: "object", + properties: { + order: { type: "array", items: { type: "string" }, "x-settings": false, default: ["deleted-bodies", "hide-files", "summarize"] }, + "deleted-bodies": { + type: "object", + title: "Collapsed code", + properties: { + min_lines: { type: "integer", title: "Shortest body to collapse", "x-group": "Collapsed code", description: "Bodies shorter than this are never summarized or collapsed.", default: 12 }, + enabled: { type: "boolean", title: "Collapse deleted functions", "x-group": "Collapsed code", description: "Collapse deleted function bodies.", default: true }, + }, + }, + "hide-files": { + type: "object", + title: "Hidden files", + properties: { + enabled: { type: "boolean", title: "Hide test files", "x-group": "Hidden files", description: "Hide files tagged test.", default: true }, + tags: { type: "array", items: { type: "string" }, "x-group": "Hidden files", "x-settings": false, default: ["test"] }, + }, + }, + summarize: { + type: "object", + title: "Summaries", + properties: { + provider: { type: "string", title: "Provider", "x-group": "Summaries", enum: ["gemini", "none"], description: "Model provider.", default: "gemini" }, + api_key: { title: "API key", "x-group": "Summaries", anyOf: [{ type: "string" }, { type: "null" }], description: "API key for the provider.", default: null }, + model: { $ref: "#/$defs/Model", title: "Model", "x-group": "Summaries" }, + system_prompt: { type: "string", title: "System prompt", "x-group": "Summaries", "x-settings": false, description: "The system instruction.", default: "Summarize." }, + }, + }, + }, + }, + }, + $defs: { + Model: { type: "string", description: "Model name.", default: "gemini-2.5-flash" }, + }, +}; +export const valuesFixture = { + plugins: { + order: ["deleted-bodies", "hide-files", "summarize"], + "deleted-bodies": { min_lines: 12, enabled: true }, + "hide-files": { enabled: false, tags: ["test"] }, + summarize: { provider: "gemini", api_key: null, model: "gemini-2.5-flash", system_prompt: "Summarize." }, + }, +}; +test("schema flattens to dotted keys with descriptions, defaults, and current values", () => { + const settings = flattenSchema(schemaFixture, valuesFixture); + expect(settings.map((s) => [s.key, s.type, s.default, s.value])).toEqual([ + ["plugins.deleted-bodies.min_lines", "integer", 12, 12], + ["plugins.deleted-bodies.enabled", "boolean", true, true], + ["plugins.hide-files.enabled", "boolean", true, false], + ["plugins.summarize.provider", "enum", "gemini", "gemini"], + ["plugins.summarize.api_key", "string", null, null], + ["plugins.summarize.model", "string", "gemini-2.5-flash", "gemini-2.5-flash"], + ]); + expect(settings.map((s) => [s.title, s.group])).toEqual([ + ["Shortest body to collapse", "Collapsed code"], + ["Collapse deleted functions", "Collapsed code"], + ["Hide test files", "Hidden files"], + ["Provider", "Summaries"], + ["API key", "Summaries"], + ["Model", "Summaries"], + ]); + expect(settings[3].options).toEqual(["gemini", "none"]); + expect(settings.map(isDefault)).toEqual([true, true, false, true, true, true]); +}); +test("a setting without a title or group is a schema error", () => { + const schema = structuredClone(schemaFixture); + delete (schema.properties.plugins.properties["deleted-bodies"].properties.min_lines as Record<string, unknown>).title; + expect(() => flattenSchema(schema, valuesFixture)).toThrow("plugins.deleted-bodies.min_lines"); +}); +test("keys marked x-settings: false are left to the file; any other list or table is a schema error", () => { + const keys = flattenSchema(schemaFixture, valuesFixture).map((s) => s.key); + expect(keys.some((key) => key.includes("order") || key.includes("queries") || key.includes("tags"))).toBe(false); + const schema = structuredClone(schemaFixture); + delete (schema.properties.plugins.properties["hide-files"].properties.tags as Record<string, unknown>)["x-settings"]; + expect(() => flattenSchema(schema, valuesFixture)).toThrow("Setting plugins.hide-files.tags has unsupported type array"); +}); +test("fuzzy filtering narrows over title, key, group and description, keeping groups together", () => { + const settings = flattenSchema(schemaFixture, valuesFixture); + expect(fuzzyScore("hfenabled", "plugins.hide-files.enabled")).not.toBeNull(); + expect(fuzzyScore("xyz", "plugins.hide-files.enabled")).toBeNull(); + // An empty query keeps schema order, which is already grouped. + expect(filterSettings(settings, "").map((s) => s.key)).toEqual(settings.map((s) => s.key)); + expect(filterSettings(settings, "hide test").map((s) => s.key)).toEqual(["plugins.hide-files.enabled"]); + expect(filterSettings(settings, "api")[0].key).toBe("plugins.summarize.api_key"); + // A description mention ("... or collapsed.") still finds the setting. + expect(filterSettings(settings, "shorter").map((s) => s.key)).toEqual(["plugins.deleted-bodies.min_lines"]); + // Matching a group name lists the group in schema order. + expect(filterSettings(settings, "summaries").map((s) => s.key)).toEqual(["plugins.summarize.provider", "plugins.summarize.api_key", "plugins.summarize.model"]); +}); +test("edited values are parsed in the setting's type and bad input is rejected", () => { + const [minLines, collapse, , provider] = flattenSchema(schemaFixture, valuesFixture); + expect(parseValue(minLines, "20")).toBe(20); + expect(() => parseValue(minLines, "2.5")).toThrow("integer"); + expect(parseValue(collapse, "false")).toBe(false); + expect(() => parseValue(collapse, "yes")).toThrow(); + expect(parseValue(provider, "none")).toBe("none"); + expect(() => parseValue(provider, "openai")).toThrow("gemini, none"); +}); diff --git a/tui/packages/hunk/src/diffr/config.ts b/tui/packages/hunk/src/diffr/config.ts new file mode 100644 index 000000000..a899870b2 --- /dev/null +++ b/tui/packages/hunk/src/diffr/config.ts @@ -0,0 +1,180 @@ +/** Read diffr's config schema and values through its CLI, and flatten them into settings rows. */ +import { spawnSync } from "node:child_process"; +export type SettingType = "boolean" | "integer" | "number" | "string" | "enum"; +export interface Setting { + /** Dotted path, e.g. `folds.min_lines`. */ + key: string; + /** The human name shown in place of the key. */ + title: string; + /** The section a settings screen lists this setting under. */ + group: string; + description: string; + type: SettingType; + options?: string[]; + default: unknown; + value: unknown; +} +export interface ConfigClient { + schema(): unknown; + show(): unknown; + set(key: string, value: string): void; +} +interface JsonSchema { + type?: string | string[]; + /** `false` on keys a settings screen does not edit, such as lists and query tables. */ + "x-settings"?: boolean; + title?: string; + "x-group"?: string; + description?: string; + default?: unknown; + enum?: unknown[]; + properties?: Record<string, JsonSchema>; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + $ref?: string; + $defs?: Record<string, JsonSchema>; + definitions?: Record<string, JsonSchema>; +} +function resolve(schema: JsonSchema, root: JsonSchema): JsonSchema { + if (!schema.$ref) return schema; + const name = schema.$ref.replace(/^#\/(\$defs|definitions)\//, ""); + const target = root.$defs?.[name] ?? root.definitions?.[name]; + if (!target) throw new Error(`Unresolved schema reference ${schema.$ref}`); + // Keywords beside a `$ref` describe this use of the target, so they win over the target's own. + const { $ref: _, ...siblings } = schema; + return { ...resolve(target, root), ...siblings }; +} +/** `Option<T>` derives to `anyOf: [T, null]`; settings edit the `T`. */ +function unwrap(schema: JsonSchema, root: JsonSchema): JsonSchema { + const resolved = resolve(schema, root); + const variants = resolved.anyOf ?? resolved.oneOf; + if (!variants) return resolved; + const present = variants.map((v) => resolve(v, root)).filter((v) => v.type !== "null"); + if (present.length !== 1) throw new Error("Settings only support optional single-typed values"); + return { + ...present[0], + title: resolved.title ?? present[0].title, + "x-group": resolved["x-group"] ?? present[0]["x-group"], + description: resolved.description ?? present[0].description, + default: "default" in resolved ? resolved.default : present[0].default, + }; +} +function settingType(schema: JsonSchema, key: string): SettingType { + if (schema.enum) return "enum"; + const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type; + if (type === "boolean" || type === "integer" || type === "number" || type === "string") return type; + throw new Error(`Setting ${key} has unsupported type ${String(type)}; diffr marks keys settings screens skip with x-settings: false`); +} +export function flattenSchema(rawSchema: unknown, rawValues: unknown): Setting[] { + const root = rawSchema as JsonSchema; + const settings: Setting[] = []; + const walk = (schema: JsonSchema, values: unknown, prefix: string) => { + // diffr marks what the screen leaves to the file; anything else unsupported is an error below. + if (schema["x-settings"] === false) return; + const node = unwrap(schema, root); + if (node["x-settings"] === false) return; + if (node.properties) { + for (const [name, child] of Object.entries(node.properties)) { + const value = values && typeof values === "object" ? (values as Record<string, unknown>)[name] : undefined; + walk(child, value, prefix ? `${prefix}.${name}` : name); + } + return; + } + const type = settingType(node, prefix); + if (!node.title || !node["x-group"]) throw new Error(`Setting ${prefix} has no title or x-group in the schema`); + settings.push({ + key: prefix, + title: node.title, + group: node["x-group"], + description: node.description ?? "", + type, + options: type === "enum" ? node.enum!.map(String) : undefined, + default: node.default, + value: values, + }); + }; + walk(root, rawValues, ""); + return settings; +} +/** Subsequence match: every query character in order, scored by tightness and word starts. */ +export function fuzzyScore(query: string, text: string): number | null { + const q = query.toLowerCase(), t = text.toLowerCase(); + if (!q) return 0; + let score = 0, position = 0; + for (const char of q) { + const index = t.indexOf(char, position); + if (index < 0) return null; + score += index === position ? 3 : index === 0 || /[^a-z0-9]/.test(t[index - 1]) ? 2 : 1; + position = index + 1; + } + return score; +} +/** Titles and keys match fuzzily; group names and descriptions only as whole substrings, so prose does not swamp the list. */ +function settingScore(setting: Setting, query: string): number | null { + const substring = (text: string) => (text.toLowerCase().includes(query.toLowerCase()) ? query.length : null); + const scores = [ + fuzzyScore(query, setting.title), + fuzzyScore(query, setting.key), + substring(setting.group), + substring(setting.description), + ].filter((score): score is number => score !== null); + return scores.length ? Math.max(...scores) : null; +} +/** Settings matching `query`, in schema order within each group; groups follow their best match. */ +export function filterSettings(settings: Setting[], query: string): Setting[] { + const matches = settings + .map((setting, index) => ({ setting, index, score: settingScore(setting, query) })) + .filter((entry): entry is { setting: Setting; index: number; score: number } => entry.score !== null); + const best = new Map<string, number>(); + for (const { setting, score } of matches) best.set(setting.group, Math.max(best.get(setting.group) ?? -1, score)); + const firstIndex = new Map<string, number>(); + for (const { setting, index } of matches) if (!firstIndex.has(setting.group)) firstIndex.set(setting.group, index); + return matches + .sort( + (a, b) => + best.get(b.setting.group)! - best.get(a.setting.group)! || + firstIndex.get(a.setting.group)! - firstIndex.get(b.setting.group)! || + b.score - a.score || + a.index - b.index, + ) + .map((entry) => entry.setting); +} +export const formatValue = (value: unknown) => + value === undefined || value === null ? "" : typeof value === "string" ? value : JSON.stringify(value); +export const isDefault = (setting: Setting) => formatValue(setting.value) === formatValue(setting.default); +/** Parse an edited value in the setting's type; throws on malformed input. */ +export function parseValue(setting: Setting, text: string): unknown { + switch (setting.type) { + case "boolean": + if (text !== "true" && text !== "false") throw new Error("Expected true or false"); + return text === "true"; + case "integer": + case "number": { + const number = Number(text); + if (text.trim() === "" || Number.isNaN(number) || (setting.type === "integer" && !Number.isInteger(number))) + throw new Error(`Expected ${setting.type === "integer" ? "an integer" : "a number"}`); + return number; + } + case "enum": + if (!setting.options!.includes(text)) throw new Error(`Expected one of ${setting.options!.join(", ")}`); + return text; + case "string": + return text; + } +} +function run(binary: string, args: string[]): string { + const result = spawnSync(binary, args, { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr.trim() || `diffr ${args.join(" ")} exited with ${result.status}`); + return result.stdout; +} +/** The CLI contract: `config schema`, `config show --json`, `config set <key> <value>`. */ +export function cliClient(binary: string): ConfigClient { + return { + schema: () => JSON.parse(run(binary, ["config", "schema"])), + show: () => JSON.parse(run(binary, ["config", "show", "--json"])), + set: (key, value) => { + run(binary, ["config", "set", key, value]); + }, + }; +} diff --git a/tui/packages/hunk/src/diffr/counts.test.ts b/tui/packages/hunk/src/diffr/counts.test.ts new file mode 100644 index 000000000..0bad94f1c --- /dev/null +++ b/tui/packages/hunk/src/diffr/counts.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { blockBar, comparisonLabel } from "./counts"; +test("the block bar splits five blocks by share and keeps a block for any non-zero side", () => { + expect(blockBar({ added: 0, removed: 0 })).toEqual(["neutral", "neutral", "neutral", "neutral", "neutral"]); + expect(blockBar({ added: 10, removed: 0 })).toEqual(["added", "added", "added", "added", "added"]); + expect(blockBar({ added: 3, removed: 1 })).toEqual(["added", "added", "added", "removed", "neutral"]); + expect(blockBar({ added: 1, removed: 1 })).toEqual(["added", "added", "removed", "removed", "neutral"]); + expect(blockBar({ added: 99, removed: 1 })).toEqual(["added", "added", "added", "added", "removed"]); + expect(blockBar({ added: 13, removed: 6 })).toEqual(["added", "added", "added", "removed", "neutral"]); + expect(blockBar({ added: 1, removed: 99 })).toEqual(["added", "removed", "removed", "removed", "removed"]); +}); +test("comparison labels shorten shas and name the special snapshots", () => { + expect(comparisonLabel({ type: "revision", rev: "main" }, { type: "revision", rev: "0123456789abcdef0123456789abcdef01234567" })) + .toBe("main…0123456"); + expect(comparisonLabel({ type: "index" }, { type: "working_tree" })).toBe("index…working tree"); + expect(comparisonLabel({ type: "empty_tree" }, { type: "path", path: "after.ts" })).toBe("empty tree…after.ts"); +}); diff --git a/tui/packages/hunk/src/diffr/counts.ts b/tui/packages/hunk/src/diffr/counts.ts new file mode 100644 index 000000000..8244648fe --- /dev/null +++ b/tui/packages/hunk/src/diffr/counts.ts @@ -0,0 +1,45 @@ +/** Change counts for the headers. The numbers are diffr's `stats`, shown verbatim: folding + * changes what is on screen, never the count. */ +import type { LineCounts } from "./wire"; +export type { LineCounts }; +export const zero: LineCounts = { added: 0, removed: 0 }; +export const add = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added + b.added, removed: a.removed + b.removed }); +/** GitHub's five-block bar: whole blocks by share, any non-zero side keeps one, the rest grey. */ +export function blockBar(counts: LineCounts, blocks = 5): ("added" | "removed" | "neutral")[] { + const total = counts.added + counts.removed; + if (total === 0) return Array.from({ length: blocks }, () => "neutral"); + let green = Math.floor((blocks * counts.added) / total); + let red = Math.floor((blocks * counts.removed) / total); + if (counts.added > 0 && green === 0) green = 1; + if (counts.removed > 0 && red === 0) red = 1; + while (green + red > blocks) { + if (green >= red) green--; + else red--; + } + return [ + ...Array.from({ length: green }, () => "added" as const), + ...Array.from({ length: red }, () => "removed" as const), + ...Array.from({ length: blocks - green - red }, () => "neutral" as const), + ]; +} +export type Snapshot = + | { type: "revision"; rev: string } + | { type: "index" } + | { type: "working_tree" } + | { type: "empty_tree" } + | { type: "path"; path: string }; +export function snapshotLabel(snapshot: Snapshot): string { + switch (snapshot.type) { + case "revision": + return /^[0-9a-f]{40}$/.test(snapshot.rev) ? snapshot.rev.slice(0, 7) : snapshot.rev; + case "index": + return "index"; + case "working_tree": + return "working tree"; + case "empty_tree": + return "empty tree"; + case "path": + return snapshot.path; + } +} +export const comparisonLabel = (lhs: Snapshot, rhs: Snapshot) => `${snapshotLabel(lhs)}…${snapshotLabel(rhs)}`; diff --git a/tui/packages/hunk/src/diffr/fileTree.test.ts b/tui/packages/hunk/src/diffr/fileTree.test.ts index 59c7b9d3e..fc92c6458 100644 --- a/tui/packages/hunk/src/diffr/fileTree.test.ts +++ b/tui/packages/hunk/src/diffr/fileTree.test.ts @@ -4,7 +4,7 @@ import { createTestDiffFile } from "./fixture"; test("tree shares directories, retains file identities, and folds subtrees", () => { const files = ["src/ui/App.tsx", "README.md", "src/core.rs", "test/ui/App.tsx"].map(path => { const file = createTestDiffFile(); - file.file.new_path = path; + file.file = { rhs: { path, oid: "0", mode: "100644" } }; return file; }); const tree = buildFileTree(files); @@ -14,6 +14,7 @@ test("tree shares directories, retains file identities, and folds subtrees", () ]); expect(flattenFileTree(tree, new Set(["/src"])).some(r => r.node.fileIndex === 0)).toBe(false); expect(parentDirectories(files[0])).toEqual(["/src", "/src/ui"]); - files[0].diff.hunks.push(files[0].diff.hunks[0]); - expect(lineCounts(files[0])).toEqual({added: 2, removed: 1}); + expect(lineCounts(files[0])).toEqual({ textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 }, fallback: undefined }); + files[0].diff = { type: "binary", lhs: { size: 1 } }; + expect(lineCounts(files[0])).toEqual({ textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }); }); diff --git a/tui/packages/hunk/src/diffr/fileTree.ts b/tui/packages/hunk/src/diffr/fileTree.ts index eae2bfe0d..d16d1fc92 100644 --- a/tui/packages/hunk/src/diffr/fileTree.ts +++ b/tui/packages/hunk/src/diffr/fileTree.ts @@ -1,4 +1,4 @@ -import type { DiffFile } from "./wire"; +import { filePath, type DiffFile, type FileChange } from "./wire"; export interface TreeNode { key: string; @@ -11,11 +11,11 @@ export interface TreeRow { depth: number; } /** Paths identify directory nodes; streamed file indexes preserve diff navigation. */ -export function buildFileTree(files: Pick<DiffFile, "file">[]): TreeNode[] { +export function buildFileTree(files: Pick<FileChange, "file">[]): TreeNode[] { const roots: TreeNode[] = []; const directories = new Map<string, TreeNode>(); files.forEach((file, fileIndex) => { - const path = file.file.new_path ?? file.file.old_path ?? ""; + const path = filePath(file.file); const parts = path.split("/").filter(Boolean); let children = roots, prefix = ""; parts.forEach((name, i) => { @@ -48,14 +48,12 @@ export function flattenFileTree(nodes: TreeNode[], closed: Set<string>, depth = ? flattenFileTree(node.children, closed, depth + 1) : []), ]); } -export function parentDirectories(file: Pick<DiffFile, "file">): string[] { - const parts = (file.file.new_path ?? file.file.old_path ?? "").split("/").filter(Boolean); +export function parentDirectories(file: Pick<FileChange, "file">): string[] { + const parts = filePath(file.file).split("/").filter(Boolean); return parts.slice(0, -1).map((_, i) => "/" + parts.slice(0, i + 1).join("/")); } -/** Count novel source lines once even when context hunks overlap. */ +/** The wire's counts, verbatim: textual and visible, and why the AST match fell back if it did. */ export function lineCounts(file: DiffFile) { - return { - added: new Set(file.diff.hunks.flatMap(h => h.novel_rhs)).size, - removed: new Set(file.diff.hunks.flatMap(h => h.novel_lhs)).size, - }; + if (file.diff.type === "binary") return { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }; + return { textual: file.diff.stats.textual, visible: file.diff.stats.visible, fallback: file.diff.stats.fallback }; } diff --git a/tui/packages/hunk/src/diffr/fixture.ts b/tui/packages/hunk/src/diffr/fixture.ts index 19302ba50..7cb3519a2 100644 --- a/tui/packages/hunk/src/diffr/fixture.ts +++ b/tui/packages/hunk/src/diffr/fixture.ts @@ -1,53 +1,76 @@ -/** Provide a hand-authored source correspondence for renderer and protocol tests. */ -import type { DiffFile } from "./wire"; +/** Provide a hand-authored wire v3 file record for renderer and protocol tests. */ +import type { DiffEvent, DiffFile, FileChange, FoldRegion, LeafRegion, Region, Span } from "./wire"; +const pos = (line: number, column = 0) => ({ line, column }); +/** + * A leaf whose `id`, `fold_state_id` and `alignment_id` are all `id`. Tests pair two leaves by + * giving them one number on both sides, so the pair repeats its `id` across sides, which diffr + * never does; the viewer keys identity per side, and the tests that tell the ids apart set them. + */ +export function leaf(id: number, start: number, end: number, changed: Span[] = []): LeafRegion { + return { id, fold_state_id: id, alignment_id: id, start: pos(start), end: pos(end), tags: [], + visibility: { collapsed: false, label: "" }, kind: "leaf", changed, children: [] }; +} +export function fold( + id: number, + start: [number, number], + end: [number, number], + children: Region[], + label = "Body", + tags = ["deleted-bodies:function"], + collapsed = false, +): FoldRegion { + return { id, fold_state_id: id, start: pos(...start), end: pos(...end), tags, + visibility: { collapsed, label }, kind: "fold", changed: [], children }; +} +export const line = (line: number, start_column: number, end_column: number): Span => + ({ line, start_column, end_column }); export function createTestDiffFile(): DiffFile { return { type: "file", file: { - old_path: "demo.ts", - new_path: "demo.ts", - class: null, - status: "modified", + lhs: { path: "demo.ts", oid: "1111111", mode: "100644" }, + rhs: { path: "demo.ts", oid: "2222222", mode: "100644" }, }, + visibility: { collapsed: false, label: "" }, diff: { - display_path: "demo.ts", - extra_info: null, - file_format: { SupportedLanguage: "TypeScript" }, - lhs_src: { Text: 'start();\nsend("old");\nfinish();\n' }, - rhs_src: { Text: 'start();\nsend("new");\nextra();\nfinish();\n' }, - lhs_positions: [ - { - pos: { line: 1, start_col: 6, end_col: 9 }, - kind: { - NovelWord: { highlight: { Atom: { String: "StringLiteral" } } }, - }, - }, - ], - rhs_positions: [ - { - pos: { line: 1, start_col: 6, end_col: 9 }, - kind: { - NovelWord: { highlight: { Atom: { String: "StringLiteral" } } }, - }, - }, - ], - aligned_rows: [[0, 0], [1, 1], [null, 2], [2, 3]], - hunks: [ - { - novel_lhs: [1], - novel_rhs: [1, 2], - lines: [ - [0, 0], - [1, 1], - [null, 2], - [2, 3], - ], - }, - ], - lhs_folds: [], - rhs_folds: [], - has_byte_changes: [3, 4], - has_syntactic_changes: true, + type: "text", + lhs: { + text: 'start();\nsend("old");\nfinish();\n', + syntax: [ + { line: 1, start_column: 0, end_column: 4, capture: "function.call" }, + { line: 1, start_column: 5, end_column: 10, capture: "string" }, + ], + regions: [leaf(1, 0, 1), leaf(2, 1, 2, [line(1, 6, 9)]), leaf(4, 2, 3)], + }, + rhs: { + text: 'start();\nsend("new");\nextra();\nfinish();\n', + syntax: [ + { line: 1, start_column: 0, end_column: 4, capture: "function.call" }, + { line: 1, start_column: 5, end_column: 10, capture: "string" }, + ], + regions: [ + leaf(1, 0, 1), + leaf(2, 1, 2, [line(1, 6, 9)]), + leaf(3, 2, 3, [line(2, 0, 8)]), + leaf(4, 3, 4), + ], + }, + stats: { textual: { added: 2, removed: 1 }, visible: { added: 2, removed: 1 } }, }, }; } +/** The manifest entry a file record answers. */ +export const manifestEntry = (file: DiffFile): FileChange => ({ file: file.file, status: "modified", tags: [] }); +/** A `start` record whose manifest lists these files. */ +export const startFor = (files: DiffFile[]): DiffEvent => + ({ type: "start", version: 3, lhs: { type: "index" }, rhs: { type: "working_tree" }, files: files.map(manifestEntry) }); +/** Replace both sides with identical numbered lines paired in one leaf. */ +export function withIdenticalLines(file: DiffFile, count: number): DiffFile { + const lines = Array.from({ length: count }, (_, i) => `line ${i}`); + const text = lines.join("\n"); + const source = () => ({ text, syntax: [], regions: [leaf(1, 0, count)] }); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = source(); + file.diff.rhs = source(); + return file; +} diff --git a/tui/packages/hunk/src/diffr/folds.test.ts b/tui/packages/hunk/src/diffr/folds.test.ts deleted file mode 100644 index 3b7af3e50..000000000 --- a/tui/packages/hunk/src/diffr/folds.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, test } from "bun:test"; -import { createTestDiffFile } from "./fixture"; -import { foldRegions, hiddenLines, nestedRegions } from "./folds"; -import { dark, rowsForFile } from "./rows"; -import type { DiffFile } from "./wire"; -/** Rust-style body folds: header and closing brace stay visible, like VS Code. */ -export function createFoldedDiffFile(): DiffFile { - const file = createTestDiffFile(); - const lines = [ - "fn outer() {", // 0 - " inner(|| {", // 1 - " a();", // 2 - " b();", // 3 - " });", // 4 - " // trailing", // 5 - " // comment", // 6 - "}", // 7 - ]; - const text = lines.join("\n") + "\n"; - file.diff.lhs_src = file.diff.rhs_src = { Text: text }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{ novel_lhs: [], novel_rhs: [3], lines: file.diff.aligned_rows }]; - const range = (a: [number, number], b: [number, number]) => ({ - start: { line: a[0], byte_column: a[1] }, - end: { line: b[0], byte_column: b[1] }, - }); - const paired = (r: ReturnType<typeof range>, placeholder: string, tags: string[]) => ({ - tags, range: r, match_kind: { Unchanged: { opposite: r } }, placeholder, - }); - file.diff.rhs_folds = [ - paired(range([0, 12], [7, 0]), "Body", ["body"]), - paired(range([1, 14], [4, 4]), "Body", ["body"]), - // A whole-node fold ending at end of line hides its last line too. - paired(range([5, 4], [6, 14]), "Comment", ["comment"]), - // Single-line folds are never foldable. - paired(range([2, 8], [2, 11]), "Call", ["call"]), - ]; - file.diff.lhs_folds = file.diff.rhs_folds; - return file; -} -test("regions keep headers visible, hide trailing lines only when nothing follows", () => { - const regions = foldRegions(createFoldedDiffFile().diff); - const right = regions.filter((r) => r.side === 1); - expect(right.map((r) => [r.id, r.startLine, r.endLine, r.hideEnd])).toEqual([ - ["R0:7", 0, 7, false], - ["R1:4", 1, 4, false], - ["R5:6", 5, 6, true], - ]); - // Unchanged folds share ids across sides so both collapse together. - expect(regions.filter((r) => r.side === 0).map((r) => r.id)).toEqual(["R0:7", "R1:4", "R5:6"]); - expect(nestedRegions(right, right[0]).map((r) => r.id)).toEqual(["R1:4", "R5:6"]); -}); -test("collapsed regions hide each side's lines between header and close", () => { - const diff = createFoldedDiffFile().diff; - const regions = foldRegions(diff); - const hidden = hiddenLines(diff.aligned_rows, regions, new Set(["R1:4", "R5:6"]), () => true); - expect(hidden.map((side) => [...side].sort())).toEqual([[2, 3, 6], [2, 3, 6]]); - // A fold whose header the viewer does not show cannot hide anything. - expect(hiddenLines(diff.aligned_rows, regions, new Set(["R1:4"]), () => false)[1].size).toBe(0); -}); -test("a fold on one side leaves the other side's lines beside blank cells", () => { - const file = createFoldedDiffFile(); - // The right-hand closure body is novel; the left keeps two lines aligned inside it. - file.diff.rhs_folds = [{ - tags: ["body"], placeholder: "Body", match_kind: "Novel", - range: { start: { line: 1, byte_column: 14 }, end: { line: 4, byte_column: 4 } }, - }]; - file.diff.lhs_folds = []; - const rows = rowsForFile(file, 0, "split", dark, true, new Set(["R1:4"])).filter((r) => r.left); - expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ - [1, 1], [2, 2], [3, undefined], [4, undefined], [5, 5], [6, 6], [7, 7], [8, 8], - ]); - expect(rows[2].right!.kind).toBe("empty"); - expect(rows[1].right!.fold?.collapsed).toBe(true); - expect(rows[1].left!.fold).toBeUndefined(); -}); -test("rows carry fold headers on both layouts and drop hidden lines", () => { - const file = createFoldedDiffFile(); - const split = rowsForFile(file, 0, "split", dark, true, new Set(["R1:4"])).filter((r) => r.right); - expect(split.map((r) => r.right!.lineNumber)).toEqual([1, 2, 5, 6, 7, 8]); - expect(split[1].right!.fold).toEqual({ id: "R1:4", placeholder: "Body", collapsed: true }); - expect(split[1].left!.fold).toEqual({ id: "R1:4", placeholder: "Body", collapsed: true }); - expect(split[0].right!.fold?.collapsed).toBe(false); - const unified = rowsForFile(file, 0, "unified", dark, true, new Set(["R0:7"])).filter((r) => r.cell); - expect(unified.map((r) => r.cell!.newLineNumber)).toEqual([1, 8]); - expect(unified[0].cell!.fold?.id).toBe("R0:7"); -}); diff --git a/tui/packages/hunk/src/diffr/folds.ts b/tui/packages/hunk/src/diffr/folds.ts deleted file mode 100644 index d920381cf..000000000 --- a/tui/packages/hunk/src/diffr/folds.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** Derive VS Code-style fold regions from diffr folds; collapsed state belongs to the viewer. */ -import type { DiffResult } from "./wire"; -export type Side = 0 | 1; -export interface FoldRegion { - /** Shared by an unchanged fold and its opposite, so both sides collapse together. */ - id: string; - side: Side; - /** The header line, always visible, carrying the chevron and the placeholder. */ - startLine: number; - /** The last line of the fold range; hidden only when nothing follows the range on it. */ - endLine: number; - hideEnd: boolean; - placeholder: string; -} -export interface RowFold { - id: string; - placeholder: string; - collapsed: boolean; -} -export const sourceLines = (text: string) => - text === "" ? [] : text.replace(/\n$/, "").split("\n"); -const encoder = new TextEncoder(); -function regionsForSide(diff: DiffResult, side: Side): FoldRegion[] { - const source = side ? diff.rhs_src : diff.lhs_src; - const folds = side ? diff.rhs_folds : diff.lhs_folds; - if (source === "Binary") return []; - const lines = sourceLines(source.Text); - const byStart = new Map<number, FoldRegion>(); - for (const fold of folds) { - const { start, end } = fold.range; - const endLine = lines[end.line]; - if (endLine === undefined) - throw new Error(`diffr fold references missing line ${end.line}`); - const hideEnd = encoder.encode(endLine.trimEnd()).length <= end.byte_column; - const hiddenLines = end.line - start.line - (hideEnd ? 0 : 1); - if (hiddenLines < 1) continue; - const paired = - fold.match_kind === "Novel" - ? null - : side - ? fold.range - : fold.match_kind.Unchanged.opposite; - const id = paired - ? `R${paired.start.line}:${paired.end.line}` - : `${side ? "R" : "L"}${start.line}:${end.line}`; - const region = { id, side, startLine: start.line, endLine: end.line, hideEnd, - placeholder: fold.placeholder }; - // Like VS Code, one region per header line: keep the outermost. - const existing = byStart.get(start.line); - if (!existing || existing.endLine < end.line) byStart.set(start.line, region); - } - return [...byStart.values()].sort((a, b) => a.startLine - b.startLine); -} -export function foldRegions(diff: DiffResult): FoldRegion[] { - return [...regionsForSide(diff, 0), ...regionsForSide(diff, 1)]; -} -/** Regions strictly inside another on the same side, for Alt-click recursive folding. */ -export function nestedRegions(regions: FoldRegion[], outer: FoldRegion) { - return regions.filter( - (r) => r.side === outer.side && r.startLine > outer.startLine && r.endLine <= outer.endLine, - ); -} -/** First aligned row showing each source line, per side. */ -function rowsOfLines(alignedRows: DiffResult["aligned_rows"]) { - const rowOfLine = [new Map<number, number>(), new Map<number, number>()]; - for (const [index, pair] of alignedRows.entries()) - for (const side of [0, 1] as const) { - const line = pair[side]; - if (line !== null && !rowOfLine[side].has(line)) rowOfLine[side].set(line, index); - } - return rowOfLine; -} -/** Source lines hidden per side. Each side folds independently; alignment is never recomputed. */ -export function hiddenLines( - alignedRows: DiffResult["aligned_rows"], - regions: FoldRegion[], - collapsed: ReadonlySet<string>, - headerShown: (rowIndex: number) => boolean, -): [Set<number>, Set<number>] { - const rowOfLine = rowsOfLines(alignedRows); - const hidden: [Set<number>, Set<number>] = [new Set(), new Set()]; - for (const region of regions) { - if (!collapsed.has(region.id)) continue; - const startRow = rowOfLine[region.side].get(region.startLine); - if (startRow === undefined) - throw new Error(`diffr fold at line ${region.startLine} is not aligned`); - if (!headerShown(startRow)) continue; - const last = region.endLine - (region.hideEnd ? 0 : 1); - for (let line = region.startLine + 1; line <= last; line++) hidden[region.side].add(line); - } - return hidden; -} -/** Header rows keyed by aligned-row index, one per side. */ -export function foldHeaders( - alignedRows: DiffResult["aligned_rows"], - regions: FoldRegion[], - collapsed: ReadonlySet<string>, -): [Map<number, RowFold>, Map<number, RowFold>] { - const rowOfLine = rowsOfLines(alignedRows); - const headers: [Map<number, RowFold>, Map<number, RowFold>] = [new Map(), new Map()]; - for (const region of regions) { - const row = rowOfLine[region.side].get(region.startLine); - if (row === undefined) - throw new Error(`diffr fold at line ${region.startLine} is not aligned`); - headers[region.side].set(row, { - id: region.id, - placeholder: region.placeholder, - collapsed: collapsed.has(region.id), - }); - } - return headers; -} diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts new file mode 100644 index 000000000..cde7efafc --- /dev/null +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -0,0 +1,285 @@ +import { expect, test } from "bun:test"; +import { createTestDiffFile, fold, leaf, line } from "./fixture"; +import { collapsedFolds, defaultCollapsed, flatten, foldHeaders, foldIds, foldTint, gapIds, hiddenLines, pairedIds } from "./regions"; +import { dark, rowsForFile } from "./rows"; +import type { DiffFile, Region } from "./wire"; +import { foldBackground } from "../ui/diff/CodeRowView"; +/** Rust-style body folds: each covers its body alone, so the lines that open and close a + * construct are leaves around it, like VS Code's rows. */ +export function createFoldedDiffFile(): DiffFile { + const file = createTestDiffFile(); + const lines = [ + "fn outer() {", // 0 + " inner(|| {", // 1 + " a();", // 2 + " b();", // 3 + " });", // 4 + " // trailing", // 5 + " // comment", // 6 + "}", // 7 + ]; + const text = lines.join("\n") + "\n"; + // ids: 10 outer body (lines 1-6), 11 closure body (2-3), 12 comment (5-6); leaves tile the file. + const regions = (changed: boolean): Region[] => [ + leaf(1, 0, 1), + fold(10, [1, 0], [7, 0], [ + leaf(7, 1, 2), + fold(11, [2, 0], [4, 0], [leaf(2, 2, 3), leaf(3, 3, 4, changed ? [line(3, 8, 12)] : [])]), + leaf(4, 4, 5), + // A whole-node fold ending at end of line covers its last line too. + fold(12, [5, 4], [6, 14], [leaf(5, 5, 7)], "Comment", ["summarize:docstring"]), + ]), + leaf(6, 7, 8), + ]; + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = { text, syntax: [], regions: regions(false) }; + file.diff.rhs = { text, syntax: [], regions: regions(true) }; + file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } }; + return file; +} +test("a fold covers its body, so collapsing it hides every line it holds", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + const { folds, leaves } = flatten(file.diff); + expect(folds[1].map((f) => [f.foldStateId, f.startLine, f.lastHidden])).toEqual([[10, 1, 6], [11, 2, 3], [12, 5, 6]]); + expect(folds[1][0].nested).toEqual([11, 12]); + expect(leaves[1].map((l) => [l.foldStateId, l.startLine, l.endLine])) + .toEqual([[1, 0, 1], [7, 1, 2], [2, 2, 3], [3, 3, 4], [4, 4, 5], [5, 5, 7], [6, 7, 8]]); + expect([...hiddenLines(folds[1], new Set([11, 12]))].sort()).toEqual([2, 3, 5, 6]); + // Everything an outer fold covers goes with it, the folds inside included. + expect([...hiddenLines(folds[1], new Set([10, 11]))].sort()).toEqual([1, 2, 3, 4, 5, 6]); + // A collapsed fold is a row of its own; only open folds mark a source line. + expect(collapsedFolds(folds[1], new Set([11])).get(2)?.foldStateId).toBe(11); + expect([...collapsedFolds(folds[1], new Set([10, 11])).keys()]).toEqual([1]); + expect(foldHeaders(folds[1], leaves[1], new Set([11]), pairedIds(file.diff)[1]).get(1)) + .toEqual({ id: 10, label: "Body", collapsed: false, tint: "neutral" }); +}); +test("visibility seeds collapsed ids, and context gaps are collapsed untagged regions holding no change", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + const collapse = (region: Region, label: string) => { region.visibility = { collapsed: true, label }; }; + for (const side of [file.diff.lhs!, file.diff.rhs!]) { + const outer = side.regions[1]; + // Leaf 6: paired and unchanged, cut out by the context plugin. + collapse(side.regions[2], "1 unchanged line"); + // Fold 12: an untagged fold wrapping unchanged lines, as context groups several siblings. + outer.children[3].tags = []; + collapse(outer.children[3], "2 unchanged lines"); + // Leaf 3: collapsed, but the right side paints a change in it. + collapse(outer.children[1].children[1], "1 line"); + } + // Fold 10: collapsed by a plugin that tagged it, so not a gap even where nothing changed. + file.diff.rhs!.regions[1].visibility = { collapsed: true, label: "Body" }; + expect([...defaultCollapsed(file.diff)].sort((a, b) => a - b)).toEqual([3, 6, 10, 12]); + expect(gapIds(file.diff).sort((a, b) => a - b)).toEqual([6, 12]); + // A collapsed stretch on one side only (a removed run) is not a gap either. + const removed = createFoldedDiffFile(); + if (removed.diff.type !== "text") throw new Error(); + const lonely = leaf(60, 7, 8); + collapse(lonely, "1 line removed"); + removed.diff.lhs!.regions[2] = lonely; + expect(gapIds(removed.diff)).toEqual([]); +}); +test("a fold on one side leaves the other side's lines beside blank cells", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // The right-hand closure body is a novel fold with its own leaves; the left keeps flat leaves. + file.diff.lhs!.regions = [leaf(1, 0, 1), leaf(7, 1, 2), leaf(2, 2, 3), leaf(3, 3, 4), leaf(4, 4, 5), leaf(5, 5, 7), leaf(6, 7, 8)]; + const rows = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.left); + expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ + [1, 1], [2, 2], [undefined, undefined], [3, undefined], [4, undefined], [5, 5], [6, 6], [7, 7], [8, 8], + ]); + expect(rows[3].right!.kind).toBe("empty"); + // The fold's own row: collapsed on the right, blank on the left, which has no such fold. + expect(rows[2].right!.fold?.collapsed).toBe(true); + expect(rows[2].left!.fold).toBeUndefined(); +}); +test("rows carry fold headers on both layouts and drop hidden lines", () => { + const file = createFoldedDiffFile(); + const split = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.right); + expect(split.map((r) => r.right!.lineNumber)).toEqual([1, 2, undefined, 5, 6, 7, 8]); + expect(split[2].right!.fold).toEqual({ id: 11, label: "Body", collapsed: true, tint: "neutral" }); + expect(split[2].left!.fold).toEqual({ id: 11, label: "Body", collapsed: true, tint: "neutral" }); + // The open outer fold marks the first line it covers, so it can be collapsed from there. + expect(split[1].right!.fold).toEqual({ id: 10, label: "Body", collapsed: false, tint: "neutral" }); + const unified = rowsForFile(file, 0, "unified", dark, new Set([10])).filter((r) => r.cell); + expect(unified.map((r) => r.cell!.newLineNumber)).toEqual([1, undefined, 8]); + expect(unified[1].cell!.fold?.id).toBe(10); +}); +test("a multi-line label hangs under the collapsed header inside the fold tint", () => { + const file = createFoldedDiffFile(); + if (file.diff.type !== "text") throw new Error(); + const pseudocode = "call a\ncall b\nreturn"; + file.diff.rhs!.regions[1].children[1].visibility = { collapsed: false, label: pseudocode }; + file.diff.lhs!.regions[1].children[1].visibility = { collapsed: false, label: pseudocode }; + const rows = rowsForFile(file, 0, "split", dark, new Set([11])); + const labels = rows.filter((r) => r.right?.foldLabel); + expect(labels.map((r) => r.right!.spans[0].text)).toEqual([" call a", " call b", " return"]); + expect(labels.map((r) => r.left!.spans[0].text)).toEqual([" call a", " call b", " return"]); + expect(rows.indexOf(labels[0])).toBe(rows.findIndex((r) => r.right?.fold?.id === 11) + 1); + const unified = rowsForFile(file, 0, "unified", dark, new Set([11])); + expect(unified.filter((r) => r.cell?.foldLabel)).toHaveLength(3); +}); +test("a collapsed leaf is one fold row with the chevron, its label, and no line number", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs!.regions[0].visibility = file.diff.rhs!.regions[0].visibility = { collapsed: true, label: "1 unchanged line" }; + const split = rowsForFile(file, 0, "split", dark, new Set([1])); + expect(split[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true, tint: "neutral" }); + expect(split[1].left!.lineNumber).toBeUndefined(); + expect(split[1].right!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: true, tint: "neutral" }); + expect(split.filter((r) => r.left?.fold?.id === 1)).toHaveLength(1); + const unified = rowsForFile(file, 0, "unified", dark, new Set([1])); + expect(unified[1].cell).toMatchObject({ fold: { id: 1, collapsed: true } }); + // Open, the leaf's first line carries the chevron so it can be collapsed again. + expect(rowsForFile(file, 0, "split", dark, new Set())[1].left!.fold).toEqual({ id: 1, label: "1 unchanged line", collapsed: false, tint: "neutral" }); + // A leaf diffr neither collapsed nor labelled is not foldable. + const rows = rowsForFile(createTestDiffFile(), 0, "split", dark, new Set()); + expect(rows[1].right!.fold).toBeUndefined(); + expect(rows[1].left!.fold).toBeUndefined(); +}); + +test("a fold that runs to the end of the file may end one past its last line", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = "fn a() {\n b();\n}\n"; + // Three lines; the fold's hull ends at (3, 0), just past the last line. + const regions: Region[] = [fold(10, [0, 0], [3, 0], [leaf(1, 0, 1), leaf(2, 1, 3)])]; + file.diff.lhs = { text, syntax: [], regions }; + file.diff.rhs = { text, syntax: [], regions }; + const { folds } = flatten(file.diff); + expect(folds[0][0]?.lastHidden).toBe(2); +}); + +test("regions sharing a fold_state_id collapse and expand as one bundle", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = [ + "/// Adds two numbers.", // 0 + "/// Wraps on overflow.", // 1 + "fn add(a: u8, b: u8) -> u8 {", // 2 + " a.wrapping_add(b)", // 3 + "}", // 4 + ].join("\n") + "\n"; + // The docstring (id 20) and the function body (id 21) share fold state 21. + const docstring: Region = { ...fold(20, [0, 0], [2, 0], [leaf(1, 0, 2)], "", ["comment"], true), fold_state_id: 21 }; + const body = fold(21, [3, 0], [4, 0], [leaf(2, 3, 4)], "s = a + b\nreturn s", ["body", "function"], true); + const regions: Region[] = [docstring, leaf(4, 2, 3), body, leaf(3, 4, 5)]; + file.diff.lhs = { text, syntax: [], regions }; + file.diff.rhs = { text, syntax: [], regions }; + const collapsed = defaultCollapsed(file.diff); + expect([...collapsed]).toEqual([21]); + const { folds } = flatten(file.diff); + // One id hides the whole docstring and the whole body. + expect([...hiddenLines(folds[1], collapsed)].sort()).toEqual([0, 1, 3]); + expect([...hiddenLines(folds[1], new Set())]).toEqual([]); + const rows = rowsForFile(file, 0, "split", dark, collapsed).filter((r) => r.right && !r.right.foldLabel); + // The docstring is a one-row ⋯ fold (empty label), the signature stays visible between the two + // rows, and both carry the same id, so either chevron toggles the pair. + const headers = rows.filter((r) => r.right!.fold).map((r) => [r.right!.lineNumber, r.right!.fold!.id, r.right!.fold!.label, r.right!.fold!.collapsed]); + expect(headers).toEqual([[undefined, 21, "", true], [undefined, 21, "s = a + b\nreturn s", true]]); + expect(rows.map((r) => r.right!.lineNumber)).toEqual([undefined, 3, undefined, 5]); +}); + +/** + * The shape diffr emits for a new, summarized function (src/tags/mod.rs, src/protocol/project.rs + * `syntax_spans`): a one-line docstring region tagged `summarize:docstring`, collapsed + * with an empty label, sharing fold state 4 with the collapsed function fold after it, right side only. + */ +export function createBundledDiffFile(): DiffFile { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = [ + "];", // 0 + "/// The built-in rule for a path.", // 1 + "pub(crate) fn from_path(path: &str) -> Option<&str> {", // 2 + " None", // 3 + "}", // 4 + ].join("\n") + "\n"; + const docstring: Region = { + ...leaf(38, 1, 2), fold_state_id: 4, tags: ["summarize:docstring"], visibility: { collapsed: true, label: "" }, + }; + const body = fold(4, [3, 0], [4, 0], [leaf(5, 3, 4)], "look up path\nreturn None", ["summarize:function"], true); + file.diff.rhs = { text, syntax: [], regions: [leaf(3, 0, 1), docstring, leaf(7, 2, 3), body, leaf(6, 4, 5)] }; + file.diff.lhs = undefined; + return file; +} +test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ row", () => { + const file = createBundledDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const collapsed = defaultCollapsed(file.diff); + expect([...collapsed]).toEqual([4]); + expect(foldIds(file.diff)).toContain(4); + const rows = rowsForFile(file, 0, "split", dark, collapsed).filter((r) => r.right && !r.right.foldLabel); + // Line 1 ("];"), the docstring as one ⋯ fold row, the signature, then the body's own row. + expect(rows.map((r) => [r.right!.lineNumber, r.right!.fold?.id, r.right!.fold?.label, r.right!.fold?.collapsed])) + .toEqual([[1, undefined, undefined, undefined], [undefined, 4, "", true], [3, undefined, undefined, undefined], + [undefined, 4, "look up path\nreturn None", true], [5, undefined, undefined, undefined]]); + // Right side only, so both the ⋯ row and the summary take the added tint. + expect(rows.filter((r) => r.right!.fold).map((r) => r.right!.fold!.tint)).toEqual(["inserted", "inserted"]); + // Expanding the shared id reveals the docstring line too. + const open = rowsForFile(file, 0, "split", dark, new Set()).filter((r) => r.right && !r.right.foldLabel); + expect(open.map((r) => r.right!.lineNumber)).toEqual([1, 2, 3, 4, 5]); +}); + +test("a collapsed fold takes its side's change tint when one-sided and stays neutral when paired", () => { + const text = "fn a() {\n x\n}\n"; + const body = (id: number, label: string) => fold(id, [1, 0], [2, 0], [leaf(id + 100, 1, 2)], label, ["deleted-bodies:function", "summarize:function"], true); + const rowsFor = (lhs: Region[] | undefined, rhs: Region[] | undefined) => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = lhs && { text, syntax: [], regions: [leaf(88, 0, 1), ...lhs, leaf(9, 2, 3)] }; + file.diff.rhs = rhs && { text, syntax: [], regions: [leaf(88, 0, 1), ...rhs, leaf(9, 2, 3)] }; + return rowsForFile(file, 0, "split", dark, defaultCollapsed(file.diff)); + }; + // Inserted: a summary on the right with no counterpart. The header and every label row are green. + const inserted = rowsFor(undefined, [body(7, "x = 1\nreturn x")]); + const header = inserted.find((r) => r.right?.fold)!.right!; + expect(header.fold!.tint).toBe("inserted"); + expect(inserted.filter((r) => r.right?.foldLabel).map((r) => r.right!.foldTint)).toEqual(["inserted", "inserted"]); + expect(foldBackground(dark, "inserted")).toBe(dark.addition); + // Removed: the same body only on the left. + const removed = rowsFor([body(7, "2 lines removed")], undefined); + expect(removed.find((r) => r.left?.fold)!.left!.fold!.tint).toBe("removed"); + expect(foldBackground(dark, "removed")).toBe(dark.deletion); + // Paired: a matched fold pair, each fold with its own id and one fold state between + // them, keeps the neutral fold background. + const paired = rowsFor([body(7, "Body")], [{ ...body(8, "Body"), fold_state_id: 7, children: body(7, "Body").children }]); + const both = paired.find((r) => r.left?.fold && r.right?.fold)!; + expect([both.left!.fold!.tint, both.right!.fold!.tint]).toEqual(["neutral", "neutral"]); + expect(foldBackground(dark, "neutral")).toBe(dark.foldBackground); + // A fold state shared only on its own side does not pair a fold. + const linked = rowsFor([{ ...body(7, "Body"), children: [{ ...leaf(107, 1, 2), fold_state_id: 7 }] }], [body(8, "Body")]); + expect(linked.find((r) => r.left?.fold)!.left!.fold!.tint).toBe("removed"); + expect(foldTint(7, 1, new Set([7]))).toBe("neutral"); + expect(foldTint(7, 1, new Set())).toBe("inserted"); + expect(foldTint(7, 0, new Set())).toBe("removed"); +}); + +test("a group is one row that stands for every collapsed region under it", () => { + // The shape of three adjacent deleted functions after the group plugin, left side only. + const text = Array.from({ length: 12 }, (_, i) => `line ${i}`).join("\n") + "\n"; + const body = (id: number, start: number) => + fold(id, [start, 0], [start + 3, 0], [leaf(id + 100, start, start + 3)], "3 lines removed", ["deleted-bodies:function"], true); + const group = fold(20, [1, 0], [12, 0], [body(1, 1), leaf(2, 4, 5), body(3, 5), leaf(4, 8, 9), body(5, 9)], "3 collapsed regions · 11 lines", [], true); + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text, syntax: [], regions: [leaf(0, 0, 1), group] }; + file.diff.rhs = { text: "line 0\n", syntax: [], regions: [leaf(0, 0, 1)] }; + const bands = (collapsed: Set<number>) => + rowsForFile(file, 0, "split", dark, collapsed) + .filter((r) => r.left?.fold?.collapsed) + .map((r) => [r.left!.fold!.id, r.left!.fold!.label]); + const initial = defaultCollapsed(file.diff); + // Collapsed, the group is the only row: everything it wraps is inside it. + expect(bands(initial)).toEqual([[20, "3 collapsed regions · 11 lines"]]); + // Opening it reveals each member's own collapsed row, and the lines between them. + initial.delete(20); + expect(bands(initial)).toEqual([[1, "3 lines removed"], [3, "3 lines removed"], [5, "3 lines removed"]]); + expect(rowsForFile(file, 0, "split", dark, initial).filter((r) => r.left?.lineNumber).map((r) => r.left!.lineNumber)) + .toEqual([1, 5, 9]); + // Fully open, the group marks the first line it covers so it can be closed again. + expect(rowsForFile(file, 0, "split", dark, new Set()).find((r) => r.left?.lineNumber === 2)!.left!.fold) + .toMatchObject({ id: 20, collapsed: false }); + expect(gapIds(file.diff)).toEqual([]); +}); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts new file mode 100644 index 000000000..f43d14de2 --- /dev/null +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -0,0 +1,238 @@ +/** Flatten diffr's per-side region trees; collapsed state belongs to the viewer. */ +import type { Region, Source, Span, TextDiff } from "./wire"; +export type Side = 0 | 1; +/** A leaf tiles its side; the same alignmentId on the other side is the leaf its rows line up with. */ +export interface Leaf { + /** The region's own name, unique across both sides. */ + id: number; + /** Pairs this leaf with its counterpart across sides; the row zip keys on it. */ + alignmentId: number; + /** Regions sharing it toggle together; collapse state keys on it. */ + foldStateId: number; + side: Side; + startLine: number; + /** Exclusive. */ + endLine: number; + changed: Map<number, Span[]>; + tags: string[]; + collapsed: boolean; + label: string; +} +export interface Fold { + /** The region's own name, unique across both sides. */ + id: number; + foldStateId: number; + side: Side; + /** First line the fold covers: collapsing it hides this line too. The header a body + * opens on is the last line of the leaf before the fold, not part of it. */ + startLine: number; + /** Last line the fold covers, inclusive. */ + lastHidden: number; + label: string; + tags: string[]; + collapsed: boolean; + /** Ids of folds nested inside, for recursive fold commands. */ + nested: number[]; +} +/** The change tint of a fold: a one-sided region takes its side's change colour, a paired one stays neutral. */ +export type FoldTint = "inserted" | "removed" | "neutral"; +export interface RowFold { + /** The fold-state id: what toggling this header toggles. */ + id: number; + label: string; + collapsed: boolean; + tint: FoldTint; +} +/** One-sided means the region is not among its side's `paired` ids (see `pairedIds`). */ +export function foldTint(id: number, side: Side, paired: ReadonlySet<number>): FoldTint { + if (paired.has(id)) return "neutral"; + return side ? "inserted" : "removed"; +} +/** + * For each side, the `id`s of its paired regions. A leaf is paired when the other side has a leaf + * with its `alignment_id`, whose rows line up with it. Folds have no `alignment_id`; a fold is + * paired when a region on the other side shares its `fold_state_id`. + */ +export function pairedIds(diff: TextDiff): readonly [Set<number>, Set<number>] { + const { leaves, folds } = flatten(diff); + const alignments = leaves.map((side) => new Set(side.map((leaf) => leaf.alignmentId))); + const states = ([0, 1] as const).map((side) => + new Set([...leaves[side], ...folds[side]].map((region) => region.foldStateId))); + return ([0, 1] as const).map((side) => { + const other = side ? 0 : 1; + return new Set([ + ...leaves[side].filter((leaf) => alignments[other]!.has(leaf.alignmentId)).map((leaf) => leaf.id), + ...folds[side].filter((fold) => states[other]!.has(fold.foldStateId)).map((fold) => fold.id), + ]); + }) as unknown as readonly [Set<number>, Set<number>]; +} +export const sourceLines = (text: string) => + text === "" ? [] : text.replace(/\n$/, "").split("\n"); +const encoder = new TextEncoder(); +/** Lines a leaf covers, half-open: an end at column zero does not touch its end line. */ +export function leafLines(region: Region): [number, number] { + return [region.start.line, region.end.column === 0 ? region.end.line : region.end.line + 1]; +} +export function flattenSide(source: Source, side: Side): { leaves: Leaf[]; folds: Fold[] } { + const lines = sourceLines(source.text); + const leaves: Leaf[] = []; + const folds: Fold[] = []; + const visit = (region: Region, ancestors: Fold[]) => { + if (region.kind === "leaf") { + const [startLine, endLine] = leafLines(region); + const changed = new Map<number, Span[]>(); + for (const span of region.changed) changed.set(span.line, [...(changed.get(span.line) ?? []), span]); + leaves.push({ id: region.id, alignmentId: region.alignment_id, foldStateId: region.fold_state_id, side, startLine, endLine, + changed, tags: region.tags, + collapsed: region.visibility.collapsed, label: region.visibility.label }); + return; + } + const { start, end } = region; + // An end at column 0 does not touch its line, so it may sit one past the last line of the + // file. Otherwise the end line is covered when the range reaches the end of its text. + const hideEnd = end.column > 0 && encoder.encode(lines[end.line]!.trimEnd()).length <= end.column; + const fold: Fold = { id: region.id, foldStateId: region.fold_state_id, side, + startLine: start.line, + lastHidden: end.line - (hideEnd ? 0 : 1), label: region.visibility.label, tags: region.tags, + collapsed: region.visibility.collapsed, nested: [] }; + for (const ancestor of ancestors) ancestor.nested.push(fold.foldStateId); + folds.push(fold); + for (const child of region.children) visit(child, [...ancestors, fold]); + }; + for (const region of source.regions) visit(region, []); + return { leaves, folds }; +} +export function flatten(diff: TextDiff) { + const lhs = diff.lhs ? flattenSide(diff.lhs, 0) : { leaves: [], folds: [] }; + const rhs = diff.rhs ? flattenSide(diff.rhs, 1) : { leaves: [], folds: [] }; + return { leaves: [lhs.leaves, rhs.leaves] as const, folds: [lhs.folds, rhs.folds] as const }; +} +/** A leaf is novel when it carries change spans or has no counterpart on the other side. */ +export function novelLeaves(leaves: readonly [Leaf[], Leaf[]]): Set<Leaf> { + const alignments = [new Set(leaves[0].map((l) => l.alignmentId)), new Set(leaves[1].map((l) => l.alignmentId))]; + const novel = new Set<Leaf>(); + for (const side of [0, 1] as const) + for (const leaf of leaves[side]) + if (leaf.changed.size > 0 || !alignments[side ? 0 : 1].has(leaf.alignmentId)) novel.add(leaf); + return novel; +} +/** Fold-state ids diffr asks to start collapsed: context gaps and folded bodies, on either side. */ +export function defaultCollapsed(diff: TextDiff): Set<number> { + const ids = new Set<number>(); + const { leaves, folds } = flatten(diff); + for (const item of [...leaves.flat(), ...folds.flat()]) if (item.collapsed) ids.add(item.foldStateId); + return ids; +} +/** Every collapsible id on either side, folds and foldable leaves alike, for fold-all commands. */ +export function foldIds(diff: TextDiff): number[] { + const { folds, leaves } = flatten(diff); + return [...new Set([...folds.flat().map((fold) => fold.foldStateId), ...leaves.flat().filter(foldableLeaf).map((l) => l.foldStateId)])]; +} +/** Ids nested inside a collapsible region, for recursive fold commands. Every region sharing the + * fold-state id counts, so a bundle such as a docstring and its function unfolds as one. */ +export function nestedIds(diff: TextDiff, id: number): number[] { + const { folds, leaves } = flatten(diff); + const members = folds.flat().filter((f) => f.foldStateId === id); + if (members.length) return [...new Set(members.flatMap((f) => f.nested))].filter((n) => n !== id); + if (!leaves.flat().some((leaf) => leaf.foldStateId === id)) throw new Error(`Unknown region ${id}`); + return []; +} +/** + * Context gaps: the stretches of unchanged lines diffr collapsed far from any change, for `c`. + * The wire has no gap kind, so a gap is derived from what defines one: a region that starts + * collapsed, carries no tags (context gaps are cut from leaves or wrapped in a new untagged + * fold; syntax folds that other plugins collapse are tagged), is paired with the other side + * (see `pairedIds`), and holds no change on either side: every leaf under it is paired and has no + * `changed` span. A group of collapsed regions the `group` plugin made from wholly unchanged code + * meets the same definition, and revealing it shows only unchanged lines. + */ +export function gapIds(diff: TextDiff): number[] { + const sides = [diff.lhs?.regions ?? [], diff.rhs?.regions ?? []]; + const walk = (regions: Region[], visit: (region: Region) => void) => { + for (const region of regions) { + visit(region); + walk(region.children, visit); + } + }; + const paired = pairedIds(diff); + // Leaf alignments with a change on either side: a paired leaf is changed when its counterpart is. + const changed = new Set<number>(); + for (const regions of sides) + walk(regions, (region) => { if (region.kind === "leaf" && region.changed.length) changed.add(region.alignment_id); }); + const gaps = new Set<number>(); + sides.forEach((regions, side) => { + const unchanged = (region: Region): boolean => + region.kind === "leaf" + ? paired[side]!.has(region.id) && !changed.has(region.alignment_id) + : region.children.every(unchanged); + walk(regions, (region) => { + if (region.visibility.collapsed && region.tags.length === 0 && paired[side]!.has(region.id) && unchanged(region)) + gaps.add(region.fold_state_id); + }); + }); + return [...gaps]; +} +/** Source lines hidden on one side: a collapsed fold hides every line it covers. */ +export function hiddenLines(folds: Fold[], collapsed: ReadonlySet<number>): Set<number> { + const hidden = new Set<number>(); + for (const fold of folds) { + if (!collapsed.has(fold.foldStateId)) continue; + for (let line = fold.startLine; line <= fold.lastHidden; line++) hidden.add(line); + } + return hidden; +} +/** + * The collapsed fold each side shows a row for, keyed by the line it starts on: the outermost + * collapsed fold there that no collapsed fold above it already hides. A collapsed fold is a row + * of its own, between the rows of the leaves around it, so nothing it covers stays on screen. + */ +export function collapsedFolds(folds: Fold[], collapsed: ReadonlySet<number>): Map<number, Fold> { + const rows = new Map<number, Fold>(); + // Outermost first: a fold starting inside one already taken is nested in it, and the + // reader never sees it. Folds are a strict tree, so containment is all there is. + const collapsedFirst = [...folds] + .filter((fold) => collapsed.has(fold.foldStateId)) + .sort((a, b) => a.startLine - b.startLine || b.lastHidden - a.lastHidden); + let covered = -1; + for (const fold of collapsedFirst) { + if (fold.startLine <= covered) continue; + rows.set(fold.startLine, fold); + covered = fold.lastHidden; + } + return rows; +} +/** A leaf collapses like a fold when diffr starts it collapsed or labelled it: a context gap + * (`"12 unchanged lines"`), the middle of a removed stretch, or a docstring bundled with its + * function, which arrives collapsed with an empty label. */ +export const foldableLeaf = (leaf: Leaf) => leaf.collapsed || leaf.label !== ""; +/** An empty label renders as a bare `⋯`. */ +export const leafLabel = (leaf: Leaf) => leaf.label; +/** + * The chevron each source line carries, one map per side. An open fold puts one on the first + * line it covers, so the reader can collapse it; a collapsed fold has a row of its own instead + * (see `collapsedFolds`) and never marks a source line. Folds can start on one line — a group + * the `group` plugin wraps around its first member — and the outermost wins. + */ +export function foldHeaders( + folds: Fold[], + leaves: Leaf[], + collapsed: ReadonlySet<number>, + paired: ReadonlySet<number>, +): Map<number, RowFold> { + const headers = new Map<number, RowFold>(); + for (const leaf of leaves) + if (foldableLeaf(leaf)) + headers.set(leaf.startLine, { id: leaf.foldStateId, label: leafLabel(leaf), collapsed: collapsed.has(leaf.foldStateId), + tint: foldTint(leaf.id, leaf.side, paired) }); + const byLine = new Map<number, Fold[]>(); + for (const fold of folds) { + if (collapsed.has(fold.foldStateId)) continue; + byLine.set(fold.startLine, [...(byLine.get(fold.startLine) ?? []), fold]); + } + for (const [line, sharing] of byLine) { + const fold = [...sharing].sort((a, b) => b.lastHidden - a.lastHidden)[0]; + headers.set(line, { id: fold.foldStateId, label: fold.label, collapsed: false, + tint: foldTint(fold.id, fold.side, paired) }); + } + return headers; +} diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index af6489f4d..b66c599fe 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -1,9 +1,11 @@ import { expect, test } from "bun:test"; -import { createTestDiffFile } from "./fixture"; -import { dark, lineSpans, rowsForFile } from "./rows"; +import { createTestDiffFile, fold, leaf, line, withIdenticalLines } from "./fixture"; +import type { Region, Span } from "./wire"; +import { captureColor, dark, light, lineSpans, rowsForFile } from "./rows"; +import { pairedIds } from "./regions"; import { measureRows, visibleRows } from "./geometry"; import { copySelection } from "./selection"; -test("split uses supplied pairs and padding, unified groups old before new", () => { +test("split zips leaves on their alignment ids, unified groups old before new", () => { const file = createTestDiffFile(); const split = rowsForFile(file, 0, "split", dark).filter((r) => r.left); expect(split.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([ @@ -12,6 +14,7 @@ test("split uses supplied pairs and padding, unified groups old before new", () [undefined, 3], [3, 4], ]); + expect(split.map((r) => r.right!.kind)).toEqual(["context", "addition", "addition", "context"]); const unified = rowsForFile(file, 0, "unified", dark).filter((r) => r.cell); expect( unified.map( @@ -24,34 +27,99 @@ test("split uses supplied pairs and padding, unified groups old before new", () "+extra();", " finish();", ]); + expect(rowsForFile(file, 0, "split", dark).filter((r) => r.hunkStart)).toHaveLength(1); }); -test("byte spans survive multibyte characters and tabs", () => { +test("matched folds zip nothing themselves: rows come from leaves, and the pair toggles by fold state", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // `b` moved above `a`: the two `b` folds share fold state 3, each with its own id, and + // none of their lines line up. `a` stayed, its leaf paired row for row. + const matched = (id: number, start: number, leafId: number, novel: Span[]) => + ({ ...fold(id, [start, 0], [start + 2, 0], [leaf(leafId, start, start + 2, novel)]), fold_state_id: 3 }); + file.diff.lhs = { text: "a {\n}\nb {\n}\n", syntax: [], + regions: [fold(1, [0, 0], [2, 0], [leaf(2, 0, 2)]), matched(3, 2, 4, [line(2, 0, 3), line(3, 0, 1)])] }; + file.diff.rhs = { text: "b {\n}\na {\n}\n", syntax: [], + regions: [matched(5, 0, 6, [line(0, 0, 3), line(1, 0, 1)]), { ...fold(7, [2, 0], [4, 0], [leaf(2, 2, 4)]), fold_state_id: 1 }] }; + const lines = (collapsed: Set<number>) => rowsForFile(file, 0, "split", dark, collapsed).filter((r) => r.left) + .map((r) => [r.left!.lineNumber, r.right!.lineNumber]); + expect(lines(new Set())).toEqual([[undefined, 1], [undefined, 2], [1, 3], [2, 4], [3, undefined], [4, undefined]]); + const rows = rowsForFile(file, 0, "split", dark).filter((r) => r.left); + // diffr sent the moved lines as new and removed; the frontend does not repaint them. + expect([rows[0].right!.kind, rows[4].left!.kind]).toEqual(["addition", "deletion"]); + expect([rows[2].left!.kind, rows[2].right!.kind]).toEqual(["context", "context"]); + // Collapsing fold state 3 folds both copies; each is one row of its own, hiding every + // line it covers, and `a` still zips row for row between them. + expect(lines(new Set([3]))).toEqual([[undefined, undefined], [1, 3], [2, 4], [undefined, undefined]]); +}); +test("rows zip leaves by alignment_id, and a paired leaf's tint follows its own id", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // As diffr numbers them: ids are unique across sides, alignments and fold states are not ids. + const leafOf = (id: number, alignment: number, state: number, start: number, end: number, changed: Span[] = []): Region => + ({ ...leaf(id, start, end, changed), alignment_id: alignment, fold_state_id: state }); + file.diff.lhs = { text: "a\nb\n", syntax: [], regions: [leafOf(0, 0, 0, 0, 1), leafOf(1, 1, 1, 1, 2)] }; + file.diff.rhs = { text: "a\nnew\nb\n", syntax: [], + regions: [leafOf(2, 0, 0, 0, 1), leafOf(3, 2, 3, 1, 2, [line(1, 0, 3)]), leafOf(4, 1, 1, 2, 3)] }; + const rows = rowsForFile(file, 0, "split", dark).filter((r) => r.left); + expect(rows.map((r) => [r.left!.lineNumber, r.right!.lineNumber])).toEqual([[1, 1], [undefined, 2], [2, 3]]); + expect(pairedIds(file.diff).map((ids) => [...ids].sort())).toEqual([[0, 1], [2, 4]]); + // A collapsed paired leaf stays neutral on both sides; a collapsed one-sided leaf takes the added tint. + for (const region of [file.diff.lhs.regions[1]!, file.diff.rhs.regions[2]!, file.diff.rhs.regions[1]!]) + region.visibility = { collapsed: true, label: "hidden" }; + const collapsed = rowsForFile(file, 0, "split", dark, new Set([1, 3])); + const tints = collapsed.flatMap((r) => [r.left?.fold?.tint, r.right?.fold?.tint]).filter((tint) => tint); + expect(tints).toEqual(["inserted", "neutral", "neutral"]); +}); +test("changed spans paint the darker word tint, distinct from the line tint", () => { + for (const theme of [dark, light]) { + const spans = lineSpans("let x = old + y;", [], [line(0, 8, 11)], "right", theme); + expect(spans.map((s) => [s.text, s.bg])).toEqual([ + ["let x = ", undefined], ["old", theme.addWord], [" + y;", undefined], + ]); + expect(theme.addWord).not.toBe(theme.addition); + expect(lineSpans("old", [], [line(0, 0, 3)], "left", theme)[0].bg).toBe(theme.deleteWord); + expect(theme.deleteWord).not.toBe(theme.deletion); + } +}); +test("every line of a novel leaf is tinted, even without a span", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: "a\n", syntax: [], regions: [leaf(1, 0, 1)] }; + // A new block: one changed word, a blank line, and an unpaired leaf with no spans at all. + file.diff.rhs = { text: "a\nb = 1\n\nc\n", syntax: [], regions: [leaf(1, 0, 1), leaf(2, 1, 3, [line(1, 4, 5)]), leaf(3, 3, 4)] }; + const rows = rowsForFile(file, 0, "split", dark).filter((r) => r.right); + expect(rows.map((r) => [r.right!.kind, r.right!.spans.some((s) => s.bg)])).toEqual([ + ["context", false], ["addition", true], ["addition", false], ["addition", false], + ]); +}); +test("byte spans survive multibyte characters and tabs; captures pick theme colours", () => { const spans = lineSpans( "é\t变量", - [ - { - pos: { line: 0, start_col: 3, end_col: 9 }, - kind: { Novel: { highlight: { Atom: "Type" } } }, - }, - ], + [{ line: 0, start_column: 3, end_column: 9, capture: "type.builtin" }], + [line(0, 3, 9)], "right", dark, ); expect(spans.map((s) => s.text).join("")).toBe("é 变量"); expect(spans.at(-1)!.bg).toBe(dark.addWord); - expect(() => - lineSpans( - "é", - [ - { - pos: { line: 0, start_col: 1, end_col: 2 }, - kind: { Novel: { highlight: "Delimiter" } }, - }, - ], - "left", - dark, - ), - ).toThrow(); + expect(spans.at(-1)!.fg).toBe(dark.syntax("type")!); + expect(captureColor("function.method", dark)).toBe(dark.syntax("function")!); + expect(captureColor("unknown.thing", dark)).toBe(dark.fg); +}); +test("the innermost syntax capture colours a nested span", () => { + const spans = lineSpans( + 'f("x")', + [ + { line: 0, start_column: 0, end_column: 6, capture: "function.call" }, + { line: 0, start_column: 2, end_column: 5, capture: "string" }, + ], + [], + "left", + dark, + ); + expect(spans.map((s) => [s.text, s.fg])).toEqual([ + ['f(', dark.syntax("function")], ['"x"', dark.syntax("string")], [")", dark.syntax("function")], + ]); }); test("wrapping adds equal split heights and windowing mounts only intersecting rows", () => { const rows = rowsForFile(createTestDiffFile(), 0, "split", dark); @@ -74,88 +142,41 @@ test("selection copies one source side and excludes padding and added lines", () ).toBe('start();\nsend("old");\nfinish();'); }); test("large file mounts a bounded viewport", () => { - const file = createTestDiffFile(); - const lines = Array.from({ length: 20000 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.hunks[0] = { - novel_lhs: [], - novel_rhs: [], - lines: lines.map((_, i) => [i, i]), - }; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - const geometry = measureRows( - rowsForFile(file, 0, "split", dark), - 120, - false, - 0, - ); + const file = withIdenticalLines(createTestDiffFile(), 20000); + const geometry = measureRows(rowsForFile(file, 0, "split", dark), 120, false, 0); expect(visibleRows(geometry, 10000, 40)).toHaveLength(40); }); - -test("full-file rows include source outside hunks and retain navigation without headers", () => { - const file = createTestDiffFile(); - file.diff.hunks[0].lines = [[1, 1], [null, 2]]; - for (const layout of ["split", "unified"] as const) { - const rows = rowsForFile(file, 0, layout, dark, true); - expect(rows.filter(r => r.label).map(r => r.label)).toEqual(["demo.ts"]); - expect(rows.filter(r => r.hunkStart)).toHaveLength(1); - expect(copySelection([file], rows, {anchor: rows[1].key, end: rows.at(-1)!.key, side: "left"})) - .toBe(file.diff.lhs_src === "Binary" ? "" : file.diff.lhs_src.Text.trimEnd()); - } - file.diff.hunks = []; - expect(rowsForFile(file, 0, "split", dark, true)).toHaveLength(5); -}); - -test("unchanged string literals use the normal text color", () => { - const span = { line: 0, start_col: 0, end_col: 3 }; - const result = lineSpans('"x"', [{ - pos: span, - kind: { UnchangedToken: { - highlight: { Atom: { String: "StringLiteral" } }, - self_pos: [span], opposite_pos: [span], - } }, - }], "right", dark); - expect(result).toEqual([{ text: '"x"', fg: dark.fg, bg: undefined }]); -}); - -test("matched tokens keep syntax styling even when their counterpart is on another row", () => { - const pos = { line: 2, start_col: 0, end_col: 3 }; - const positions = [{ - pos, kind: { UnchangedToken: { - highlight: "Delimiter" as const, self_pos: [pos], - opposite_pos: [{ ...pos, line: 8 }], - } }, - }]; - for (const side of ["left", "right"] as const) { - expect(lineSpans("foo", positions, side, dark)[0]) - .toEqual({ text: "foo", fg: dark.fg, bg: undefined }); - } -}); - -test("compact context preserves Rust-selected signature and changes with one gap per omitted run", () => { +test("context gaps come from collapsed unchanged leaves, one row per gap", () => { const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({ length: 12 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: [[1,1], [2,2], [8,8], [9,9]]}]; + const gap = (id: number, start: number, end: number) => { + const region = leaf(id, start, end); + region.visibility = { collapsed: true, label: `${end - start} unchanged lines` }; + return region; + }; + const regions = () => [gap(1, 0, 1), leaf(2, 1, 3, [line(1, 0, 6)]), gap(3, 3, 8), leaf(4, 8, 10), gap(5, 10, 12)]; + file.diff.lhs = { text: lines.join("\n"), syntax: [], regions: regions() }; + file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: regions() }; + const split = rowsForFile(file, 0, "split", dark, new Set([1, 3, 5])); + const shown = (fold: { label: string; collapsed: boolean } | undefined, line: number | undefined) => + fold?.collapsed ? fold.label : line; + expect(split.slice(1).map(r => shown(r.left?.fold, r.left?.lineNumber))) + .toEqual(["1 unchanged lines", 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); + // Unified shows the novel leaf's lines once per side, removals first. + const unified = rowsForFile(file, 0, "unified", dark, new Set([1, 3, 5])); + expect(unified.slice(1).map(r => shown(r.cell?.fold, r.cell?.newLineNumber ?? r.cell?.oldLineNumber))) + .toEqual(["1 unchanged lines", 2, 3, 2, 3, "5 unchanged lines", 9, 10, "2 unchanged lines"]); for (const layout of ["split", "unified"] as const) { - const rows = rowsForFile(file, 0, layout, dark); - expect(rows.slice(1).map(r => r.label ?? r.left?.lineNumber ?? r.cell?.oldLineNumber)) - .toEqual(["…", 2, 3, "…", 9, 10, "…"]); - expect(rows.filter(r => r.hunkStart)).toHaveLength(1); - expect(rowsForFile(file, 0, layout, dark, true)).toHaveLength(13); + expect(rowsForFile(file, 0, layout, dark, new Set([1, 3, 5])).filter(r => r.hunkStart)).toHaveLength(1); + expect(rowsForFile(file, 0, layout, dark, new Set()).length).toBeGreaterThanOrEqual(13); } }); - -test("unified trusts diffr flags despite different source indentation", () => { +test("unified trusts diffr's changed spans despite different source indentation", () => { const file = createTestDiffFile(); - file.diff.lhs_src = {Text: " call();\n"}; - file.diff.rhs_src = {Text: " call();\n"}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = [[0, 0]]; - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: [[0, 0]]}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: " call();\n", syntax: [], regions: [leaf(1, 0, 1)] }; + file.diff.rhs = { text: " call();\n", syntax: [], regions: [leaf(1, 0, 1)] }; const rows = rowsForFile(file, 0, "unified", dark); expect(rows).toHaveLength(2); expect(rows[1].cell).toMatchObject({kind: "context", sign: " ", oldLineNumber: 1, newLineNumber: 1}); @@ -164,13 +185,20 @@ test("unified trusts diffr flags despite different source indentation", () => { expect(copySelection([file], rows, {anchor: rows[1].key, end: rows[1].key, side})) .toBe(side === "left" ? " call();" : " call();"); } - // Only the side flagged novel may receive change styling. - file.diff.hunks[0].novel_rhs = [0]; + // Only the side with a changed span may receive change styling. + file.diff.rhs.regions = [leaf(1, 0, 1, [line(0, 0, 11)])]; const changed = rowsForFile(file, 0, "unified", dark).slice(1); expect(changed.map(r => r.cell!.kind)).toEqual(["context", "addition"]); - file.diff.aligned_rows = [[0, null], [null, 0]]; - file.diff.hunks[0].novel_rhs = []; - file.diff.hunks[0].lines = file.diff.aligned_rows; - expect(rowsForFile(file, 0, "unified", dark).slice(1).map(r => r.cell!.kind)) - .toEqual(["context", "context"]); +}); +test("binary and one-sided files render without a second side", () => { + const file = createTestDiffFile(); + file.diff = { type: "binary", lhs: { size: 10 }, rhs: { size: 12 } }; + expect(rowsForFile(file, 0, "split", dark).map((r) => r.label)).toEqual(["demo.ts", "Binary file"]); + const added = createTestDiffFile(); + added.file = { rhs: added.file.rhs }; + if (added.diff.type !== "text") throw new Error(); + added.diff = { type: "text", rhs: { text: "new\n", syntax: [], regions: [leaf(1, 0, 1, [line(0, 0, 3)])] }, + stats: { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } } }; + const rows = rowsForFile(added, 0, "split", dark).filter((r) => r.left); + expect(rows.map((r) => [r.left!.kind, r.right!.lineNumber])).toEqual([["empty", 1]]); }); diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 0e12f9fc9..d1b961382 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -1,126 +1,63 @@ -/** Project Rust full-file correspondence into Hunk terminal cells; never compute a second diff. */ -import type { DiffFile, DiffResult, Highlight, MatchedPos } from "./wire"; -import type { - RenderSpan, - SplitLineCell, - UnifiedLineCell, -} from "../ui/diff/diffRowModel"; +/** Project diffr's per-side region trees into terminal cells by zipping leaves on their ids. */ +import type { DiffFile, Span, SyntaxSpan } from "./wire"; +import { filePath } from "./wire"; +import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diffRowModel"; import { measureTextWidth } from "../ui/lib/text"; -import { foldHeaders, foldRegions, hiddenLines, sourceLines } from "./folds"; +import { collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, leafLabel, novelLeaves, pairedIds, sourceLines, type Fold, type Leaf, type RowFold } from "./regions"; +import { loadBundledTheme, type Palette } from "./theme"; export { sourceLines }; export type Layout = "split" | "unified"; export interface ViewerRow { key: string; fileIndex: number; - hunkIndex?: number; + /** First row of a run of changed rows, for `[` and `]`. */ hunkStart?: boolean; label?: string; + /** A hidden-file placeholder: "Load diff" reveals the file. */ + loadDiff?: boolean; left?: SplitLineCell; right?: SplitLineCell; cell?: UnifiedLineCell; } -export interface Palette { - bg: string; - fg: string; - muted: string; - addition: string; - deletion: string; - addWord: string; - deleteWord: string; - keyword: string; - type: string; - comment: string; - /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ - foldBackground: string; - foldPlaceholder: string; +export type { Palette } from "./theme"; +/** The bundled defaults, for tests and the settings screen. */ +export const dark: Palette = loadBundledTheme("default-dark"); +export const light: Palette = loadBundledTheme("default-light"); +/** Foreground for a tree-sitter capture: the theme's scope, its parents, else plain text. */ +export function captureColor(capture: string, theme: Palette): string { + return theme.syntax(capture) ?? theme.fg; } -export const dark: Palette = { - bg: "#0d1117", - fg: "#e6edf3", - muted: "#8b949e", - addition: "#12261e", - deletion: "#301a20", - addWord: "#24583a", - deleteWord: "#74333c", - keyword: "#ff7b72", - type: "#79c0ff", - comment: "#8b949e", - foldBackground: "#152434", - foldPlaceholder: "#808080", -}; -export const light: Palette = { - bg: "#ffffff", - fg: "#24292f", - muted: "#57606a", - addition: "#dafbe1", - deletion: "#ffebe9", - addWord: "#aceebb", - deleteWord: "#ffcecb", - keyword: "#cf222e", - type: "#0550ae", - comment: "#6e7781", - foldBackground: "#e6f3ff", - foldPlaceholder: "#808080", -}; -function color(token: Highlight, theme: Palette) { - if (token === "Delimiter") return theme.fg; - const atom = token.Atom; - return typeof atom === "object" - ? theme.fg - : atom === "Keyword" - ? theme.keyword - : atom === "Type" - ? theme.type - : atom === "Comment" - ? theme.comment - : theme.fg; -} -function byLine(positions: MatchedPos[]) { - const result = new Map<number, MatchedPos[]>(); - for (const position of positions) { - const list = result.get(position.pos.line) ?? []; - list.push(position); - result.set(position.pos.line, list); - } - return result; -} -/** Translate UTF-8 byte spans before expanding tabs into terminal cells. */ +/** Colour a line from byte-addressed syntax and change spans, then expand tabs into cells. */ export function lineSpans( text: string, - positions: MatchedPos[], + syntax: SyntaxSpan[], + changed: Span[], side: "left" | "right", theme: Palette, ): RenderSpan[] { const bytes = new TextEncoder().encode(text), decoder = new TextDecoder("utf-8", { fatal: true }); + const bounds = new Set([0, bytes.length]); + for (const span of [...syntax, ...changed]) { + bounds.add(span.start_column); + bounds.add(span.end_column); + } + const sorted = [...bounds].sort((a, b) => a - b); + const wordBg = side === "left" ? theme.deleteWord : theme.addWord; const spans: RenderSpan[] = []; - let cursor = 0; - for (const position of [...positions].sort( - (a, b) => a.pos.start_col - b.pos.start_col, - )) { - const { start_col: start, end_col: end } = position.pos; - if (start < cursor || end < start || end > bytes.length) - throw new Error("Invalid or overlapping diffr token span"); - if (start > cursor) - spans.push({ - text: decoder.decode(bytes.slice(cursor, start)), - fg: theme.fg, - }); - const kind = Object.values(position.kind)[0]; - const novel = "Novel" in position.kind || "NovelWord" in position.kind; + for (let i = 0; i + 1 < sorted.length; i++) { + const start = sorted[i], end = sorted[i + 1]; + // Innermost syntax capture wins, so pick the narrowest span covering this segment. + const capture = syntax + .filter((s) => s.start_column <= start && s.end_column >= end) + .sort((a, b) => a.end_column - a.start_column - (b.end_column - b.start_column))[0]; + const emphasized = changed.some((s) => s.start_column <= start && s.end_column >= end); spans.push({ text: decoder.decode(bytes.slice(start, end)), - fg: color(kind.highlight, theme), - bg: novel - ? side === "left" - ? theme.deleteWord - : theme.addWord - : undefined, + fg: capture ? captureColor(capture.capture, theme) : theme.fg, + bg: emphasized ? wordBg : undefined, }); - cursor = end; } - if (cursor < bytes.length) - spans.push({ text: decoder.decode(bytes.slice(cursor)), fg: theme.fg }); let column = 0; return spans.map((span) => ({ ...span, @@ -134,166 +71,202 @@ export function lineSpans( .join(""), })); } -/** Render the complete Rust alignment; hunks supply change flags and navigation only. */ +function byLine(spans: SyntaxSpan[]) { + const result = new Map<number, SyntaxSpan[]>(); + for (const span of spans) result.set(span.line, [...(result.get(span.line) ?? []), span]); + return result; +} +const indentOf = (text: string) => text.match(/^\s*/)![0]; +/** Rows for a hidden file: GitHub's "Load diff" placeholder under the header. */ +export function placeholderRows(fileIndex: number, label: string): ViewerRow[] { + return [ + { key: `${fileIndex}:load`, fileIndex, label: "Load diff", loadDiff: true }, + { key: `${fileIndex}:why`, fileIndex, label: label || "Hidden by default" }, + ]; +} +/** Render both sides by zipping leaves on their ids; folding never realigns. */ export function rowsForFile( file: DiffFile, fileIndex: number, layout: Layout, theme: Palette, - fullContext = false, - collapsed: ReadonlySet<string> = new Set(), + collapsed: ReadonlySet<number> = new Set(), ): ViewerRow[] { const d = file.diff; - const rows: ViewerRow[] = [ - { - key: `${fileIndex}:header`, - fileIndex, - label: file.file.new_path ?? file.file.old_path ?? d.display_path, - }, - ]; - if (d.lhs_src === "Binary" || d.rhs_src === "Binary") - return [ - ...rows, - { key: `${fileIndex}:binary`, fileIndex, label: "Binary file" }, - ]; - const left = sourceLines(d.lhs_src.Text), - right = sourceLines(d.rhs_src.Text); - const positions = [byLine(d.lhs_positions), byLine(d.rhs_positions)]; - const caches = [ - new Map<number, RenderSpan[]>(), - new Map<number, RenderSpan[]>(), - ]; - const cell = ( - line: number | null, - side: 0 | 1, - novel: Set<number>, - ): SplitLineCell => { - if (line === null) return { kind: "empty", sign: " ", spans: [] }; - const text = (side ? right : left)[line]; - if (text === undefined) - throw new Error(`diffr alignment references missing line ${line}`); + const rows: ViewerRow[] = [{ key: `${fileIndex}:header`, fileIndex, label: filePath(file.file) }]; + if (d.type === "binary") + return [...rows, { key: `${fileIndex}:binary`, fileIndex, label: "Binary file" }]; + const texts = [sourceLines(d.lhs?.text ?? ""), sourceLines(d.rhs?.text ?? "")]; + const syntax = [byLine(d.lhs?.syntax ?? []), byLine(d.rhs?.syntax ?? [])]; + const { leaves, folds } = flatten(d); + const hidden = [hiddenLines(folds[0], collapsed), hiddenLines(folds[1], collapsed)]; + // A collapsed fold is a row of its own, where its first line would have been. + const bands = [collapsedFolds(folds[0], collapsed), collapsedFolds(folds[1], collapsed)]; + const paired = pairedIds(d); + const headers = [foldHeaders(folds[0], leaves[0], collapsed, paired[0]), foldHeaders(folds[1], leaves[1], collapsed, paired[1])]; + const caches = [new Map<number, RenderSpan[]>(), new Map<number, RenderSpan[]>()]; + // Every line of a novel leaf is tinted; the spans inside get the darker word tint on top. + const novelSet = novelLeaves(leaves); + const novel = (leaf: Leaf) => novelSet.has(leaf); + const cell = (leaf: Leaf | null, line: number | null, side: 0 | 1): SplitLineCell => { + if (line === null || leaf === null) return { kind: "empty", sign: " ", spans: [] }; + const text = texts[side][line]!; let spans = caches[side].get(line); if (!spans) { - spans = lineSpans( - text, - positions[side].get(line) ?? [], - side ? "right" : "left", - theme, - ); + spans = lineSpans(text, syntax[side].get(line) ?? [], leaf.changed.get(line) ?? [], + side ? "right" : "left", theme); caches[side].set(line, spans); } - const changed = novel.has(line); + const changed = novel(leaf); return { kind: changed ? (side ? "addition" : "deletion") : "context", sign: changed ? (side ? "+" : "-") : " ", lineNumber: line + 1, spans, + fold: headers[side].get(line), }; }; - const novelLeft = new Set(d.hunks.flatMap(h => h.novel_lhs)); - const novelRight = new Set(d.hunks.flatMap(h => h.novel_rhs)); - const hunkLeft = new Map<number, number>(), hunkRight = new Map<number, number>(); - for (const [index, hunk] of d.hunks.entries()) { - for (const [l, r] of hunk.lines) { - if (l !== null && !hunkLeft.has(l)) hunkLeft.set(l, index); - if (r !== null && !hunkRight.has(r)) hunkRight.set(r, index); - } - } - const regions = foldRegions(d); - const shown = (index: number) => { - const [l, r] = d.aligned_rows[index]; - return fullContext || (l !== null && hunkLeft.has(l)) || (r !== null && hunkRight.has(r)); - }; - const hidden = hiddenLines(d.aligned_rows, regions, collapsed, shown); - const headers = foldHeaders(d.aligned_rows, regions, collapsed); - let pendingOld: ViewerRow[] = [], - pendingNew: ViewerRow[] = []; + let pendingOld: ViewerRow[] = [], pendingNew: ViewerRow[] = []; const flush = () => { rows.push(...pendingOld, ...pendingNew); pendingOld = []; pendingNew = []; }; - for (const [index, pair] of d.aligned_rows.entries()) { - const hunkIndex = (pair[0] === null ? undefined : hunkLeft.get(pair[0])) - ?? (pair[1] === null ? undefined : hunkRight.get(pair[1])); - // Folded lines lose their cell; a row folded on one side keeps the other side's - // line beside a blank cell, and a row folded on both sides disappears. - const l = pair[0] !== null && hidden[0].has(pair[0]) ? null : pair[0], - r = pair[1] !== null && hidden[1].has(pair[1]) ? null : pair[1]; - if (l === null && r === null) continue; - // Rust's hunk selection includes nearby lines and enclosing syntax context. - // Keep both cells of a selected alignment row; never realign after hiding. - if (!fullContext && hunkIndex === undefined) { + // A collapsed region is one row: chevron and label, no line number, whether the region is a + // fold or a leaf the context plugin cut out. + const foldedCell = (region: Leaf | Fold | null, label: string): SplitLineCell => region + ? { kind: "context", sign: " ", spans: [], fold: { id: region.foldStateId, label, collapsed: true, + tint: foldTint(region.id, region.side, paired[region.side]) } } + : { kind: "empty", sign: " ", spans: [] }; + const collapsedRow = (kind: string, left: SplitLineCell, right: SplitLineCell, state: number) => { + flush(); + const key = `${fileIndex}:${kind}:${state}`; + if (layout === "split") rows.push({ key, fileIndex, left, right }); + else rows.push({ key, fileIndex, cell: { kind: "context", sign: " ", spans: [], fold: (right.fold ?? left.fold)! } }); + }; + const collapsedLeaf = (left: Leaf | null, right: Leaf | null) => { + const anchor = (left ?? right)!; + collapsedRow("gap", foldedCell(left, left ? leafLabel(left) : ""), + foldedCell(right, right ? leafLabel(right) : ""), anchor.foldStateId); + }; + const collapsedFold = (left: Fold | null, right: Fold | null) => { + const anchor = (left ?? right)!; + collapsedRow("fold", foldedCell(left, left?.label ?? ""), foldedCell(right, right?.label ?? ""), + anchor.foldStateId); + }; + const emit = (l: number | null, r: number | null, left: Leaf | null, right: Leaf | null) => { + if (l !== null && hidden[0].has(l)) l = null; + if (r !== null && hidden[1].has(r)) r = null; + if (l === null && r === null) return; + const a = cell(left, l, 0), b = cell(right, r, 1); + const key = `${fileIndex}:${l ?? "_"}:${r ?? "_"}`; + if (layout === "split") { + rows.push({ key, fileIndex, left: a, right: b }); + return; + } + // Correspondence and novelty come from diffr, including formatting-only changes. + if (l !== null && r !== null && a.kind === "context" && b.kind === "context") { flush(); - if (rows.at(-1)?.label !== "…") { - rows.push({ key: `${fileIndex}:gap:${l ?? "_"}:${r ?? "_"}`, fileIndex, label: "…" }); - } - continue; + rows.push({ key, fileIndex, cell: { kind: "context", sign: " ", oldLineNumber: l + 1, + newLineNumber: r + 1, fold: b.fold ?? a.fold, spans: b.spans } }); + return; } - const a = { ...cell(l, 0, novelLeft), fold: headers[0].get(index) }, - b = { ...cell(r, 1, novelRight), fold: headers[1].get(index) }; - const key = `${fileIndex}:${l ?? "_"}:${r ?? "_"}`; - if (layout === "split") - rows.push({ key, fileIndex, hunkIndex, left: a, right: b }); - else { - // Correspondence and novelty come from diffr, including formatting-only changes. - const shared = - l !== null && - r !== null && - !novelLeft.has(l) && - !novelRight.has(r); - if (shared) { - flush(); - rows.push({ - key, - fileIndex, - hunkIndex, - cell: { - kind: "context", - sign: " ", - oldLineNumber: l + 1, - newLineNumber: r + 1, - fold: b.fold ?? a.fold, - spans: b.spans, - }, - }); - } else { - if (l !== null) - pendingOld.push({ - key: `${key}:old`, - fileIndex, - hunkIndex, - cell: { - kind: a.kind === "deletion" ? "deletion" : "context", - sign: a.sign, - oldLineNumber: l + 1, - fold: a.fold, - spans: a.spans, - }, - }); - if (r !== null) - pendingNew.push({ - key: `${key}:new`, - fileIndex, - hunkIndex, - cell: { - kind: b.kind === "addition" ? "addition" : "context", - sign: b.sign, - newLineNumber: r + 1, - fold: b.fold, - spans: b.spans, - }, - }); - } + if (l !== null) + pendingOld.push({ key: `${key}:old`, fileIndex, cell: { kind: a.kind === "deletion" ? "deletion" : "context", + sign: a.sign, oldLineNumber: l + 1, fold: a.fold, spans: a.spans } }); + if (r !== null) + pendingNew.push({ key: `${key}:new`, fileIndex, cell: { kind: b.kind === "addition" ? "addition" : "context", + sign: b.sign, newLineNumber: r + 1, fold: b.fold, spans: b.spans } }); + }; + const leafRows = (left: Leaf | null, right: Leaf | null) => { + const anchor = left ?? right; + if (!anchor) return; + // Every leaf is split at its folds' edges, so a fold's first line is a leaf's: the fold's + // row goes here, before the leaf it would have started. + const leftBand = left ? bands[0].get(left.startLine) ?? null : null; + const rightBand = right ? bands[1].get(right.startLine) ?? null : null; + if (leftBand || rightBand) collapsedFold(leftBand, rightBand); + if (collapsed.has(anchor.foldStateId)) { + if (!hidden[anchor.side].has(anchor.startLine)) collapsedLeaf(left, right); + return; + } + const length = Math.max(left ? left.endLine - left.startLine : 0, right ? right.endLine - right.startLine : 0); + for (let i = 0; i < length; i++) + emit(left && i < left.endLine - left.startLine ? left.startLine + i : null, + right && i < right.endLine - right.startLine ? right.startLine + i : null, left, right); + }; + // Zip: walk the left leaves; a partner ahead on the right flushes what precedes it as right-only. + // diffr sends paired leaves in the same order on both sides. + const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); + let cursor = 0; + const flushRight = (until: number) => { + for (; cursor < until; cursor++) leafRows(null, leaves[1][cursor]); + }; + for (const left of leaves[0]) { + const partner = rightIndex.get(left.alignmentId); + if (partner === undefined) { + leafRows(left, null); + continue; } + flushRight(partner); + leafRows(left, leaves[1][partner]); + cursor = partner + 1; } + flushRight(leaves[1].length); flush(); - const visited = new Set<number>(); + return withFoldLabels(markHunks(rows), texts, theme); +} +function markHunks(rows: ViewerRow[]): ViewerRow[] { + let inHunk = false; for (const row of rows) { - if (row.hunkIndex !== undefined && !visited.has(row.hunkIndex)) { - row.hunkStart = true; - visited.add(row.hunkIndex); - } + const changed = row.cell + ? row.cell.kind !== "context" + : row.left !== undefined && (row.left.kind !== "context" || row.right!.kind !== "context"); + if (changed && !inHunk) row.hunkStart = true; + inHunk = changed; } return rows; } +/** + * A collapsed region with a multi-line label shows the label under its row, inside the fold + * tint, indented one step past the code around it. A collapsed row carries no line number, so + * its indent comes from the line after the last one shown on that side: the first line it hides. + */ +function withFoldLabels(rows: ViewerRow[], texts: string[][], theme: Palette): ViewerRow[] { + const result: ViewerRow[] = []; + const labelLines = (fold: RowFold | undefined) => + fold?.collapsed && fold.label.includes("\n") ? fold.label.split("\n") : []; + const labelCell = (text: string | undefined, kind: SplitLineCell["kind"], indent: string, fold?: RowFold): SplitLineCell => + text === undefined + ? { kind: "empty", sign: " ", spans: [] } + : { kind, sign: " ", foldLabel: true, foldTint: fold?.tint, spans: [{ text: indent + text, fg: theme.foldPlaceholder }] }; + // The last line shown on each side, zero-based, so a row with no line number indents itself + // by the line that would have come next. + const shown = [-1, -1]; + const indentAt = (side: 0 | 1, lineNumber: number | undefined) => { + // A collapsed row stands in for the lines it hides, so it takes their indent; a label + // hanging under a line of code is indented one step past it. + if (lineNumber === undefined) return indentOf(texts[side][shown[side]! + 1] ?? ""); + shown[side] = lineNumber - 1; + return indentOf(texts[side][lineNumber - 1] ?? "") + " "; + }; + for (const row of rows) { + result.push(row); + if (row.cell) { + const side = row.cell.newLineNumber === undefined && row.cell.oldLineNumber !== undefined ? 0 : 1; + const indent = indentAt(side, row.cell.newLineNumber ?? row.cell.oldLineNumber); + const lines = labelLines(row.cell.fold); + lines.forEach((text, i) => result.push({ key: `${row.key}:label:${i}`, fileIndex: row.fileIndex, + cell: { ...labelCell(text, "context", indent, row.cell!.fold), oldLineNumber: undefined, newLineNumber: undefined } as UnifiedLineCell })); + continue; + } + if (!row.left || !row.right) continue; + const indents = [indentAt(0, row.left.lineNumber), indentAt(1, row.right.lineNumber)]; + const left = labelLines(row.left.fold), right = labelLines(row.right.fold); + for (let i = 0; i < Math.max(left.length, right.length); i++) + result.push({ key: `${row.key}:label:${i}`, fileIndex: row.fileIndex, + left: labelCell(left[i], row.left.kind === "empty" ? "context" : row.left.kind, indents[0], row.left.fold), + right: labelCell(right[i], row.right.kind === "empty" ? "context" : row.right.kind, indents[1], row.right.fold) }); + } + return result; +} diff --git a/tui/packages/hunk/src/diffr/selection.ts b/tui/packages/hunk/src/diffr/selection.ts index c6abe16e0..af503611f 100644 --- a/tui/packages/hunk/src/diffr/selection.ts +++ b/tui/packages/hunk/src/diffr/selection.ts @@ -33,18 +33,16 @@ export function copySelection( const key = `${row.fileIndex}:${n}`; if (seen.has(key)) continue; seen.add(key); - const source = - selection.side === "left" - ? files[row.fileIndex].diff.lhs_src - : files[row.fileIndex].diff.rhs_src; - if (source !== "Binary") { - let lines = sources.get(row.fileIndex); - if (!lines) { - lines = source.Text.split("\n"); - sources.set(row.fileIndex, lines); - } - result.push(lines[n - 1]); + const diff = files[row.fileIndex].diff; + if (diff.type !== "text") continue; + const source = selection.side === "left" ? diff.lhs : diff.rhs; + if (!source) continue; + let lines = sources.get(row.fileIndex); + if (!lines) { + lines = source.text.split("\n"); + sources.set(row.fileIndex, lines); } + result.push(lines[n - 1]); } return result.join("\n"); } diff --git a/tui/packages/hunk/src/diffr/store.ts b/tui/packages/hunk/src/diffr/store.ts index a34b9c25f..ffcf86f35 100644 --- a/tui/packages/hunk/src/diffr/store.ts +++ b/tui/packages/hunk/src/diffr/store.ts @@ -1,6 +1,9 @@ /** Hold streamed files separately from presentation state and notify React in batches. */ -import { fileIdentity, type FileChange, type DiffEvent, type DiffFile } from "./wire"; +import { fileIdentity, filePath, type FileChange, type DiffEvent, type DiffFile } from "./wire"; +type StartEvent = Extract<DiffEvent, { type: "start" }>; export interface Snapshot { + /** The two ends of the comparison, from the start event. */ + comparison: { lhs: StartEvent["lhs"]; rhs: StartEvent["rhs"] } | null; files: DiffFile[]; inventory: FileChange[]; failedFiles: Map<string, string>; @@ -10,6 +13,7 @@ export interface Snapshot { } export class DiffStore { private value: Snapshot = { + comparison: null, files: [], inventory: [], failedFiles: new Map(), @@ -27,22 +31,26 @@ export class DiffStore { getSnapshot = () => this.value; accept(event: DiffEvent) { if (event.type === "start") - this.value = { ...this.value, total: event.total, inventory: event.files }; - if (event.type === "file") - this.value = { ...this.value, files: [...this.value.files, event], - inventory: this.value.inventory.some(f => fileIdentity(f) === fileIdentity(event.file)) - ? this.value.inventory : [...this.value.inventory, event.file] }; - if (event.type === "file_error") + this.value = { ...this.value, comparison: { lhs: event.lhs, rhs: event.rhs }, total: event.files.length, inventory: event.files }; + if (event.type === "file") { + const identity = fileIdentity(event.file); + if (event.diff) + this.value = { ...this.value, files: [...this.value.files, { ...event, diff: event.diff }] }; + else if (event.error) + this.value = { + ...this.value, + failedFiles: new Map(this.value.failedFiles).set(identity, event.error.message), + errors: [...this.value.errors, `${filePath(event.file)}: ${event.error.message}`], + }; + } + if (event.type === "complete") this.value = { ...this.value, - failedFiles: new Map(this.value.failedFiles).set(fileIdentity(event.file), event.message), - errors: [ - ...this.value.errors, - `${event.file.new_path ?? event.file.old_path}: ${event.message}`, - ], + complete: true, + errors: event.aborted + ? [...this.value.errors, `diffr stopped early: ${event.aborted.message}`] + : this.value.errors, }; - if (event.type === "complete") - this.value = { ...this.value, complete: true }; this.listeners.forEach((listener) => listener()); } fail(error: unknown) { diff --git a/tui/packages/hunk/src/diffr/stream.test.ts b/tui/packages/hunk/src/diffr/stream.test.ts index 20b26cdd9..e3424da97 100644 --- a/tui/packages/hunk/src/diffr/stream.test.ts +++ b/tui/packages/hunk/src/diffr/stream.test.ts @@ -1,13 +1,15 @@ import { expect, test } from "bun:test"; import { readDiffStream } from "./stream"; -import { createTestDiffFile } from "./fixture"; +import { createTestDiffFile, fold, leaf } from "./fixture"; +import type { FileChange } from "./wire"; +const manifest = (file: ReturnType<typeof createTestDiffFile>): FileChange => + ({ file: file.file, status: "modified", tags: [] }); const start = { type: "start", - version: 1, - before: { kind: "index" }, - after: { kind: "working_tree" }, - total: 1, - files: [createTestDiffFile().file], + version: 3, + lhs: { type: "index" }, + rhs: { type: "working_tree" }, + files: [manifest(createTestDiffFile())], }; async function decode(events: unknown[]) { const text = events.map((e) => JSON.stringify(e)).join("\n"), @@ -17,59 +19,84 @@ async function decode(events: unknown[]) { } return Array.fromAsync(readDiffStream(chunks())); } -test("decode byte-fragmented Unicode stream and preserve fold metadata", async () => { +test("decode byte-fragmented Unicode stream and preserve region trees", async () => { const file = createTestDiffFile(); - file.file.new_path = "变量.ts"; - file.diff.lhs_folds = [ - { - tags: ["function"], - range: { - start: { line: 0, byte_column: 0 }, - end: { line: 2, byte_column: 8 }, - }, - placeholder: "…", - summary: null, - match_kind: "Novel", - }, - ]; - const events = [{...start, files: [file.file]}, file, { type: "complete", succeeded: 1, failed: 0 }]; + file.file = { lhs: { ...file.file.lhs!, path: "变量.ts" }, rhs: { ...file.file.rhs!, path: "变量.ts" } }; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs!.regions = [fold(9, [0, 0], [2, 8], [leaf(1, 0, 3)], "Body", ["function"], true)]; + const events = [{...start, files: [manifest(file)]}, file, { type: "complete", succeeded: 1, failed: 0 }]; expect((await decode(events)) as unknown).toEqual(events); }); -test("reject missing completion, counts, unknown version and invalid ordering", async () => { +test("omitted defaults are filled in", async () => { + const file = createTestDiffFile(); + const bare = { type: "file", file: file.file, diff: { type: "text", + lhs: { text: "a\n", regions: [{ id: 1, fold_state_id: 1, kind: "leaf", alignment_id: 0, start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, + rhs: { text: "a\n", regions: [{ id: 2, fold_state_id: 1, kind: "leaf", alignment_id: 0, start: { line: 0, column: 0 }, end: { line: 1, column: 0 } }] }, + stats: { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } } } }; + const [, decoded] = await decode([{ ...start, files: [{ file: file.file, status: "modified" }] }, bare, + { type: "complete", succeeded: 1, failed: 0 }]); + expect(decoded).toMatchObject({ diff: { lhs: { syntax: [], regions: [{ tags: [], changed: [], children: [], + visibility: { collapsed: false, label: "" } }] } } }); +}); +test("reject a stream without completion, an unknown version and malformed records", async () => { for (const events of [ [start], - [{ ...start, version: 2 }], - [createTestDiffFile()], - [start, { type: "complete", succeeded: 1, failed: 0 }], - [start, start], + [{ ...start, version: 1 }, { type: "complete", succeeded: 0, failed: 0 }], + [start, { type: "file" }, { type: "complete", succeeded: 0, failed: 0 }], ]) await expect(decode(events)).rejects.toThrow(); + const text = new TextEncoder().encode(`${JSON.stringify(start)}\nnot json\n`); + async function* chunks() { + yield text; + } + await expect(Array.fromAsync(readDiffStream(chunks()))).rejects.toThrow(); }); -test("file errors complete without discarding earlier successful results", async () => { +test("records are trusted as diffr sends them: counts and order are not checked", async () => { const file = createTestDiffFile(); - const failedFile = {...file.file, old_path: "failed.ts", new_path: "failed.ts"}; - expect( - ( - await decode([ - { ...start, total: 2, files: [file.file, failedFile] }, - file, - { type: "file_error", file: failedFile, message: "unreadable" }, - { type: "complete", succeeded: 1, failed: 1 }, - ]) - ).length, - ).toBe(4); + const events = [start, file, file, { type: "complete", succeeded: 5, failed: 0 }]; + expect((await decode(events)).map((event) => event.type)).toEqual(["start", "file", "file", "complete"]); }); - -test("manifest validates count and identities while allowing results in arrival order", async () => { +test("file errors and aborts complete without discarding earlier successful results", async () => { + const file = createTestDiffFile(); + const failed = { lhs: { path: "failed.ts", oid: "3", mode: "100644" }, rhs: { path: "failed.ts", oid: "4", mode: "100644" } }; + const decoded = await decode([ + { ...start, files: [manifest(file), { file: failed, status: "modified" }] }, + file, + { type: "file", file: failed, error: { code: "not_utf8", message: "unreadable" } }, + { type: "complete", succeeded: 1, failed: 1 }, + ]); + expect(decoded.length).toBe(4); + const aborted = await decode([ + { ...start, files: [manifest(file), { file: failed, status: "modified" }] }, + file, + { type: "complete", succeeded: 1, failed: 0, aborted: { code: "hook_failed", message: "503" } }, + ]); + expect(aborted.length).toBe(3); +}); +test("results arrive in any order", async () => { const a = createTestDiffFile(), b = createTestDiffFile(); - b.file = {...b.file, old_path: "b.ts", new_path: "b.ts"}; - for (const events of [ - [{...start, files: []}], - [{...start, total: 2, files: [a.file, a.file]}], - [{...start, total: 2, files: [a.file, b.file]}, b, b], - [start, b], - ]) await expect(decode(events)).rejects.toThrow(); - expect((await decode([{...start, total: 2, files: [a.file, b.file]}, b, a, + b.file = { lhs: { path: "b.ts", oid: "5", mode: "100644" }, rhs: { path: "b.ts", oid: "6", mode: "100644" } }; + expect((await decode([{...start, files: [manifest(a), manifest(b)]}, b, a, {type: "complete", succeeded: 2, failed: 0}])).map(e => e.type)) .toEqual(["start", "file", "file", "complete"]); }); + +test("a stream recorded from diffr parses, with manifest tags and each record's visibility", async () => { + // Recorded with `diffr <base> <head> --format ndjson --syntax --jobs 1` from a repository with + // one source file and one test file; the test file is hidden by the hide-files plugin. + const bytes = await Bun.file(new URL("../../../../test/fixtures/comparison.ndjson", import.meta.url)).bytes(); + async function* chunks() { + yield bytes; + } + const events = await Array.fromAsync(readDiffStream(chunks())); + const [start, source, test, complete] = events; + if (start.type !== "start" || source.type !== "file" || test.type !== "file") throw new Error("unexpected records"); + expect(start.files.map((file) => [file.file.rhs?.path, file.tags])).toEqual([ + ["src/greet.ts", []], + ["tests/greet.test.ts", ["test"]], + ]); + expect(source.visibility).toEqual({ collapsed: false, label: "" }); + expect(test.visibility).toEqual({ collapsed: true, label: "Test file · hidden by default" }); + expect(source.diff?.type === "text" && source.diff.rhs!.syntax.length).toBeGreaterThan(0); + expect(complete).toEqual({ type: "complete", succeeded: 2, failed: 0 }); +}); diff --git a/tui/packages/hunk/src/diffr/stream.ts b/tui/packages/hunk/src/diffr/stream.ts index fabddc313..bd0eaa1b9 100644 --- a/tui/packages/hunk/src/diffr/stream.ts +++ b/tui/packages/hunk/src/diffr/stream.ts @@ -1,49 +1,18 @@ -/** Decode stdout incrementally; reject truncated, reordered, or inconsistent streams. */ -import { fileIdentity, eventSchema, type DiffEvent } from "./wire"; +/** Decode stdout incrementally. diffr's records are trusted as sent: only malformed JSON, an + * unknown protocol version, and a stream that ends before `complete` are errors. */ +import { eventSchema, type DiffEvent } from "./wire"; export async function* readDiffStream( chunks: AsyncIterable<Uint8Array>, ): AsyncGenerator<DiffEvent> { const decoder = new TextDecoder("utf-8", { fatal: true }); let buffer = "", - started = false, - complete = false, - succeeded = 0, - failed = 0, - total = 0; - let inventory = new Set<string>(); - const received = new Set<string>(); + complete = false; function parse(line: string): DiffEvent { if (line.length > 128 * 1024 * 1024) throw new Error("diffr event exceeds 128 MiB"); const event = eventSchema.parse(JSON.parse(line)); - if (complete) throw new Error("Data after diffr completion"); - if (event.type === "start") { - if (started) throw new Error("Duplicate diffr start"); - started = true; - total = event.total; - inventory = new Set(event.files.map(fileIdentity)); - if (event.files.length !== total || inventory.size !== total) - throw new Error("Inconsistent diffr file manifest"); - } else { - if (!started) throw new Error("Missing diffr start"); - if (event.type === "file" || event.type === "file_error") { - const identity = fileIdentity(event.file); - if (!inventory.has(identity) || received.has(identity)) - throw new Error("Unknown or duplicate diffr file result"); - received.add(identity); - } - if (event.type === "file") succeeded++; - if (event.type === "file_error") failed++; - if (event.type === "complete") { - if ( - event.succeeded !== succeeded || - event.failed !== failed || - succeeded + failed !== total - ) - throw new Error("Inconsistent diffr completion counts"); - complete = true; - } - } + // A stream cut short, say by a signal, ends without `complete`. + if (event.type === "complete") complete = true; return event; } for await (const chunk of chunks) { diff --git a/tui/packages/hunk/src/diffr/theme.test.ts b/tui/packages/hunk/src/diffr/theme.test.ts new file mode 100644 index 000000000..aec8213ed --- /dev/null +++ b/tui/packages/hunk/src/diffr/theme.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { bundledThemes, colorOf, loadBundledTheme, paletteFromHelix, parseHelixTheme, scopeStyle, themeConfig, themesFromConfig } from "./theme"; +const sample = ` +"keyword" = { fg = "red", modifiers = ["bold"] } +"keyword.control" = { fg = "purple" } +"string" = "green" +"comment" = { fg = "#808080", modifiers = ["italic"] } +"ui.background" = { bg = "black" } +"ui.text" = { fg = "white" } +"ui.selection" = { bg = "#264f78" } +"diff.plus" = "green" +"diff.minus" = "red" + +[palette] +red = "#e06c75" +purple = "#c678dd" +green = "#98c379" +black = "#282c34" +white = "#abb2bf" +`; +test("scopes fall back to their parents and colours resolve through the palette", () => { + const theme = parseHelixTheme(sample, "sample"); + expect(scopeStyle(theme, "keyword.return")!.fg).toBe("red"); + expect(scopeStyle(theme, "keyword.control.import")!.fg).toBe("purple"); + expect(scopeStyle(theme, "string.special")!.fg).toBe("green"); + expect(scopeStyle(theme, "variable.parameter")).toBeUndefined(); + expect(colorOf(theme, "red")).toBe("#e06c75"); + expect(colorOf(theme, "#123456")).toBe("#123456"); + expect(colorOf(theme, "light-blue")).toBe("#3b8eea"); + expect(() => colorOf(theme, "chartreuse")).toThrow("unknown colour"); + const palette = paletteFromHelix(theme); + expect(palette.bg).toBe("#282c34"); + expect(palette.isLight).toBe(false); + expect(palette.syntax("keyword.return")).toBe("#e06c75"); + expect(palette.syntax("comment")).toBe("#808080"); + expect(palette.syntax("variable")).toBeUndefined(); + expect(palette.highlight).toBe("#264f78"); + expect(palette.addWord).not.toBe(palette.addition); +}); +test("a theme without ui colours or with inheritance is rejected", () => { + expect(() => paletteFromHelix(parseHelixTheme('"keyword" = "red"', "bare"))).toThrow("ui.background"); + expect(() => parseHelixTheme('inherits = "onedark"\n"ui.background" = { bg = "black" }', "child")).toThrow("inherits"); +}); +test("bundled themes load, the defaults are aliases, and captures are visibly distinct", () => { + for (const name of Object.keys(bundledThemes)) { + const palette = loadBundledTheme(name); + const colours = ["keyword", "string", "comment", "type", "function"].map((c) => palette.syntax(c)); + expect(colours.every((c) => c !== undefined)).toBe(true); + // gruvbox shares one green between strings and functions; everything else stays apart. + expect(new Set(colours).size).toBeGreaterThanOrEqual(4); + expect(palette.syntax("keyword.storage.modifier.ref")).toBeDefined(); + } + expect(loadBundledTheme("default-dark").isLight).toBe(false); + expect(loadBundledTheme("default-light").isLight).toBe(true); + expect(loadBundledTheme("default-dark").bg).toBe(loadBundledTheme("onedark").bg); + expect(() => loadBundledTheme("dracula")).toThrow("Unknown theme dracula"); +}); +test("the theme set follows diffr's config and errors on a missing section", () => { + expect(themeConfig({ theme: { name: "gruvbox", path: null } })).toEqual({ name: "gruvbox", path: null }); + expect(() => themeConfig({ folds: {} })).toThrow("theme section"); + const set = themesFromConfig({ name: "gruvbox", path: null }); + expect(set.initial.name).toBe("gruvbox"); + expect(set.dark.name).toBe("default-dark"); + expect(set.light.name).toBe("default-light"); + expect(() => themesFromConfig({ name: "nope", path: null })).toThrow("Unknown theme"); + expect(() => themesFromConfig({ name: "default-dark", path: "/nonexistent/theme.toml" })).toThrow(); +}); diff --git a/tui/packages/hunk/src/diffr/theme.ts b/tui/packages/hunk/src/diffr/theme.ts new file mode 100644 index 000000000..f5ea465a2 --- /dev/null +++ b/tui/packages/hunk/src/diffr/theme.ts @@ -0,0 +1,174 @@ +/** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +export interface Style { + fg?: string; + bg?: string; + modifiers: string[]; +} +export interface HelixTheme { + name: string; + styles: Map<string, Style>; + palette: Map<string, string>; +} +/** Colours the row painter reads; syntax colours come from the theme by capture name. */ +export interface Palette { + name: string; + isLight: boolean; + bg: string; + fg: string; + muted: string; + /** Header, menu, and sidebar chrome. */ + chrome: string; + /** Sidebar highlight for the active file. */ + highlight: string; + addition: string; + deletion: string; + addWord: string; + deleteWord: string; + addedText: string; + removedText: string; + /** An accent for interactive text such as links and the layout badge. */ + accent: string; + /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ + foldBackground: string; + foldPlaceholder: string; + /** Foreground for a tree-sitter capture such as `keyword.return`; undefined when the theme has no scope for it. */ + syntax: (capture: string) => string | undefined; +} +/** Helix's named terminal colours, used when a theme writes `fg = "red"` outside its palette. */ +const ansi: Record<string, string> = { + black: "#000000", red: "#cd3131", green: "#0dbc79", yellow: "#e5e510", blue: "#2472c8", + magenta: "#bc3fbc", cyan: "#11a8cd", white: "#e5e5e5", gray: "#666666", grey: "#666666", + "light-red": "#f14c4c", "light-green": "#23d18b", "light-yellow": "#f5f543", "light-blue": "#3b8eea", + "light-magenta": "#d670d6", "light-cyan": "#29b8db", "light-white": "#ffffff", "light-gray": "#a0a0a0", + "light-grey": "#a0a0a0", "light-black": "#333333", +}; +interface RawStyle { + fg?: string; + bg?: string; + modifiers?: string[]; +} +export function parseHelixTheme(text: string, name: string): HelixTheme { + const raw = Bun.TOML.parse(text) as Record<string, unknown>; + const palette = new Map<string, string>(); + const rawPalette = raw.palette; + if (rawPalette && typeof rawPalette === "object") + for (const [key, value] of Object.entries(rawPalette as Record<string, unknown>)) { + if (typeof value !== "string") throw new Error(`Theme ${name}: palette entry ${key} is not a colour`); + palette.set(key, value); + } + const styles = new Map<string, Style>(); + for (const [scope, value] of Object.entries(raw)) { + if (scope === "palette" || scope === "inherits") continue; + if (typeof value === "string") styles.set(scope, { fg: value, modifiers: [] }); + else if (value && typeof value === "object") { + const style = value as RawStyle; + styles.set(scope, { fg: style.fg, bg: style.bg, modifiers: style.modifiers ?? [] }); + } else throw new Error(`Theme ${name}: scope ${scope} has an unsupported value`); + } + if (typeof raw.inherits === "string") + throw new Error(`Theme ${name} inherits from ${raw.inherits}; inherited themes are not supported`); + return { name, styles, palette }; +} +/** A palette name, a hex colour, or one of Helix's terminal colour names. */ +export function colorOf(theme: HelixTheme, value: string): string { + const fromPalette = theme.palette.get(value); + if (fromPalette !== undefined) return colorOf(theme, fromPalette); + if (/^#[0-9a-fA-F]{6}$/.test(value)) return value; + const named = ansi[value]; + if (named) return named; + throw new Error(`Theme ${theme.name}: unknown colour ${value}`); +} +/** The style for a scope, falling back to its parent scopes: `keyword.return` → `keyword`. */ +export function scopeStyle(theme: HelixTheme, scope: string): Style | undefined { + const parts = scope.split("."); + while (parts.length) { + const style = theme.styles.get(parts.join(".")); + if (style) return style; + parts.pop(); + } + return undefined; +} +const scopeFg = (theme: HelixTheme, scope: string) => { + const style = scopeStyle(theme, scope); + return style?.fg === undefined ? undefined : colorOf(theme, style.fg); +}; +const scopeBg = (theme: HelixTheme, scope: string) => { + const style = scopeStyle(theme, scope); + return style?.bg === undefined ? undefined : colorOf(theme, style.bg); +}; +function luminance(hex: string) { + const channel = (i: number) => parseInt(hex.slice(i, i + 2), 16) / 255; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} +function mix(hex: string, other: string, amount: number) { + const channel = (i: number) => + Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - amount) + parseInt(other.slice(i, i + 2), 16) * amount); + return `#${[1, 3, 5].map((i) => channel(i).toString(16).padStart(2, "0")).join("")}`; +} +/** Build the painter's palette: chrome from `ui.*`, syntax by capture, change tints mixed into the background. */ +export function paletteFromHelix(theme: HelixTheme): Palette { + const bg = scopeBg(theme, "ui.background"); + const fg = scopeFg(theme, "ui.text"); + if (!bg || !fg) throw new Error(`Theme ${theme.name} lacks ui.background or ui.text`); + const isLight = luminance(bg) > 0.5; + const muted = scopeFg(theme, "ui.linenr") ?? scopeFg(theme, "comment") ?? mix(fg, bg, 0.4); + const plus = scopeFg(theme, "diff.plus") ?? (isLight ? "#1a7f37" : "#7ee787"); + const minus = scopeFg(theme, "diff.minus") ?? (isLight ? "#cf222e" : "#ffa198"); + const selection = scopeBg(theme, "ui.selection") ?? mix(bg, fg, 0.15); + return { + name: theme.name, + isLight, + bg, + fg, + muted, + chrome: scopeBg(theme, "ui.statusline") ?? mix(bg, fg, 0.06), + highlight: selection, + addition: mix(bg, plus, 0.18), + deletion: mix(bg, minus, 0.18), + addWord: mix(bg, plus, 0.42), + deleteWord: mix(bg, minus, 0.42), + addedText: plus, + removedText: minus, + accent: scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg, + foldBackground: scopeBg(theme, "ui.cursorline.primary") ?? mix(bg, fg, 0.1), + foldPlaceholder: muted, + syntax: (capture) => scopeFg(theme, capture), + }; +} +/** Bundled Helix themes under tui/themes; the two defaults are aliases. */ +export const bundledThemes: Record<string, string> = { + "default-dark": "onedark", + "default-light": "onelight", + onedark: "onedark", + onelight: "onelight", + gruvbox: "gruvbox", + solarized_light: "solarized_light", +}; +const themesDir = resolve(import.meta.dir, "../../../../themes"); +export function loadBundledTheme(name: string): Palette { + const file = bundledThemes[name]; + if (!file) throw new Error(`Unknown theme ${name}; bundled themes: ${Object.keys(bundledThemes).join(", ")}`); + return paletteFromHelix(parseHelixTheme(readFileSync(resolve(themesDir, `${file}.toml`), "utf8"), name)); +} +export function loadThemeFile(path: string): Palette { + return paletteFromHelix(parseHelixTheme(readFileSync(path, "utf8"), path)); +} +export interface ThemeSet { + initial: Palette; + dark: Palette; + light: Palette; +} +/** Resolve the theme diffr's config names, plus the two defaults the `t` key toggles between. */ +export function themesFromConfig(config: { name: string; path: string | null }): ThemeSet { + const dark = loadBundledTheme("default-dark"), light = loadBundledTheme("default-light"); + const initial = config.path ? loadThemeFile(config.path) : loadBundledTheme(config.name); + return { initial, dark, light }; +} +export function themeConfig(show: unknown): { name: string; path: string | null } { + const theme = (show as { theme?: { name?: unknown; path?: unknown } }).theme; + if (!theme || typeof theme.name !== "string" || (theme.path !== null && theme.path !== undefined && typeof theme.path !== "string")) + throw new Error("diffr config show did not include a theme section"); + return { name: theme.name, path: theme.path ?? null }; +} diff --git a/tui/packages/hunk/src/diffr/wire.ts b/tui/packages/hunk/src/diffr/wire.ts index f83d32a16..2677f363c 100644 --- a/tui/packages/hunk/src/diffr/wire.ts +++ b/tui/packages/hunk/src/diffr/wire.ts @@ -1,120 +1,126 @@ -/** Validate the lossless Rust wire format without translating it through patch metadata. */ +/** Parse diffr's wire v3 (src/protocol/mod.rs, docs/streaming.md): tagged enums, pairings by presence, defaults omitted. The shapes are checked; invariants between records are diffr's and trusted. */ import { z } from "zod"; const uint = z.number().int().nonnegative(); -const point = z.object({ line: uint, byte_column: uint }); -const range = z.object({ start: point, end: point }); -const span = z.object({ line: uint, start_col: uint, end_col: uint }); -const highlight = z.union([ - z.literal("Delimiter"), - z.object({ - Atom: z.union([ - z.enum([ - "Normal", - "Type", - "Keyword", - "Comment", - "TreeSitterError", - "CanIgnore", - ]), - z.object({ String: z.enum(["StringLiteral", "Text"]) }), - ]), - }), +/** `{lhs, rhs}`, `{lhs}` or `{rhs}`; diffr never sends neither. */ +function pairing<T extends z.ZodTypeAny>(item: T) { + return z.object({ lhs: item.optional(), rhs: item.optional() }); +} +export type Pairing<T> = { lhs?: T; rhs?: T }; +const fileRef = z.object({ path: z.string(), oid: z.string(), mode: z.string() }); +const visibility = z.object({ + collapsed: z.boolean().default(false), + label: z.string().default(""), +}); +const problem = z.object({ code: z.string(), message: z.string() }); +const fileChange = z.object({ + file: pairing(fileRef), + status: z.enum(["added", "deleted", "modified", "renamed", "copied", "type_changed"]), + /** What the file is (`generated`, `vendored`, `docs`, `test`, or a `diffr-tags` attribute), sorted. */ + tags: z.array(z.string()).default([]), +}); +const snapshot = z.discriminatedUnion("type", [ + z.object({ type: z.literal("revision"), rev: z.string() }), + z.object({ type: z.literal("index") }), + z.object({ type: z.literal("working_tree") }), + z.object({ type: z.literal("empty_tree") }), + z.object({ type: z.literal("path"), path: z.string() }), ]); -const position = z.object({ - pos: span, - kind: z.union([ - z.object({ - UnchangedToken: z.object({ - highlight, - self_pos: z.array(span), - opposite_pos: z.array(span), - }), - }), - z.object({ - UnchangedPartOfNovelItem: z.object({ - highlight, - self_pos: span, - opposite_pos: z.array(span), - }), - }), - z.object({ Novel: z.object({ highlight }) }), - z.object({ NovelWord: z.object({ highlight }) }), - z.object({ Ignored: z.object({ highlight }) }), - ]), +const sourcePos = z.object({ line: uint, column: uint }); +const span = z.object({ line: uint, start_column: uint, end_column: uint }); +const syntaxSpan = span.extend({ capture: z.string() }); +interface RegionBase { + /** Names this region, unique within the file across both sides. Keys anything about the region itself. */ + id: number; + /** Regions sharing it open and close together, on either side. Keys collapse state. */ + fold_state_id: number; + start: { line: number; column: number }; + end: { line: number; column: number }; + tags: string[]; + visibility: { collapsed: boolean; label: string }; + changed: { line: number; start_column: number; end_column: number }[]; + children: Region[]; +} +export interface LeafRegion extends RegionBase { + kind: "leaf"; + /** Same value on the other side: the leaf whose rows line up with this one, one-to-one. Keys the row zip. */ + alignment_id: number; +} +export interface FoldRegion extends RegionBase { + kind: "fold"; +} +export type Region = LeafRegion | FoldRegion; +const regionBase = z.object({ + id: uint, + fold_state_id: uint, + start: sourcePos, + end: sourcePos, + tags: z.array(z.string()).default([]), + visibility: visibility.default({ collapsed: false, label: "" }), }); -const fold = z.object({ - tags: z.array(z.string()), - range, - match_kind: z.union([ - z.literal("Novel"), - z.object({ Unchanged: z.object({ opposite: range }) }), +const region: z.ZodType<Region> = z.lazy(() => + z.discriminatedUnion("kind", [ + regionBase.extend({ kind: z.literal("leaf"), alignment_id: uint, changed: z.array(span).default([]) }) + .transform((leaf) => ({ ...leaf, children: [] as Region[] })), + regionBase.extend({ kind: z.literal("fold"), children: z.array(region) }) + .transform((fold) => ({ ...fold, changed: [] as Region["changed"] })), ]), - placeholder: z.string(), - summary: z.string().nullable(), +); +const source = z.object({ + text: z.string(), + syntax: z.array(syntaxSpan).default([]), + regions: z.array(region).default([]), }); -const source = z.union([z.literal("Binary"), z.object({ Text: z.string() })]); -export const diffResultSchema = z.object({ - display_path: z.string(), - extra_info: z.string().nullable(), - file_format: z.union([ - z.enum(["PlainText", "Binary"]), - z.object({ SupportedLanguage: z.string() }), - z.object({ TextFallback: z.object({ reason: z.string() }) }), - ]), - lhs_src: source, - rhs_src: source, - lhs_positions: z.array(position), - rhs_positions: z.array(position), - lhs_folds: z.array(fold), - rhs_folds: z.array(fold), - aligned_rows: z.array(z.tuple([uint.nullable(), uint.nullable()])), - hunks: z.array( - z.object({ - novel_lhs: z.array(uint), - novel_rhs: z.array(uint), - lines: z.array(z.tuple([uint.nullable(), uint.nullable()])), - }), - ), - has_byte_changes: z.tuple([uint, uint]).nullable(), - has_syntactic_changes: z.boolean(), +const lineCounts = z.object({ added: uint, removed: uint }); +const stats = z.object({ + textual: lineCounts, + /** Changed lines shown under diffr's default fold state; the frontend adjusts it as folds toggle. */ + visible: lineCounts, + /** Present when tree-sitter did not run and this is a line diff. */ + fallback: problem.optional(), }); -const operand = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("file"), path: z.string() }), - z.object({ kind: z.literal("revision"), ref: z.string() }), - z.object({ kind: z.literal("index") }), - z.object({ kind: z.literal("working_tree") }), - z.object({ kind: z.literal("empty_tree") }), +const diff = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text"), lhs: source.optional(), rhs: source.optional(), stats }), + z.object({ + type: z.literal("binary"), + lhs: z.object({ size: uint }).optional(), + rhs: z.object({ size: uint }).optional(), + }), ]); -const file = z.object({ - old_path: z.string().nullable(), - new_path: z.string().nullable(), - status: z.enum([ - "added", - "deleted", - "modified", - "renamed", - "type_changed", - "conflicted", - ]), - class: z.string().nullable(), +/** diffr sends exactly one of `diff` and `error`. */ +const fileEvent = z.object({ + type: z.literal("file"), + file: pairing(fileRef), + /** How the file starts out, set by the plugins after diffing: a hidden file is collapsed behind its reason. */ + visibility: visibility.default({ collapsed: false, label: "" }), + diff: diff.optional(), + error: problem.optional(), }); export const eventSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("start"), - version: z.literal(1), - before: operand, - after: operand, - total: uint, - files: z.array(file), + version: z.literal(3), + lhs: snapshot, + rhs: snapshot, + files: z.array(fileChange), }), - z.object({ type: z.literal("file"), file, diff: diffResultSchema }), - z.object({ type: z.literal("file_error"), file, message: z.string() }), - z.object({ type: z.literal("complete"), succeeded: uint, failed: uint }), + fileEvent, + z.object({ type: z.literal("complete"), succeeded: uint, failed: uint, aborted: problem.optional() }), ]); -export type FileChange = z.infer<typeof file>; -export const fileIdentity = (file: FileChange) => JSON.stringify([file.old_path, file.new_path]); -export type DiffResult = z.infer<typeof diffResultSchema>; +export type FileRef = z.infer<typeof fileRef>; +export type FileChange = z.infer<typeof fileChange>; +export type Visibility = z.infer<typeof visibility>; +export type Problem = z.infer<typeof problem>; +export type Source = z.infer<typeof source>; +export type TextDiff = Extract<z.infer<typeof diff>, { type: "text" }>; +export type Diff = z.infer<typeof diff>; +export type Span = z.infer<typeof span>; +export type SyntaxSpan = z.infer<typeof syntaxSpan>; +export type Stats = z.infer<typeof stats>; +export type LineCounts = z.infer<typeof lineCounts>; export type DiffEvent = z.infer<typeof eventSchema>; -export type DiffFile = Extract<DiffEvent, { type: "file" }>; -export type MatchedPos = DiffResult["lhs_positions"][number]; -export type Highlight = z.infer<typeof highlight>; +export type FileEvent = Extract<DiffEvent, { type: "file" }>; +/** A file record that carries a diff; failures are kept separately by the store. */ +export type DiffFile = FileEvent & { diff: Diff }; +export const fileIdentity = (file: Pairing<FileRef>) => + JSON.stringify([file.lhs?.path ?? null, file.rhs?.path ?? null]); +export const filePath = (file: Pairing<FileRef>) => file.rhs?.path ?? file.lhs?.path ?? ""; diff --git a/tui/packages/hunk/src/main.tsx b/tui/packages/hunk/src/main.tsx index ac7d3f00f..25b9a36e3 100644 --- a/tui/packages/hunk/src/main.tsx +++ b/tui/packages/hunk/src/main.tsx @@ -6,16 +6,51 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { readDiffStream } from "./diffr/stream"; import { DiffStore } from "./diffr/store"; +import { cliClient } from "./diffr/config"; +import { loadBundledTheme, loadThemeFile, themeConfig, themesFromConfig, type ThemeSet } from "./diffr/theme"; import { App } from "./ui/App"; +import { Settings } from "./ui/Settings"; const args = process.argv.slice(2), store = new DiffStore(); +// `diffr config` opens the settings screen: bun run main.tsx --settings --diffr /path/to/diffr [query] +if (args[0] === "--settings") { + if (args[1] !== "--diffr" || !args[2]) { + console.error("Usage: bun run start --settings --diffr /path/to/diffr [initial query]"); + process.exit(2); + } + const settingsRenderer = await createCliRenderer({ + useMouse: false, + exitOnCtrlC: false, + screenMode: "alternate-screen", + }); + const quitSettings = () => { + settingsRenderer.destroy(); + process.exit(0); + }; + process.once("SIGTERM", quitSettings); + process.once("SIGINT", quitSettings); + createRoot(settingsRenderer).render( + <Settings client={cliClient(args[2])} onQuit={quitSettings} initialQuery={args.slice(3).join(" ")} />, + ); + await new Promise(() => {}); +} let child: ReturnType<typeof spawn> | undefined; let chunks: AsyncIterable<Uint8Array>; let input: NodeJS.ReadStream = process.stdin; let ttyFd: number | undefined; let expectsDifferenceExit = false; let comparisonExitCode = 0; +let themes: ThemeSet; if (args[0] === "--input" && args[1]) { + // A recording has no diffr to ask; `--theme <name|path>` picks one, else the dark default. + const themeArg = args.indexOf("--theme"); + const chosen = themeArg >= 0 ? args[themeArg + 1] : "default-dark"; + if (!chosen) throw new Error("--theme needs a bundled name or a Helix theme path"); + themes = { + initial: chosen.endsWith(".toml") ? loadThemeFile(chosen) : loadBundledTheme(chosen), + dark: loadBundledTheme("default-dark"), + light: loadBundledTheme("default-light"), + }; chunks = args[1] === "-" ? process.stdin : createReadStream(args[1]); if (args[1] === "-") { ttyFd = openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"); @@ -23,17 +58,20 @@ if (args[0] === "--input" && args[1]) { } } else if (args[0] === "--diffr" && args[1]) { const comparison = args.slice(args[2] === "--" ? 3 : 2); + // The theme comes from diffr's own config, so every frontend reads the same choice. + themes = themesFromConfig(themeConfig(cliClient(args[1]).show())); const separator = comparison.indexOf("--"); expectsDifferenceExit = comparison .slice(0, separator < 0 ? comparison.length : separator) .includes("--exit-code"); - child = spawn(args[1], ["--format", "ndjson", ...comparison], { + // The frontend has no tokenizer, so it asks Rust for syntax spans. + child = spawn(args[1], ["--format", "ndjson", "--syntax", ...comparison], { stdio: ["ignore", "pipe", "pipe"], }); chunks = child.stdout!; } else { console.error( - "Usage: bun run start --diffr /path/to/diffr -- [comparison arguments]\n bun run start --input recording.ndjson (or -)", + "Usage: bun run start --diffr /path/to/diffr -- [comparison arguments]\n bun run start --input recording.ndjson (or -) [--theme name|file.toml]\n bun run start --settings --diffr /path/to/diffr [query]", ); process.exit(2); } @@ -61,7 +99,7 @@ function quit() { process.once("SIGTERM", quit); process.once("SIGINT", quit); const root = createRoot(renderer); -root.render(<App store={store} onQuit={quit} />); +root.render(<App store={store} onQuit={quit} themes={themes!} />); let stderr = ""; child?.stderr?.on("data", (data) => { stderr = (stderr + data.toString()).slice(-16384); diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index 9788b3256..021113a28 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -5,13 +5,21 @@ import { TextRenderable, type BaseRenderable } from "@opentui/core"; import { App } from "./App"; import { DiffStore } from "../diffr/store"; -import { createTestDiffFile } from "../diffr/fixture"; -import { createFoldedDiffFile } from "../diffr/folds.test"; +import { createTestDiffFile, leaf, line, manifestEntry, startFor, withIdenticalLines } from "../diffr/fixture"; +import type { DiffFile } from "../diffr/wire"; +import { createBundledDiffFile, createFoldedDiffFile } from "../diffr/regions.test"; +import { loadBundledTheme } from "../diffr/theme"; +const themes = { initial: loadBundledTheme("default-dark"), dark: loadBundledTheme("default-dark"), light: loadBundledTheme("default-light") }; +const at = (file: DiffFile, path: string) => { + file.file = { lhs: { path, oid: "1", mode: "100644" }, rhs: { path, oid: "2", mode: "100644" } }; + return file; +}; test("render real OpenTUI rows, switch layout, collapse and reopen file with mouse", async () => { - const store = new DiffStore(); - store.accept(createTestDiffFile()); + const store = new DiffStore(), file = createTestDiffFile(); + store.accept(startFor([file])); + store.accept(file); const testRenderer = await testRender( - <App store={store} onQuit={() => {}} />, + <App store={store} onQuit={() => {}} themes={themes} />, { width: 150, height: 20 }, ); try { @@ -26,7 +34,7 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou "copyToClipboardOSC52", ).mockReturnValue(true); await act(async () => { - await testRenderer.mockMouse.drag(40, 2, 40, 5); + await testRenderer.mockMouse.drag(40, 3, 40, 6); }); await act(async () => { testRenderer.mockInput.pressKey("y"); @@ -42,13 +50,13 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou frame.indexOf('send("new")'), ); await act(async () => { - await testRenderer.mockMouse.click(32, 1); + await testRenderer.mockMouse.click(32, 2); }); await testRenderer.waitForFrame( (frame) => frame.includes("▸") && !frame.includes('send("old")'), ); await act(async () => { - await testRenderer.mockMouse.click(32, 1); + await testRenderer.mockMouse.click(32, 2); }); await testRenderer.waitForFrame((frame) => frame.includes('send("old")')); } finally { @@ -60,16 +68,11 @@ test("render real OpenTUI rows, switch layout, collapse and reopen file with mou test("scrolling a large stream keeps terminal renderables bounded", async () => { const store = new DiffStore(), file = createTestDiffFile(); - const lines = Array.from({ length: 5000 }, (_, i) => `line ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: lines.join("\n") }; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.hunks = [ - { novel_lhs: [], novel_rhs: [], lines: lines.map((_, i) => [i, i]) }, - ]; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); + withIdenticalLines(file, 5000); + store.accept(startFor([file])); store.accept(file); const testRenderer = await testRender( - <App store={store} onQuit={() => {}} />, + <App store={store} onQuit={() => {}} themes={themes} />, { width: 150, height: 20 }, ); try { @@ -86,7 +89,7 @@ test("scrolling a large stream keeps terminal renderables bounded", async () => } expect(count(testRenderer.renderer.root)).toBeLessThan(250); await act(async () => { - await testRenderer.mockMouse.scroll(70, 8, "up"); + await testRenderer.mockMouse.scroll(70, 9, "up"); }); await testRenderer.waitForFrame((frame) => !frame.includes("line 4999")); } finally { @@ -97,37 +100,37 @@ test("scrolling a large stream keeps terminal renderables bounded", async () => }); test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", async () => { const store = new DiffStore(); - for (const path of ["src/alpha.ts", "src/nested/beta.ts"]) { - const file = createTestDiffFile(); - file.file.new_path = path; + const paths = ["src/alpha.ts", "src/nested/beta.ts"]; + store.accept(startFor(paths.map((path) => at(createTestDiffFile(), path)))); + for (const path of paths) { + const file = at(createTestDiffFile(), path); const lines = Array.from({length: 60}, (_, i) => `code ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text: lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [20], novel_rhs: [20,21], lines: file.diff.aligned_rows}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 20), leaf(2, 20, 21, [line(20, 0, 7)]), leaf(3, 21, 60)] }; + file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 20), leaf(2, 20, 21, [line(20, 0, 7)]), leaf(4, 21, 22, [line(21, 0, 7)]), leaf(3, 22, 60)] }; store.accept(file); } - const t = await testRender(<App store={store} onQuit={() => {}} />, {width:150, height:20}); + const t = await testRender(<App store={store} onQuit={() => {}} themes={themes} />, {width:150, height:20}); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("▾ src")); expect(t.captureCharFrame()).toContain("▾ nested"); // Sorted tree: src / nested / beta.ts / alpha.ts. - await act(async () => { await t.mockMouse.click(8, 3); }); - await t.waitForFrame(f => f.split("\n")[1].includes("src/nested/beta.ts")); + await act(async () => { await t.mockMouse.click(8, 4); }); + await t.waitForFrame(f => f.split("\n")[2].includes("src/nested/beta.ts")); await act(async () => { t.mockInput.pressKey("\x1b[6~"); }); await t.waitForFrame(f => f.includes("code 20")); - expect(t.captureCharFrame().split("\n")[1]).toContain("src/nested/beta.ts"); - expect(t.captureCharFrame().split("\n")[1]).toContain("+2 -1"); + expect(t.captureCharFrame().split("\n")[2]).toContain("src/nested/beta.ts"); + expect(t.captureCharFrame().split("\n")[2]).toContain("+2 −1"); await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => !f.includes("▾ src ")); - expect(t.captureCharFrame().split("\n")[1].trimStart()).toStartWith("▾ src/nested/beta.ts"); + expect(t.captureCharFrame().split("\n")[2].trimStart()).toStartWith("▾ src/nested/beta.ts"); await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => f.includes("▾ nested")); await act(async () => { await t.mockMouse.click(9, 0); }); - await t.waitForFrame(f => f.includes("Context: compact")); + await t.waitForFrame(f => f.includes("Toggle context gaps")); await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise(resolve => setTimeout(resolve, 50)); }); - await t.waitForFrame(f => !f.includes("Context: compact")); + await t.waitForFrame(f => !f.includes("Toggle context gaps")); } finally { await act(async () => { t.renderer.destroy(); }); } @@ -135,13 +138,12 @@ test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", as test("Hunk navigation chords and draggable sidebar preserve viewport behavior", async () => { const store = new DiffStore(), file = createTestDiffFile(); const lines = Array.from({length: 150}, (_, i) => `row ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text: lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i, i]); - file.diff.hunks = [{novel_lhs: [], novel_rhs: [], lines: file.diff.aligned_rows}]; + if (file.diff.type !== "text") throw new Error(); + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 150)] }; + store.accept(startFor([file])); store.accept(file); - const t = await testRender(<App store={store} onQuit={() => {}} />, {width:150, height:20}); - const firstSource = () => Number(t.captureCharFrame().split("\n")[2].match(/row (\d+)/)?.[1]); + const t = await testRender(<App store={store} onQuit={() => {}} themes={themes} />, {width:150, height:20}); + const firstSource = () => Number(t.captureCharFrame().split("\n")[3].match(/row (\d+)/)?.[1]); const press = async (name: string, ctrl = false) => { await act(async () => { t.mockInput.pressKey(name, {ctrl}); }); await t.renderOnce(); @@ -150,11 +152,11 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("row 0")); await press("d", true); - expect(firstSource()).toBe(9); + expect(firstSource()).toBe(8); await press("u", true); expect(firstSource()).toBe(0); await press("f", true); - expect(firstSource()).toBe(18); + expect(firstSource()).toBe(17); await press("b", true); expect(firstSource()).toBe(0); await press("f"); @@ -164,14 +166,14 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", expect(t.captureCharFrame()).toContain("row 149"); await press("g"); expect(firstSource()).toBe(0); - expect(t.captureCharFrame().split("\n")[2].indexOf("│")).toBe(27); + expect(t.captureCharFrame().split("\n")[3].indexOf("│")).toBe(27); await act(async () => { await t.mockMouse.drag(27, 8, 47, 8); }); - await t.waitForFrame(f => f.split("\n")[2].indexOf("│") === 47); + await t.waitForFrame(f => f.split("\n")[3].indexOf("│") === 47); await press("\\"); await press("\\"); - expect(t.captureCharFrame().split("\n")[2].indexOf("│")).toBe(47); + expect(t.captureCharFrame().split("\n")[3].indexOf("│")).toBe(47); await act(async () => { await t.mockMouse.drag(47, 8, 2, 8); }); - await t.waitForFrame(f => f.split("\n")[2].indexOf("│") === 15); + await t.waitForFrame(f => f.split("\n")[3].indexOf("│") === 15); expect(firstSource()).toBe(0); } finally { await act(async () => { t.renderer.destroy(); }); @@ -179,21 +181,21 @@ test("Hunk navigation chords and draggable sidebar preserve viewport behavior", }); test("initial manifest renders pending tree and remembers a jump until its diff arrives", async () => { const store = new DiffStore(), a = createTestDiffFile(), b = createTestDiffFile(); - a.file = {...a.file, old_path:"src/a.ts", new_path:"src/a.ts"}; - b.file = {...b.file, old_path:"src/b.ts", new_path:"src/b.ts"}; - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:2, files:[a.file,b.file]}); - const t = await testRender(<App store={store} onQuit={() => {}} />, {width:150, height:20}); + at(a, "src/a.ts"); + at(b, "src/b.ts"); + const entry = manifestEntry; + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:[entry(a), entry(b)]}); + const t = await testRender(<App store={store} onQuit={() => {}} themes={themes} />, {width:150, height:20}); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame(f => f.includes("◌ a.ts") && f.includes("◌ b.ts")); expect(t.captureCharFrame()).not.toContain('send("old")'); - await act(async () => { await t.mockMouse.click(8,3); }); + await act(async () => { await t.mockMouse.click(8,4); }); await t.waitForFrame(f => f.includes("Waiting for b.ts")); await act(async () => { store.accept(a); }); await t.waitForFrame(f => f.includes("◌ b.ts")); await act(async () => { store.accept(b); }); - await t.waitForFrame(f => f.split("\n")[1].includes("src/b.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("src/b.ts")); expect(t.captureCharFrame()).not.toContain("◌ b.ts"); } finally { await act(async () => { t.renderer.destroy(); }); @@ -202,39 +204,36 @@ test("initial manifest renders pending tree and remembers a jump until its diff test("streaming diffs follow tree order without moving the visible source row", async () => { const store = new DiffStore(); const files = ["z/last.ts", "a/first.ts", "m/middle.ts"].map(path => { - const file = createTestDiffFile(); - file.file = {...file.file, old_path:path, new_path:path}; + const file = at(createTestDiffFile(), path); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({length:50}, (_, i) => `code ${i}`); - file.diff.lhs_src = file.diff.rhs_src = {Text:lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i,i]); - file.diff.hunks = [{novel_lhs:[], novel_rhs:[], lines:file.diff.aligned_rows}]; + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 50)] }; return file; }); - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:3, files:files.map(f => f.file)}); - const t = await testRender(<App store={store} onQuit={() => {}} />, {width:150, height:20}); - const sidebarLines = () => t.captureCharFrame().split("\n").slice(1,8).map(line => line.slice(0,27).trim()); + const entry = manifestEntry; + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); + const t = await testRender(<App store={store} onQuit={() => {}} themes={themes} />, {width:150, height:20}); + const sidebarLines = () => t.captureCharFrame().split("\n").slice(2,9).map(line => line.slice(0,27).trim()); try { await act(async () => { await t.renderOnce(); store.accept(files[0]); store.accept(files[2]); }); await t.waitForFrame(f => f.includes("m/middle.ts")); expect(sidebarLines().slice(0,6)).toEqual(["▾ a", "◌ first.ts", "▾ m", "middle.ts", "▾ z", "last.ts"]); await act(async () => { t.mockInput.pressKey("g"); }); - await t.waitForFrame(f => f.split("\n")[1].includes("m/middle.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("m/middle.ts")); await act(async () => { t.mockInput.pressKey("d", {ctrl:true}); }); await t.renderOnce(); - const before = t.captureCharFrame().split("\n")[2].slice(28); + const before = t.captureCharFrame().split("\n")[3].slice(28); await act(async () => { store.accept(files[1]); }); - await t.waitForFrame(f => !f.includes("◌ first.ts") && f.split("\n")[1].includes("m/middle.ts")); - expect(t.captureCharFrame().split("\n")[2].slice(28)).toBe(before); + await t.waitForFrame(f => !f.includes("◌ first.ts") && f.split("\n")[2].includes("m/middle.ts")); + expect(t.captureCharFrame().split("\n")[3].slice(28)).toBe(before); expect(sidebarLines().slice(0,6)).toEqual(["▾ a", "first.ts", "▾ m", "middle.ts", "▾ z", "last.ts"]); await act(async () => { store.accept({type:"complete", succeeded:3, failed:0}); }); await t.renderOnce(); - expect(t.captureCharFrame().split("\n")[2].slice(28)).toBe(before); + expect(t.captureCharFrame().split("\n")[3].slice(28)).toBe(before); await act(async () => { t.mockInput.pressKey("g"); }); - await t.waitForFrame(f => f.split("\n")[1].includes("a/first.ts")); - await act(async () => { await t.mockMouse.click(8,4); }); - await t.waitForFrame(f => f.split("\n")[1].includes("m/middle.ts")); + await t.waitForFrame(f => f.split("\n")[2].includes("a/first.ts")); + await act(async () => { await t.mockMouse.click(8,5); }); + await t.waitForFrame(f => f.split("\n")[2].includes("m/middle.ts")); } finally { await act(async () => { t.renderer.destroy(); }); } @@ -244,22 +243,19 @@ for (const wrap of [false, true]) for (const unified of [false, true]) test(`stream arrivals preserve code in every commit (wrap=${wrap}, unified=${unified})`, async () => { const store = new DiffStore(); const files = ["m/current.ts", "a/earlier.ts", "z/later.ts"].map(path => { - const file = createTestDiffFile(); - file.file = {...file.file, old_path:path, new_path:path}; + const file = at(createTestDiffFile(), path); + if (file.diff.type !== "text") throw new Error(); const lines = Array.from({length:50}, (_, i) => `source ${path} ${i} ${"word ".repeat(20)}`); - file.diff.lhs_src = file.diff.rhs_src = {Text:lines.join("\n")}; - file.diff.lhs_positions = file.diff.rhs_positions = []; - file.diff.aligned_rows = lines.map((_, i) => [i,i]); - file.diff.hunks = [{novel_lhs:[], novel_rhs:[], lines:file.diff.aligned_rows}]; + file.diff.lhs = file.diff.rhs = { text: lines.join("\n"), syntax: [], regions: [leaf(1, 0, 50)] }; return file; }); - store.accept({type:"start", version:1, before:{kind:"index"}, after:{kind:"working_tree"}, - total:files.length, files:files.map(f => f.file)}); + const entry = manifestEntry; + store.accept({type:"start", version:3, lhs:{type:"index"}, rhs:{type:"working_tree"}, files:files.map(entry)}); store.accept(files[0]); let capture: (() => void) | undefined; const commits: string[][] = []; const t = await testRender(<Profiler id="viewport" onRender={() => capture?.()}> - <App store={store} onQuit={() => {}} /> + <App store={store} onQuit={() => {}} themes={themes} /> </Profiler>, {width:150, height:20}); const sourceCells = (node: BaseRenderable): string[] => { if (node instanceof TextRenderable) { @@ -292,20 +288,22 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a const store = new DiffStore(); const file = createFoldedDiffFile(); // Pad the file past the viewport so the z key can act on a scrolled-to row. - const text = (file.diff.rhs_src as { Text: string }).Text; + if (file.diff.type !== "text") throw new Error(); const tail = Array.from({ length: 20 }, (_, i) => `tail ${i}`); - file.diff.lhs_src = file.diff.rhs_src = { Text: text + tail.join("\n") + "\n" }; - const rows = file.diff.aligned_rows.length; - file.diff.aligned_rows.push(...tail.map((_, i): [number, number] => [rows + i, rows + i])); - file.diff.hunks[0].lines = file.diff.aligned_rows; + for (const source of [file.diff.lhs!, file.diff.rhs!]) { + source.text += tail.join("\n") + "\n"; + source.regions.push(leaf(99, 8, 28)); + } + store.accept(startFor([file])); store.accept(file); - const t = await testRender(<App store={store} onQuit={() => {}} />, { width: 150, height: 20 }); + const t = await testRender(<App store={store} onQuit={() => {}} themes={themes} />, { width: 150, height: 20 }); try { await act(async () => { await t.renderOnce(); }); await t.waitForFrame((f) => f.includes("inner(|| {")); const lines = () => t.captureCharFrame().split("\n"); const rowOf = (needle: string) => lines().findIndex((l) => l.includes(needle)); - const inner = rowOf("inner(|| {"); + // The closure body opens on the line after `inner(|| {`, which carries its chevron. + const inner = rowOf("a();"); // Every foldable row shows its chevron without hovering. const chevronX = lines()[inner].indexOf("▾"); expect(chevronX).toBeGreaterThan(0); @@ -320,28 +318,29 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a const chord = async (...keys: string[]) => { for (const key of keys) await act(async () => { t.mockInput.pressKey(key); }); }; - await chord("j"); + await chord("j", "j"); await t.waitForFrame((f) => !f.includes("fn outer() {")); await chord("z", "c"); await t.waitForFrame((f) => !f.includes("inner(|| {") && !f.includes("a();")); await chord("z", "c"); await chord("z", "a"); - await t.waitForFrame((f) => f.includes("inner(|| {")); + await t.waitForFrame((f) => f.includes("a();")); // zC closes recursively, then zo reopens only the outer fold. await chord("z", "C"); - await t.waitForFrame((f) => !f.includes("inner(|| {")); + await t.waitForFrame((f) => !f.includes("a();") && !f.includes("});")); await chord("z", "o"); - await t.waitForFrame((f) => f.includes("inner(|| {") && !f.includes("a();")); + await t.waitForFrame((f) => f.includes("});") && !f.includes("a();")); await chord("z", "O"); await t.waitForFrame((f) => f.includes("a();")); // zj scrolls to the next fold header; an unknown z command is ignored. await chord("z", "j"); - await t.waitForFrame((f) => !f.includes("inner(|| {") && f.includes("a();")); + await t.waitForFrame((f) => !f.includes("a();") && f.includes("b();")); await chord("z", "x"); - await chord("k", "k"); + await chord("k", "k", "k"); await t.waitForFrame((f) => f.includes("fn outer() {")); - // Alt-click on the outer chevron folds nested regions too, so reopening keeps them folded. - const outer = rowOf("fn outer() {"); + // Alt-click on the outer body's chevron folds nested regions too, so reopening keeps them + // folded. That chevron sits on `inner(|| {`, the first line the outer body covers. + const outer = rowOf("inner(|| {"); await act(async () => { await t.mockMouse.click(chevronX, outer, 0, { modifiers: { alt: true } }); }); await t.waitForFrame((f) => !f.includes("inner(|| {")); await act(async () => { await t.mockMouse.click(chevronX, outer); }); @@ -354,3 +353,117 @@ test("folds collapse from the gutter chevron and expand from the placeholder", a await act(async () => { t.renderer.destroy(); }); } }); + +test("the summary strip shows the wire's visible totals, unmoved by folding, and i opens the breakdown", async () => { + const store = new DiffStore(); + store.accept({type:"start", version:3, lhs:{type:"revision", rev:"main"}, rhs:{type:"working_tree"}, + files:[manifestEntry(createFoldedDiffFile())]}); + store.accept(createFoldedDiffFile()); + const t = await testRender(<App store={store} onQuit={() => {}} themes={themes} />, { width: 150, height: 24 }); + const render = async () => { await act(async () => { await t.renderOnce(); }); }; + const lines = () => t.captureCharFrame().split("\n"); + try { + await render(); + await render(); + expect(t.captureCharFrame()).toContain("inner(|| {"); + // One changed rhs line inside the closure body; still loading, so the total is partial. + expect(lines()[1]).toContain("main…working tree"); + expect(lines()[1]).toContain("1 files"); + expect(lines()[1]).toContain("+1 −0…"); + expect(lines()[1]).toContain("■■■■■"); + expect(lines()[2]).toContain("+1 −0"); + await act(async () => { store.accept({ type: "complete", succeeded: 1, failed: 0 }); }); + await render(); + expect(lines()[1]).not.toContain("−0…"); + // Collapse the closure body: the change is hidden, but the counts are diffr's and stay put. + const inner = lines().findIndex((l) => l.includes("inner(|| {")); + const chevronX = lines()[inner].indexOf("▾"); + await act(async () => { await t.mockMouse.click(chevronX, inner); }); + await render(); + expect(t.captureCharFrame()).not.toContain("a();"); + expect(lines()[1]).toContain("+1 −0"); + expect(lines()[1]).toContain("■■■■■"); + expect(lines()[2]).toContain("+1 −0"); + await act(async () => { t.mockInput.pressKey("i"); }); + await render(); + const frame = t.captureCharFrame(); + expect(frame).toContain("All files"); + expect(frame).toContain("visible +1 −0"); + expect(frame).toContain("textual +1 −0"); + expect(frame).not.toContain("line diff"); + await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise((resolve) => setTimeout(resolve, 100)); }); + await render(); + expect(t.captureCharFrame()).not.toContain("visible +1"); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); + +test("a docstring and its function sharing a fold_state_id open and close together from either row", async () => { + const store = new DiffStore(); + const file = createBundledDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // Pad past the viewport so z chords can act on a row scrolled to the top. + const tail = Array.from({ length: 20 }, (_, i) => `tail ${i}`); + file.diff.rhs!.text += tail.join("\n") + "\n"; + file.diff.rhs!.regions.push(leaf(99, 5, 25)); + store.accept(startFor([file])); + store.accept(file); + const t = await testRender(<App store={store} onQuit={() => {}} themes={themes} />, { width: 150, height: 20 }); + const lines = () => t.captureCharFrame().split("\n"); + const rowOf = (needle: string) => lines().findIndex((l) => l.includes(needle)); + const docOpen = (f: string) => f.includes("/// The built-in rule") && f.includes(" None"); + const docClosed = (f: string) => !f.includes("/// The built-in rule") && !f.includes(" None") && f.includes("fn from_path"); + const chord = async (...keys: string[]) => { for (const key of keys) await act(async () => { t.mockInput.pressKey(key); }); }; + try { + await act(async () => { await t.renderOnce(); }); + await t.waitForFrame(docClosed); + // Mouse, docstring row: its ⋯ row opens the docstring and the body together. + const bare = lines().findIndex((l) => /▸\s+⋯/.test(l)); + expect(bare).toBeGreaterThan(0); + expect(bare).toBeLessThan(rowOf("fn from_path")); + await act(async () => { await t.mockMouse.click(lines()[bare].indexOf("▸"), bare); }); + await t.waitForFrame(docOpen); + // Mouse, body row: the chevron on the first line the body fold covers closes both again. + const header = rowOf(" None"); + await act(async () => { await t.mockMouse.click(lines()[header].indexOf("▾"), header); }); + await t.waitForFrame(docClosed); + // Keys: z chords act on the top row, under the sticky file header. Scroll past line 1 so the + // docstring's ⋯ row is the top row; zo / zc / za then act on the pair. + await chord("j", "j"); + await t.waitForFrame((f) => !f.includes("1 ];")); + // The top row sits under the sticky header, so judge by the body: open shows it, closed shows + // the pseudocode label instead. + const bodyOpen = (f: string) => f.includes(" None") && !f.includes("look up path"); + const bodyClosed = (f: string) => !f.includes(" None") && f.includes("look up path"); + await chord("z", "o"); + await t.waitForFrame(bodyOpen); + await chord("z", "c"); + await t.waitForFrame(bodyClosed); + await chord("z", "a"); + await t.waitForFrame(bodyOpen); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); + +test("a file record's visibility hides the file behind its reason until it is opened", async () => { + const store = new DiffStore(), file = createTestDiffFile(); + file.visibility = { collapsed: true, label: "Generated file · hidden by default" }; + store.accept(startFor([file])); + store.accept(file); + const t = await testRender(<App store={store} onQuit={() => {}} themes={themes} />, { width: 150, height: 20 }); + try { + await act(async () => { await t.renderOnce(); }); + await t.waitForFrame((f) => f.includes("Generated file · hidden by default")); + const frame = t.captureCharFrame(); + expect(frame).toContain("Load diff"); + expect(frame).not.toContain('send("old")'); + const lines = frame.split("\n"); + const load = lines.findIndex((l) => l.includes("Load diff")); + await act(async () => { await t.mockMouse.click(lines[load].indexOf("Load diff"), load); }); + await t.waitForFrame((f) => f.includes('send("old")')); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index 11e455bbc..e52a31072 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -16,20 +16,21 @@ import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; import { - dark, - light, rowsForFile, type Layout, type ViewerRow, } from "../diffr/rows"; +import type { Palette, ThemeSet } from "../diffr/theme"; import { measureRows, visibleRows } from "../diffr/geometry"; import { copySelection, selectionBounds, type SourceSelection, } from "../diffr/selection"; -import { fileIdentity, type DiffFile } from "../diffr/wire"; -import { foldRegions, nestedRegions, type FoldRegion, type RowFold } from "../diffr/folds"; +import { fileIdentity, filePath, type DiffFile } from "../diffr/wire"; +import { defaultCollapsed, foldIds, gapIds, nestedIds, type RowFold } from "../diffr/regions"; +import { placeholderRows } from "../diffr/rows"; +import { add, blockBar, comparisonLabel, zero, type LineCounts } from "../diffr/counts"; import type { DiffStore } from "../diffr/store"; import { sanitizeTerminalLine } from "../lib/terminalText"; import { sliceTextByWidth } from "./lib/text"; @@ -38,9 +39,11 @@ const fit = (text: string, width: number) => export function App({ store, onQuit, + themes, }: { store: DiffStore; onQuit: () => void; + themes: ThemeSet; }) { const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); const renderer = useRenderer(), @@ -48,15 +51,15 @@ export function App({ const [mode, setMode] = useState<Layout | "auto">("auto"), [showSidebar, setShowSidebar] = useState(true), [wrap, setWrap] = useState(false), - [fullContext, setFullContext] = useState(false), - [isLight, setLight] = useState(false); + [theme, setTheme] = useState<Palette>(themes.initial); const [scroll, setScroll] = useState(0), - [horizontal, setHorizontal] = useState(0), - [closed, setClosed] = useState<Set<number>>(new Set()); + [horizontal, setHorizontal] = useState(0); + // Files the user closed or opened; unset files follow the visibility on their file record. + const [closed, setClosed] = useState<Map<number, boolean>>(new Map()); const [selection, setSelection] = useState<SourceSelection | null>(null), [message, setMessage] = useState(""); - // Fold ids collapsed per loaded file; VS Code keeps this per editor model. - const [collapsed, setCollapsed] = useState<Map<number, ReadonlySet<string>>>(new Map()); + // Fold ids collapsed per loaded file; unset files start where diffr's visibility says. + const [collapsed, setCollapsed] = useState<Map<number, ReadonlySet<number>>>(new Map()); // Vim's z prefix: the next key names the fold command. const pendingZ = useRef(false); const [closedDirectories, setClosedDirectories] = useState<Set<string>>(new Set()); @@ -65,22 +68,29 @@ export function App({ const sidebarDrag = useRef<{ x: number; width: number } | null>(null); const [pendingFile, setPendingFile] = useState<string | null>(null); const [menu, setMenu] = useState<string | null>(null); + const [showBreakdown, setShowBreakdown] = useState(false); const dragging = useRef(false), thumbDragging = useRef(false); - const theme = isLight ? light : dark, - sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; + // `t` swaps between the two bundled defaults; a configured theme is left by the first press. + const toggleTheme = () => setTheme((current) => (current.isLight ? themes.dark : themes.light)); + const sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; const contentWidth = Math.max(10, width - sidebar - 1), - viewportHeight = Math.max(1, height - 2); + viewportHeight = Math.max(1, height - 3); const layout = mode === "auto" ? (contentWidth >= 100 ? "split" : "unified") : mode; const loadedByIdentity = useMemo(() => new Map(snapshot.files.map((f, i) => [fileIdentity(f.file), i])), [snapshot.files]); - const inventory = useMemo(() => snapshot.inventory.map(file => ({file})), [snapshot.inventory]); + const inventory = snapshot.inventory; + const foldsOf = (index: number): ReadonlySet<number> => { + const file = snapshot.files[index]; + return collapsed.get(index) ?? (file.diff.type === "text" ? defaultCollapsed(file.diff) : new Set()); + }; + const isClosed = (index: number) => closed.get(index) ?? snapshot.files[index].visibility.collapsed; const tree = useMemo(() => buildFileTree(inventory), [inventory]); // Keep loaded indexes stable for row keys, selections and file expansion. // Present arriving diffs in tree order throughout loading. const fileOrder = useMemo(() => flattenFileTree(tree, new Set()).flatMap(({node}) => { if (node.fileIndex === undefined) return []; - const loaded = loadedByIdentity.get(fileIdentity(snapshot.inventory[node.fileIndex])); + const loaded = loadedByIdentity.get(fileIdentity(snapshot.inventory[node.fileIndex].file)); return loaded === undefined ? [] : [loaded]; }), [snapshot.inventory, tree, loadedByIdentity]); @@ -90,19 +100,22 @@ export function App({ const rows = useMemo(() => { const all = fileOrder.flatMap(index => { const file = snapshot.files[index]; - const folds = collapsed.get(index) ?? new Set<string>(); - const key = `${index}:${layout}:${isLight}:${fullContext}:${[...folds].sort().join(",")}`; + const folds = foldsOf(index); + const key = `${index}:${layout}:${theme.name}:${[...folds].sort((a, b) => a - b).join(",")}`; let cached = rowCache.current.get(file); if (cached?.key !== key) { - cached = { key, rows: rowsForFile(file, index, layout, theme, fullContext, folds) }; + cached = { key, rows: rowsForFile(file, index, layout, theme, folds) }; rowCache.current.set(file, cached); } - return closed.has(index) ? cached.rows.slice(0, 1) : cached.rows; + if (!isClosed(index)) return cached.rows; + return file.visibility.collapsed + ? [cached.rows[0], ...placeholderRows(index, file.visibility.label)] + : cached.rows.slice(0, 1); }); for (const [i, error] of snapshot.errors.entries()) all.push({ key: `error:${i}`, fileIndex: -1, label: error }); return all; - }, [snapshot.files, snapshot.errors, layout, theme, closed, fullContext, fileOrder, collapsed]); + }, [snapshot.files, snapshot.errors, snapshot.inventory, layout, theme, closed, fileOrder, collapsed]); const geometry = useMemo( () => measureRows(rows, contentWidth, wrap, horizontal), [rows, contentWidth, wrap, horizontal], @@ -127,39 +140,29 @@ export function App({ } const move = (amount: number) => setScroll((current) => Math.max(0, Math.min(maxScroll, current + amount))); - const toggleFile = (index: number) => { - setClosed((old) => { - const next = new Set(old); - if (next.has(index)) next.delete(index); - else next.add(index); - return next; - }); - }; - const regionCache = useRef(new WeakMap<DiffFile, FoldRegion[]>()); - const regionsOf = (file: DiffFile) => { - let regions = regionCache.current.get(file); - if (!regions) { - regions = foldRegions(file.diff); - regionCache.current.set(file, regions); - } - return regions; - }; - const setFolds = (fileIndex: number, ids: string[], collapse: boolean) => + const toggleFile = (index: number) => + setClosed((old) => new Map(old).set(index, !isClosed(index))); + const setFolds = (fileIndex: number, ids: number[], collapse: boolean) => setCollapsed((old) => { - const next = new Set(old.get(fileIndex) ?? []); + const next = new Set(foldsOf(fileIndex)); for (const id of ids) if (collapse) next.add(id); else next.delete(id); return new Map(old).set(fileIndex, next); }); - // Recursive commands (Alt-click, zC, zO, zA) include every region nested inside. + // Recursive commands (Alt-click, zC, zO, zA) include every fold nested inside. const setFold = (fileIndex: number, fold: RowFold, collapse: boolean, recursive: boolean) => { - const regions = regionsOf(snapshot.files[fileIndex]); - const region = regions.find((r) => r.id === fold.id); - if (!region) throw new Error(`Unknown fold ${fold.id}`); - const ids = recursive - ? [fold.id, ...nestedRegions(regions, region).map((r) => r.id)] - : [fold.id]; + const file = snapshot.files[fileIndex]; + if (file.diff.type !== "text") throw new Error("Binary files have no folds"); + const ids = recursive ? [fold.id, ...nestedIds(file.diff, fold.id)] : [fold.id]; setFolds(fileIndex, ids, collapse); }; + // `c`: reveal every context gap, or hide them again. + const toggleContext = () => { + const opened = snapshot.files.some((file, index) => + file.diff.type === "text" && gapIds(file.diff).some((id) => !foldsOf(index).has(id))); + snapshot.files.forEach((file, index) => { + if (file.diff.type === "text") setFolds(index, gapIds(file.diff), opened); + }); + }; const toggleFold = (fileIndex: number, fold: RowFold, recursive: boolean) => setFold(fileIndex, fold, !fold.collapsed, recursive); const rowFold = (row: ViewerRow) => row.cell?.fold ?? row.right?.fold ?? row.left?.fold; @@ -186,15 +189,10 @@ export function App({ else if (letter === "o") setFold(current.fileIndex, fold, false, recursive); else if (letter === "c") setFold(current.fileIndex, fold, true, recursive); }; - const foldAll = (collapse: boolean) => { - const byFile = new Map<number, string[]>(); - for (const row of rows) { - const fold = rowFold(row); - if (fold) byFile.set(row.fileIndex, [...(byFile.get(row.fileIndex) ?? []), fold.id]); - } - if (collapse) for (const [fileIndex, ids] of byFile) setFolds(fileIndex, ids, true); - else setCollapsed(new Map()); - }; + const foldAll = (collapse: boolean) => + snapshot.files.forEach((file, index) => { + if (file.diff.type === "text") setFolds(index, foldIds(file.diff), collapse); + }); const jump = (index: number) => { const row = geometry.rows.find((r) => r.row.fileIndex === index); if (row) setScroll(Math.min(maxScroll, row.top)); @@ -235,7 +233,8 @@ export function App({ else if (is("pageup", "b", "shift+space", "ctrl+b")) move(-viewportHeight); else if (is("down", "j")) move(1); else if (is("up", "k")) move(-1); - else if (is("g", "home")) setScroll(0); + else if (is("g")) setScroll(0); + else if (is("home")) setScroll(0); else if (is("G", "end")) setScroll(maxScroll); else if (is("right", "shift+right", "l")) setHorizontal(n => n + (key.shift ? 16 : 4)); else if (is("left", "shift+left", "h")) setHorizontal(n => Math.max(0, n - (key.shift ? 16 : 4))); @@ -245,10 +244,11 @@ export function App({ setMode(layout === "split" ? "unified" : "split"); setSelection(null); } else if (key.name === "w") setWrap((v) => !v); - else if (key.name === "c") { setFullContext(v => !v); setSelection(null); } - else if (key.name === "t") setLight((v) => !v); + else if (key.name === "c") { toggleContext(); setSelection(null); } + else if (key.name === "t") toggleTheme(); else if (key.name === "y") copy(); - else if (key.name === "escape") { setSelection(null); setMenu(null); } + else if (key.name === "escape") { setSelection(null); setMenu(null); setShowBreakdown(false); } + else if (key.name === "i") setShowBreakdown((v) => !v); else if (key.name === "return") { const current = visibleRows(geometry, top, 1)[0]; if (current && current.row.fileIndex >= 0) @@ -266,7 +266,7 @@ export function App({ const viewport = visibleRows(geometry, top, viewportHeight); const currentFile = viewport[0]?.row.fileIndex ?? 0; const activeIdentity = pendingFile ?? (snapshot.files[currentFile] ? fileIdentity(snapshot.files[currentFile].file) : null); - const currentTreeFile = snapshot.inventory.findIndex(file => fileIdentity(file) === activeIdentity); + const currentTreeFile = snapshot.inventory.findIndex(entry => fileIdentity(entry.file) === activeIdentity); useEffect(() => { if (pendingFile === null) return; const index = loadedByIdentity.get(pendingFile); @@ -280,6 +280,13 @@ export function App({ }, [pendingFile, loadedByIdentity, geometry, maxScroll, snapshot.failedFiles, snapshot.complete]); const treeRows = useMemo(() => flattenFileTree(tree, closedDirectories), [tree, closedDirectories]); const counts = useMemo(() => snapshot.files.map(lineCounts), [snapshot.files]); + // Headline numbers are diffr's stats.visible, verbatim: folding never changes them. + const totals = useMemo(() => ({ + visible: counts.reduce((sum, c) => add(sum, c.visible), zero), + textual: counts.reduce((sum, c) => add(sum, c.textual), zero), + fallbacks: counts.filter((c) => c.fallback).length, + }), [counts]); + const plusMinus = (c: LineCounts) => `+${c.added} −${c.removed}`; useEffect(() => { const file = inventory[currentTreeFile]; if (!file) return; @@ -296,18 +303,18 @@ export function App({ }, [currentTreeFile, treeRows, viewportHeight]); const sidebarStart = Math.min(treeScroll, Math.max(0, treeRows.length - viewportHeight)); const fileHeader = (fileIndex: number, key: string) => { - const file = snapshot.files[fileIndex], count = counts[fileIndex]; - if (!file) return null; - const path = file.file.new_path ?? file.file.old_path ?? file.diff.display_path; + const file = snapshot.files[fileIndex], count = counts[fileIndex]?.visible; + if (!file || !count) return null; + const path = filePath(file.file); const statsWidth = String(count.added).length + String(count.removed).length + 5; return <box key={key} height={1} width={contentWidth} flexDirection="row" - backgroundColor={isLight ? "#f0f2f5" : "#161b22"} + backgroundColor={theme.chrome} onMouseUp={() => toggleFile(fileIndex)}> <text width={Math.max(1, contentWidth - statsWidth)} fg={theme.fg} selectable={false}> - {fit(sanitizeTerminalLine(`${closed.has(fileIndex) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} + {fit(sanitizeTerminalLine(`${isClosed(fileIndex) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} </text> - <text fg={isLight ? "#1a7f37" : "#7ee787"} selectable={false}>{` +${count.added}`}</text> - <text fg={isLight ? "#cf222e" : "#ffa198"} selectable={false}>{` -${count.removed} `}</text> + <text fg={theme.addedText} selectable={false}>{` +${count.added}`}</text> + <text fg={theme.removedText} selectable={false}>{` −${count.removed} `}</text> </box>; }; const rendered = []; @@ -328,20 +335,13 @@ export function App({ key={row.key} height={1} width={contentWidth} - fg={theme.muted} + fg={row.loadDiff ? theme.accent : theme.muted} selectable={false} onMouseUp={() => { - if (row.key.endsWith(":header")) toggleFile(row.fileIndex); + if (row.loadDiff) toggleFile(row.fileIndex); }} > - {fit( - sanitizeTerminalLine( - row.key.endsWith(":header") - ? `${closed.has(row.fileIndex) ? "▸" : "▾"} ${row.label}` - : row.label, - ), - contentWidth, - )} + {fit(sanitizeTerminalLine(row.loadDiff ? ` ${row.label}` : row.label), contentWidth)} </text>, ); else @@ -396,15 +396,16 @@ export function App({ File: [["Toggle file tree ⌘B / \\", () => setShowSidebar(v => !v)], ["Copy selection y", copy], ["Quit q", onQuit]], View: [[`Layout: ${layout} s`, () => { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }], [`Wrap: ${wrap ? "on" : "off"} w`, () => setWrap(v => !v)], - [`Context: ${fullContext ? "all" : "compact"} c`, () => { setFullContext(v => !v); setSelection(null); }], + ["Toggle context gaps c", () => { toggleContext(); setSelection(null); }], ["Fold all zM", () => foldAll(true)], ["Unfold all zR", () => foldAll(false)]], Navigate: [["Previous change [", () => navigateHunk(-1)], ["Next change ]", () => navigateHunk(1)], ["First file Home", () => setScroll(0)], ["Last file End", () => setScroll(maxScroll)]], - Theme: [["Dark", () => setLight(false)], ["Light", () => setLight(true)]], + Theme: [[`Dark (${themes.dark.name}) t`, () => setTheme(themes.dark)], [`Light (${themes.light.name}) t`, () => setTheme(themes.light)]], Help: [["Scroll: j/k · h/l · gg/G", () => setMessage("j/k scroll · h/l pan · gg first · G last")], ["Half page: Ctrl-D / Ctrl-U", () => setMessage("d / Ctrl-D: half down · u / Ctrl-U: half up")], ["Full page: Ctrl-F / Ctrl-B", () => setMessage("Ctrl-F: page down · Ctrl-B: page up")], ["Drag to select · y to copy", () => setMessage("Drag source lines; y copies original source")], + ["Change breakdown i", () => setShowBreakdown(true)], ["Folds: click ▾ · za zo zc · zM zR", () => setMessage("Click the chevron or ⋯ · za toggle, zo open, zc close the top fold (zA zO zC recursive) · zM/zR fold/unfold all · zj/zk next/previous fold")]], }; return ( @@ -434,17 +435,29 @@ export function App({ sidebarDrag.current = null; }} > - <box height={1} flexDirection="row" backgroundColor={isLight ? "#f0f2f5" : "#161b22"}> + <box height={1} flexDirection="row" backgroundColor={theme.chrome}> {["File", "View", "Navigate", "Theme", "Help"].map(name => ( <text key={name} fg={menu === name ? theme.fg : theme.muted} selectable={false} onMouseUp={() => setMenu(old => old === name ? null : name)}> {` ${name} `} </text> ))} - <text fg={theme.type} selectable={false} onMouseUp={() => { + <text fg={theme.accent} selectable={false} onMouseUp={() => { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }}>{` ${layout} [s] `}</text> - <text fg={theme.muted} selectable={false}>{` · ${snapshot.total || snapshot.inventory.length} files`}</text> + </box> + <box height={1} flexDirection="row" backgroundColor={theme.chrome}> + <text fg={theme.fg} selectable={false}> + {` ${snapshot.comparison ? comparisonLabel(snapshot.comparison.lhs, snapshot.comparison.rhs) : "diffr"}`} + </text> + <text fg={theme.muted} selectable={false}>{` · ${snapshot.total || snapshot.inventory.length} files · `}</text> + <text fg={theme.addedText} selectable={false} onMouseUp={() => setShowBreakdown((v) => !v)}>{`+${totals.visible.added}`}</text> + <text fg={theme.removedText} selectable={false} onMouseUp={() => setShowBreakdown((v) => !v)}>{` −${totals.visible.removed}`}</text> + <text fg={theme.muted} selectable={false}>{snapshot.complete ? " " : "… "}</text> + {blockBar(totals.visible).map((block, i) => ( + <text key={i} fg={block === "added" ? theme.addedText : block === "removed" ? theme.removedText : theme.muted} + selectable={false} onMouseUp={() => setShowBreakdown((v) => !v)}>{block === "neutral" ? "□" : "■"}</text> + ))} </box> <box height={viewportHeight} @@ -473,11 +486,11 @@ export function App({ {treeRows.slice(sidebarStart, sidebarStart + viewportHeight).map(({node, depth}) => ( <text key={node.key} height={1} width={sidebar - 1} fg={node.fileIndex === currentTreeFile ? theme.fg : theme.muted} - bg={node.fileIndex === currentTreeFile ? (isLight ? "#ddf4ff" : "#1c3045") : theme.bg} + bg={node.fileIndex === currentTreeFile ? theme.highlight : theme.bg} selectable={false} onMouseUp={() => { if (node.fileIndex !== undefined) { - const identity = fileIdentity(snapshot.inventory[node.fileIndex]); + const identity = fileIdentity(snapshot.inventory[node.fileIndex].file); const loaded = loadedByIdentity.get(identity); if (loaded !== undefined) { setPendingFile(null); setMessage(""); jump(loaded); } else if (snapshot.failedFiles.has(identity)) setMessage(snapshot.failedFiles.get(identity)!); @@ -490,8 +503,8 @@ export function App({ }); }}> {fit(sanitizeTerminalLine(" ".repeat(depth) + (node.fileIndex === undefined - ? (closedDirectories.has(node.key) ? "▸ " : "▾ ") : snapshot.failedFiles.has(fileIdentity(snapshot.inventory[node.fileIndex])) ? "! " - : loadedByIdentity.has(fileIdentity(snapshot.inventory[node.fileIndex])) ? " " : "◌ ") + node.name), sidebar - 1)} + ? (closedDirectories.has(node.key) ? "▸ " : "▾ ") : snapshot.failedFiles.has(fileIdentity(snapshot.inventory[node.fileIndex].file)) ? "! " + : loadedByIdentity.has(fileIdentity(snapshot.inventory[node.fileIndex].file)) ? " " : "◌ ") + node.name), sidebar - 1)} </text> ))} </box> @@ -545,9 +558,30 @@ export function App({ /> </box> </box> + {showBreakdown && (() => { + const file = snapshot.files[currentFile]; + const fallback = file && counts[currentFile].fallback; + const sections: [string, LineCounts, LineCounts, string | null][] = [ + ["All files", totals.visible, totals.textual, totals.fallbacks ? `line diff: ${totals.fallbacks} files` : null], + ...(file ? [[filePath(file.file), counts[currentFile].visible, counts[currentFile].textual, + fallback ? `line diff: ${fallback.code}` : null] as [string, LineCounts, LineCounts, string | null]] : []), + ]; + const boxWidth = Math.min(width, 44); + const rowCount = sections.reduce((n, s) => n + 3 + (s[3] ? 1 : 0), 0); + return <box position="absolute" top={2} left={Math.max(0, width - boxWidth - 1)} width={boxWidth} + height={rowCount + 1} flexDirection="column" zIndex={10} backgroundColor={theme.chrome}> + {sections.flatMap(([title, visible, textual, note]) => [ + <text key={`${title}:t`} height={1} fg={theme.fg} selectable={false}>{fit(` ${title}`, boxWidth)}</text>, + <text key={`${title}:v`} height={1} fg={theme.muted} selectable={false}>{fit(` visible ${plusMinus(visible)}`, boxWidth)}</text>, + <text key={`${title}:x`} height={1} fg={theme.muted} selectable={false}>{fit(` textual ${plusMinus(textual)}`, boxWidth)}</text>, + ...(note ? [<text key={`${title}:f`} height={1} fg={theme.muted} selectable={false}>{fit(` ${note}`, boxWidth)}</text>] : []), + ])} + <text height={1} fg={theme.muted} selectable={false}>{fit(" esc close", boxWidth)}</text> + </box>; + })()} {menu && <box position="absolute" top={1} left={0} width={38} height={menuItems[menu].length} flexDirection="column" zIndex={10} - backgroundColor={isLight ? "#eaeef2" : "#21262d"}> + backgroundColor={theme.chrome}> {menuItems[menu].map(([label, action]) => ( <text key={label} height={1} width={38} fg={theme.fg} selectable={false} onMouseUp={() => { action(); setMenu(null); }}> @@ -557,7 +591,7 @@ export function App({ </box>} <text height={1} fg={theme.muted} selectable={false}> {fit( - `${snapshot.files.length}/${snapshot.total} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · drag selects lines · y copy · q quit ${message}`, + `${snapshot.files.length}/${snapshot.total} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · i breakdown · drag selects lines · y copy · q quit ${message}`, width, )} </text> diff --git a/tui/packages/hunk/src/ui/Settings.test.tsx b/tui/packages/hunk/src/ui/Settings.test.tsx new file mode 100644 index 000000000..7a470933d --- /dev/null +++ b/tui/packages/hunk/src/ui/Settings.test.tsx @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { Settings, displayValue, isSecret, nextValue } from "./Settings"; +import { schemaFixture, valuesFixture } from "../diffr/config.test"; +import { flattenSchema, type ConfigClient } from "../diffr/config"; + +test("values show as not set, secrets only as stored, and toggles flip or cycle", () => { + const [minLines, collapse, , provider, apiKey] = flattenSchema(schemaFixture, valuesFixture); + expect(displayValue(minLines)).toBe("12"); + expect(displayValue({ ...minLines, value: null })).toBe("not set"); + expect(displayValue(apiKey)).toBe("not set"); + expect(displayValue({ ...apiKey, value: "abc" })).toBe("✓ stored"); + expect(isSecret("plugins.summarize.api_key")).toBe(true); + expect(isSecret("plugins.deleted-bodies.min_lines")).toBe(false); + expect(nextValue(collapse)).toBe("false"); + expect(nextValue(provider)).toBe("none"); + expect(nextValue({ ...provider, value: "none" })).toBe("gemini"); +}); + +test("rows lead with titles under group headings; toggles change in place and typed values open a prompt", async () => { + const writes: [string, string][] = []; + const client: ConfigClient = { + schema: () => schemaFixture, + show: () => structuredClone(valuesFixture), + set: (key, value) => { + writes.push([key, value]); + }, + }; + let quit = false; + const t = await testRender(<Settings client={client} onQuit={() => { quit = true; }} />, { width: 100, height: 24 }); + const press = async (key: string) => { + await act(async () => { t.mockInput.pressKey(key); }); + await act(async () => { await t.renderOnce(); }); + }; + const type = async (text: string) => { + for (const char of text) await press(char); + }; + const clear = async () => { + for (let i = 0; i < 12; i++) await press("BACKSPACE"); + }; + const escape = async () => { + await press("ESCAPE"); + // A lone escape is only recognised once the parser's escape-sequence timeout passes. + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); + await act(async () => { await t.renderOnce(); }); + }; + const frame = () => t.captureCharFrame(); + const line = (text: string) => frame().split("\n").find((l) => l.includes(text)) ?? ""; + try { + await act(async () => { await t.renderOnce(); }); + // Groups in schema order, each setting under its heading, titles first and keys only in the detail line. + const order = ["Collapsed code", "Shortest body to collapse", "Collapse deleted functions", "Hidden files", "Hide test files", "Summaries", "Provider"] + .map((text) => frame().split("\n").findIndex((l) => l.includes(text))); + expect(order).toEqual([...order].sort((a, b) => a - b)); + expect(order.every((index) => index >= 0)).toBe(true); + expect(line("Shortest body to collapse")).toContain("→"); + expect(line("Shortest body to collapse")).not.toContain("plugins.deleted-bodies.min_lines"); + expect(frame()).toContain("plugins.deleted-bodies.min_lines · default 12"); + expect(frame()).toContain("Bodies shorter than this are never summarized or collapsed."); + expect(frame()).toContain("(1/6)"); + expect(frame()).toContain("Type to search · Enter/Space to change · Esc to quit"); + expect(line("API key")).toContain("not set"); + + // Boolean: space flips it in place and writes through the CLI. + await type("hidetest"); + expect(frame()).toContain("(1/1)"); + expect(line("Hide test files")).toContain("false"); + await press(" "); + expect(writes).toEqual([["plugins.hide-files.enabled", "true"]]); + expect(line("Hide test files")).toContain("true"); + expect(frame()).toContain("Hide test files: true"); + + // Enum: enter cycles to the next option. + await clear(); + await type("provider"); + await press("RETURN"); + expect(writes.at(-1)).toEqual(["plugins.summarize.provider", "none"]); + expect(line("Provider")).toContain("none"); + + // Number: enter opens a prompt titled by the setting; escape discards, enter saves. + await clear(); + await type("shortest"); + await press("RETURN"); + expect(frame()).toContain("Shortest body to collapse"); + expect(frame()).toContain("> 12"); + expect(frame()).toContain("(escape/ctrl+c to cancel, enter to submit)"); + await press("BACKSPACE"); + await type("9"); + await escape(); + expect(frame()).toContain("Type to search"); + expect(writes).toHaveLength(2); + await press("RETURN"); + await press("BACKSPACE"); + await press("BACKSPACE"); + await type("2.5"); + await press("RETURN"); + expect(frame()).toContain("Expected an integer"); + expect(writes).toHaveLength(2); + await press("BACKSPACE"); + await press("BACKSPACE"); + await press("BACKSPACE"); + await type("20"); + await press("RETURN"); + expect(writes.at(-1)).toEqual(["plugins.deleted-bodies.min_lines", "20"]); + expect(line("Shortest body to collapse")).toContain("20"); + + // Secret: the prompt starts empty and masks typing; the row only says it is stored. + await clear(); + await type("api"); + await press("RETURN"); + expect(frame()).toContain("API key for the provider."); + // Keys that arrive in one burst, before any re-render, all land, including the enter after them. + await act(async () => { + for (const key of ["a", "b", "c"]) t.mockInput.pressKey(key); + }); + await act(async () => { await t.renderOnce(); }); + expect(frame()).toContain("> •••"); + expect(frame()).not.toContain("abc"); + await act(async () => { + t.mockInput.pressKey("d"); + t.mockInput.pressKey("RETURN"); + }); + await act(async () => { await t.renderOnce(); }); + expect(writes.at(-1)).toEqual(["plugins.summarize.api_key", "abcd"]); + expect(line("API key")).toContain("✓ stored"); + expect(frame()).not.toContain("abc"); + + await escape(); + expect(quit).toBe(true); + } finally { + await act(async () => { t.renderer.destroy(); }); + } +}); diff --git a/tui/packages/hunk/src/ui/Settings.tsx b/tui/packages/hunk/src/ui/Settings.tsx new file mode 100644 index 000000000..52020bcb4 --- /dev/null +++ b/tui/packages/hunk/src/ui/Settings.tsx @@ -0,0 +1,249 @@ +/** A searchable, grouped settings screen over diffr's config schema; every change writes through the CLI. */ +import { useReducer, useRef } from "react"; +import { TextAttributes } from "@opentui/core"; +import { useKeyboard, usePaste, useTerminalDimensions } from "@opentui/react"; +import { + filterSettings, + flattenSchema, + formatValue, + isDefault, + parseValue, + type ConfigClient, + type Setting, +} from "../diffr/config"; +import { dark } from "../diffr/rows"; +import { sliceTextByWidth } from "./lib/text"; + +const fit = (text: string, width: number) => sliceTextByWidth(text, 0, Math.max(0, width)).text; +const pad = (text: string, width: number) => fit(text, width).padEnd(Math.max(0, width)); +/** Schema descriptions keep their source line breaks; the screen shows them as one line. */ +const oneLine = (text: string) => text.replace(/\s*\n\s*/g, " "); +/** Word-wrap a description into lines of at most `width` cells, keeping at most `max` lines. */ +export function wrap(text: string, width: number, max: number): string[] { + const lines: string[] = []; + let line = ""; + for (const word of oneLine(text).split(" ")) { + if (line && line.length + 1 + word.length > width) { + lines.push(line); + line = word; + } else line = line ? `${line} ${word}` : word; + } + if (line) lines.push(line); + return lines.length > max ? [...lines.slice(0, max - 1), fit(lines.slice(max - 1).join(" "), width - 1) + "…"] : lines; +} + +/** Keys that look like credentials are typed masked and shown only as stored or not. */ +export const isSecret = (key: string) => /(api_key|secret|token|password)/i.test(key); + +/** The value column: secrets never show their text, and an unset value says so. */ +export const displayValue = (setting: Setting) => { + const text = formatValue(setting.value); + if (isSecret(setting.key)) return text === "" ? "not set" : "✓ stored"; + return text === "" ? "not set" : text; +}; + +/** Booleans and enums change in place; everything else needs typed input. */ +const toggles = (setting: Setting) => setting.type === "boolean" || setting.type === "enum"; + +/** The value a toggle moves to next: booleans flip, enums cycle through their options. */ +export function nextValue(setting: Setting): string { + if (setting.type === "boolean") return setting.value === true ? "false" : "true"; + const options = setting.options!; + return options[(options.indexOf(formatValue(setting.value)) + 1) % options.length]; +} + +interface Prompt { + setting: Setting; + draft: string; + error: string; +} + +interface ScreenState { + settings: Setting[]; + query: string; + cursor: number; + prompt: Prompt | null; + status: { text: string; error: boolean } | null; +} + +type Line = { kind: "gap"; group: string } | { kind: "group"; group: string } | { kind: "setting"; setting: Setting; index: number }; + +export function Settings({ client, onQuit, initialQuery = "" }: { client: ConfigClient; onQuit: () => void; initialQuery?: string }) { + const { width, height } = useTerminalDimensions(); + // Keys can arrive in one burst before React re-renders (a fast typist, a paste followed by + // enter), so the handlers read and write this ref and only then ask for a render. + const state = useRef<ScreenState>({ + settings: flattenSchema(client.schema(), client.show()), + query: initialQuery, + cursor: 0, + prompt: null, + status: null, + }); + const [, render] = useReducer((n: number) => n + 1, 0); + const update = (change: Partial<ScreenState>) => { + state.current = { ...state.current, ...change }; + render(); + }; + const { settings, query, prompt, status } = state.current; + const visible = filterSettings(settings, query); + const selected = Math.min(state.current.cursor, Math.max(0, visible.length - 1)); + const current = visible[selected]; + const theme = dark; + + /** Write through the CLI first; the row changes only once the write succeeded. */ + const save = (setting: Setting, text: string) => { + const value = parseValue(setting, text); + client.set(setting.key, text); + update({ + settings: state.current.settings.map((s) => (s.key === setting.key ? { ...s, value } : s)), + status: { text: `${setting.title}: ${displayValue({ ...setting, value })}`, error: false }, + }); + }; + const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); + const type = (text: string) => { + const { prompt, query } = state.current; + if (prompt) update({ prompt: { ...prompt, draft: prompt.draft + text, error: "" } }); + else update({ query: query + text, cursor: 0 }); + }; + + useKeyboard((key) => { + const { prompt, query, settings, cursor } = state.current; + if (prompt) { + if (key.name === "escape" || (key.ctrl && key.name === "c")) update({ prompt: null }); + else if (key.name === "return") { + try { + save(prompt.setting, prompt.draft); + update({ prompt: null }); + } catch (error) { + update({ prompt: { ...prompt, error: message(error) } }); + } + } else if (key.name === "backspace") update({ prompt: { ...prompt, draft: prompt.draft.slice(0, -1), error: "" } }); + else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) type(key.sequence); + return; + } + const matches = filterSettings(settings, query); + const at = Math.min(cursor, Math.max(0, matches.length - 1)); + const here = matches[at]; + if (key.name === "escape" || (key.ctrl && key.name === "c")) onQuit(); + else if (key.name === "down" || (key.ctrl && key.name === "n")) update({ cursor: Math.min(at + 1, matches.length - 1) }); + else if (key.name === "up" || (key.ctrl && key.name === "p")) update({ cursor: Math.max(at - 1, 0) }); + else if ((key.name === "return" || key.name === "space") && here) { + if (toggles(here)) { + try { + save(here, nextValue(here)); + } catch (error) { + update({ status: { text: message(error), error: true } }); + } + } else update({ prompt: { setting: here, draft: isSecret(here.key) ? "" : formatValue(here.value), error: "" } }); + } else if (key.name === "backspace") update({ query: query.slice(0, -1), cursor: 0 }); + else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) type(key.sequence); + }); + // A pasted value arrives as one event; line breaks around it are not part of the value. + usePaste((event) => type(new TextDecoder().decode(event.bytes).replace(/[\r\n]+/g, ""))); + + const rule = <text height={1} fg={theme.accent} selectable={false}>{"─".repeat(width)}</text>; + const blank = (id: string) => <text key={id} height={1} selectable={false}>{" "}</text>; + + if (prompt) { + const { setting, draft, error } = prompt; + const field = isSecret(setting.key) ? "•".repeat(draft.length) : draft; + return ( + <box width={width} height={height} flexDirection="column" backgroundColor={theme.bg}> + <box flexGrow={1} /> + {rule} + {blank("top")} + <text height={1} fg={theme.accent} attributes={TextAttributes.BOLD} selectable={false}>{fit(` ${setting.title}`, width)}</text> + {blank("title")} + {wrap(setting.description, width - 2, 3).map((text, i) => ( + <text key={`description:${i}`} height={1} fg={theme.fg} selectable={false}>{` ${text}`}</text> + ))} + <box height={1} flexDirection="row"> + <text fg={theme.fg} selectable={false}>{"> "}</text> + <text fg={theme.fg} selectable={false}>{fit(field, width - 3)}</text> + <text fg={theme.bg} bg={theme.fg} selectable={false}>{" "}</text> + </box> + {error + ? <text height={1} fg={theme.deletion} selectable={false}>{fit(` ${error}`, width)}</text> + : <box height={1} flexDirection="row"> + <text fg={theme.fg} selectable={false}>{" ("}</text> + <text fg={theme.muted} selectable={false}>{"escape/ctrl+c"}</text> + <text fg={theme.fg} selectable={false}>{" to cancel, "}</text> + <text fg={theme.muted} selectable={false}>{"enter"}</text> + <text fg={theme.fg} selectable={false}>{" to submit)"}</text> + </box>} + {blank("bottom")} + {rule} + </box> + ); + } + + const lines: Line[] = []; + visible.forEach((setting, index) => { + if (setting.group !== visible[index - 1]?.group) { + if (index > 0) lines.push({ kind: "gap", group: setting.group }); + lines.push({ kind: "group", group: setting.group }); + } + lines.push({ kind: "setting", setting, index }); + }); + // Two rules, search, blank, count, blank, up to three detail lines, status, hint. + const listHeight = Math.max(3, height - 11); + const cursorLine = lines.findIndex((line) => line.kind === "setting" && line.index === selected); + // Keep the selected row's group heading on screen with it where possible. + const start = Math.max(0, Math.min(cursorLine - Math.floor(listHeight / 2), lines.length - listHeight)); + const shown = lines.slice(start, start + listHeight); + // A heading whose rows are cut off below says nothing; drop it with its gap. + while (shown.length && shown.at(-1)!.kind !== "setting") shown.pop(); + // The arrow gutter, the longest title, then a two-cell gap before values. + const titleWidth = Math.min(width - 16, 3 + Math.max(...settings.map((s) => s.title.length)) + 2); + const valueWidth = Math.max(8, width - titleWidth - 4); + const detail = current + ? `${current.key} · default ${displayValue({ ...current, value: current.default })}` + : ""; + + return ( + <box width={width} height={height} flexDirection="column" backgroundColor={theme.bg}> + {rule} + <box height={1} flexDirection="row"> + <text fg={theme.fg} selectable={false}>{"> "}</text> + <text fg={theme.fg} selectable={false}>{fit(query, width - 3)}</text> + <text fg={theme.bg} bg={theme.fg} selectable={false}>{" "}</text> + </box> + {blank("search")} + {shown.map((line) => { + if (line.kind === "gap") return blank(`gap:${line.group}`); + if (line.kind === "group") + return ( + <text key={`group:${line.group}`} height={1} fg={theme.fg} attributes={TextAttributes.BOLD} selectable={false}> + {fit(` ${line.group}`, width)} + </text> + ); + const { setting, index } = line; + const isSelected = index === selected; + const changed = !isDefault(setting); + return ( + <box key={setting.key} height={1} flexDirection="row"> + <text fg={isSelected ? theme.accent : theme.fg} selectable={false}> + {pad(fit(`${isSelected ? " → " : " "}${setting.title}`, titleWidth - 2), titleWidth)} + </text> + <text fg={isSelected ? theme.accent : changed ? theme.fg : theme.muted} selectable={false}> + {fit(displayValue(setting), valueWidth)} + </text> + </box> + ); + })} + {visible.length === 0 && <text height={1} fg={theme.muted} selectable={false}>{" No settings match"}</text>} + <text height={1} fg={theme.muted} selectable={false}> + {fit(` (${visible.length ? selected + 1 : 0}/${visible.length})`, width)} + </text> + {blank("count")} + {wrap(current?.description ?? "", width - 4, 2).map((text, i) => ( + <text key={`description:${i}`} height={1} fg={theme.muted} selectable={false}>{` ${text}`}</text> + ))} + <text height={1} fg={theme.muted} selectable={false}>{fit(` ${detail}`, width)}</text> + <box flexGrow={1} /> + <text height={1} fg={status?.error ? theme.deletion : theme.addition} selectable={false}>{fit(` ${status?.text ?? ""}`, width)}</text> + <text height={1} fg={theme.muted} selectable={false}>{fit(" Type to search · Enter/Space to change · Esc to quit", width)}</text> + {rule} + </box> + ); +} diff --git a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index 73c71606f..66b68e364 100644 --- a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -7,8 +7,8 @@ import type { UnifiedLineCell, } from "./diffRowModel"; import type { Geometry, MeasuredRow } from "../../diffr/geometry"; -import type { Palette } from "../../diffr/rows"; -import type { RowFold } from "../../diffr/folds"; +import type { Palette } from "../../diffr/theme"; +import type { FoldTint, RowFold } from "../../diffr/regions"; import { measureTextWidth } from "../lib/text"; const colors = new Map<string, ReturnType<typeof parseColor>>(); function color(value: string) { @@ -29,13 +29,19 @@ function styled(spans: RenderSpan[], theme: Palette, bg: string) { })), ); } +/** A collapsed fold takes its side's change tint when it is one-sided, like Review's bands. */ +export function foldBackground(theme: Palette, tint: FoldTint | undefined) { + if (tint === undefined) throw new Error("A collapsed fold row has no tint"); + return tint === "inserted" ? theme.addition : tint === "removed" ? theme.deletion : theme.foldBackground; +} /** VS Code's showFoldingControls "always": expandable rows keep their chevron visible. */ function chevron(fold: RowFold | undefined) { if (!fold) return " "; return fold.collapsed ? "▸" : "▾"; } +/** A multi-line label (pseudocode) hangs under the header, so the header shows only the ellipsis. */ const placeholderText = (fold: RowFold) => - fold.placeholder === "…" ? " ⋯" : ` ⋯ ${fold.placeholder}`; + fold.label === "" || fold.label.includes("\n") ? " ⋯" : ` ⋯ ${fold.label}`; export const CodeRowView = memo(function CodeRowView({ measured, visualLine, @@ -67,13 +73,13 @@ export const CodeRowView = memo(function CodeRowView({ const fold = value.fold; const bg = selectedSide === side - ? "#264f78" - : value.kind === "addition" - ? theme.addition - : value.kind === "deletion" - ? theme.deletion - : fold?.collapsed - ? theme.foldBackground + ? theme.highlight + : value.foldLabel || fold?.collapsed + ? foldBackground(theme, value.foldTint ?? fold?.tint) + : value.kind === "addition" + ? theme.addition + : value.kind === "deletion" + ? theme.deletion : theme.bg; // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. const digits = geometry.gutter - 4; diff --git a/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/tui/packages/hunk/src/ui/diff/diffRowModel.ts index d517665c6..79899b108 100644 --- a/tui/packages/hunk/src/ui/diff/diffRowModel.ts +++ b/tui/packages/hunk/src/ui/diff/diffRowModel.ts @@ -6,8 +6,7 @@ * column math, the highlight worker, geometry — can share these types without importing * the builders themselves. */ -import type { RowFold } from "../../diffr/folds"; -type DiffLineMoveKind = "moved"; +import type { FoldTint, RowFold } from "../../diffr/regions"; export interface RenderSpan { text: string; @@ -21,9 +20,12 @@ export interface SplitLineCell { kind: "context" | "addition" | "deletion" | "empty"; sign: string; lineNumber?: number; - moveKind?: DiffLineMoveKind; /** This cell starts a fold region; the chevron and placeholder come from here. */ fold?: RowFold; + /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ + foldLabel?: boolean; + /** The tint of the fold a label line belongs to. */ + foldTint?: FoldTint; spans: RenderSpan[]; } @@ -32,7 +34,8 @@ export interface UnifiedLineCell { sign: string; oldLineNumber?: number; newLineNumber?: number; - moveKind?: DiffLineMoveKind; fold?: RowFold; + foldLabel?: boolean; + foldTint?: FoldTint; spans: RenderSpan[]; } diff --git a/tui/test/fixtures/after.ts b/tui/test/fixtures/after.ts deleted file mode 100644 index 1399899f4..000000000 --- a/tui/test/fixtures/after.ts +++ /dev/null @@ -1,4 +0,0 @@ -function greet(name: string) { - console.log(name); - return "hi " + name; -} diff --git a/tui/test/fixtures/before.ts b/tui/test/fixtures/before.ts deleted file mode 100644 index 539b2cc79..000000000 --- a/tui/test/fixtures/before.ts +++ /dev/null @@ -1,3 +0,0 @@ -function greet(name: string) { - return "hello " + name; -} diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson index cb798b4ea..e0cf01c30 100644 --- a/tui/test/fixtures/comparison.ndjson +++ b/tui/test/fixtures/comparison.ndjson @@ -1,3 +1,4 @@ -{"after":{"kind":"file","path":"tui/test/fixtures/after.ts"},"before":{"kind":"file","path":"tui/test/fixtures/before.ts"},"files":[{"class":null,"new_path":"tui/test/fixtures/after.ts","old_path":"tui/test/fixtures/before.ts","status":"modified"}],"total":1,"type":"start","version":1} -{"type":"file","file":{"old_path":"tui/test/fixtures/before.ts","new_path":"tui/test/fixtures/after.ts","status":"modified","class":null},"diff":{"aligned_rows":[[0,0],[null,1],[1,2],[2,3]],"display_path":"tui/test/fixtures/after.ts","extra_info":null,"file_format":{"SupportedLanguage":"TypeScript"},"has_byte_changes":[59,77],"has_syntactic_changes":true,"hunks":[{"lines":[[0,0],[null,1],[1,2],[2,3]],"novel_lhs":[1],"novel_rhs":[1,2]}],"lhs_folds":[{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":0,"line":3},"start":{"byte_column":30,"line":0}}}},"placeholder":"Body","range":{"end":{"byte_column":0,"line":2},"start":{"byte_column":30,"line":0}},"tags":["body"]},{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":14,"line":2},"start":{"byte_column":9,"line":2}}}},"placeholder":"String","range":{"end":{"byte_column":17,"line":1},"start":{"byte_column":9,"line":1}},"tags":["string"]}],"lhs_positions":[{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":0,"start_col":0}],"self_pos":[{"end_col":8,"line":0,"start_col":0}]}},"pos":{"end_col":8,"line":0,"start_col":0}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":14,"line":0,"start_col":9}],"self_pos":[{"end_col":14,"line":0,"start_col":9}]}},"pos":{"end_col":14,"line":0,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":15,"line":0,"start_col":14}],"self_pos":[{"end_col":15,"line":0,"start_col":14}]}},"pos":{"end_col":15,"line":0,"start_col":14}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":19,"line":0,"start_col":15}],"self_pos":[{"end_col":19,"line":0,"start_col":15}]}},"pos":{"end_col":19,"line":0,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":20,"line":0,"start_col":19}],"self_pos":[{"end_col":20,"line":0,"start_col":19}]}},"pos":{"end_col":20,"line":0,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Type"},"opposite_pos":[{"end_col":27,"line":0,"start_col":21}],"self_pos":[{"end_col":27,"line":0,"start_col":21}]}},"pos":{"end_col":27,"line":0,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":28,"line":0,"start_col":27}],"self_pos":[{"end_col":28,"line":0,"start_col":27}]}},"pos":{"end_col":28,"line":0,"start_col":27}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":30,"line":0,"start_col":29}],"self_pos":[{"end_col":30,"line":0,"start_col":29}]}},"pos":{"end_col":30,"line":0,"start_col":29}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":2,"start_col":2}],"self_pos":[{"end_col":8,"line":1,"start_col":2}]}},"pos":{"end_col":8,"line":1,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":{"String":"StringLiteral"}}}},"pos":{"end_col":17,"line":1,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":16,"line":2,"start_col":15}],"self_pos":[{"end_col":19,"line":1,"start_col":18}]}},"pos":{"end_col":19,"line":1,"start_col":18}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":21,"line":2,"start_col":17}],"self_pos":[{"end_col":24,"line":1,"start_col":20}]}},"pos":{"end_col":24,"line":1,"start_col":20}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":22,"line":2,"start_col":21}],"self_pos":[{"end_col":25,"line":1,"start_col":24}]}},"pos":{"end_col":25,"line":1,"start_col":24}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":1,"line":3,"start_col":0}],"self_pos":[{"end_col":1,"line":2,"start_col":0}]}},"pos":{"end_col":1,"line":2,"start_col":0}}],"lhs_src":{"Text":"function greet(name: string) {\n return \"hello \" + name;\n}\n"},"rhs_folds":[{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":0,"line":2},"start":{"byte_column":30,"line":0}}}},"placeholder":"Body","range":{"end":{"byte_column":0,"line":3},"start":{"byte_column":30,"line":0}},"tags":["body"]},{"match_kind":{"Unchanged":{"opposite":{"end":{"byte_column":17,"line":1},"start":{"byte_column":9,"line":1}}}},"placeholder":"String","range":{"end":{"byte_column":14,"line":2},"start":{"byte_column":9,"line":2}},"tags":["string"]}],"rhs_positions":[{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":0,"start_col":0}],"self_pos":[{"end_col":8,"line":0,"start_col":0}]}},"pos":{"end_col":8,"line":0,"start_col":0}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":14,"line":0,"start_col":9}],"self_pos":[{"end_col":14,"line":0,"start_col":9}]}},"pos":{"end_col":14,"line":0,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":15,"line":0,"start_col":14}],"self_pos":[{"end_col":15,"line":0,"start_col":14}]}},"pos":{"end_col":15,"line":0,"start_col":14}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":19,"line":0,"start_col":15}],"self_pos":[{"end_col":19,"line":0,"start_col":15}]}},"pos":{"end_col":19,"line":0,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":20,"line":0,"start_col":19}],"self_pos":[{"end_col":20,"line":0,"start_col":19}]}},"pos":{"end_col":20,"line":0,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Type"},"opposite_pos":[{"end_col":27,"line":0,"start_col":21}],"self_pos":[{"end_col":27,"line":0,"start_col":21}]}},"pos":{"end_col":27,"line":0,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":28,"line":0,"start_col":27}],"self_pos":[{"end_col":28,"line":0,"start_col":27}]}},"pos":{"end_col":28,"line":0,"start_col":27}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":30,"line":0,"start_col":29}],"self_pos":[{"end_col":30,"line":0,"start_col":29}]}},"pos":{"end_col":30,"line":0,"start_col":29}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":9,"line":1,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":10,"line":1,"start_col":9}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":13,"line":1,"start_col":10}},{"kind":{"Novel":{"highlight":"Delimiter"}},"pos":{"end_col":14,"line":1,"start_col":13}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":18,"line":1,"start_col":14}},{"kind":{"Novel":{"highlight":"Delimiter"}},"pos":{"end_col":19,"line":1,"start_col":18}},{"kind":{"Novel":{"highlight":{"Atom":"Normal"}}},"pos":{"end_col":20,"line":1,"start_col":19}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":8,"line":1,"start_col":2}],"self_pos":[{"end_col":8,"line":2,"start_col":2}]}},"pos":{"end_col":8,"line":2,"start_col":2}},{"kind":{"Novel":{"highlight":{"Atom":{"String":"StringLiteral"}}}},"pos":{"end_col":14,"line":2,"start_col":9}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Keyword"},"opposite_pos":[{"end_col":19,"line":1,"start_col":18}],"self_pos":[{"end_col":16,"line":2,"start_col":15}]}},"pos":{"end_col":16,"line":2,"start_col":15}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":24,"line":1,"start_col":20}],"self_pos":[{"end_col":21,"line":2,"start_col":17}]}},"pos":{"end_col":21,"line":2,"start_col":17}},{"kind":{"UnchangedToken":{"highlight":{"Atom":"Normal"},"opposite_pos":[{"end_col":25,"line":1,"start_col":24}],"self_pos":[{"end_col":22,"line":2,"start_col":21}]}},"pos":{"end_col":22,"line":2,"start_col":21}},{"kind":{"UnchangedToken":{"highlight":"Delimiter","opposite_pos":[{"end_col":1,"line":2,"start_col":0}],"self_pos":[{"end_col":1,"line":3,"start_col":0}]}},"pos":{"end_col":1,"line":3,"start_col":0}}],"rhs_src":{"Text":"function greet(name: string) {\n console.log(name);\n return \"hi \" + name;\n}\n"}}} -{"type":"complete","succeeded":1,"failed":0} +{"type":"start","version":3,"lhs":{"type":"revision","rev":"38049b0b067444916e9de65efceef73100b3e615"},"rhs":{"type":"revision","rev":"15afc787a8bfc95644bd00f97511015fb9a32aeb"},"files":[{"file":{"lhs":{"path":"src/greet.ts","oid":"8529d7a6dc7709c707ebd16b3689352f90f91993","mode":"100644"},"rhs":{"path":"src/greet.ts","oid":"f9895c906b6b46c0c562bc9e8c39b1702096cf4f","mode":"100644"}},"status":"modified"},{"file":{"lhs":{"path":"tests/greet.test.ts","oid":"0231e3a6a765dbd026034d9e12a988347d559f88","mode":"100644"},"rhs":{"path":"tests/greet.test.ts","oid":"ae1cd95b02cc02cff3387547fceff8bda367db5b","mode":"100644"}},"status":"modified","tags":["test"]}]} +{"type":"file","file":{"lhs":{"path":"src/greet.ts","oid":"8529d7a6dc7709c707ebd16b3689352f90f91993","mode":"100644"},"rhs":{"path":"src/greet.ts","oid":"f9895c906b6b46c0c562bc9e8c39b1702096cf4f","mode":"100644"}},"diff":{"type":"text","lhs":{"text":"export function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":15,"capture":"keyword"},{"line":0,"start_column":16,"end_column":21,"capture":"variable"},{"line":0,"start_column":21,"end_column":22,"capture":"punctuation.bracket"},{"line":0,"start_column":22,"end_column":26,"capture":"variable"},{"line":0,"start_column":28,"end_column":34,"capture":"type.builtin"},{"line":0,"start_column":34,"end_column":35,"capture":"punctuation.bracket"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"tags":["deleted-bodies:function","removed-runs:function","summarize:function"],"visibility":{"label":"Function"},"kind":"fold","children":[{"id":1,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":0}]},{"id":2,"fold_state_id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","alignment_id":1}]},"rhs":{"text":"export function greet(name: string, punctuation = \"!\") {\n return \"hello \" + name + punctuation;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":15,"capture":"keyword"},{"line":0,"start_column":16,"end_column":21,"capture":"variable"},{"line":0,"start_column":21,"end_column":22,"capture":"punctuation.bracket"},{"line":0,"start_column":22,"end_column":26,"capture":"variable"},{"line":0,"start_column":28,"end_column":34,"capture":"type.builtin"},{"line":0,"start_column":34,"end_column":35,"capture":"punctuation.delimiter"},{"line":0,"start_column":36,"end_column":47,"capture":"variable"},{"line":0,"start_column":48,"end_column":49,"capture":"operator"},{"line":0,"start_column":50,"end_column":53,"capture":"string"},{"line":0,"start_column":53,"end_column":54,"capture":"punctuation.bracket"},{"line":0,"start_column":55,"end_column":56,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":25,"end_column":26,"capture":"operator"},{"line":1,"start_column":27,"end_column":38,"capture":"variable"},{"line":1,"start_column":38,"end_column":39,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":3,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"tags":["deleted-bodies:function","removed-runs:function","summarize:function"],"visibility":{"label":"Function"},"kind":"fold","children":[{"id":4,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":0,"changed":[{"line":0,"start_column":34,"end_column":35},{"line":0,"start_column":36,"end_column":47},{"line":0,"start_column":48,"end_column":49},{"line":0,"start_column":50,"end_column":53},{"line":1,"start_column":25,"end_column":26},{"line":1,"start_column":27,"end_column":38}]}]},{"id":5,"fold_state_id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","alignment_id":1}]},"stats":{"textual":{"added":2,"removed":2},"visible":{"added":2,"removed":0}}}} +{"type":"file","file":{"lhs":{"path":"tests/greet.test.ts","oid":"0231e3a6a765dbd026034d9e12a988347d559f88","mode":"100644"},"rhs":{"path":"tests/greet.test.ts","oid":"ae1cd95b02cc02cff3387547fceff8bda367db5b","mode":"100644"}},"visibility":{"collapsed":true,"label":"Test file · hidden by default"},"diff":{"type":"text","lhs":{"text":"import { greet } from \"../src/greet\";\ntest(\"greets\", () => expect(greet(\"a\")).toBe(\"hello a\"));\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":8,"capture":"punctuation.bracket"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":15,"end_column":16,"capture":"punctuation.bracket"},{"line":0,"start_column":17,"end_column":21,"capture":"keyword"},{"line":0,"start_column":22,"end_column":36,"capture":"string"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.delimiter"},{"line":1,"start_column":0,"end_column":4,"capture":"variable"},{"line":1,"start_column":4,"end_column":5,"capture":"punctuation.bracket"},{"line":1,"start_column":5,"end_column":13,"capture":"string"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.delimiter"},{"line":1,"start_column":15,"end_column":17,"capture":"punctuation.bracket"},{"line":1,"start_column":18,"end_column":20,"capture":"operator"},{"line":1,"start_column":21,"end_column":27,"capture":"variable"},{"line":1,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":1,"start_column":28,"end_column":33,"capture":"variable"},{"line":1,"start_column":33,"end_column":34,"capture":"punctuation.bracket"},{"line":1,"start_column":34,"end_column":37,"capture":"string"},{"line":1,"start_column":37,"end_column":39,"capture":"punctuation.bracket"},{"line":1,"start_column":39,"end_column":40,"capture":"punctuation.delimiter"},{"line":1,"start_column":40,"end_column":44,"capture":"property"},{"line":1,"start_column":44,"end_column":45,"capture":"punctuation.bracket"},{"line":1,"start_column":45,"end_column":54,"capture":"string"},{"line":1,"start_column":54,"end_column":56,"capture":"punctuation.bracket"},{"line":1,"start_column":56,"end_column":57,"capture":"punctuation.delimiter"}],"regions":[{"id":0,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf","alignment_id":0},{"id":1,"fold_state_id":1,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":1}]},"rhs":{"text":"import { greet } from \"../src/greet\";\ntest(\"greets\", () => expect(greet(\"a\")).toBe(\"hello a!\"));\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":8,"capture":"punctuation.bracket"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":15,"end_column":16,"capture":"punctuation.bracket"},{"line":0,"start_column":17,"end_column":21,"capture":"keyword"},{"line":0,"start_column":22,"end_column":36,"capture":"string"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.delimiter"},{"line":1,"start_column":0,"end_column":4,"capture":"variable"},{"line":1,"start_column":4,"end_column":5,"capture":"punctuation.bracket"},{"line":1,"start_column":5,"end_column":13,"capture":"string"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.delimiter"},{"line":1,"start_column":15,"end_column":17,"capture":"punctuation.bracket"},{"line":1,"start_column":18,"end_column":20,"capture":"operator"},{"line":1,"start_column":21,"end_column":27,"capture":"variable"},{"line":1,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":1,"start_column":28,"end_column":33,"capture":"variable"},{"line":1,"start_column":33,"end_column":34,"capture":"punctuation.bracket"},{"line":1,"start_column":34,"end_column":37,"capture":"string"},{"line":1,"start_column":37,"end_column":39,"capture":"punctuation.bracket"},{"line":1,"start_column":39,"end_column":40,"capture":"punctuation.delimiter"},{"line":1,"start_column":40,"end_column":44,"capture":"property"},{"line":1,"start_column":44,"end_column":45,"capture":"punctuation.bracket"},{"line":1,"start_column":45,"end_column":55,"capture":"string"},{"line":1,"start_column":55,"end_column":57,"capture":"punctuation.bracket"},{"line":1,"start_column":57,"end_column":58,"capture":"punctuation.delimiter"}],"regions":[{"id":2,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf","alignment_id":0},{"id":3,"fold_state_id":1,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":1,"changed":[{"line":1,"start_column":53,"end_column":54}]}]},"stats":{"textual":{"added":1,"removed":1},"visible":{"added":1,"removed":0}}}} +{"type":"complete","succeeded":2,"failed":0} diff --git a/tui/test/pty/launch-integration.test.ts b/tui/test/pty/launch-integration.test.ts deleted file mode 100644 index c63877ab3..000000000 --- a/tui/test/pty/launch-integration.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { expect, test } from "bun:test"; -import { resolve } from "node:path"; -import { spawnSync } from "node:child_process"; -import { readDiffStream } from "../../packages/hunk/src/diffr/stream"; -import { rowsForFile, dark } from "../../packages/hunk/src/diffr/rows"; -const root = resolve(import.meta.dir, "../../.."), - binary = process.env.DIFFR_TEST_BIN ?? resolve(root, "target/debug/diffr"); -const before = resolve(import.meta.dir, "../fixtures/before.ts"), - after = resolve(import.meta.dir, "../fixtures/after.ts"); -test.skipIf(process.platform === "win32")( - "actual CLI terminal lifecycle and mouse controls", - () => { - const result = spawnSync( - "python3", - [ - resolve(import.meta.dir, "launch.py"), - binary, - process.execPath, - before, - after, - ], - { encoding: "utf8", timeout: 25000 }, - ); - expect(result.stderr).toBe(""); - expect(result.status).toBe(0); - }, - 30000, -); -test("actual Rust output satisfies wire schema and renders both layouts", async () => { - const process = Bun.spawn( - [binary, "--no-index", "--format", "ndjson", "--", before, after], - { stdout: "pipe", stderr: "pipe" }, - ); - const events = await Array.fromAsync(readDiffStream(process.stdout)); - expect(await process.exited).toBe(0); - const file = events.find((e) => e.type === "file"); - expect(file?.type).toBe("file"); - if (file?.type === "file") { - expect(rowsForFile(file, 0, "split", dark).some((r) => r.left)).toBe(true); - expect(rowsForFile(file, 0, "unified", dark).some((r) => r.cell)).toBe( - true, - ); - } -}); -test("redirected output and explicit text bypass the TUI even without Bun", () => { - for (const format of [[], ["--format", "text"]]) { - const result = spawnSync( - binary, - ["--no-index", ...format, "--", before, after], - { - encoding: "utf8", - env: { ...process.env, DIFFR_BUN: "nonexistent-bun-for-test" }, - }, - ); - expect(result.status).toBe(1); - expect(result.stdout).not.toContain("\x1b[?1049h"); - expect(result.stderr).not.toContain("launch terminal"); - } -}); - -test.skipIf(process.platform === "win32")( - "interactive exit-code preserves the comparison status", - () => { - const result = spawnSync( - "python3", - [ - resolve(import.meta.dir, "launch.py"), - binary, - process.execPath, - before, - after, - ], - { - encoding: "utf8", - timeout: 25000, - env: { ...process.env, DIFFR_TEST_EXIT_CODE: "1" }, - }, - ); - expect(result.stderr).toBe(""); - expect(result.status).toBe(0); - }, - 30000, -); diff --git a/tui/test/pty/launch.py b/tui/test/pty/launch.py deleted file mode 100644 index 8dc47b903..000000000 --- a/tui/test/pty/launch.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Exercise Rust -> Bun -> NDJSON -> terminal, including real mouse escape sequences.""" -import errno -import fcntl -import os -import pty -import select -import signal -import struct -import sys -import termios -import time - -binary, bun, before, after = sys.argv[1:] -pid, master = pty.fork() -if pid == 0: - os.environ['DIFFR_BUN'] = bun - os.environ['TERM'] = 'xterm-256color' - os.execv(binary, [binary, '--no-index', *(['--exit-code'] if os.environ.get('DIFFR_TEST_EXIT_CODE') else []), '--', before, after]) -fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 100, 0, 0)) -output = bytearray() -def until(token): - deadline = time.monotonic() + 12 - while time.monotonic() < deadline: - if token in output: - output.clear() - return - if select.select([master], [], [], 0.1)[0]: - try: - data = os.read(master, 65536) - except OSError as error: - if error.errno == errno.EIO: - raise AssertionError(f'TUI exited before {token!r}: {bytes(output)[-2000:]!r}') - raise - if not data: - break - output.extend(data) - raise AssertionError(f'Missing {token!r}: {bytes(output)[-2000:]!r}') -try: - until(b'console.log(name);') - os.write(master, b's') - until(b'split [s]') - # Width 100 has a 28-column file sidebar. The header is at x=30,y=2 (1-based). - os.write(master, b'\x1b[<0;31;2M\x1b[<0;31;2m') - until('▸'.encode()) - os.write(master, b'\x1b[<0;31;2M\x1b[<0;31;2m') - until('▾'.encode()) - os.write(master, b'q') - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - if select.select([master], [], [], 0)[0]: - try: os.read(master, 65536) - except OSError: pass - result, status = os.waitpid(pid, os.WNOHANG) - if result: - assert os.waitstatus_to_exitcode(status) == (1 if os.environ.get('DIFFR_TEST_EXIT_CODE') else 0), status - pid = 0 - print('PTY launch, streamed rendering, layout switch, mouse file toggle, and clean exit passed') - break - time.sleep(0.05) - else: - raise AssertionError('TUI did not exit') -finally: - if pid: - os.kill(pid, signal.SIGKILL) - os.waitpid(pid, 0) - os.close(master) diff --git a/tui/themes/LICENSE b/tui/themes/LICENSE new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/tui/themes/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/tui/themes/README.md b/tui/themes/README.md new file mode 100644 index 000000000..05d1a2e35 --- /dev/null +++ b/tui/themes/README.md @@ -0,0 +1,14 @@ +# Bundled themes + +These are Helix editor themes, vendored unchanged from +https://github.com/helix-editor/helix/tree/master/runtime/themes and licensed under the +Mozilla Public License 2.0 (see LICENSE in this directory). + +| name | file | +| ----------------- | --------------------- | +| `default-dark` | `onedark.toml` | +| `default-light` | `onelight.toml` | +| `gruvbox` | `gruvbox.toml` | +| `solarized_light` | `solarized_light.toml` | + +Any other Helix theme works through `theme.path` in diffr's config. diff --git a/tui/themes/gruvbox.toml b/tui/themes/gruvbox.toml new file mode 100644 index 000000000..6c5bcd098 --- /dev/null +++ b/tui/themes/gruvbox.toml @@ -0,0 +1,153 @@ +# Author : Jakub Bartodziej <kubabartodziej@gmail.com> +# The theme uses the gruvbox dark palette with standard contrast: github.com/morhetz/gruvbox + +"annotation" = { fg = "fg1" } + +"attribute" = { fg = "aqua1", modifiers = ["italic"] } + +"comment" = { fg = "gray", modifiers = ["italic"] } + +"constant" = { fg = "purple1" } +"constant.character" = { fg = "aqua1" } +"constant.character.escape" = { fg = "orange1" } +"constant.macro" = { fg = "aqua1" } +"constructor" = { fg = "purple1" } + +"definition" = { underline = { color = "aqua1" } } + +"diagnostic" = { underline = { color = "orange1", style = "curl" } } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } +"diagnostic.error" = { underline = { color = "red1", style = "curl" } } +"diagnostic.hint" = { underline = { color = "blue1", style = "curl" } } +"diagnostic.info" = { underline = { color = "aqua1", style = "curl" } } +"diagnostic.warning" = { underline = { color = "yellow1", style = "curl" } } +# "diagnostic.unnecessary" = { modifiers = ["dim"] } # do not remove this for future resolving + +"error" = { fg = "red1" } +"hint" = { fg = "blue1" } +"info" = { fg = "aqua1" } +"warning" = { fg = "yellow1" } + +"diff.delta" = { fg = "yellow1" } +"diff.minus" = { fg = "red1" } +"diff.plus" = { fg = "green1" } + +"function" = { fg = "green1" } +"function.builtin" = { fg = "yellow1" } +"function.macro" = { fg = "blue1" } + +"keyword" = { fg = "red1" } +"keyword.control.import" = { fg = "aqua1" } + +"label" = { fg = "red1" } + +"markup.bold" = { modifiers = ["bold"] } +"markup.heading" = "aqua1" +"markup.italic" = { modifiers = ["italic"] } +"markup.link.text" = "red1" +"markup.link.url" = { fg = "green1", modifiers = ["underlined"] } +"markup.raw" = "red1" +"markup.strikethrough" = { modifiers = ["crossed_out"] } + +"module" = { fg = "aqua1" } + +"namespace" = { fg = "fg1" } + +"operator" = { fg = "purple1" } + +"punctuation" = { fg = "orange1" } + +"special" = { fg = "purple0" } + +"string" = { fg = "green1" } +"string.regexp" = { fg = "orange1" } +"string.special" = { fg = "orange1" } +"string.symbol" = { fg = "yellow1" } + +"tag" = { fg = "aqua1" } + +"type" = { fg = "yellow1" } +"type.enum.variant" = { modifiers = ["italic"] } + +"ui.background" = { bg = "bg0" } +"ui.bufferline" = { fg = "fg1", bg = "bg1" } +"ui.bufferline.active" = { fg = "bg0", bg = "yellow0" } +"ui.bufferline.background" = { bg = "bg2" } + +"ui.cursor" = { fg = "bg1", bg = "bg2" } +"ui.cursor.insert" = { fg = "bg1", bg = "blue0" } +"ui.cursor.normal" = { fg = "bg1", bg = "gray" } +"ui.cursor.select" = { fg = "bg1", bg = "orange0" } +"ui.cursor.match" = { fg = "fg3", bg = "bg3" } + +"ui.cursor.primary" = { bg = "fg3", fg = "bg1" } +"ui.cursor.primary.insert" = { fg = "bg1", bg = "blue1" } +"ui.cursor.primary.normal" = { fg = "bg1", bg = "fg3" } +"ui.cursor.primary.select" = { fg = "bg1", bg = "orange1" } + +"ui.cursorline" = { bg = "bg0_s" } +"ui.cursorline.primary" = { bg = "bg1" } + +"ui.help" = { bg = "bg1", fg = "fg1" } +"ui.linenr" = { fg = "bg3" } +"ui.linenr.selected" = { fg = "yellow1" } +"ui.menu" = { fg = "fg1", bg = "bg2" } +"ui.menu.selected" = { fg = "green1", bg = "bg1", modifiers = ["bold"] } +"ui.popup" = { bg = "bg1" } +"ui.picker.header.column" = { underline.style = "line" } +"ui.picker.header.column.active" = { modifiers = ["bold"], underline.style = "line" } +"ui.selection" = { bg = "bg2" } +"ui.selection.primary" = { bg = "bg3" } + +"ui.statusline" = { fg = "fg1", bg = "bg2" } +"ui.statusline.inactive" = { fg = "fg4", bg = "bg2" } +"ui.statusline.insert" = { fg = "bg1", bg = "blue1", modifiers = ["bold"] } +"ui.statusline.normal" = { fg = "bg1", bg = "fg3", modifiers = ["bold"] } +"ui.statusline.select" = { fg = "bg1", bg = "orange1", modifiers = ["bold"] } + +"ui.text" = { fg = "fg1" } +"ui.text.focus" = { fg = "green1", bg="bg1" } +"ui.text.directory" = { fg = "blue1" } +"ui.virtual.inlay-hint" = { fg = "gray" } +"ui.virtual.jump-label" = { fg = "purple0", modifiers = ["bold"] } +"ui.virtual.ruler" = { bg = "bg1" } +"ui.virtual.whitespace" = "bg2" +"ui.virtual.wrap" = { fg = "bg2" } +"ui.window" = { bg = "bg1" } + +"variable" = { fg = "fg1" } +"variable.builtin" = { fg = "orange1", modifiers = ["italic"] } +"variable.other.member" = { fg = "blue1" } +"variable.parameter" = { fg = "blue1", modifiers = ["italic"] } + + +[palette] +bg0 = "#282828" # main background +bg0_s = "#32302f" +bg1 = "#3c3836" +bg2 = "#504945" +bg3 = "#665c54" +bg4 = "#7c6f64" + +fg0 = "#fbf1c7" +fg1 = "#ebdbb2" # main foreground +fg2 = "#d5c4a1" +fg3 = "#bdae93" +fg4 = "#a89984" + +gray = "#928374" + +red0 = "#cc241d" # neutral +red1 = "#fb4934" # bright +green0 = "#98971a" +green1 = "#b8bb26" +yellow0 = "#d79921" +yellow1 = "#fabd2f" +blue0 = "#458588" +blue1 = "#83a598" +purple0 = "#b16286" +purple1 = "#d3869b" +aqua0 = "#689d6a" +aqua1 = "#8ec07c" +orange0 = "#d65d0e" +orange1 = "#fe8019" diff --git a/tui/themes/onedark.toml b/tui/themes/onedark.toml new file mode 100644 index 000000000..9349f02d3 --- /dev/null +++ b/tui/themes/onedark.toml @@ -0,0 +1,118 @@ +# Author : Gokul Soumya <gokulps15@gmail.com> + +"tag" = { fg = "red" } +"attribute" = { fg = "yellow" } +"comment" = { fg = "light-gray", modifiers = ["italic"] } +"constant" = { fg = "cyan" } +"constant.numeric" = { fg = "gold" } +"constant.builtin" = { fg = "gold" } +"constant.character.escape" = { fg = "gold" } +"constructor" = { fg = "blue" } +"function" = { fg = "blue" } +"function.builtin" = { fg = "blue" } +"function.macro" = { fg = "purple" } +"keyword" = { fg = "red" } +"keyword.control" = { fg = "purple" } +"keyword.control.import" = { fg = "red" } +"keyword.directive" = { fg = "purple" } +"keyword.storage" = { fg = "purple" } +"label" = { fg = "purple" } +"namespace" = { fg = "blue" } +"operator" = { fg = "purple" } +"keyword.operator" = { fg = "purple" } +"special" = { fg = "blue" } +"string" = { fg = "green" } +"type" = { fg = "yellow" } +"variable" = { fg = "white" } +"variable.builtin" = { fg = "blue" } +"variable.parameter" = { fg = "red" } +"variable.other.member" = { fg = "red" } +"punctuation" = { fg = "white" } +"punctuation.special" = { fg = "purple" } + +"markup.heading" = { fg = "red" } +"markup.raw.inline" = { fg = "green" } +"markup.bold" = { fg = "gold", modifiers = ["bold"] } +"markup.italic" = { fg = "purple", modifiers = ["italic"] } +"markup.strikethrough" = { modifiers = ["crossed_out"] } +"markup.list" = { fg = "red" } +"markup.quote" = { fg = "yellow" } +"markup.link.url" = { fg = "cyan", modifiers = ["underlined"]} +"markup.link.text" = { fg = "purple" } + +"diff.plus" = "green" +"diff.delta" = "gold" +"diff.minus" = "red" + +"diagnostic.info".underline = { color = "blue", style = "curl" } +"diagnostic.hint".underline = { color = "green", style = "curl" } +"diagnostic.warning".underline = { color = "yellow", style = "curl" } +"diagnostic.error".underline = { color = "red", style = "curl" } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } +"info" = { fg = "blue", modifiers = ["bold"] } +"hint" = { fg = "green", modifiers = ["bold"] } +"warning" = { fg = "yellow", modifiers = ["bold"] } +"error" = { fg = "red", modifiers = ["bold"] } + +"ui.background" = { bg = "black" } +"ui.virtual" = { fg = "faint-gray" } +"ui.virtual.indent-guide" = { fg = "faint-gray" } +"ui.virtual.whitespace" = { fg = "light-gray" } +"ui.virtual.ruler" = { bg = "gray" } +"ui.virtual.inlay-hint" = { fg = "light-gray" } +"ui.virtual.jump-label" = { fg = "light-gray", modifiers = ["bold"] } + +"ui.cursor" = { fg = "white", modifiers = ["reversed"] } +"ui.cursor.primary" = { fg = "white", modifiers = ["reversed"] } +"ui.cursor.match" = { fg = "blue", modifiers = ["underlined"]} + +"ui.selection" = { bg = "faint-gray" } +"ui.selection.primary" = { bg = "light-gray" } +"ui.cursorline.primary" = { bg = "light-black" } + +"ui.highlight" = { bg = "gray" } +"ui.highlight.frameline" = { bg = "#97202a" } + +"ui.linenr" = { fg = "linenr" } +"ui.linenr.selected" = { fg = "white" } + +"ui.statusline" = { fg = "white", bg = "light-black" } +"ui.statusline.inactive" = { fg = "light-gray", bg = "light-black" } +"ui.statusline.normal" = { fg = "light-black", bg = "blue", modifiers = ["bold"] } +"ui.statusline.insert" = { fg = "light-black", bg = "green", modifiers = ["bold"] } +"ui.statusline.select" = { fg = "light-black", bg = "purple", modifiers = ["bold"] } + +"ui.bufferline" = { fg = "light-gray", bg = "light-black" } +"ui.bufferline.active" = { fg = "light-black", bg = "blue", underline = { color = "light-black", style = "line" } } +"ui.bufferline.background" = { bg = "light-black" } + +"ui.text" = { fg = "white" } +"ui.text.directory" = { fg = "blue" } +"ui.text.focus" = { fg = "white", bg = "light-black", modifiers = ["bold"] } + +"ui.help" = { fg = "white", bg = "gray" } +"ui.popup" = { bg = "gray" } +"ui.window" = { fg = "gray" } +"ui.menu" = { fg = "white", bg = "gray" } +"ui.menu.selected" = { fg = "black", bg = "blue" } +"ui.menu.scroll" = { fg = "white", bg = "light-gray" } + +"ui.debug" = { fg = "red" } + +[palette] + +yellow = "#E5C07B" +blue = "#61AFEF" +red = "#E06C75" +purple = "#C678DD" +green = "#98C379" +gold = "#D19A66" +cyan = "#56B6C2" +white = "#ABB2BF" +black = "#282C34" +light-black = "#2C323C" +gray = "#3E4452" +faint-gray = "#3B4048" +light-gray = "#5C6370" +linenr = "#4B5263" diff --git a/tui/themes/onelight.toml b/tui/themes/onelight.toml new file mode 100644 index 000000000..9fc8ef650 --- /dev/null +++ b/tui/themes/onelight.toml @@ -0,0 +1,201 @@ +# Author : erasin<erasinoo@gmail.com> + +"attribute" = { fg = "yellow" } +"label" = { fg = "cyan" } +"operator" = { fg = "red" } +"tag" = { fg = "cyan" } +"special" = { fg = "deep-purple" } +"property" = { fg = "purple" } +"constructor" = { fg = "blue" } +"namespace" = { fg = "blue" } +"module" = { fg = "blue" } + +"type" = { fg = "gold" } +"type.builtin" = { fg = "yellow" } +"type.enum" = { fg = "cyan" } +"type.enum.variant" = { fg = "cyan" } + +"constant" = { fg = "cyan", modifiers = ["bold"] } +"constant.builtin" = { fg = "deep-purple" } +"constant.builtin.boolean" = { fg = "purple" , modifiers = ["bold"]} +"constant.character" = { fg = "green" } +"constant.character.escape" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric.integer" = { fg = "brown" , modifiers = ["bold"]} +"constant.numeric.float" = { fg = "brown" , modifiers = ["bold"]} + +"string" = { fg = "green" } +"string.regexp" = { fg = "purple" } +"string.special" = { fg = "green" } +"string.special.path" = { fg = "blue" } +"string.special.url" = { fg = "light-blue" } +"string.special.symbol" = { fg = "pink" } + +"comment" = { fg = "grey", modifiers = ["italic"] } +"comment.line" = { fg = "grey", modifiers = ["italic"] } +"comment.line.documentation" = { fg = "grey", modifiers = ["italic"] } +"comment.block" = { fg = "grey", modifiers = ["italic"] } +"comment.block.documentation" = { fg = "grey", modifiers = ["italic"] } + +# "variable" = { fg = "black" } +"variable.builtin" = { fg = "light-blue" } +"variable.parameter" = { fg = "red" } +"variable.other" = { fg = "pink" } +"variable.other.member" = { fg = "pink" } + +"punctuation" = { fg = "black" } +"punctuation.delimiter" = { fg = "purple" } +"punctuation.bracket" = { fg = "brown" } +"punctuation.special" = { fg = "brown" } + +"keyword" = { fg = "purple" } +"keyword.control" = { fg = "purple" } +"keyword.control.conditional" = { fg = "red", modifiers = ["bold"] } +"keyword.control.repeat" = { fg = "pink", modifiers = ["bold"] } +"keyword.control.import" = { fg = "red" } +"keyword.control.return" = { fg = "deep-purple", modifiers = ["bold"] } +"keyword.control.exception" = { fg = "purple" } +"keyword.operator" = { fg = "red" } +"keyword.directive" = { fg = "deep-purple" } +"keyword.function" = { fg = "purple" } +"keyword.storage" = { fg = "purple" } +"keyword.storage.type" = { fg = "purple" } +"keyword.storage.modifier" = { fg = "purple", modifiers = ["bold"] } + +"function" = { fg = "blue" } +"function.builtin" = { fg = "cyan" } +"function.method" = { fg = "light-blue" } +"function.macro" = { fg = "pink", modifiers = ["bold"] } +"function.special" = { fg = "cyan" } + +"markup.heading" = { fg = "red" } +"markup.heading.marker" = { fg = "red" } +"markup.heading.1" = { fg = "red", modifiers = ["bold"] } +"markup.heading.2" = { fg = "gold", modifiers = [ + "bold", +], underline = { style = "line" } } +"markup.heading.3" = { fg = "yellow", modifiers = ["bold"] } +"markup.heading.4" = { fg = "green", modifiers = ["bold"] } +"markup.heading.5" = { fg = "blue", modifiers = ["bold"] } +"markup.heading.6" = { fg = "purple", modifiers = ["bold"] } +"markup.list" = { fg = "light-blue" } +"markup.list.unnumbered" = { fg = "light-blue" } +"markup.list.numbered" = { fg = "light-blue" } +"markup.list.checked" = { fg = "green" } +"markup.list.unchecked" = { fg = "blue" } +"markup.bold" = { fg = "yellow", modifiers = ["bold"] } +"markup.italic" = { fg = "purple", modifiers = ["italic"] } +"markup.strikethrough" = { fg = "red", modifiers = ["crossed_out"] } +"markup.link" = { fg = "light-blue" } +"markup.link.url" = { fg = "cyan", modifiers = ["underlined"] } +"markup.link.text" = { fg = "light-blue" } +"markup.quote" = { fg = "grey" } +"markup.raw" = { fg = "brown" } +"markup.raw.inline" = { fg = "green" } +"markup.raw.block" = { fg = "brown" } + +"diff" = { fg = "red" } +"diff.plus" = { fg = "green" } +"diff.minus" = { fg = "red" } +"diff.delta" = { fg = "cyan" } +"diff.delta.moved" = { fg = "cyan" } +"diff.delta.conflict" = {fg = "blue"} + +"ui.background" = { bg = "white" } +"ui.background.separator" = { bg = "white" } + +"ui.cursor" = { fg = "white", bg = "grey" } +"ui.cursor.normal" = { fg = "white", bg = "grey" } +"ui.cursor.insert" = { fg = "white", bg = "grey" } +"ui.cursor.select" = { fg = "white", bg = "grey" } +"ui.cursor.match" = { bg = "grey-300", modifiers = ["bold"] } +"ui.cursor.primary" = { fg = "white", bg = "black" } +"ui.cursor.primary.normal" = { fg = "white", bg = "black" } +"ui.cursor.primary.insert" = { fg = "red", bg = "black" } +"ui.cursor.primary.select" = { fg = "white", bg = "black" } + +"ui.gutter" = { fg = "grey-500" } +"ui.gutter.selected" = { fg = "black" } + +"ui.linenr" = { fg = "grey-500" } +"ui.linenr.selected" = { fg = "black", modifiers = ["bold"] } + +"ui.statusline" = { fg = "black", bg = "grey-300" } +"ui.statusline.inactive" = { fg = "grey", bg = "grey-200" } +"ui.statusline.normal" = { fg = "grey-300", bg = "light-blue" } +"ui.statusline.insert" = { fg = "grey-300", bg = "green" } +"ui.statusline.select" = { fg = "grey-300", bg = "purple" } + +"ui.popup" = { fg = "black", bg = "grey-200" } +"ui.popup.info" = { fg = "black", bg = "grey-200" } +"ui.window" = { fg = "grey-500", bg = "grey-100" } +"ui.help" = { fg = "black", bg = "grey-200" } + +"ui.text" = { fg = "black" } +"ui.text.focus" = { fg = "red", bg = "grey-300", modifiers = ["bold"] } +"ui.text.inactive" = { fg = "grey" } +"ui.text.info" = { fg = "black" } +"ui.text.directory" = { fg = "blue", underline = { style = "line" } } + +"ui.virtual" = { fg = "grey-500" } +"ui.virtual.ruler" = { bg = "grey-200" } +"ui.virtual.wrap" = { fg = "grey-500" } +"ui.virtual.whitespace" = { fg = "grey-400" } +"ui.virtual.indent-guide" = { fg = "grey-500" } +"ui.virtual.inlay-hint" = { fg = "grey-500" } +"ui.virtual.inlay-hint.parameter" = { fg = "grey-500", modifiers = ["italic"] } +"ui.virtual.inlay-hint.type" = { fg = "grey-500" } +"ui.virtual.jump-label" = { fg = "black", bg = "grey-200", modifiers = ["bold" ] } + +"ui.menu" = { fg = "black", bg = "grey-300" } +"ui.menu.selected" = { fg = "white", bg = "light-blue" } +"ui.menu.scroll" = { fg = "light-blue", bg = "white" } + +"ui.selection" = { bg = "grey-300", modifiers = ["dim"] } +"ui.selection.primary" = { bg = "grey-300" } + +"ui.cursorline.primary" = { fg = "white", bg = "grey-100" } +"ui.cursorline.secondary" = { fg = "white", bg = "grey-200" } + +"ui.cursorcolumn.primary" = { fg = "white", bg = "grey-100" } +"ui.cursorcolumn.secondary" = { fg = "white", bg = "grey-200" } + +"ui.highlight" = { bg = "grey-300" } + +"ui.picker.header" = { fg = "purple"} +"ui.picker.header.column.active" = { fg = "blue"} + +"diagnostic.info" = { underline = { color = "blue", style = "dotted" } } +"diagnostic.hint" = { underline = { color = "green", style = "dashed" } } +"diagnostic.warning" = { underline = { color = "yellow", style = "curl" } } +"diagnostic.error" = { underline = { color = "red", style = "curl" } } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } + +"info" = { fg = "blue", modifiers = ["bold"] } +"hint" = { fg = "green", modifiers = ["bold"] } +"warning" = { fg = "yellow", modifiers = ["bold"] } +"error" = { fg = "red", modifiers = ["bold"] } + +"tabstop" = { modifiers = ["italic"], bg = "grey-300" } + +[palette] +white = "#FAFAFA" +yellow = "#FF6F00" +gold = "#D35400" +brown = "#795548" +blue = "#0061FF" +light-blue = "#0091EA" +red = "#D50000" +pink = "#C2185B" +purple = "#B500A9" +deep-purple = "#651FFF" +green = "#24A443" +cyan = "#0086C1" +black = "#282C34" +grey = "#5C6370" +grey-500 = "#9E9E9E" +grey-400 = "#BDBDBD" +grey-300 = "#E0E0E0" +grey-200 = "#EEEEEE" +grey-100 = "#F2F2F2" diff --git a/tui/themes/solarized_light.toml b/tui/themes/solarized_light.toml new file mode 100644 index 000000000..2db54ca16 --- /dev/null +++ b/tui/themes/solarized_light.toml @@ -0,0 +1,155 @@ +"attribute" = { fg = "violet" } +"keyword" = { fg = "green" } +"keyword.directive" = { fg = "orange" } +"namespace" = { fg = "violet" } +"operator" = { fg = "green" } +"special" = { fg = "orange" } +"variable.builtin" = { fg = "cyan", modifiers = ["bold"] } +"variable.function" = { fg = "blue" } +"type" = { fg = "yellow" } +"type.builtin" = { fg = "yellow", modifiers = ["bold"] } +"constructor" = { fg = "blue" } +"function" = { fg = "blue" } +"function.macro" = { fg = "magenta" } +"function.builtin" = { fg = "blue", modifiers = ["bold"] } +"function.special" = { fg = "magenta" } +"comment" = { fg = "base01" } +"string" = { fg = "cyan" } +"constant" = { fg = "cyan" } +"constant.builtin" = { fg = "cyan", modifiers = ["bold"] } +"constant.character.escape" = { fg = "red", modifiers = ["bold"] } +"label" = { fg = "green" } +"module" = { fg = "violet" } +"tag" = { fg = "magenta" } + +# TODO +"markup.heading" = "blue" +"markup.list" = "red" +"markup.bold" = { fg = "yellow", modifiers = ["bold"] } +"markup.italic" = { fg = "magenta", modifiers = ["italic"] } +"markup.strikethrough" = { modifiers = ["crossed_out"] } +"markup.link.url" = { fg = "yellow", modifiers = ["underlined"] } +"markup.link.text" = "red" +"markup.quote" = "cyan" +"markup.raw" = "green" + +"diff.plus" = { fg = "green" } +"diff.delta" = { fg = "yellow" } +"diff.minus" = { fg = "red" } + +# 背景 +# background +"ui.background" = { bg = "base03" } + +"ui.virtual.whitespace" = { fg = "base01" } +"ui.virtual.inlay-hint" = { fg = "base01", modifiers = ["italic"] } +"ui.virtual.jump-label" = { fg = "red", modifiers = ["bold"] } + +# 行号栏 +# line number column +"ui.linenr" = { fg = "base0", bg = "base02" } +# 当前行号栏 +# current line number column +"ui.linenr.selected" = { fg = "blue", modifiers = ["bold"] } +# cursorline +"ui.cursorline" = { bg = "base0" } + +# 状态栏 +# status bar +"ui.statusline" = { fg = "base03", bg = "base0" } +"ui.statusline.normal" = { bg = "blue" } +"ui.statusline.insert" = { bg = "green" } +"ui.statusline.select" = { bg = "yellow" } + +# 非活动状态栏 +# inactive status bar +"ui.statusline.inactive" = { fg = "base1", bg = "base01" } + +# 补全窗口, preview窗口 +# Completion window, preview window +"ui.popup" = { bg = "base02" } +# 影响 补全选中 cmd弹出信息选中 +# Affect completion selection, cmd pop-up information selection +"ui.menu.selected" = { fg = "base02", bg = "base2"} +"ui.menu" = { fg = "base0", bg = "base02" } +# ?? +"ui.window" = { fg = "base3" } +# 命令行 补全的帮助信息 +# Command line completion help information +"ui.help" = { modifiers = ["reversed"] } + +# 快捷键窗口 +# Shortcut window +"ui.popup.info" = { bg = "base1" } +# 快捷键字体 +# Shortcut font +"ui.text.info" = {fg = "base02", modifiers = ["bold"]} + +# 普通ui的字体样式 +# Normal ui font style +"ui.text" = { fg = "base1" } +# 影响 picker列表选中, 快捷键帮助窗口文本 +# Affects picker list selection, shortcut key help window text +"ui.text.focus" = { fg = "blue", modifiers = ["bold"]} + +# 主光标/selection +# main cursor/selection +"ui.cursor.primary" = { fg = "base03", bg = "base1" } +"ui.cursor.select" = { fg = "base02", bg = "cyan" } + +"ui.cursorline.primary" = { bg = "base02" } +"ui.cursorline.secondary" = { bg = "base025" } + +"ui.selection" = { bg = "base0175" } +"ui.selection.primary" = { bg = "base015" } + +"ui.virtual.indent-guide" = { fg = "base02" } +"ui.virtual.ruler" = { bg = "base02" } + +# normal模式的光标 +# normal mode cursor +"ui.cursor" = {fg = "base02", bg = "cyan"} +"ui.cursor.insert" = {fg = "base03", bg = "base3"} +# 当前光标匹配的标点符号 +# The punctuation character matched by the current cursor +"ui.cursor.match" = { fg = "base02", bg = "base015" } + +"warning" = { fg = "orange", modifiers= ["bold", "underlined"] } +"error" = { fg = "red", modifiers= ["bold", "underlined"] } +"info" = { fg = "blue", modifiers= ["bold", "underlined"] } +"hint" = { fg = "base01", modifiers= ["bold", "underlined"] } + +"diagnostic.warning" = { underline = { style = "curl", color = "orange" } } +"diagnostic.error" = { underline = { style = "curl", color = "red" } } +"diagnostic.info" = { underline = { style = "curl", color = "blue" } } +"diagnostic.hint" = { underline = { style = "curl", color = "base01" } } +"diagnostic.unnecessary" = { modifiers = ["dim"] } +"diagnostic.deprecated" = { modifiers = ["crossed_out"] } + + +[palette] +red = '#dc322f' +green = '#859900' +yellow = '#b58900' +blue = '#268bd2' +magenta = '#d33682' +cyan = '#2aa198' +orange = '#cb4b16' +violet = '#6c71c4' + +# 深色 越来越深 +# dark getting darker +base0 = '#657b83' +base1 = '#586e75' +base2 = '#073642' +base3 = '#002b36' + +# 浅色 越來越浅 +# Lighter and lighter +base00 = '#839496' +base01 = '#93a1a1' +base015 = '#c5c8bd' +base0175 = '#dddbcc' +base02 = '#eee8d5' +base025 = '#f5eedb' +base03 = '#fdf6e3' From 59575419c62e685a508c359ac832d58945c5afe5 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 00:12:49 -0700 Subject: [PATCH 10/20] Ask the summarizer for plain pseudocode The default system prompt no longer asks for python-flavored pseudocode; summaries are language-neutral pseudocode. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 --- plugins/summarize/plugin.toml | 2 +- plugins/summarize/src/lib.rs | 3 +-- src/plugin/tests/summarize.rs | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/summarize/plugin.toml b/plugins/summarize/plugin.toml index ee5dfd5be..5a095191c 100644 --- a/plugins/summarize/plugin.toml +++ b/plugins/summarize/plugin.toml @@ -65,7 +65,7 @@ title = "System prompt" description = "The system instruction sent with every request. Each request's own message, the file's numbered lines and the folds to summarize, is built by diffr." "x-settings" = false default = """\ -For each listed fold, rewrite that function body as short python-flavored \ +For each listed fold, rewrite that function body as short \ 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 \ diff --git a/plugins/summarize/src/lib.rs b/plugins/summarize/src/lib.rs index d5efa928a..ac2529328 100644 --- a/plugins/summarize/src/lib.rs +++ b/plugins/summarize/src/lib.rs @@ -1,5 +1,4 @@ -//! The summarizer: large new function bodies become short python-flavored -//! pseudocode, shown +//! The summarizer: large new function bodies become short pseudocode, shown //! in place of the collapsed body. //! //! It needs an API key: `new` fails without one, naming how to set it or diff --git a/src/plugin/tests/summarize.rs b/src/plugin/tests/summarize.rs index f0a7c73eb..726404a7c 100644 --- a/src/plugin/tests/summarize.rs +++ b/src/plugin/tests/summarize.rs @@ -344,9 +344,9 @@ fn the_system_prompt_is_the_configured_one() { )) .unwrap(); assert_eq!(system, default.system_prompt.as_str()); - assert!(default - .system_prompt - .starts_with("For each listed fold, rewrite that function body as short python-flavored pseudocode. Keep the names.")); + assert!(default.system_prompt.starts_with( + "For each listed fold, rewrite that function body as short pseudocode. Keep the names." + )); assert!(!default.system_prompt.contains('\n')); } From 216cd6114afb02ebadd0715bd3051e212d1480a8 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 12:06:37 -0700 Subject: [PATCH 11/20] Generate the plugin records from the WIT `wit/plugin.wit` is the plugin contract, but the SDK's `types` module hand-mirrored its records in plain Rust and the component export glue converted between the two. Two definitions of every record, free to drift, and a field added to one side had to be added to the other by hand. `wit_bindgen::generate!` emits those records as plain Rust; only the export macro it also emits is wasm-specific, and `export!` already calls that behind `#[cfg(target_arch = "wasm32")]`. So generate the bindings for every target and let `types` re-export them: one definition of each record, taken from the contract itself. `types` keeps only what the WIT cannot say, `ROOT` and the `Range::lines` and `Visibility::default` impls, and the guest resource now hands the plugin the records it was given rather than converting them. The generated `move` variant is a WIT variant, so its cases carry their payload positionally: `Move::SetCollapsed((region, collapsed))` rather than `Move::SetCollapsed { region, collapsed }`. The names are unchanged. diffr's own side of a component call still converts, since wasmtime's `bindgen!` generates host records of its own that must implement its lowering traits; those records come from the same WIT, and a drift between them and the contract fails to compile. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0ab99-9486-74f2-ba5e-cdb10b5dbb28 --- crates/diffr-plugin-sdk/Cargo.toml | 8 +- crates/diffr-plugin-sdk/src/apply.rs | 152 +++++++-------------------- crates/diffr-plugin-sdk/src/draft.rs | 53 +++------- crates/diffr-plugin-sdk/src/guest.rs | 99 ++--------------- crates/diffr-plugin-sdk/src/lib.rs | 11 +- crates/diffr-plugin-sdk/src/types.rs | 149 ++++---------------------- docs/plugins.md | 14 +-- examples/plugins/fixtures/src/lib.rs | 29 ++--- plugins/hide-files/src/lib.rs | 10 +- src/plugin/tests/group.rs | 2 +- src/plugin/tests/hide_files.rs | 10 +- src/plugin/tests/mod.rs | 5 +- src/plugin/wasm.rs | 14 +-- 13 files changed, 131 insertions(+), 425 deletions(-) diff --git a/crates/diffr-plugin-sdk/Cargo.toml b/crates/diffr-plugin-sdk/Cargo.toml index c5c935f4e..a1221ef2a 100644 --- a/crates/diffr-plugin-sdk/Cargo.toml +++ b/crates/diffr-plugin-sdk/Cargo.toml @@ -10,10 +10,12 @@ publish = false [dependencies] anyhow = "1.0" serde = "1.0" +# Generates the contract's records from `wit/plugin.wit`, for every target: +# the records are plain Rust, and only the export glue is wasm-specific. The +# newest wit-bindgen whose rust-version (1.85) matches diffr's. +wit-bindgen = "0.60.0" -# The guest bindings exist only in a component. +# The export glue exists only in a component. [target.'cfg(target_arch = "wasm32")'.dependencies] -# The newest wit-bindgen whose rust-version (1.85) matches diffr's. -wit-bindgen = "0.60.0" # The resource's `new` deserializes the options string. serde_json = "1.0" diff --git a/crates/diffr-plugin-sdk/src/apply.rs b/crates/diffr-plugin-sdk/src/apply.rs index e8ee7e0b2..bfbd0f1df 100644 --- a/crates/diffr-plugin-sdk/src/apply.rs +++ b/crates/diffr-plugin-sdk/src/apply.rs @@ -39,7 +39,7 @@ //! largest leaf `alignment_id`; each is handed out in the order the moves //! need them, lhs before rhs. use crate::tree::{walk, walk_mut, Node, Pairing, Region, Source}; -use crate::types::{Move, Position, Range, Visibility, ROOT}; +use crate::types::{Cut, Move, Position, Range, Visibility, ROOT}; use anyhow::{bail, ensure}; use std::collections::BTreeSet; @@ -64,17 +64,14 @@ impl Applier { file: &mut Visibility, ) -> anyhow::Result<()> { match next { - Move::Cut { region, at } => cut(sides, region, at, &mut self.fresh), - Move::JoinFolds { regions } => join(sides, ®ions, &mut self.fresh), - Move::LinkFoldState { regions } => link(sides, ®ions), - Move::SetCollapsed { - region: ROOT, - collapsed, - } => { + Move::Cut(Cut { region, at }) => cut(sides, region, at, &mut self.fresh), + Move::JoinFolds(regions) => join(sides, ®ions, &mut self.fresh), + Move::LinkFoldState(regions) => link(sides, ®ions), + Move::SetCollapsed((ROOT, collapsed)) => { file.collapsed = collapsed; Ok(()) } - Move::SetCollapsed { region, collapsed } => { + Move::SetCollapsed((region, collapsed)) => { let state = region_of(sides, region)?.fold_state_id; for tree in trees(sides) { walk_mut(tree, &mut |region| { @@ -85,21 +82,18 @@ impl Applier { } Ok(()) } - Move::SetLabel { - region: ROOT, - label, - } => { + Move::SetLabel((ROOT, label)) => { file.label = label.unwrap_or_default(); Ok(()) } - Move::SetLabel { region, label } => { + Move::SetLabel((region, label)) => { region_mut(sides, region)?.visibility.label = label.unwrap_or_default(); Ok(()) } - Move::SetTags { region: ROOT, .. } => { + Move::SetTags((ROOT, _)) => { bail!("the file's tags are its manifest entry's; only a region's tags can be set") } - Move::SetTags { region, tags } => { + Move::SetTags((region, tags)) => { region_mut(sides, region)?.tags = tags; Ok(()) } @@ -564,11 +558,11 @@ mod tests { ], ); let moves = vec![ - Move::Cut { + Move::Cut(Cut { region: target, at: 2, - }, - Move::Cut { region: 5, at: 2 }, + }), + Move::Cut(Cut { region: 5, at: 2 }), ]; run(moves, &mut sides).unwrap(); let (lhs, rhs) = sides_of(&sides); @@ -609,7 +603,7 @@ mod tests { vec![leaf(1, 0, 0, 1, &[]), leaf(2, 1, 1, 6, &[])], vec![in_state(leaf(3, 0, 0, 1, &[]), 1)], ); - run(vec![Move::Cut { region: 2, at: 1 }], &mut sides).unwrap(); + run(vec![Move::Cut(Cut { region: 2, at: 1 })], &mut sides).unwrap(); let (lhs, rhs) = sides_of(&sides); assert_eq!( shape(&lhs.regions), @@ -633,17 +627,9 @@ mod tests { vec![in_state(fold(4, false, vec![leaf(5, 2, 0, 4, &[])]), 2)], ); let moves = vec![ - Move::LinkFoldState { - regions: vec![2, 1], - }, - Move::SetCollapsed { - region: 4, - collapsed: true, - }, - Move::SetLabel { - region: 2, - label: Some("summary".to_owned()), - }, + Move::LinkFoldState(vec![2, 1]), + Move::SetCollapsed((4, true)), + Move::SetLabel((2, Some("summary".to_owned()))), ]; run(moves, &mut sides).unwrap(); let (lhs, rhs) = sides_of(&sides); @@ -660,16 +646,7 @@ mod tests { (4, None, 2, 0, 4, true, String::new()) ); run( - vec![ - Move::SetCollapsed { - region: 1, - collapsed: false, - }, - Move::SetLabel { - region: 2, - label: None, - }, - ], + vec![Move::SetCollapsed((1, false)), Move::SetLabel((2, None))], &mut sides, ) .unwrap(); @@ -698,26 +675,14 @@ mod tests { ) }; let mut sides = tree(); - run( - vec![Move::LinkFoldState { - regions: vec![7, 5], - }], - &mut sides, - ) - .unwrap(); + run(vec![Move::LinkFoldState(vec![7, 5])], &mut sides).unwrap(); let (lhs, rhs) = sides_of(&sides); assert_eq!(lhs.regions[0].fold_state_id, 7, "the pair stays together"); assert_eq!(rhs.regions[1].fold_state_id, 7); assert_eq!(rhs.regions[0].fold_state_id, 3); let mut sides = tree(); - run( - vec![Move::LinkFoldState { - regions: vec![8, 1], - }], - &mut sides, - ) - .unwrap(); + run(vec![Move::LinkFoldState(vec![8, 1])], &mut sides).unwrap(); let (lhs, rhs) = sides_of(&sides); for region in [&lhs.regions[0], &lhs.regions[1], &rhs.regions[1]] { assert_eq!( @@ -744,9 +709,7 @@ mod tests { in_state(fold(6, true, vec![leaf(13, 5, 5, 8, &[])]), 3), ], ); - let moves = vec![Move::JoinFolds { - regions: vec![1, 2, 3, 5, 7, 6], - }]; + let moves = vec![Move::JoinFolds(vec![1, 2, 3, 5, 7, 6])]; run(moves, &mut sides).unwrap(); let (lhs, rhs) = sides_of(&sides); assert_eq!(lhs.regions.len(), 1); @@ -772,9 +735,7 @@ mod tests { ], vec![in_state(leaf(7, 0, 0, 1, &[]), 1)], ); - let moves = vec![Move::JoinFolds { - regions: vec![2, 4, 5], - }]; + let moves = vec![Move::JoinFolds(vec![2, 4, 5])]; run(moves, &mut sides).unwrap(); let (lhs, rhs) = sides_of(&sides); assert_eq!(lhs.regions.len(), 2); @@ -801,45 +762,22 @@ mod tests { ) }; let error = |next: Move| run(vec![next], &mut tree()).unwrap_err().to_string(); - assert!(error(Move::SetCollapsed { - region: 9, - collapsed: true - }) - .contains("no region 9")); - assert!(error(Move::Cut { region: 3, at: 3 }).contains("not inside region 3")); - assert!(error(Move::Cut { region: 3, at: 0 }).contains("not inside region 3")); - assert!(error(Move::Cut { region: 2, at: 1 }).contains("is a fold")); - assert!(error(Move::Cut { + assert!(error(Move::SetCollapsed((9, true))).contains("no region 9")); + assert!(error(Move::Cut(Cut { region: 3, at: 3 })).contains("not inside region 3")); + assert!(error(Move::Cut(Cut { region: 3, at: 0 })).contains("not inside region 3")); + assert!(error(Move::Cut(Cut { region: 2, at: 1 })).contains("is a fold")); + assert!(error(Move::Cut(Cut { region: ROOT, at: 1 - }) + })) .contains("the file cannot be cut")); - assert!(error(Move::JoinFolds { - regions: vec![1, 4] - }) - .contains("not consecutive")); - assert!(error(Move::JoinFolds { - regions: vec![1, 2, 99] - }) - .contains("no region 99")); - assert!(error(Move::JoinFolds { - regions: vec![ROOT, 1] - }) - .contains("cannot include the file")); - assert!(error(Move::JoinFolds { - regions: vec![1, 2, 5] - }) - .contains("holds only one")); - assert!(error(Move::LinkFoldState { regions: vec![1] }).contains("at least two")); - assert!(error(Move::LinkFoldState { - regions: vec![1, 1] - }) - .contains("twice")); - assert!(error(Move::SetTags { - region: ROOT, - tags: vec![] - }) - .contains("manifest")); + assert!(error(Move::JoinFolds(vec![1, 4])).contains("not consecutive")); + assert!(error(Move::JoinFolds(vec![1, 2, 99])).contains("no region 99")); + assert!(error(Move::JoinFolds(vec![ROOT, 1])).contains("cannot include the file")); + assert!(error(Move::JoinFolds(vec![1, 2, 5])).contains("holds only one")); + assert!(error(Move::LinkFoldState(vec![1])).contains("at least two")); + assert!(error(Move::LinkFoldState(vec![1, 1])).contains("twice")); + assert!(error(Move::SetTags((ROOT, vec![]))).contains("manifest")); } #[test] @@ -850,22 +788,10 @@ mod tests { ); let visibility = run( vec![ - Move::SetLabel { - region: ROOT, - label: Some("first".to_owned()), - }, - Move::SetCollapsed { - region: ROOT, - collapsed: true, - }, - Move::SetLabel { - region: ROOT, - label: Some("Generated file".to_owned()), - }, - Move::SetTags { - region: 2, - tags: vec!["mine:tag".to_owned()], - }, + Move::SetLabel((ROOT, Some("first".to_owned()))), + Move::SetCollapsed((ROOT, true)), + Move::SetLabel((ROOT, Some("Generated file".to_owned()))), + Move::SetTags((2, vec!["mine:tag".to_owned()])), ], &mut sides, ) diff --git a/crates/diffr-plugin-sdk/src/draft.rs b/crates/diffr-plugin-sdk/src/draft.rs index 77f4b25fd..9fef1a065 100644 --- a/crates/diffr-plugin-sdk/src/draft.rs +++ b/crates/diffr-plugin-sdk/src/draft.rs @@ -8,7 +8,7 @@ //! a leaf, and grouping siblings under a labelled fold. use crate::apply::{find, trees_ref, Applier}; use crate::tree::{siblings_of, walk, Node, Pairing, Region, Source}; -use crate::types::{Move, Visibility}; +use crate::types::{Cut, Move, Visibility}; use anyhow::{anyhow, ensure}; pub struct Draft { @@ -89,21 +89,12 @@ impl Draft { cleared.push(region.id); } }); - self.push(Move::SetCollapsed { - region: id, - collapsed: true, - })?; + self.push(Move::SetCollapsed((id, true)))?; for region in named { - self.push(Move::SetLabel { - region, - label: Some(label.clone()), - })?; + self.push(Move::SetLabel((region, Some(label.clone()))))?; } for region in cleared { - self.push(Move::SetLabel { - region, - label: None, - })?; + self.push(Move::SetLabel((region, None)))?; } Ok(()) } @@ -127,23 +118,15 @@ impl Draft { cleared.push(region.id); } }); - self.push(Move::LinkFoldState { - regions: ids.to_vec(), - })?; + self.push(Move::LinkFoldState(ids.to_vec()))?; if !collapsed { return Ok(()); } if !first_collapsed { - self.push(Move::SetCollapsed { - region: ids[0], - collapsed: true, - })?; + self.push(Move::SetCollapsed((ids[0], true)))?; } for region in cleared { - self.push(Move::SetLabel { - region, - label: None, - })?; + self.push(Move::SetLabel((region, None)))?; } Ok(()) } @@ -165,17 +148,17 @@ impl Draft { ); let mut piece = id; if start > 0 { - self.push(Move::Cut { + self.push(Move::Cut(Cut { region: id, at: start, - })?; + }))?; piece = self.next_sibling(id)?; } if end < len { - self.push(Move::Cut { + self.push(Move::Cut(Cut { region: piece, at: end - start, - })?; + }))?; } Ok(piece) } @@ -195,9 +178,7 @@ impl Draft { /// Wrap `ids`, consecutive siblings on each side that holds them, in a /// new fold on each such side, starting collapsed behind `label`. pub fn group(&mut self, ids: Vec<u32>, label: String) -> anyhow::Result<()> { - self.push(Move::JoinFolds { - regions: ids.clone(), - })?; + self.push(Move::JoinFolds(ids.clone()))?; let mut folds = Vec::new(); for tree in trees_ref(&self.sides) { let mut parent = None; @@ -211,15 +192,9 @@ impl Draft { folds.extend(parent); } let first = *folds.first().expect("a join adds a fold on some side"); - self.push(Move::SetCollapsed { - region: first, - collapsed: true, - })?; + self.push(Move::SetCollapsed((first, true)))?; for region in folds { - self.push(Move::SetLabel { - region, - label: Some(label.clone()), - })?; + self.push(Move::SetLabel((region, Some(label.clone()))))?; } Ok(()) } diff --git a/crates/diffr-plugin-sdk/src/guest.rs b/crates/diffr-plugin-sdk/src/guest.rs index 1ab4a8edd..213d33609 100644 --- a/crates/diffr-plugin-sdk/src/guest.rs +++ b/crates/diffr-plugin-sdk/src/guest.rs @@ -1,9 +1,9 @@ //! The component side of [`crate::export!`]: the `plugin` resource over a -//! [`Plugin`], with the generated guest bindings' records converted to the -//! contract's Rust records and back around each call. -use crate::bindings::diffr::plugin::types as wit; +//! [`Plugin`]. The generated guest bindings' records are the contract's +//! records ([`crate::types`] re-exports them), so a call hands the plugin +//! what it was given and returns what the plugin returned. use crate::bindings::exports::diffr::plugin::guest; -use crate::types; +use crate::types::{FileEntry, Move, Source}; use crate::Plugin; /// One instance of the plugin `P`, the component's `plugin` resource. @@ -17,95 +17,18 @@ impl<P: Plugin + 'static> guest::GuestPlugin for Instance<P> { Ok(guest::Plugin::new(Instance(plugin))) } - fn classify(&self, file: wit::FileEntry) -> Result<Vec<String>, String> { - self.0 - .classify(&file_entry(file)) - .map_err(|error| format!("{error:#}")) + fn classify(&self, file: FileEntry) -> Result<Vec<String>, String> { + self.0.classify(&file).map_err(|error| format!("{error:#}")) } fn mutate( &self, - file: wit::FileEntry, - lhs: Option<wit::Source>, - rhs: Option<wit::Source>, - ) -> Result<Vec<wit::Move>, String> { - let lhs = lhs.map(source); - let rhs = rhs.map(source); + file: FileEntry, + lhs: Option<Source>, + rhs: Option<Source>, + ) -> Result<Vec<Move>, String> { self.0 - .mutate(&file_entry(file), lhs.as_ref(), rhs.as_ref()) - .map(|moves| moves.into_iter().map(lower).collect()) + .mutate(&file, lhs.as_ref(), rhs.as_ref()) .map_err(|error| format!("{error:#}")) } } - -fn file_entry(file: wit::FileEntry) -> types::FileEntry { - types::FileEntry { - path: file.path, - old_path: file.old_path, - status: match file.status { - wit::FileStatus::Added => types::FileStatus::Added, - wit::FileStatus::Deleted => types::FileStatus::Deleted, - wit::FileStatus::Modified => types::FileStatus::Modified, - wit::FileStatus::Renamed => types::FileStatus::Renamed, - wit::FileStatus::Copied => types::FileStatus::Copied, - wit::FileStatus::TypeChanged => types::FileStatus::TypeChanged, - }, - tags: file.tags, - } -} - -fn source(side: wit::Source) -> types::Source { - let position = |position: wit::Position| types::Position { - line: position.line, - column: position.column, - }; - types::Source { - text: side.text, - regions: side - .regions - .into_iter() - .map(|region| types::Region { - id: region.id, - parent: region.parent, - fold_state_id: region.fold_state_id, - range: types::Range { - start: position(region.range.start), - end: position(region.range.end), - }, - tags: region.tags, - visibility: types::Visibility { - collapsed: region.visibility.collapsed, - label: region.visibility.label, - }, - kind: match region.kind { - wit::Kind::Leaf(leaf) => types::Kind::Leaf(types::Leaf { - alignment_id: leaf.alignment_id, - changed: leaf - .changed - .into_iter() - .map(|span| types::Span { - line: span.line, - start_column: span.start_column, - end_column: span.end_column, - }) - .collect(), - }), - wit::Kind::Fold => types::Kind::Fold, - }, - }) - .collect(), - } -} - -fn lower(next: types::Move) -> wit::Move { - match next { - types::Move::Cut { region, at } => wit::Move::Cut(wit::Cut { region, at }), - types::Move::JoinFolds { regions } => wit::Move::JoinFolds(regions), - types::Move::LinkFoldState { regions } => wit::Move::LinkFoldState(regions), - types::Move::SetCollapsed { region, collapsed } => { - wit::Move::SetCollapsed((region, collapsed)) - } - types::Move::SetLabel { region, label } => wit::Move::SetLabel((region, label)), - types::Move::SetTags { region, tags } => wit::Move::SetTags((region, tags)), - } -} diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs index 82569a168..f3bec0d03 100644 --- a/crates/diffr-plugin-sdk/src/lib.rs +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -3,7 +3,10 @@ //! every plugin whether diffr compiles it in or runs it as a WASM component. //! //! - [`types`] holds the contract's records: the file entry, each side's -//! flat preorder region list with parent ids and text, and the moves. +//! flat preorder region list with parent ids and text, and the moves. They +//! are generated from `wit/plugin.wit` itself, so there is one definition +//! of each, and a plugin hands diffr the same records natively and as a +//! component. //! - [`Plugin`] is the one trait every plugin implements: `new`, which makes //! it from its options, then `classify` and `mutate`, taking and returning //! exactly those records. @@ -65,7 +68,10 @@ pub trait Plugin: Sized { ) -> anyhow::Result<Vec<Move>>; } -#[cfg(target_arch = "wasm32")] +/// The contract generated from `wit/plugin.wit`. Its records are plain Rust +/// and compile for every target, so [`types`] re-exports them and a plugin +/// works with the generated records wherever it runs; only the `export!` +/// macro this generates is wasm-specific, and [`export!`] calls it there. #[doc(hidden)] pub mod bindings { wit_bindgen::generate!({ @@ -73,6 +79,7 @@ pub mod bindings { world: "plugin", pub_export_macro: true, default_bindings_module: "diffr_plugin_sdk::bindings", + additional_derives: [PartialEq, Eq], }); } diff --git a/crates/diffr-plugin-sdk/src/types.rs b/crates/diffr-plugin-sdk/src/types.rs index d6ed96b03..d19522f7f 100644 --- a/crates/diffr-plugin-sdk/src/types.rs +++ b/crates/diffr-plugin-sdk/src/types.rs @@ -1,52 +1,17 @@ -//! The records of `wit/plugin.wit`'s `types` interface, as plain Rust. A -//! plugin receives and returns exactly these, natively and in a component; -//! in a component the export macro converts them to and from the generated -//! guest bindings. +//! The records of `wit/plugin.wit`'s `types` interface. They are generated +//! from the WIT itself by [`crate::bindings`], so the contract has one +//! definition of each record and nothing to keep in step: a plugin receives +//! and returns exactly these, natively and in a component. +//! +//! This module adds only what the WIT cannot say: [`ROOT`], the id that names +//! the file rather than a region, and the two impls below. +pub use crate::bindings::diffr::plugin::types::{ + Cut, FileEntry, FileStatus, Kind, Leaf, Move, Position, Range, Region, Side, Source, Span, + Visibility, +}; -/// The before (`Lhs`) or after (`Rhs`) side of a comparison. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Side { - Lhs, - Rhs, -} - -/// Git's delta status. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FileStatus { - Added, - Deleted, - Modified, - Renamed, - Copied, - TypeChanged, -} - -/// One changed file, as its manifest entry describes it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FileEntry { - /// The path on the after side, or on the before side when the file was - /// deleted. - pub path: String, - /// The path on the before side, when the file has one. - pub old_path: Option<String>, - pub status: FileStatus, - /// Sorted and deduplicated. During `classify`, the tags so far. - pub tags: Vec<String>, -} - -/// 0-based line, and 0-based byte column in the UTF-8 text. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Position { - pub line: u32, - pub column: u32, -} - -/// Half-open. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Range { - pub start: Position, - pub end: Position, -} +/// The id that names the file itself rather than a region. +pub const ROOT: u32 = 0; impl Range { /// The lines this range touches, half-open. A range ending at column @@ -61,84 +26,12 @@ impl Range { } } -/// Bytes painted as changed on one line. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Span { - pub line: u32, - pub start_column: u32, - pub end_column: u32, -} - -/// How a region starts out: collapsed or open, and the label shown while it -/// is collapsed (empty for none). -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Visibility { - pub collapsed: bool, - pub label: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Leaf { - /// Row alignment: the leaf on the other side with the same value lines - /// up with this one. - pub alignment_id: u32, - pub changed: Vec<Span>, -} - -/// A leaf tiles the file; a fold's range is the hull of its children. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Kind { - Leaf(Leaf), - Fold, -} - -/// One region of a side's tree. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Region { - /// Unique across both sides of the file, never [`ROOT`]. - pub id: u32, - /// The fold that holds this region, or [`ROOT`] for a top-level region. - pub parent: u32, - /// Regions sharing it open and close together, on either side. - pub fold_state_id: u32, - pub range: Range, - /// The tags the fold queries set, `<plugin>:<name>`. - pub tags: Vec<String>, - pub visibility: Visibility, - pub kind: Kind, -} - -/// One side of a diffed file. `regions` lists the tree in preorder: a fold -/// comes before its children, and siblings keep their order. A binary file's -/// sides have empty text and no regions. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Source { - pub text: String, - pub regions: Vec<Region>, -} - -/// The id that names the file itself rather than a region. -pub const ROOT: u32 = 0; - -/// What a plugin asks for. A region is named by its id, or the file by -/// [`ROOT`] where a move allows it; `wit/plugin.wit` documents each move and -/// how fresh ids are handed out, and [`crate::apply`] carries them out. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Move { - /// Split a leaf, and the leaf paired with it, at a line offset relative - /// to its first line. - Cut { region: u32, at: u32 }, - /// Wrap two or more consecutive siblings on each side that holds them in - /// a new open, unlabelled fold without tags. - JoinFolds { regions: Vec<u32> }, - /// Every region in the listed regions' fold states takes the first - /// region's fold state id and collapsed state. - LinkFoldState { regions: Vec<u32> }, - /// Collapse or open every region sharing the region's fold state, or - /// hide or show the file. - SetCollapsed { region: u32, collapsed: bool }, - /// Set or clear the label of one region, or of the file. - SetLabel { region: u32, label: Option<String> }, - /// Replace one region's tags. - SetTags { region: u32, tags: Vec<String> }, +/// An open region with no label, the state every region starts in. +impl Default for Visibility { + fn default() -> Self { + Self { + collapsed: false, + label: String::new(), + } + } } diff --git a/docs/plugins.md b/docs/plugins.md index 3d719f325..af3650679 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -193,7 +193,9 @@ A component gets full access; diffr does not sandbox it: `crates/diffr-plugin-sdk` is the contract in Rust, and every bundled plugin is written with it: -- `types`: the contract's records, as plain Rust. +- `types`: the contract's records, generated from `wit/plugin.wit` itself by + `wit_bindgen::generate!`, so there is one definition of each. A plugin + works with the same records natively and as a component. - `Plugin`: the one trait, mirroring the `plugin` resource. A plugin is a struct with an `Options` type (deserialized from the options JSON), and implements `new` (make the plugin from its options; where it can fail), @@ -202,10 +204,10 @@ written with it: - `export!(MyPlugin)`: built for `wasm32`, exports the plugin as the component's `plugin` resource: its `new` deserializes the options string into `Options` and calls `Plugin::new`, and its `classify` and `mutate` - call the instance, converting between the records and the generated guest - bindings. Built for anything else, it expands to nothing, and diffr's - native registry deserializes the options and calls the trait itself. The - same source builds both ways. + call the instance with the records it was given, since the guest bindings' + records are the contract's. Built for anything else, it expands to nothing, + and diffr's native registry deserializes the options and calls the trait + itself. The same source builds both ways. - `host::{read_head, git, log}`: the host functions, the same call natively and in a component. - `tree::sides(lhs, rhs)` rebuilds the records as region trees @@ -252,7 +254,7 @@ impl Plugin for HideAll { _: Option<&Source>, _: Option<&Source>, ) -> anyhow::Result<Vec<Move>> { - Ok(vec![Move::SetCollapsed { region: ROOT, collapsed: true }]) + Ok(vec![Move::SetCollapsed((ROOT, true))]) } } diff --git a/examples/plugins/fixtures/src/lib.rs b/examples/plugins/fixtures/src/lib.rs index ef9a4458d..361259fd5 100644 --- a/examples/plugins/fixtures/src/lib.rs +++ b/examples/plugins/fixtures/src/lib.rs @@ -5,7 +5,7 @@ //! but the first line of its first leaf, naming the piece its cut creates by //! predicting the id. use diffr_plugin_sdk::apply::Fresh; -use diffr_plugin_sdk::types::Source; +use diffr_plugin_sdk::types::{Cut, Source}; use diffr_plugin_sdk::{ anyhow, export, host, line_count, tree, FileEntry, FileStatus, Move, Node, Pairing, Plugin, Side, ROOT, @@ -62,14 +62,8 @@ impl Plugin for Fixtures { let subject = subject.trim(); host::log(&format!("{}: last changed in {subject:?}", file.path)); let mut moves = vec![ - Move::SetCollapsed { - region: ROOT, - collapsed: true, - }, - Move::SetLabel { - region: ROOT, - label: Some(format!("Fixture · {subject}")), - }, + Move::SetCollapsed((ROOT, true)), + Move::SetLabel((ROOT, Some(format!("Fixture · {subject}")))), ]; // The first top-level leaf of two or more lines, on the before side // when there is one. A cut hands out its piece ids lhs first, so the @@ -86,18 +80,15 @@ impl Plugin for Fixtures { if let Some(leaf) = leaf { let piece = Fresh::of(&sides).id(); moves.extend([ - Move::Cut { + Move::Cut(Cut { region: leaf.id, at: 1, - }, - Move::SetCollapsed { - region: piece, - collapsed: true, - }, - Move::SetLabel { - region: piece, - label: Some(format!("{} fixture lines", line_count(leaf) - 1)), - }, + }), + Move::SetCollapsed((piece, true)), + Move::SetLabel(( + piece, + Some(format!("{} fixture lines", line_count(leaf) - 1)), + )), ]); } Ok(moves) diff --git a/plugins/hide-files/src/lib.rs b/plugins/hide-files/src/lib.rs index 2f62a6027..ba81ab22a 100644 --- a/plugins/hide-files/src/lib.rs +++ b/plugins/hide-files/src/lib.rs @@ -45,14 +45,8 @@ impl Plugin for HideFiles { } }; Ok(vec![ - Move::SetCollapsed { - region: ROOT, - collapsed: true, - }, - Move::SetLabel { - region: ROOT, - label: Some(format!("{what} file · hidden by default")), - }, + Move::SetCollapsed((ROOT, true)), + Move::SetLabel((ROOT, Some(format!("{what} file · hidden by default")))), ]) } } diff --git a/src/plugin/tests/group.rs b/src/plugin/tests/group.rs index 726aee514..3a47758cc 100644 --- a/src/plugin/tests/group.rs +++ b/src/plugin/tests/group.rs @@ -187,7 +187,7 @@ fn matched_runs_are_grouped_on_both_sides_with_one_fold_state() { let joins: Vec<Move> = moves(&bundled("group", json!({})), &file, &wire(sides.clone())) .unwrap() .into_iter() - .filter(|next| matches!(next, Move::JoinFolds { .. })) + .filter(|next| matches!(next, Move::JoinFolds(_))) .collect(); assert_eq!(joins.len(), 1, "one join lists both runs: {joins:?}"); run_trees("group", json!({}), &file, &mut sides); diff --git a/src/plugin/tests/hide_files.rs b/src/plugin/tests/hide_files.rs index fcde84ac2..1e38cf46e 100644 --- a/src/plugin/tests/hide_files.rs +++ b/src/plugin/tests/hide_files.rs @@ -14,14 +14,8 @@ fn reasons(options: serde_json::Value, status: FileStatus, tags: &[&str]) -> Vec fn hide(reason: &str) -> Vec<Move> { vec![ - Move::SetCollapsed { - region: types::ROOT, - collapsed: true, - }, - Move::SetLabel { - region: types::ROOT, - label: Some(reason.to_owned()), - }, + Move::SetCollapsed((types::ROOT, true)), + Move::SetLabel((types::ROOT, Some(reason.to_owned()))), ] } diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs index f8f398e32..5d271d3fb 100644 --- a/src/plugin/tests/mod.rs +++ b/src/plugin/tests/mod.rs @@ -334,10 +334,7 @@ impl Plugin for Bad { _: Option<&types::Source>, _: Option<&types::Source>, ) -> anyhow::Result<Vec<Move>> { - Ok(vec![Move::SetCollapsed { - region: 99_999, - collapsed: true, - }]) + Ok(vec![Move::SetCollapsed((99_999, true))]) } } diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 54ee0b8f5..fa960995f 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -282,13 +282,15 @@ fn source(side: &contract::Source) -> types::Source { fn lift(next: types::Move) -> contract::Move { match next { - types::Move::Cut(types::Cut { region, at }) => contract::Move::Cut { region, at }, - types::Move::JoinFolds(regions) => contract::Move::JoinFolds { regions }, - types::Move::LinkFoldState(regions) => contract::Move::LinkFoldState { regions }, + types::Move::Cut(types::Cut { region, at }) => { + contract::Move::Cut(contract::Cut { region, at }) + } + types::Move::JoinFolds(regions) => contract::Move::JoinFolds(regions), + types::Move::LinkFoldState(regions) => contract::Move::LinkFoldState(regions), types::Move::SetCollapsed((region, collapsed)) => { - contract::Move::SetCollapsed { region, collapsed } + contract::Move::SetCollapsed((region, collapsed)) } - types::Move::SetLabel((region, label)) => contract::Move::SetLabel { region, label }, - types::Move::SetTags((region, tags)) => contract::Move::SetTags { region, tags }, + types::Move::SetLabel((region, label)) => contract::Move::SetLabel((region, label)), + types::Move::SetTags((region, tags)) => contract::Move::SetTags((region, tags)), } } From 2c51ff20ccb8e90fe4068091549ef614ee2361c9 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 12:10:22 -0700 Subject: [PATCH 12/20] Drop the read-head host import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `read-head` handed a plugin the first bytes of one side of a file, and diffr carried a closure down every classify and mutate call to serve it: a handle on the repository of its own, a `Head` type, a `no_head` for `new`, and a slice of the diffed text during `mutate`. A plugin does not need diffr for this. A component has the repository's working directory preopened, so it reads the working-tree file itself; any other side is `git show`, over the `git` import it already has. During `mutate` it has both sides' text in the records it was handed. So remove it from the WIT, the SDK, the wasmtime linker and the native path, and with it the error stashing on the native side: `read-head` returned no error, so a host failure had to be kept aside and raised when the plugin returned. `git` has an error case, and now uses it — a host failure (git cannot be started, or wrote something that is not UTF-8) is the call's error natively and in a component alike, rather than being a trap in one and a deferred abort in the other. The fixtures example reads the file it is classifying from the working directory. A file the working tree no longer has, a deleted one, is classified by its path alone. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0ab99-9486-74f2-ba5e-cdb10b5dbb28 --- crates/diffr-plugin-sdk/src/host.rs | 40 +++----------- crates/diffr-plugin-sdk/src/lib.rs | 3 +- docs/plugins.md | 20 ++++--- examples/plugins/fixtures/src/lib.rs | 30 +++++++---- src/git.rs | 26 ++------- src/plugin/host.rs | 79 ++++------------------------ src/plugin/mod.rs | 39 ++------------ src/plugin/native.rs | 7 +-- src/plugin/tests/mod.rs | 7 ++- src/plugin/wasm.rs | 19 +++---- tests/wasm.rs | 7 ++- wit/plugin.wit | 8 --- 12 files changed, 70 insertions(+), 215 deletions(-) diff --git a/crates/diffr-plugin-sdk/src/host.rs b/crates/diffr-plugin-sdk/src/host.rs index ecc4ed0fb..12239ceae 100644 --- a/crates/diffr-plugin-sdk/src/host.rs +++ b/crates/diffr-plugin-sdk/src/host.rs @@ -1,17 +1,8 @@ -//! What diffr gives every plugin: `read_head`, `git` and `log`, the `host` -//! interface of `wit/plugin.wit`. A plugin calls these functions the same -//! way wherever it runs. In a component they call the imports. Natively, -//! diffr runs each call of a plugin inside [`scope`], with its own -//! implementation of [`Host`], and the functions call that. -use crate::types::Side; - -/// Up to `max_bytes` from the start of one side of the file: during -/// `classify` the blob (or working-tree file), none for a side the file does -/// not have or one that is not a regular file; during `mutate` the side's -/// text as diffed; during `new` none. -pub fn read_head(side: Side, max_bytes: u32) -> Option<Vec<u8>> { - imp::read_head(side, max_bytes) -} +//! What diffr gives every plugin: `git` and `log`, the `host` interface of +//! `wit/plugin.wit`. A plugin calls these functions the same way wherever it +//! runs. In a component they call the imports. Natively, diffr runs each call +//! of a plugin inside [`scope`], with its own implementation of [`Host`], and +//! the functions call that. /// Run `git` with `args` in the repository's working directory: its stdout /// when it exits successfully, its stderr otherwise. @@ -26,16 +17,7 @@ pub fn log(message: &str) { #[cfg(target_arch = "wasm32")] mod imp { - use crate::bindings::diffr::plugin::{host, types}; - use crate::types::Side; - - pub(super) fn read_head(side: Side, max_bytes: u32) -> Option<Vec<u8>> { - let side = match side { - Side::Lhs => types::Side::Lhs, - Side::Rhs => types::Side::Rhs, - }; - host::read_head(side, max_bytes) - } + use crate::bindings::diffr::plugin::host; pub(super) fn git(args: &[String]) -> Result<String, String> { host::git(args) @@ -47,12 +29,9 @@ mod imp { } /// diffr's implementation of the host functions for one call of a native -/// plugin. A failure of the host itself (git cannot be started, a blob -/// cannot be read) is diffr's to report: it fails the call once the plugin -/// returns, as a trap fails a component's call. +/// plugin. #[cfg(not(target_arch = "wasm32"))] pub trait Host { - fn read_head(&self, side: Side, max_bytes: u32) -> Option<Vec<u8>>; fn git(&self, args: &[String]) -> Result<String, String>; fn log(&self, message: &str); } @@ -67,7 +46,6 @@ pub fn scope<R>(host: std::rc::Rc<dyn Host>, call: impl FnOnce() -> R) -> R { #[cfg(not(target_arch = "wasm32"))] mod imp { use super::Host; - use crate::types::Side; use std::cell::RefCell; use std::rc::Rc; @@ -98,10 +76,6 @@ mod imp { }) } - pub(super) fn read_head(side: Side, max_bytes: u32) -> Option<Vec<u8>> { - current().read_head(side, max_bytes) - } - pub(super) fn git(args: &[String]) -> Result<String, String> { current().git(args) } diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs index f3bec0d03..49e0414ab 100644 --- a/crates/diffr-plugin-sdk/src/lib.rs +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -10,8 +10,7 @@ //! - [`Plugin`] is the one trait every plugin implements: `new`, which makes //! it from its options, then `classify` and `mutate`, taking and returning //! exactly those records. -//! - [`host`] holds what diffr gives every plugin: `read_head`, `git` and -//! `log`. +//! - [`host`] holds what diffr gives every plugin: `git` and `log`. //! - [`export!`] makes a plugin the `plugin` resource a component exports //! when the crate is built for `wasm32-wasip2`, and expands to nothing //! otherwise, where diffr's native registry calls the trait directly. The same source diff --git a/docs/plugins.md b/docs/plugins.md index af3650679..00c8a09dc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -164,19 +164,16 @@ code a plugin predicts ids with. Besides WASI, which only a component gets, diffr gives every plugin `diffr:plugin/host`: -- `read-head(side, max-bytes) -> option<list<u8>>`: the first bytes of one - side of the file. During `classify` it reads the blob (or the working-tree - file); it is `none` for a side the file does not have, or one that is not a - regular file. During `mutate` it reads the side's text as diffed. During - `new` it is `none`. - `git(args) -> result<string, string>`: runs `git` with these arguments in the repository's working directory (the current directory for `--no-index`), returning stdout on success and stderr otherwise. - `log(message)`: writes `diffr plugin <name>: <message>` to stderr. -A host failure (git cannot be started, its output is not UTF-8, a blob cannot -be read) fails the call: it traps a component, and fails a native plugin's -call when it returns. +A host failure (git cannot be started, or its output is not UTF-8) is the +call's error, natively and in a component alike. + +A plugin reads files itself: a component has the working directory preopened, +and reads any side of a file the working tree does not have with `git show`. ### Access @@ -208,7 +205,7 @@ written with it: records are the contract's. Built for anything else, it expands to nothing, and diffr's native registry deserializes the options and calls the trait itself. The same source builds both ways. -- `host::{read_head, git, log}`: the host functions, the same call natively +- `host::{git, log}`: the host functions, the same call natively and in a component. - `tree::sides(lhs, rhs)` rebuilds the records as region trees (`Pairing<Source>`, each `Region` holding its children), and the helpers @@ -279,8 +276,9 @@ next run. ## Examples - `examples/plugins/fixtures`: `classify` tags a file `fixture` when it is - under a `fixtures/` directory or its first line is `// fixture` (read with - `read-head`); `mutate` hides a fixture behind the subject of the last + under a `fixtures/` directory or its working-tree file starts with a + `// fixture` line (read from the filesystem, so a deleted file is + classified by its path alone); `mutate` hides a fixture behind the subject of the last commit that touched it (from `git log -1 --format=%s -- <path>`), and collapses all but the first line of its first leaf, naming the cut's piece by predicting its id. Its `fail` option makes `mutate` return an error. diff --git a/examples/plugins/fixtures/src/lib.rs b/examples/plugins/fixtures/src/lib.rs index 361259fd5..4f9af92d2 100644 --- a/examples/plugins/fixtures/src/lib.rs +++ b/examples/plugins/fixtures/src/lib.rs @@ -1,6 +1,9 @@ //! An example diffr plugin. `classify` tags a file `fixture` when it sits -//! under a `fixtures/` directory or its first line is `// fixture`, read with -//! the host's `read_head`. `mutate` hides a fixture behind the subject of the +//! under a `fixtures/` directory or its working-tree file starts with a +//! `// fixture` line, read from the repository's working directory: a plugin +//! reads files itself, and needs nothing of diffr to do it. A file the +//! working tree no longer has, a deleted one, is classified by its path +//! alone. `mutate` hides a fixture behind the subject of the //! last commit that touched it, asked of the host's `git`, and collapses all //! but the first line of its first leaf, naming the piece its cut creates by //! predicting the id. @@ -8,9 +11,11 @@ use diffr_plugin_sdk::apply::Fresh; use diffr_plugin_sdk::types::{Cut, Source}; use diffr_plugin_sdk::{ anyhow, export, host, line_count, tree, FileEntry, FileStatus, Move, Node, Pairing, Plugin, - Side, ROOT, + ROOT, }; use serde::Deserialize; +use std::fs::{symlink_metadata, File}; +use std::io::Read as _; const TAG: &str = "fixture"; const MARKER: &[u8] = b"// fixture\n"; @@ -36,12 +41,19 @@ impl Plugin for Fixtures { if file.path.starts_with("fixtures/") || file.path.contains("/fixtures/") { return Ok(vec![TAG.to_owned()]); } - let side = match file.status { - FileStatus::Deleted => Side::Lhs, - _ => Side::Rhs, - }; - let head = host::read_head(side, MARKER.len() as u32); - Ok(match head.as_deref() == Some(MARKER) { + if file.status == FileStatus::Deleted { + return Ok(Vec::new()); + } + // Only a regular file has a first line to read; a symlink or a + // submodule is not a fixture. + if !symlink_metadata(&file.path)?.is_file() { + return Ok(Vec::new()); + } + let mut head = Vec::new(); + File::open(&file.path)? + .take(MARKER.len() as u64) + .read_to_end(&mut head)?; + Ok(match head == MARKER { true => vec![TAG.to_owned()], false => Vec::new(), }) diff --git a/src/git.rs b/src/git.rs index 75e6b5e9b..041b67e64 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,8 +1,7 @@ //! Git comparison selection and lazy source loading used by the CLI and its stdout stream. use crate::config::Params; -use crate::constants::Side; use crate::pairing::Pairing; -use crate::plugin::{Head, Pipeline}; +use crate::plugin::Pipeline; use crate::protocol; use crate::summary::DiffResult; use crate::tags::{self, Attributes, Prefix, PREFIX_BYTES}; @@ -13,7 +12,7 @@ use std::{ fmt, io::Read as _, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::Arc, }; pub(crate) type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>; @@ -426,9 +425,6 @@ impl DiffSession { pipeline: &Pipeline, ) -> Result<Self> { let repo = Repository::open(workspace)?; - // A plugin's store owns what it reads sources with, so classifying - // plugins share a handle of their own. - let shared = Arc::new(Mutex::new(Repository::open(workspace)?)); let comparison = comparison.resolve(&repo)?; let pending = { let diff = comparison.diff(&repo, files)?; @@ -521,9 +517,8 @@ impl DiffSession { } } file.tags = attributes.resolve(bundled); - let head = head_of(&shared, &before, &after); file.tags = pipeline - .classify(&file.manifest_entry(), &head) + .classify(&file.manifest_entry()) .map_err(|error| format!("{error:#}"))?; pending.push(PendingFile { before, @@ -557,21 +552,6 @@ impl DiffSession { } } -/// What a classifying plugin reads of a file: the start of either side. -fn head_of(repo: &Arc<Mutex<Repository>>, before: &Source, after: &Source) -> Head { - let (repo, before, after) = (Arc::clone(repo), before.clone(), after.clone()); - Arc::new(move |side, max| { - let repo = repo - .lock() - .expect("no reader panics holding the repository"); - match side { - Side::Left => &before, - Side::Right => &after, - } - .head(&repo, max) - }) -} - /// Sources read on the session thread; diffing needs no repository access. pub(crate) struct LoadedFile { pub(crate) file: FileChange, diff --git a/src/plugin/host.rs b/src/plugin/host.rs index 9433889f1..67d8462c8 100644 --- a/src/plugin/host.rs +++ b/src/plugin/host.rs @@ -1,13 +1,9 @@ -//! diffr's side of the host functions every plugin calls: `read_head`, -//! `git` and `log`. A component reaches them through its imports -//! ([`super::wasm`]); a native plugin through the SDK's host functions, -//! which call [`Native`] ([`super::native`]). Both run this code. -use super::Head; -use crate::constants::Side; +//! diffr's side of the host functions every plugin calls: `git` and `log`. +//! A component reaches them through its imports ([`super::wasm`]); a native +//! plugin through the SDK's host functions, which call [`Host`] itself +//! ([`super::native`]). Both run this code. use anyhow::Context as _; use diffr_plugin_sdk::host::Host as SdkHost; -use diffr_plugin_sdk::types; -use std::cell::RefCell; use std::path::Path; use std::process::Command; use std::sync::Arc; @@ -17,25 +13,9 @@ use std::sync::Arc; pub(crate) struct Host { pub(crate) name: Arc<str>, pub(crate) workdir: Arc<Path>, - /// The start of either side of the file the call is about: the blob - /// during `classify`, the text as diffed during `mutate`, nothing during - /// `new`. - pub(crate) head: Head, } impl Host { - pub(crate) fn read_head( - &self, - side: types::Side, - max_bytes: u32, - ) -> anyhow::Result<Option<Vec<u8>>> { - let side = match side { - types::Side::Lhs => Side::Left, - types::Side::Rhs => Side::Right, - }; - (self.head)(side, max_bytes as usize) - } - /// `git`'s stdout when it exits successfully, its stderr otherwise. /// `Err` is the host failing: git could not be started, or wrote /// something that is not UTF-8. @@ -60,54 +40,15 @@ impl Host { } } -/// The host of one call of a native plugin. The SDK's host functions cannot -/// fail, as a component's imports cannot; a failure of the host is kept here -/// and fails the call when the plugin returns, as it would trap a component. -pub(crate) struct Native { - host: Host, - failure: RefCell<Option<anyhow::Error>>, -} - -impl Native { - pub(crate) fn new(host: Host) -> Self { - Self { - host, - failure: RefCell::new(None), - } - } - - /// The first failure of the host during the call, if any. - pub(crate) fn finish(&self) -> anyhow::Result<()> { - match self.failure.borrow_mut().take() { - Some(error) => Err(error), - None => Ok(()), - } - } - - fn fail(&self, error: anyhow::Error) { - self.failure.borrow_mut().get_or_insert(error); - } -} - -impl SdkHost for Native { - fn read_head(&self, side: types::Side, max_bytes: u32) -> Option<Vec<u8>> { - self.host - .read_head(side, max_bytes) - .unwrap_or_else(|error| { - self.fail(error); - None - }) - } - +impl SdkHost for Host { + /// A failure of the host itself (git cannot be started, or wrote + /// something that is not UTF-8) reaches the plugin as the call's error, + /// exactly as it does in a component. fn git(&self, args: &[String]) -> Result<String, String> { - self.host.git(args).unwrap_or_else(|error| { - let message = format!("{error:#}"); - self.fail(error); - Err(message) - }) + Host::git(self, args).unwrap_or_else(|error| Err(format!("{error:#}"))) } fn log(&self, message: &str) { - self.host.log(message) + Host::log(self, message) } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 54764586c..428547b22 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -38,7 +38,6 @@ pub(crate) mod wasm; #[cfg(test)] mod tests; -use crate::constants::Side; use crate::pairing::Pairing; use crate::protocol::{self, FileChange, FileStatus, SourcePos, SourceRange}; use anyhow::{anyhow, Context as _}; @@ -52,10 +51,6 @@ use std::path::Path; use std::sync::Arc; use std::time::Instant; -/// Up to a number of bytes from the start of one side of a file, or `None` -/// when the file has no such side or it is not a regular file. -pub(crate) type Head = Arc<dyn Fn(Side, usize) -> anyhow::Result<Option<Vec<u8>>> + Send + Sync>; - /// One plugin instance: its native code or its component, made once from /// its options, behind the contract's two calls on a file. Each call gets /// the host for that call. @@ -110,11 +105,6 @@ impl Default for Pipeline { } } -/// A head that reads nothing: `new` is about no file. -fn no_head() -> Head { - Arc::new(|_, _| Ok(None)) -} - impl Pipeline { /// Load every enabled plugin in `plugins.order` and make its instance. A /// folder with a component runs it; any other folder runs the native @@ -158,30 +148,29 @@ impl Pipeline { /// end of the pipeline. fn push(&mut self, name: &str, options: Value, create: Create<'_>) -> anyhow::Result<()> { let name: Arc<str> = name.into(); - let runner = create(self.host(&name, no_head()), &options.to_string()) + let runner = create(self.host(&name), &options.to_string()) .with_context(|| format!("plugins.{name}"))?; self.plugins.push(Loaded { name, runner }); Ok(()) } - fn host(&self, name: &Arc<str>, head: Head) -> Host { + fn host(&self, name: &Arc<str>) -> Host { Host { name: Arc::clone(name), workdir: Arc::clone(&self.workdir), - head, } } /// The file's tags once every plugin has classified it, in order: each /// sees the tags the ones before it left. Sorted and deduplicated. - pub(crate) fn classify(&self, file: &FileChange, head: &Head) -> anyhow::Result<Vec<String>> { + pub(crate) fn classify(&self, file: &FileChange) -> anyhow::Result<Vec<String>> { let mut entry = file_entry(file); for plugin in &self.plugins { let name = &plugin.name; let path = &entry.path; let added = plugin .runner - .classify(self.host(&plugin.name, Arc::clone(head)), &entry) + .classify(self.host(&plugin.name), &entry) .with_context(|| format!("plugin {name}: classify {path}"))?; if let Some(bad) = added.iter().find(|tag| !crate::tags::is_tag(tag)) { anyhow::bail!( @@ -215,27 +204,9 @@ impl Pipeline { let started = Instant::now(); let lhs = trees.lhs().map(tree::Source::to_record); let rhs = trees.rhs().map(tree::Source::to_record); - let texts: (Option<Arc<str>>, Option<Arc<str>>) = ( - lhs.as_ref().map(|side| side.text.as_str().into()), - rhs.as_ref().map(|side| side.text.as_str().into()), - ); - let head: Head = Arc::new(move |side, max| { - let text = match side { - Side::Left => &texts.0, - Side::Right => &texts.1, - }; - Ok(text - .as_ref() - .map(|text| text.as_bytes()[..text.len().min(max)].to_vec())) - }); let moves = plugin .runner - .mutate( - self.host(&plugin.name, head), - &entry, - lhs.as_ref(), - rhs.as_ref(), - ) + .mutate(self.host(&plugin.name), &entry, lhs.as_ref(), rhs.as_ref()) .with_context(|| MutationFailed(plugin.name.to_string()))?; log::debug!( "plugin {}: mutate {} took {:?}", diff --git a/src/plugin/native.rs b/src/plugin/native.rs index 07eafe4ba..2c61cbdc5 100644 --- a/src/plugin/native.rs +++ b/src/plugin/native.rs @@ -2,7 +2,7 @@ //! is an SDK [`Plugin`], the same trait a component's source implements, made //! the same way from the same options string, and diffr calls it with the //! same records it hands a component. -use super::host::{self, Host}; +use super::host::Host; use super::Runner; use anyhow::anyhow; use diffr_plugin_sdk::types::{FileEntry, Move, Source}; @@ -56,10 +56,7 @@ pub(crate) fn native<P: Plugin + Send + Sync + 'static>( /// Run `call`, one call of a native plugin, inside a host scope of its own. fn call<R>(host: Host, call: impl FnOnce() -> anyhow::Result<R>) -> anyhow::Result<R> { - let native = Rc::new(host::Native::new(host)); - let result = diffr_plugin_sdk::host::scope(native.clone(), call); - native.finish()?; - result + diffr_plugin_sdk::host::scope(Rc::new(host), call) } /// A native plugin's instance. diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs index 5d271d3fb..944900876 100644 --- a/src/plugin/tests/mod.rs +++ b/src/plugin/tests/mod.rs @@ -193,7 +193,7 @@ pub(crate) fn moves( sides.rhs().map(tree::Source::to_record), ); plugin.runner.mutate( - pipeline.host(&plugin.name, no_head()), + pipeline.host(&plugin.name), &file_entry(file), lhs.as_ref(), rhs.as_ref(), @@ -421,15 +421,14 @@ fn pipeline(plugins: Vec<(&str, native::Constructor)>) -> Pipeline { fn classifying_plugins_add_tags_in_order_and_a_bad_tag_is_an_error() { let (mut file, _) = project("a.rs", "", ""); file.tags = vec!["z".to_owned()]; - let head = no_head(); let tagging = pipeline(vec![ ("first", native::native::<TagsAZ>), ("second", native::native::<TagsB>), ]); - assert_eq!(tagging.classify(&file, &head).unwrap(), ["a", "b", "z"]); + assert_eq!(tagging.classify(&file).unwrap(), ["a", "b", "z"]); let bad = pipeline(vec![("bad", native::native::<NotATag>)]); assert_eq!( - format!("{:#}", bad.classify(&file, &head).unwrap_err()), + format!("{:#}", bad.classify(&file).unwrap_err()), "plugin bad: classify a.rs: \"Not A Tag\" is not a tag; use lowercase letters, digits, '-' and '_'" ); } diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index fa960995f..6e132e4bc 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -69,20 +69,13 @@ impl WasiView for State { impl types::Host for State {} impl host::Host for State { - fn read_head( - &mut self, - side: types::Side, - max_bytes: u32, - ) -> wasmtime::Result<Option<Vec<u8>>> { - let side = match side { - types::Side::Lhs => contract::Side::Lhs, - types::Side::Rhs => contract::Side::Rhs, - }; - self.host.read_head(side, max_bytes) - } - + /// A failure of the host itself reaches the plugin as the call's error, + /// as it does for a native plugin, rather than trapping. fn git(&mut self, args: Vec<String>) -> wasmtime::Result<Result<String, String>> { - self.host.git(&args) + Ok(self + .host + .git(&args) + .unwrap_or_else(|error| Err(format!("{error:#}")))) } fn log(&mut self, message: String) -> wasmtime::Result<()> { diff --git a/tests/wasm.rs b/tests/wasm.rs index ac57f599c..34e7b0568 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -1,7 +1,6 @@ //! WASM component plugins: the bundled plugins built as components shape //! files exactly as the same source compiled into diffr does, and the -//! fixtures example classifies, reads the head of a file, runs git and moves -//! regions. +//! fixtures example classifies, reads a file, runs git and moves regions. use git2::{IndexAddOption, Repository, Signature, Time}; use serde_json::Value; use std::fs; @@ -313,7 +312,7 @@ fn fixtures_repo() -> (Fixture, String, String) { } #[test] -fn the_fixtures_example_classifies_reads_heads_runs_git_and_moves_regions() { +fn the_fixtures_example_classifies_reads_files_runs_git_and_moves_regions() { build_plugins(); let (fixture, base, head) = fixtures_repo(); let output = fixture.run(&fixtures_config(&fixture, ""), &base, &head); @@ -340,7 +339,7 @@ fn the_fixtures_example_classifies_reads_heads_runs_git_and_moves_regions() { assert_eq!( tags("src/marked.txt"), Some(serde_json::json!(["fixture"])), - "read-head finds the marker" + "the plugin reads the file and finds the marker" ); assert_eq!(tags("src/plain.txt"), None); diff --git a/wit/plugin.wit b/wit/plugin.wit index 6b6317ddd..4f1b78ecb 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -165,14 +165,6 @@ interface types { /// `.`, the environment is inherited, and the network is open. Its stdout /// goes to diffr's stderr, since diffr's stdout is the stream. interface host { - use types.{side}; - - /// Up to `max-bytes` from the start of one side of the file being - /// classified, or none when the file has no such side or it is not a - /// regular file (a symlink or submodule). During `mutate`, the start of - /// the side's text as diffed. During `new`, always none. - read-head: func(side: side, max-bytes: u32) -> option<list<u8>>; - /// Run `git` with these arguments in the repository's working /// directory: its stdout when it exits successfully, its stderr /// otherwise. From 45899da8307ad1de952b9af8ba27fb1eddc6fe76 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 12:13:53 -0700 Subject: [PATCH 13/20] Drop the log host import, and give a guest a real stderr A plugin had to call `log` to say anything, because a component's stdout went to diffr's stderr unprefixed and there was nothing else. A component should just print: it is a program, and it has stderr. So configure the WASI context with stdout and stderr of diffr's own, which write what the guest writes to diffr's stderr a line at a time, each line prefixed with `[<plugin name>] `. Neither may reach diffr's stdout, which is the NDJSON stream. A line the guest leaves unfinished is written when the store is dropped. With that, `log` goes from the WIT, the SDK and the host, and `git` is the only host import. A native plugin prints to diffr's stderr directly; it is not prefixed, since diffr would have to capture its own stderr to do it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0ab99-9486-74f2-ba5e-cdb10b5dbb28 --- Cargo.lock | 1 + Cargo.toml | 2 + crates/diffr-plugin-sdk/src/host.rs | 31 +++----- crates/diffr-plugin-sdk/src/lib.rs | 2 +- docs/plugins.md | 20 +++--- examples/plugins/fixtures/src/lib.rs | 2 +- src/plugin/host.rs | 16 ++--- src/plugin/wasm.rs | 102 ++++++++++++++++++++++++--- tests/wasm.rs | 4 +- wit/plugin.wit | 8 +-- 10 files changed, 128 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d05dbdf9..0464ade42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -751,6 +751,7 @@ dependencies = [ "anyhow", "assert_cmd", "bumpalo", + "bytes", "cc", "clap", "diffr-plugin-context", diff --git a/Cargo.toml b/Cargo.toml index b8b7e8c0c..9a9e2775e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,8 @@ jsonschema = { version = "0.56.0", default-features = false } # rust-version (1.85) matches ours. wasmtime = { version = "34.0.2", default-features = false, features = ["runtime", "cranelift", "component-model", "cache", "parallel-compilation", "std"] } wasmtime-wasi = { version = "34.0.2", default-features = false } +# The byte buffer a guest's output stream is handed, wasmtime-wasi's own. +bytes = "1.12" # The plugin contract, and the bundled plugins: each a crate the native # registry calls, which also builds as a WASM component. diffr-plugin-sdk = { path = "crates/diffr-plugin-sdk" } diff --git a/crates/diffr-plugin-sdk/src/host.rs b/crates/diffr-plugin-sdk/src/host.rs index 12239ceae..cfa9b339d 100644 --- a/crates/diffr-plugin-sdk/src/host.rs +++ b/crates/diffr-plugin-sdk/src/host.rs @@ -1,8 +1,11 @@ -//! What diffr gives every plugin: `git` and `log`, the `host` interface of -//! `wit/plugin.wit`. A plugin calls these functions the same way wherever it -//! runs. In a component they call the imports. Natively, diffr runs each call -//! of a plugin inside [`scope`], with its own implementation of [`Host`], and -//! the functions call that. +//! What diffr gives every plugin: `git`, the `host` interface of +//! `wit/plugin.wit`. A plugin calls it the same way wherever it runs. In a +//! component it calls the import. Natively, diffr runs each call of a plugin +//! inside [`scope`], with its own implementation of [`Host`], and the +//! function calls that. +//! +//! Everything else a plugin needs it does itself: it reads files, and writes +//! to stderr, which diffr captures and writes to its own. /// Run `git` with `args` in the repository's working directory: its stdout /// when it exits successfully, its stderr otherwise. @@ -10,11 +13,6 @@ pub fn git(args: &[String]) -> Result<String, String> { imp::git(args) } -/// Write a line to diffr's stderr, prefixed with the plugin's name. -pub fn log(message: &str) { - imp::log(message) -} - #[cfg(target_arch = "wasm32")] mod imp { use crate::bindings::diffr::plugin::host; @@ -22,10 +20,6 @@ mod imp { pub(super) fn git(args: &[String]) -> Result<String, String> { host::git(args) } - - pub(super) fn log(message: &str) { - host::log(message) - } } /// diffr's implementation of the host functions for one call of a native @@ -33,11 +27,10 @@ mod imp { #[cfg(not(target_arch = "wasm32"))] pub trait Host { fn git(&self, args: &[String]) -> Result<String, String>; - fn log(&self, message: &str); } /// Run `call`, one call of a native plugin, with `host` behind the host -/// functions on this thread. +/// function on this thread. #[cfg(not(target_arch = "wasm32"))] pub fn scope<R>(host: std::rc::Rc<dyn Host>, call: impl FnOnce() -> R) -> R { imp::scope(host, call) @@ -72,15 +65,11 @@ mod imp { current .borrow() .clone() - .expect("a host function is called only while diffr runs a plugin") + .expect("the host function is called only while diffr runs a plugin") }) } pub(super) fn git(args: &[String]) -> Result<String, String> { current().git(args) } - - pub(super) fn log(message: &str) { - current().log(message) - } } diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs index 49e0414ab..1279f641f 100644 --- a/crates/diffr-plugin-sdk/src/lib.rs +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -10,7 +10,7 @@ //! - [`Plugin`] is the one trait every plugin implements: `new`, which makes //! it from its options, then `classify` and `mutate`, taking and returning //! exactly those records. -//! - [`host`] holds what diffr gives every plugin: `git` and `log`. +//! - [`host`] holds what diffr gives every plugin: `git`. //! - [`export!`] makes a plugin the `plugin` resource a component exports //! when the crate is built for `wasm32-wasip2`, and expands to nothing //! otherwise, where diffr's native registry calls the trait directly. The same source diff --git a/docs/plugins.md b/docs/plugins.md index 00c8a09dc..9d1ea8eae 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -161,19 +161,20 @@ code a plugin predicts ids with. ## Host imports -Besides WASI, which only a component gets, diffr gives every plugin -`diffr:plugin/host`: +Besides WASI, which only a component gets, diffr gives every plugin one +import, `diffr:plugin/host`: - `git(args) -> result<string, string>`: runs `git` with these arguments in the repository's working directory (the current directory for `--no-index`), returning stdout on success and stderr otherwise. -- `log(message)`: writes `diffr plugin <name>: <message>` to stderr. A host failure (git cannot be started, or its output is not UTF-8) is the call's error, natively and in a component alike. -A plugin reads files itself: a component has the working directory preopened, -and reads any side of a file the working tree does not have with `git show`. +A plugin does everything else itself. It reads files: a component has the +working directory preopened, and reads any side of a file the working tree +does not have with `git show`. And it prints what it wants to say to stderr, +rather than calling diffr to say it. ### Access @@ -182,8 +183,10 @@ A component gets full access; diffr does not sandbox it: - WASI with the working directory preopened read-write as `.`. - The environment inherited. - The network open, with name lookup. -- Its stderr inherited; its stdout goes to diffr's stderr, because diffr's - stdout is the stream. +- Its stdout and stderr captured, and written to diffr's stderr a line at a + time, each line prefixed with `[<plugin name>] `. Neither may reach diffr's + stdout, which is the stream. A native plugin writes to diffr's stderr + directly, and is not prefixed. ## Writing a plugin in Rust @@ -205,8 +208,7 @@ written with it: records are the contract's. Built for anything else, it expands to nothing, and diffr's native registry deserializes the options and calls the trait itself. The same source builds both ways. -- `host::{git, log}`: the host functions, the same call natively - and in a component. +- `host::git`: the host function, the same call natively and in a component. - `tree::sides(lhs, rhs)` rebuilds the records as region trees (`Pairing<Source>`, each `Region` holding its children), and the helpers the bundled plugins read trees with: `walk`, `OtherSide`, `one_sided`, diff --git a/examples/plugins/fixtures/src/lib.rs b/examples/plugins/fixtures/src/lib.rs index 4f9af92d2..ff52dce78 100644 --- a/examples/plugins/fixtures/src/lib.rs +++ b/examples/plugins/fixtures/src/lib.rs @@ -72,7 +72,7 @@ impl Plugin for Fixtures { let args = ["log", "-1", "--format=%s", "--", &file.path].map(str::to_owned); let subject = host::git(&args).map_err(|stderr| anyhow::anyhow!("git log: {stderr}"))?; let subject = subject.trim(); - host::log(&format!("{}: last changed in {subject:?}", file.path)); + eprintln!("{}: last changed in {subject:?}", file.path); let mut moves = vec![ Move::SetCollapsed((ROOT, true)), Move::SetLabel((ROOT, Some(format!("Fixture · {subject}")))), diff --git a/src/plugin/host.rs b/src/plugin/host.rs index 67d8462c8..b9fd7112a 100644 --- a/src/plugin/host.rs +++ b/src/plugin/host.rs @@ -1,7 +1,7 @@ -//! diffr's side of the host functions every plugin calls: `git` and `log`. -//! A component reaches them through its imports ([`super::wasm`]); a native -//! plugin through the SDK's host functions, which call [`Host`] itself -//! ([`super::native`]). Both run this code. +//! diffr's side of the host function every plugin calls: `git`. A component +//! reaches it through its import ([`super::wasm`]); a native plugin through +//! the SDK's host function, which calls [`Host`] itself ([`super::native`]). +//! Both run this code. use anyhow::Context as _; use diffr_plugin_sdk::host::Host as SdkHost; use std::path::Path; @@ -34,10 +34,6 @@ impl Host { false => Err(text(output.stderr)?), }) } - - pub(crate) fn log(&self, message: &str) { - eprintln!("diffr plugin {}: {message}", self.name); - } } impl SdkHost for Host { @@ -47,8 +43,4 @@ impl SdkHost for Host { fn git(&self, args: &[String]) -> Result<String, String> { Host::git(self, args).unwrap_or_else(|error| Err(format!("{error:#}"))) } - - fn log(&self, message: &str) { - Host::log(self, message) - } } diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 6e132e4bc..3798a3e24 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -12,18 +12,25 @@ //! for field into the generated bindings. //! //! A plugin runs with full access: WASI with the working directory preopened -//! read-write as `.`, the environment inherited, and the network open. Its -//! stdout goes to diffr's stderr, since diffr's stdout is the stream. +//! read-write as `.`, the environment inherited, and the network open. What +//! it writes to stdout or stderr diffr writes to its own stderr, a line at a +//! time and with the plugin's name in front: diffr's stdout is the stream, +//! and a plugin has no logging call of its own, it just prints. use super::host::Host; use super::Runner; use anyhow::Context as _; +use bytes::Bytes; use diffr_plugin_sdk::types as contract; +use std::io::Write as _; use std::path::Path; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Instant; use wasmtime::component::{Component, HasSelf, Linker, ResourceAny, ResourceTable}; use wasmtime::{Cache, CacheConfig, Config, Engine, Store}; -use wasmtime_wasi::p2::{IoView, WasiCtx, WasiCtxBuilder, WasiView}; +use wasmtime_wasi::p2::{ + IoView, OutputStream, Pollable, StdoutStream, StreamError, StreamResult, WasiCtx, + WasiCtxBuilder, WasiView, +}; use wasmtime_wasi::{DirPerms, FilePerms}; mod bindings { @@ -77,11 +84,90 @@ impl host::Host for State { .git(&args) .unwrap_or_else(|error| Err(format!("{error:#}")))) } +} + +/// One of a guest's output streams, stdout or stderr, and what it has +/// written since its last newline. +struct Pending { + name: Arc<str>, + line: Vec<u8>, +} + +impl Pending { + /// Write one line to diffr's stderr with the plugin's name in front. + fn write_line(&self, line: &[u8]) -> std::io::Result<()> { + let line = String::from_utf8_lossy(line); + writeln!(std::io::stderr().lock(), "[{}] {line}", self.name) + } +} + +/// Whatever a guest wrote without ending the line still reaches diffr's +/// stderr, when the store the streams belong to is dropped. +impl Drop for Pending { + fn drop(&mut self) { + if !self.line.is_empty() { + let line = std::mem::take(&mut self.line); + let _ = self.write_line(&line); + } + } +} + +/// A guest's stdout or stderr, written to diffr's stderr a line at a time, +/// each line prefixed with `[<plugin name>] `. Every stream WASI makes of it +/// shares the one unfinished line, so a write that does not end a line waits +/// for the rest of it. +#[derive(Clone)] +struct Prefixed(Arc<Mutex<Pending>>); + +impl Prefixed { + fn new(name: Arc<str>) -> Self { + Self(Arc::new(Mutex::new(Pending { + name, + line: Vec::new(), + }))) + } +} - fn log(&mut self, message: String) -> wasmtime::Result<()> { - self.host.log(&message); +impl StdoutStream for Prefixed { + fn stream(&self) -> Box<dyn OutputStream> { + Box::new(self.clone()) + } + + fn isatty(&self) -> bool { + false + } +} + +#[wasmtime_wasi::async_trait] +impl Pollable for Prefixed { + async fn ready(&mut self) {} +} + +impl OutputStream for Prefixed { + fn write(&mut self, bytes: Bytes) -> StreamResult<()> { + let mut pending = self + .0 + .lock() + .expect("no write panics while it holds a guest's output"); + pending.line.extend_from_slice(&bytes); + while let Some(end) = pending.line.iter().position(|byte| *byte == b'\n') { + let line: Vec<u8> = pending.line.drain(..=end).take(end).collect(); + pending + .write_line(&line) + .map_err(|error| StreamError::LastOperationFailed(error.into()))?; + } Ok(()) } + + fn flush(&mut self) -> StreamResult<()> { + std::io::stderr() + .flush() + .map_err(|error| StreamError::LastOperationFailed(error.into())) + } + + fn check_write(&mut self) -> StreamResult<usize> { + Ok(1024 * 1024) + } } /// A compiled, linked component. @@ -121,8 +207,8 @@ impl WasmPlugin { let started = Instant::now(); let mut wasi = WasiCtxBuilder::new(); wasi.inherit_env() - .stdout(wasmtime_wasi::p2::stderr()) - .inherit_stderr() + .stdout(Prefixed::new(Arc::clone(&host.name))) + .stderr(Prefixed::new(Arc::clone(&host.name))) .inherit_network() .allow_ip_name_lookup(true) .preopened_dir(&*host.workdir, ".", DirPerms::all(), FilePerms::all()) diff --git a/tests/wasm.rs b/tests/wasm.rs index 34e7b0568..fd7656087 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -367,9 +367,7 @@ fn the_fixtures_example_classifies_reads_files_runs_git_and_moves_regions() { assert!(plain.get("visibility").is_none(), "{plain}"); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( - stderr.contains( - "diffr plugin fixtures: fixtures/data.txt: last changed in \"Update fixtures\"" - ), + stderr.contains("[fixtures] fixtures/data.txt: last changed in \"Update fixtures\""), "{stderr}" ); } diff --git a/wit/plugin.wit b/wit/plugin.wit index 4f1b78ecb..310967c93 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -162,16 +162,14 @@ interface types { /// What diffr gives every plugin. A component also gets WASI, with full /// access: the repository's working directory is preopened read-write as -/// `.`, the environment is inherited, and the network is open. Its stdout -/// goes to diffr's stderr, since diffr's stdout is the stream. +/// `.`, the environment is inherited, and the network is open. What it +/// writes to stdout or stderr reaches diffr's stderr, a line at a time and +/// with the plugin's name in front, since diffr's stdout is the stream. interface host { /// Run `git` with these arguments in the repository's working /// directory: its stdout when it exits successfully, its stderr /// otherwise. git: func(args: list<string>) -> result<string, string>; - - /// Write a line to diffr's stderr, prefixed with the plugin's name. - log: func(message: string); } /// What every plugin exports: the plugin itself, as a resource. From 16207a2e6d53019d50a19879c4e193e9e2004bd6 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 12:24:33 -0700 Subject: [PATCH 14/20] Do not let an error freeze the terminal The settings screen read the config during its first render, so a config the binary cannot parse threw inside the reconciler: the alternate screen was already up with raw input on, nothing tore it down, and the window froze behind a React stack trace. Read the config before the renderer exists, so the failure prints as diffr wrote it and exits 2, and hand the screen the settings it should show rather than a client to fetch them from. For anything else that throws once a renderer is live, put the terminal back before reporting: both screens now destroy their renderer on an uncaught exception or rejection. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0ab99-9486-74f2-ba5e-cdb10b5dbb28 --- tui/packages/hunk/src/main.tsx | 30 ++++++++++++++++++++-- tui/packages/hunk/src/ui/Settings.test.tsx | 5 +++- tui/packages/hunk/src/ui/Settings.tsx | 15 ++++++++--- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/tui/packages/hunk/src/main.tsx b/tui/packages/hunk/src/main.tsx index 25b9a36e3..94c2c69d2 100644 --- a/tui/packages/hunk/src/main.tsx +++ b/tui/packages/hunk/src/main.tsx @@ -6,18 +6,42 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { readDiffStream } from "./diffr/stream"; import { DiffStore } from "./diffr/store"; -import { cliClient } from "./diffr/config"; +import { cliClient, flattenSchema, type Setting } from "./diffr/config"; import { loadBundledTheme, loadThemeFile, themeConfig, themesFromConfig, type ThemeSet } from "./diffr/theme"; import { App } from "./ui/App"; import { Settings } from "./ui/Settings"; const args = process.argv.slice(2), store = new DiffStore(); +/** + * An error thrown outside the render loop's own handling would otherwise leave the terminal in + * the alternate screen with raw input on, which reads as a frozen window. Put the terminal back + * and print what happened. + */ +function restoreOnCrash(renderer: { destroy: () => void }) { + const fatal = (error: unknown) => { + renderer.destroy(); + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + process.exit(2); + }; + process.on("uncaughtException", fatal); + process.on("unhandledRejection", fatal); +} // `diffr config` opens the settings screen: bun run main.tsx --settings --diffr /path/to/diffr [query] if (args[0] === "--settings") { if (args[1] !== "--diffr" || !args[2]) { console.error("Usage: bun run start --settings --diffr /path/to/diffr [initial query]"); process.exit(2); } + // Reading the config can fail: a key the binary no longer knows, a malformed file. Read it + // before the alternate screen exists, so the error reaches the terminal the user is looking at. + const client = cliClient(args[2]); + let settings: Setting[]; + try { + settings = flattenSchema(client.schema(), client.show()); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } const settingsRenderer = await createCliRenderer({ useMouse: false, exitOnCtrlC: false, @@ -29,8 +53,9 @@ if (args[0] === "--settings") { }; process.once("SIGTERM", quitSettings); process.once("SIGINT", quitSettings); + restoreOnCrash(settingsRenderer); createRoot(settingsRenderer).render( - <Settings client={cliClient(args[2])} onQuit={quitSettings} initialQuery={args.slice(3).join(" ")} />, + <Settings client={client} initial={settings} onQuit={quitSettings} initialQuery={args.slice(3).join(" ")} />, ); await new Promise(() => {}); } @@ -98,6 +123,7 @@ function quit() { } process.once("SIGTERM", quit); process.once("SIGINT", quit); +restoreOnCrash(renderer); const root = createRoot(renderer); root.render(<App store={store} onQuit={quit} themes={themes!} />); let stderr = ""; diff --git a/tui/packages/hunk/src/ui/Settings.test.tsx b/tui/packages/hunk/src/ui/Settings.test.tsx index 7a470933d..63b2ec812 100644 --- a/tui/packages/hunk/src/ui/Settings.test.tsx +++ b/tui/packages/hunk/src/ui/Settings.test.tsx @@ -28,7 +28,10 @@ test("rows lead with titles under group headings; toggles change in place and ty }, }; let quit = false; - const t = await testRender(<Settings client={client} onQuit={() => { quit = true; }} />, { width: 100, height: 24 }); + const t = await testRender( + <Settings client={client} initial={flattenSchema(schemaFixture, valuesFixture)} onQuit={() => { quit = true; }} />, + { width: 100, height: 24 }, + ); const press = async (key: string) => { await act(async () => { t.mockInput.pressKey(key); }); await act(async () => { await t.renderOnce(); }); diff --git a/tui/packages/hunk/src/ui/Settings.tsx b/tui/packages/hunk/src/ui/Settings.tsx index 52020bcb4..74aaa4be9 100644 --- a/tui/packages/hunk/src/ui/Settings.tsx +++ b/tui/packages/hunk/src/ui/Settings.tsx @@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core"; import { useKeyboard, usePaste, useTerminalDimensions } from "@opentui/react"; import { filterSettings, - flattenSchema, formatValue, isDefault, parseValue, @@ -68,12 +67,22 @@ interface ScreenState { type Line = { kind: "gap"; group: string } | { kind: "group"; group: string } | { kind: "setting"; setting: Setting; index: number }; -export function Settings({ client, onQuit, initialQuery = "" }: { client: ConfigClient; onQuit: () => void; initialQuery?: string }) { +export function Settings({ + client, + initial, + onQuit, + initialQuery = "", +}: { + client: ConfigClient; + initial: Setting[]; + onQuit: () => void; + initialQuery?: string; +}) { const { width, height } = useTerminalDimensions(); // Keys can arrive in one burst before React re-renders (a fast typist, a paste followed by // enter), so the handlers read and write this ref and only then ask for a render. const state = useRef<ScreenState>({ - settings: flattenSchema(client.schema(), client.show()), + settings: initial, query: initialQuery, cursor: 0, prompt: null, From bc96cc141b63614d5f58d1a6d8129aa1bf07cfe4 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 13:05:05 -0700 Subject: [PATCH 15/20] Give a plugin the file's sides, with their blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file-entry` flattened the manifest entry it comes from: `Pairing<FileRef>` became `path` and `old-path`, and each side's `oid` and `mode` were dropped. So a plugin could not read the content of the file it was classifying. The fixtures example worked around it by reading the working tree, which has no answer at all for a deleted file. Carry the pairing instead, as the wire already does. `file-entry.file` is `both`, `left-only` or `right-only` of a `file-ref` of `path`, `oid` and `mode`: a deleted file cannot have an after side, and each blob comes attached to the path it belongs to. `status` stays as it was — it says how the two sides relate, which is a different question from which sides exist, and the one that `renamed` and `type-changed` answer. The SDK keeps `Pairing<T>` as the type its own code speaks and converts at the boundary, so `FileEntry::sides()` hands a plugin the pairing while the record stays generated from the WIT. `side()` is the side a file is named by, the rule `FileChange::path` already had, in one place rather than per plugin. The example now reads the blob over the `git` it already has, so it classifies a deleted file as readily as a new one, and asks `mode` rather than the filesystem whether the file is a regular one. The test deletes a marked file to cover it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f --- crates/diffr-plugin-sdk/src/lib.rs | 4 ++- crates/diffr-plugin-sdk/src/types.rs | 33 +++++++++++++++-- docs/plugins.md | 9 +++-- examples/plugins/fixtures/src/lib.rs | 53 ++++++++++++++-------------- plugins/summarize/src/lib.rs | 2 +- src/pairing.rs | 16 --------- src/plugin/mod.rs | 16 ++++++--- src/plugin/wasm.rs | 14 ++++++-- tests/wasm.rs | 9 +++++ wit/plugin.wit | 29 +++++++++++---- 10 files changed, 123 insertions(+), 62 deletions(-) diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs index 1279f641f..dda0b0fe8 100644 --- a/crates/diffr-plugin-sdk/src/lib.rs +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -38,7 +38,9 @@ pub use tree::{ before_and_after_ids, docstring_of, has_tag, is_fold, line_count, one_sided, path_to, siblings_of, sides_with_other_ids, walk, walk_mut, Node, OtherSide, Pairing, Region, Source, }; -pub use types::{FileEntry, FileStatus, Move, Position, Range, Side, Span, Visibility, ROOT}; +pub use types::{ + FileEntry, FileRef, FileSides, FileStatus, Move, Position, Range, Side, Span, Visibility, ROOT, +}; /// A diffr plugin: the `plugin` resource of `wit/plugin.wit`. diffr makes one /// with [`Plugin::new`] when it builds its pipeline, before any file, and diff --git a/crates/diffr-plugin-sdk/src/types.rs b/crates/diffr-plugin-sdk/src/types.rs index d19522f7f..4c7dd7dc7 100644 --- a/crates/diffr-plugin-sdk/src/types.rs +++ b/crates/diffr-plugin-sdk/src/types.rs @@ -4,15 +4,42 @@ //! and returns exactly these, natively and in a component. //! //! This module adds only what the WIT cannot say: [`ROOT`], the id that names -//! the file rather than a region, and the two impls below. +//! the file rather than a region, and the impls below. pub use crate::bindings::diffr::plugin::types::{ - Cut, FileEntry, FileStatus, Kind, Leaf, Move, Position, Range, Region, Side, Source, Span, - Visibility, + Cut, FileEntry, FileRef, FileSides, FileStatus, Kind, Leaf, Move, Position, Range, Region, + Side, Source, Span, Visibility, }; +use crate::tree::Pairing; + /// The id that names the file itself rather than a region. pub const ROOT: u32 = 0; +impl FileEntry { + /// The sides the file has, as the pairing the rest of the SDK speaks. + pub fn sides(&self) -> Pairing<&FileRef> { + match &self.file { + FileSides::Both((lhs, rhs)) => Pairing::Both { lhs, rhs }, + FileSides::LeftOnly(lhs) => Pairing::LeftOnly { lhs }, + FileSides::RightOnly(rhs) => Pairing::RightOnly { rhs }, + } + } + + /// The side the file is named and shown by: the after side, or the + /// before side for a deleted file. + pub fn side(&self) -> &FileRef { + match &self.file { + FileSides::Both((_, rhs)) | FileSides::RightOnly(rhs) => rhs, + FileSides::LeftOnly(lhs) => lhs, + } + } + + /// [`FileEntry::side`]'s path, the path to show the file under. + pub fn path(&self) -> &str { + &self.side().path + } +} + impl Range { /// The lines this range touches, half-open. A range ending at column /// zero does not touch its end line. diff --git a/docs/plugins.md b/docs/plugins.md index 9d1ea8eae..1282e953c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -111,8 +111,13 @@ cannot fail. - `options`: the plugin's entry as a JSON object, defaults filled in and already validated against `plugin.toml`. Only `new` gets it; what the plugin keeps from it lasts the run. -- `file-entry`: `path` (the after path, or the before path of a deleted - file), `old-path` (the before path, when there is one), `status` and `tags`. +- `file-entry`: `file`, `status` and `tags`. `file` is the manifest entry's + sides — `both`, `left-only` (a deletion) or `right-only` (an addition) — and + each side is a `file-ref` of `path`, `oid` (the blob; empty outside a + repository) and `mode`. `status` says how the two sides relate, which the + sides alone do not: a path that changed is `renamed`, an object kind that + changed `type-changed`. The SDK's `FileEntry::side()` is the side a file is + named by and `path()` its path. - `source`: one side's `text` and its `regions`, the tree flattened in preorder: each `region` carries its `parent` (a fold's `id`, or `0` for a top-level region), `id`, `fold-state-id`, `range`, `tags`, `visibility`, and diff --git a/examples/plugins/fixtures/src/lib.rs b/examples/plugins/fixtures/src/lib.rs index ff52dce78..4e22c3f8c 100644 --- a/examples/plugins/fixtures/src/lib.rs +++ b/examples/plugins/fixtures/src/lib.rs @@ -1,24 +1,27 @@ //! An example diffr plugin. `classify` tags a file `fixture` when it sits -//! under a `fixtures/` directory or its working-tree file starts with a -//! `// fixture` line, read from the repository's working directory: a plugin -//! reads files itself, and needs nothing of diffr to do it. A file the -//! working tree no longer has, a deleted one, is classified by its path -//! alone. `mutate` hides a fixture behind the subject of the -//! last commit that touched it, asked of the host's `git`, and collapses all -//! but the first line of its first leaf, naming the piece its cut creates by -//! predicting the id. +//! under a `fixtures/` directory or its first line is `// fixture`, read from +//! the blob the file entry names: a plugin reads content itself, over the +//! `git` it already has, and so reads a deleted file as well as a new one. +//! `mutate` hides a fixture behind the subject of the last commit that +//! touched it, and collapses all but the first line of its first leaf, naming +//! the piece its cut creates by predicting the id. use diffr_plugin_sdk::apply::Fresh; use diffr_plugin_sdk::types::{Cut, Source}; use diffr_plugin_sdk::{ - anyhow, export, host, line_count, tree, FileEntry, FileStatus, Move, Node, Pairing, Plugin, - ROOT, + anyhow, export, host, line_count, tree, FileEntry, Move, Node, Pairing, Plugin, ROOT, }; use serde::Deserialize; -use std::fs::{symlink_metadata, File}; -use std::io::Read as _; const TAG: &str = "fixture"; -const MARKER: &[u8] = b"// fixture\n"; +const MARKER: &str = "// fixture\n"; +/// Git's mode for a plain file; a symlink or a submodule has another. +const REGULAR: &str = "100644"; + +/// `git`, with its stderr as the error. +fn git(args: &[&str]) -> anyhow::Result<String> { + let args: Vec<String> = args.iter().map(|arg| (*arg).to_owned()).collect(); + host::git(&args).map_err(|stderr| anyhow::anyhow!("git {}: {stderr}", args[0])) +} #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -38,22 +41,19 @@ impl Plugin for Fixtures { } fn classify(&self, file: &FileEntry) -> anyhow::Result<Vec<String>> { - if file.path.starts_with("fixtures/") || file.path.contains("/fixtures/") { + let side = file.side(); + if file.path().starts_with("fixtures/") || file.path().contains("/fixtures/") { return Ok(vec![TAG.to_owned()]); } - if file.status == FileStatus::Deleted { - return Ok(Vec::new()); - } // Only a regular file has a first line to read; a symlink or a // submodule is not a fixture. - if !symlink_metadata(&file.path)?.is_file() { + if side.mode != REGULAR { return Ok(Vec::new()); } - let mut head = Vec::new(); - File::open(&file.path)? - .take(MARKER.len() as u64) - .read_to_end(&mut head)?; - Ok(match head == MARKER { + // The blob, not the working tree: a deleted file has content too, and + // it is the content that was diffed either way. + let text = git(&["cat-file", "blob", &side.oid])?; + Ok(match text.starts_with(MARKER) { true => vec![TAG.to_owned()], false => Vec::new(), }) @@ -65,14 +65,13 @@ impl Plugin for Fixtures { lhs: Option<&Source>, rhs: Option<&Source>, ) -> anyhow::Result<Vec<Move>> { - anyhow::ensure!(!self.options.fail, "asked to fail on {}", file.path); + anyhow::ensure!(!self.options.fail, "asked to fail on {}", file.path()); if !file.tags.iter().any(|tag| tag == TAG) { return Ok(Vec::new()); } - let args = ["log", "-1", "--format=%s", "--", &file.path].map(str::to_owned); - let subject = host::git(&args).map_err(|stderr| anyhow::anyhow!("git log: {stderr}"))?; + let subject = git(&["log", "-1", "--format=%s", "--", file.path()])?; let subject = subject.trim(); - eprintln!("{}: last changed in {subject:?}", file.path); + eprintln!("{}: last changed in {subject:?}", file.path()); let mut moves = vec![ Move::SetCollapsed((ROOT, true)), Move::SetLabel((ROOT, Some(format!("Fixture · {subject}")))), diff --git a/plugins/summarize/src/lib.rs b/plugins/summarize/src/lib.rs index ac2529328..32a0beba6 100644 --- a/plugins/summarize/src/lib.rs +++ b/plugins/summarize/src/lib.rs @@ -421,7 +421,7 @@ impl Plugin for Summarize { } let lines: Vec<&str> = rhs.text.split_terminator('\n').collect(); let mut texts = self - .complete(&file.path, &rhs.text, &folds) + .complete(file.path(), &rhs.text, &folds) .context("summarizer")?; texts.retain(|id, summary| { let fold = folds diff --git a/src/pairing.rs b/src/pairing.rs index d588c38ee..701a5588e 100644 --- a/src/pairing.rs +++ b/src/pairing.rs @@ -26,22 +26,6 @@ impl<T> Pairing<T> { Self::RightOnly { rhs } => Pairing::RightOnly { rhs: f(rhs) }, } } - - /// The before side, when there is one. - pub(crate) fn lhs(&self) -> Option<&T> { - match self { - Self::Both { lhs, .. } | Self::LeftOnly { lhs } => Some(lhs), - Self::RightOnly { .. } => None, - } - } - - /// The after side, or the before side when there is no after side. - pub(crate) fn rhs_or_lhs(&self) -> &T { - match self { - Self::Both { rhs, .. } | Self::RightOnly { rhs } => rhs, - Self::LeftOnly { lhs } => lhs, - } - } } #[derive(Serialize, Deserialize)] diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 428547b22..1e225493e 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -167,7 +167,7 @@ impl Pipeline { let mut entry = file_entry(file); for plugin in &self.plugins { let name = &plugin.name; - let path = &entry.path; + let path = entry.path(); let added = plugin .runner .classify(self.host(&plugin.name), &entry) @@ -211,7 +211,7 @@ impl Pipeline { log::debug!( "plugin {}: mutate {} took {:?}", plugin.name, - entry.path, + entry.path(), started.elapsed() ); apply::apply(moves, &mut trees, &mut visibility) @@ -245,9 +245,17 @@ impl Pipeline { /// The contract's record of a manifest entry. pub(crate) fn file_entry(file: &FileChange) -> types::FileEntry { + let file_ref = |side: &protocol::FileRef| types::FileRef { + path: side.path.clone(), + oid: side.oid.clone(), + mode: side.mode.clone(), + }; types::FileEntry { - path: file.file.rhs_or_lhs().path.clone(), - old_path: file.file.lhs().map(|side| side.path.clone()), + file: match &file.file { + Pairing::Both { lhs, rhs } => types::FileSides::Both((file_ref(lhs), file_ref(rhs))), + Pairing::LeftOnly { lhs } => types::FileSides::LeftOnly(file_ref(lhs)), + Pairing::RightOnly { rhs } => types::FileSides::RightOnly(file_ref(rhs)), + }, status: match file.status { FileStatus::Added => types::FileStatus::Added, FileStatus::Deleted => types::FileStatus::Deleted, diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 3798a3e24..08eff1fbb 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -301,9 +301,19 @@ impl Runner for WasmInstance { } fn file_entry(file: &contract::FileEntry) -> types::FileEntry { + let file_ref = |side: &contract::FileRef| types::FileRef { + path: side.path.clone(), + oid: side.oid.clone(), + mode: side.mode.clone(), + }; types::FileEntry { - path: file.path.clone(), - old_path: file.old_path.clone(), + file: match &file.file { + contract::FileSides::Both((lhs, rhs)) => { + types::FileSides::Both((file_ref(lhs), file_ref(rhs))) + } + contract::FileSides::LeftOnly(lhs) => types::FileSides::LeftOnly(file_ref(lhs)), + contract::FileSides::RightOnly(rhs) => types::FileSides::RightOnly(file_ref(rhs)), + }, status: match file.status { contract::FileStatus::Added => types::FileStatus::Added, contract::FileStatus::Deleted => types::FileStatus::Deleted, diff --git a/tests/wasm.rs b/tests/wasm.rs index fd7656087..6513881b5 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -303,10 +303,14 @@ fn fixtures_repo() -> (Fixture, String, String) { fixture.write("fixtures/data.txt", &data("five")); fixture.write("src/marked.txt", &format!("// fixture\n{}", data("five"))); fixture.write("src/plain.txt", &data("five")); + // Deleted at head: the working tree no longer has it, so only the blob + // the file entry names can say whether it was a fixture. + fixture.write("src/gone.txt", &format!("// fixture\n{}", data("five"))); let base = fixture.commit("Add fixtures\n"); fixture.write("fixtures/data.txt", &data("six")); fixture.write("src/marked.txt", &format!("// fixture\n{}", data("six"))); fixture.write("src/plain.txt", &data("six")); + fixture.remove("src/gone.txt"); let head = fixture.commit("Update fixtures\n"); (fixture, base, head) } @@ -342,6 +346,11 @@ fn the_fixtures_example_classifies_reads_files_runs_git_and_moves_regions() { "the plugin reads the file and finds the marker" ); assert_eq!(tags("src/plain.txt"), None); + assert_eq!( + tags("src/gone.txt"), + Some(serde_json::json!(["fixture"])), + "a deleted file is read from its blob, not the working tree" + ); for path in ["fixtures/data.txt", "src/marked.txt"] { let record = file_record(&records, path); diff --git a/wit/plugin.wit b/wit/plugin.wit index 310967c93..6247d7120 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -35,13 +35,30 @@ interface types { type-changed, } - /// One changed file, as its manifest entry describes it. - record file-entry { - /// The path on the after side, or on the before side when the file - /// was deleted. + /// One side of a git delta, as the manifest names it. + record file-ref { path: string, - /// The path on the before side, when the file has one. - old-path: option<string>, + /// The blob. Empty when the comparison is not of a repository: two + /// paths compared directly have no blobs. + oid: string, + /// Git's octal mode text, e.g. `100644`. + mode: string, + } + + /// The sides a changed file has. A deletion is `left-only`, an addition + /// `right-only`. + variant file-sides { + both(tuple<file-ref, file-ref>), + left-only(file-ref), + right-only(file-ref), + } + + /// One changed file, as its manifest entry describes it. `file` says + /// which sides the file has and names each; `status` says how the two + /// relate, which having both sides does not answer: a path that changed + /// is `renamed`, an object kind that changed `type-changed`. + record file-entry { + file: file-sides, status: file-status, /// Sorted and deduplicated. During `classify`, the tags so far: the /// bundled rules, git attributes, and the plugins before this one. From e85bd85c150692af07caf0ba0788ac0dd4962279 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 13:13:29 -0700 Subject: [PATCH 16/20] Hand mutate the sides, not two options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mutate` took `lhs: option<source>, rhs: option<source>`, which can say a file has no sides at all. Every plugin's first line was the same `tree::sides(lhs, rhs)?`, rebuilding the pairing the WIT could not express and carrying an error case none of them could hit. Take `source-sides` instead, shaped like the `file-sides` of the entry and naming the same sides. The SDK rebuilds them as trees on the way in, once, so `Plugin::mutate` receives `&Pairing<Source>`: plugins speak the SDK's own type and the record stays generated from the WIT. That is the SDK's one conversion, and it is the same on both paths — a component converts in the guest bindings, diffr in its native runner. Seven plugins lose their rebuild line and the example loses its `tree` import. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f --- crates/diffr-plugin-sdk/src/guest.rs | 17 +++++------ crates/diffr-plugin-sdk/src/lib.rs | 16 ++++------- crates/diffr-plugin-sdk/src/tree.rs | 29 +++++++++---------- crates/diffr-plugin-sdk/src/types.rs | 2 +- docs/plugins.md | 28 +++++++++--------- examples/plugins/fixtures/src/lib.rs | 14 +++------ plugins/context/src/lib.rs | 15 +++------- plugins/deleted-bodies/src/lib.rs | 15 +++------- plugins/group/src/lib.rs | 17 ++++------- plugins/hide-files/src/lib.rs | 12 +++----- plugins/removed-runs/src/lib.rs | 17 ++++------- plugins/summarize/src/lib.rs | 17 ++++------- plugins/test-bodies/src/lib.rs | 15 +++------- src/plugin/mod.rs | 19 ++++++++---- src/plugin/native.rs | 12 ++++---- src/plugin/tests/mod.rs | 43 ++++++---------------------- src/plugin/wasm.rs | 18 ++++++++---- wit/plugin.wit | 17 +++++++---- 18 files changed, 130 insertions(+), 193 deletions(-) diff --git a/crates/diffr-plugin-sdk/src/guest.rs b/crates/diffr-plugin-sdk/src/guest.rs index 213d33609..ad872172b 100644 --- a/crates/diffr-plugin-sdk/src/guest.rs +++ b/crates/diffr-plugin-sdk/src/guest.rs @@ -1,10 +1,11 @@ //! The component side of [`crate::export!`]: the `plugin` resource over a //! [`Plugin`]. The generated guest bindings' records are the contract's //! records ([`crate::types`] re-exports them), so a call hands the plugin -//! what it was given and returns what the plugin returned. +//! what it was given and returns what the plugin returned; the sides are +//! rebuilt as trees on the way in, the one conversion the SDK makes. use crate::bindings::exports::diffr::plugin::guest; -use crate::types::{FileEntry, Move, Source}; -use crate::Plugin; +use crate::types::{FileEntry, Move, SourceSides}; +use crate::{tree, Plugin}; /// One instance of the plugin `P`, the component's `plugin` resource. pub struct Instance<P>(P); @@ -21,14 +22,10 @@ impl<P: Plugin + 'static> guest::GuestPlugin for Instance<P> { self.0.classify(&file).map_err(|error| format!("{error:#}")) } - fn mutate( - &self, - file: FileEntry, - lhs: Option<Source>, - rhs: Option<Source>, - ) -> Result<Vec<Move>, String> { + fn mutate(&self, file: FileEntry, sides: SourceSides) -> Result<Vec<Move>, String> { + let sides = tree::sides(&sides).map_err(|error| format!("{error:#}"))?; self.0 - .mutate(&file, lhs.as_ref(), rhs.as_ref()) + .mutate(&file, &sides) .map_err(|error| format!("{error:#}")) } } diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs index dda0b0fe8..4d4863902 100644 --- a/crates/diffr-plugin-sdk/src/lib.rs +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -18,8 +18,9 @@ //! //! Plugins reason about regions with the same code: //! -//! - [`tree`] rebuilds a side's list as a tree ([`tree::sides`]) and holds -//! the helpers for reading trees ([`walk`], [`OtherSide`], [`one_sided`], +//! - [`tree`] rebuilds a side's list as a tree ([`tree::sides`], which the +//! SDK calls on the way in so a plugin is handed [`Pairing`] of [`Source`] +//! rather than the flat records) and holds the helpers for reading trees ([`walk`], [`OtherSide`], [`one_sided`], //! [`docstring_of`], and the rest). //! - [`apply`] carries moves out. diffr carries every plugin's moves out with //! it, so [`Draft`], which carries a plugin's moves out on a copy as it @@ -59,14 +60,9 @@ pub trait Plugin: Sized { /// plugin that does not classify returns none. fn classify(&self, file: &FileEntry) -> anyhow::Result<Vec<String>>; - /// The moves that shape how the diffed file starts out. `lhs` and `rhs` - /// are the sides the file has. - fn mutate( - &self, - file: &FileEntry, - lhs: Option<&types::Source>, - rhs: Option<&types::Source>, - ) -> anyhow::Result<Vec<Move>>; + /// The moves that shape how the diffed file starts out. `sides` are the + /// sides the file has, already rebuilt as trees. + fn mutate(&self, file: &FileEntry, sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>>; } /// The contract generated from `wit/plugin.wit`. Its records are plain Rust diff --git a/crates/diffr-plugin-sdk/src/tree.rs b/crates/diffr-plugin-sdk/src/tree.rs index d5d8bbea0..9cdabed7e 100644 --- a/crates/diffr-plugin-sdk/src/tree.rs +++ b/crates/diffr-plugin-sdk/src/tree.rs @@ -178,22 +178,21 @@ impl Source { } } -/// The sides `mutate` receives, as trees. diffr passes at least one side. -pub fn sides( - lhs: Option<&types::Source>, - rhs: Option<&types::Source>, -) -> anyhow::Result<Pairing<Source>> { - Ok( - match ( - lhs.map(Source::from_record).transpose()?, - rhs.map(Source::from_record).transpose()?, - ) { - (Some(lhs), Some(rhs)) => Pairing::Both { lhs, rhs }, - (Some(lhs), None) => Pairing::LeftOnly { lhs }, - (None, Some(rhs)) => Pairing::RightOnly { rhs }, - (None, None) => anyhow::bail!("diffr passed a file with no sides"), +/// The contract's sides as the trees the SDK works on. diffr calls this once +/// on the way in, so a plugin is handed the pairing rather than the record. +pub fn sides(sides: &types::SourceSides) -> anyhow::Result<Pairing<Source>> { + Ok(match sides { + types::SourceSides::Both((lhs, rhs)) => Pairing::Both { + lhs: Source::from_record(lhs)?, + rhs: Source::from_record(rhs)?, }, - ) + types::SourceSides::LeftOnly(lhs) => Pairing::LeftOnly { + lhs: Source::from_record(lhs)?, + }, + types::SourceSides::RightOnly(rhs) => Pairing::RightOnly { + rhs: Source::from_record(rhs)?, + }, + }) } // ── region helpers ──────────────────────────────────────────────────────── diff --git a/crates/diffr-plugin-sdk/src/types.rs b/crates/diffr-plugin-sdk/src/types.rs index 4c7dd7dc7..ae4b85db3 100644 --- a/crates/diffr-plugin-sdk/src/types.rs +++ b/crates/diffr-plugin-sdk/src/types.rs @@ -7,7 +7,7 @@ //! the file rather than a region, and the impls below. pub use crate::bindings::diffr::plugin::types::{ Cut, FileEntry, FileRef, FileSides, FileStatus, Kind, Leaf, Move, Position, Range, Region, - Side, Source, Span, Visibility, + Side, Source, SourceSides, Span, Visibility, }; use crate::tree::Pairing; diff --git a/docs/plugins.md b/docs/plugins.md index 1282e953c..656755a7f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -101,7 +101,7 @@ optional exports; a plugin that does not classify returns an empty list): resource plugin { new: static func(options: string) -> result<plugin, string>; classify: func(file: file-entry) -> result<list<string>, string>; - mutate: func(file: file-entry, lhs: option<source>, rhs: option<source>) -> result<list<move>, string>; + mutate: func(file: file-entry, sides: source-sides) -> result<list<move>, string>; } ``` @@ -118,6 +118,9 @@ cannot fail. sides alone do not: a path that changed is `renamed`, an object kind that changed `type-changed`. The SDK's `FileEntry::side()` is the side a file is named by and `path()` its path. +- `source-sides`: the sides the diffed file has, shaped like `file-sides`. + The SDK rebuilds them as trees on the way in, so a plugin's `mutate` is + handed a `Pairing<Source>` rather than the flat records. - `source`: one side's `text` and its `regions`, the tree flattened in preorder: each `region` carries its `parent` (a fold's `id`, or `0` for a top-level region), `id`, `fold-state-id`, `range`, `tags`, `visibility`, and @@ -214,9 +217,10 @@ written with it: and diffr's native registry deserializes the options and calls the trait itself. The same source builds both ways. - `host::git`: the host function, the same call natively and in a component. -- `tree::sides(lhs, rhs)` rebuilds the records as region trees - (`Pairing<Source>`, each `Region` holding its children), and the helpers - the bundled plugins read trees with: `walk`, `OtherSide`, `one_sided`, +- `tree::sides` rebuilds the contract's sides as region trees + (`Pairing<Source>`, each `Region` holding its children). The SDK calls it + itself, so `mutate` receives the trees; the module also holds the helpers + the bundled plugins read them with: `walk`, `OtherSide`, `one_sided`, `docstring_of`, `before_and_after_ids`, and the rest. - `Draft` carries moves out on a copy of the trees as the plugin makes them, with diffr's applier, so `draft.cut_lines`, `draft.collapse`, `draft.link` @@ -231,8 +235,7 @@ serde = { version = "1.0", features = ["derive"] } ``` ```rust -use diffr_plugin_sdk::types::Source; -use diffr_plugin_sdk::{anyhow, export, FileEntry, Move, Plugin, ROOT}; +use diffr_plugin_sdk::{anyhow, export, FileEntry, Move, Pairing, Plugin, Source, ROOT}; use serde::Deserialize; #[derive(Deserialize)] @@ -252,12 +255,7 @@ impl Plugin for HideAll { Ok(Vec::new()) } - fn mutate( - &self, - _: &FileEntry, - _: Option<&Source>, - _: Option<&Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _: &FileEntry, _: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { Ok(vec![Move::SetCollapsed((ROOT, true))]) } } @@ -283,9 +281,9 @@ next run. ## Examples - `examples/plugins/fixtures`: `classify` tags a file `fixture` when it is - under a `fixtures/` directory or its working-tree file starts with a - `// fixture` line (read from the filesystem, so a deleted file is - classified by its path alone); `mutate` hides a fixture behind the subject of the last + under a `fixtures/` directory or its first line is `// fixture` (read from + the blob the file entry names, with `git cat-file`, so a deleted file is + read as readily as a new one); `mutate` hides a fixture behind the subject of the last commit that touched it (from `git log -1 --format=%s -- <path>`), and collapses all but the first line of its first leaf, naming the cut's piece by predicting its id. Its `fail` option makes `mutate` return an error. diff --git a/examples/plugins/fixtures/src/lib.rs b/examples/plugins/fixtures/src/lib.rs index 4e22c3f8c..9f0c4a0a8 100644 --- a/examples/plugins/fixtures/src/lib.rs +++ b/examples/plugins/fixtures/src/lib.rs @@ -6,9 +6,9 @@ //! touched it, and collapses all but the first line of its first leaf, naming //! the piece its cut creates by predicting the id. use diffr_plugin_sdk::apply::Fresh; -use diffr_plugin_sdk::types::{Cut, Source}; +use diffr_plugin_sdk::types::Cut; use diffr_plugin_sdk::{ - anyhow, export, host, line_count, tree, FileEntry, Move, Node, Pairing, Plugin, ROOT, + anyhow, export, host, line_count, FileEntry, Move, Node, Pairing, Plugin, Source, ROOT, }; use serde::Deserialize; @@ -59,12 +59,7 @@ impl Plugin for Fixtures { }) } - fn mutate( - &self, - file: &FileEntry, - lhs: Option<&Source>, - rhs: Option<&Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, file: &FileEntry, sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { anyhow::ensure!(!self.options.fail, "asked to fail on {}", file.path()); if !file.tags.iter().any(|tag| tag == TAG) { return Ok(Vec::new()); @@ -79,7 +74,6 @@ impl Plugin for Fixtures { // The first top-level leaf of two or more lines, on the before side // when there is one. A cut hands out its piece ids lhs first, so the // piece on this leaf's side takes the first fresh id either way. - let sides = tree::sides(lhs, rhs)?; let side = match &sides { Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => lhs, Pairing::RightOnly { rhs } => rhs, @@ -89,7 +83,7 @@ impl Plugin for Fixtures { .iter() .find(|region| matches!(region.node, Node::Leaf { .. }) && line_count(region) >= 2); if let Some(leaf) = leaf { - let piece = Fresh::of(&sides).id(); + let piece = Fresh::of(sides).id(); moves.extend([ Move::Cut(Cut { region: leaf.id, diff --git a/plugins/context/src/lib.rs b/plugins/context/src/lib.rs index c23ac0636..a3454646a 100644 --- a/plugins/context/src/lib.rs +++ b/plugins/context/src/lib.rs @@ -20,10 +20,9 @@ //! stretch there, so no fold is hidden on one side while it holds changed //! lines on the other, such as a matched function that moved elsewhere. //! Both folds of a matched pair are labelled with their line count. -use diffr_plugin_sdk::types::Source as SourceRecord; use diffr_plugin_sdk::{ - anyhow, export, has_tag, is_fold, tree, walk, Draft, FileEntry, Move, Node, Pairing, Plugin, - Region, Source, + anyhow, export, has_tag, is_fold, walk, Draft, FileEntry, Move, Node, Pairing, Plugin, Region, + Source, }; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; @@ -216,14 +215,8 @@ impl Plugin for Context { Ok(Vec::new()) } - fn mutate( - &self, - _file: &FileEntry, - lhs: Option<&SourceRecord>, - rhs: Option<&SourceRecord>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _file: &FileEntry, sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { let options = &self.options; - let sides = tree::sides(lhs, rhs)?; let Pairing::Both { lhs, rhs } = &sides else { // A one-sided file is all changed lines. return Ok(Vec::new()); @@ -322,7 +315,7 @@ impl Plugin for Context { .collect(); // Stretches are shaped in reverse document order, so a cut leaf's id // still names the piece that starts where the leaf did. - let mut draft = Draft::new(&sides); + let mut draft = Draft::new(sides); for ((lhs_start, lhs_end), (rhs_start, rhs_end)) in stretches.into_iter().rev() { let total = lhs_end - lhs_start; if total < MIN_GAP && changed { diff --git a/plugins/deleted-bodies/src/lib.rs b/plugins/deleted-bodies/src/lib.rs index 97b0e03fa..c36823d06 100644 --- a/plugins/deleted-bodies/src/lib.rs +++ b/plugins/deleted-bodies/src/lib.rs @@ -1,8 +1,7 @@ //! Collapse function bodies that were deleted. -use diffr_plugin_sdk::types::Source; use diffr_plugin_sdk::{ anyhow, before_and_after_ids, docstring_of, export, has_tag, is_fold, line_count, one_sided, - tree, walk, Draft, FileEntry, Move, Plugin, + walk, Draft, FileEntry, Move, Pairing, Plugin, Source, }; use serde::Deserialize; @@ -35,15 +34,9 @@ impl Plugin for DeletedBodies { Ok(Vec::new()) } - fn mutate( - &self, - _file: &FileEntry, - lhs: Option<&Source>, - rhs: Option<&Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _file: &FileEntry, sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { let options = &self.options; - let sides = tree::sides(lhs, rhs)?; - let Some((lhs, rhs)) = before_and_after_ids(&sides) else { + let Some((lhs, rhs)) = before_and_after_ids(sides) else { return Ok(Vec::new()); }; // Each deleted body: its id, line count and docstring. @@ -58,7 +51,7 @@ impl Plugin for DeletedBodies { bodies.push((region.id, count, docstring_of(lhs, region, PLUGIN))); } }); - let mut draft = Draft::new(&sides); + let mut draft = Draft::new(sides); for (id, count, docstring) in bodies { draft.collapse(id, format!("{count} lines removed"))?; if let Some(docstring) = docstring { diff --git a/plugins/group/src/lib.rs b/plugins/group/src/lib.rs index 37ed7bf1b..c8d237905 100644 --- a/plugins/group/src/lib.rs +++ b/plugins/group/src/lib.rs @@ -21,10 +21,9 @@ //! grouped in one join, and the two groups open and close together. A //! run whose regions are paired across sides some other way stays as it //! is. -use diffr_plugin_sdk::types::Source; use diffr_plugin_sdk::{ - anyhow, export, is_fold, line_count, sides_with_other_ids, tree, Draft, FileEntry, Move, Node, - Plugin, Region, + anyhow, export, is_fold, line_count, sides_with_other_ids, Draft, FileEntry, Move, Node, + Pairing, Plugin, Region, Source, }; use serde::Deserialize; use std::collections::BTreeSet; @@ -57,19 +56,13 @@ impl Plugin for Group { Ok(Vec::new()) } - fn mutate( - &self, - _file: &FileEntry, - lhs: Option<&Source>, - rhs: Option<&Source>, - ) -> anyhow::Result<Vec<Move>> { - let sides = tree::sides(lhs, rhs)?; - let per_side: Vec<_> = sides_with_other_ids(&sides) + fn mutate(&self, _file: &FileEntry, sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { + let per_side: Vec<_> = sides_with_other_ids(sides) .into_iter() .map(|(source, other_ids)| (runs(&source.regions), other_ids)) .collect(); let mut groups: BTreeSet<BTreeSet<u32>> = BTreeSet::new(); - let mut draft = Draft::new(&sides); + let mut draft = Draft::new(sides); for (side, (runs, other_ids)) in per_side.iter().enumerate() { // A one-sided file has no other side, and so no other runs. let other_runs: &[Run<'_>] = match per_side.get(1 - side) { diff --git a/plugins/hide-files/src/lib.rs b/plugins/hide-files/src/lib.rs index ba81ab22a..763515e81 100644 --- a/plugins/hide-files/src/lib.rs +++ b/plugins/hide-files/src/lib.rs @@ -1,6 +1,7 @@ //! Hide whole files by their tags or status. -use diffr_plugin_sdk::types::Source; -use diffr_plugin_sdk::{anyhow, export, FileEntry, FileStatus, Move, Plugin, ROOT}; +use diffr_plugin_sdk::{ + anyhow, export, FileEntry, FileStatus, Move, Pairing, Plugin, Source, ROOT, +}; use serde::Deserialize; /// A deleted file, when `deleted` is set, or a file carrying one of `tags` @@ -29,12 +30,7 @@ impl Plugin for HideFiles { Ok(Vec::new()) } - fn mutate( - &self, - file: &FileEntry, - _lhs: Option<&Source>, - _rhs: Option<&Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, file: &FileEntry, _sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { let options = &self.options; let what = if options.deleted && file.status == FileStatus::Deleted { "Deleted".to_owned() diff --git a/plugins/removed-runs/src/lib.rs b/plugins/removed-runs/src/lib.rs index 37fd6d257..b3cdca070 100644 --- a/plugins/removed-runs/src/lib.rs +++ b/plugins/removed-runs/src/lib.rs @@ -1,8 +1,7 @@ //! Collapse the middle of long removed stretches. -use diffr_plugin_sdk::types::Source; use diffr_plugin_sdk::{ - anyhow, before_and_after_ids, export, has_tag, line_count, one_sided, tree, Draft, FileEntry, - Move, Node, OtherSide, Plugin, Region, + anyhow, before_and_after_ids, export, has_tag, line_count, one_sided, Draft, FileEntry, Move, + Node, OtherSide, Pairing, Plugin, Region, Source, }; use serde::Deserialize; @@ -38,15 +37,9 @@ impl Plugin for RemovedRuns { Ok(Vec::new()) } - fn mutate( - &self, - _file: &FileEntry, - lhs: Option<&Source>, - rhs: Option<&Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _file: &FileEntry, sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { let options = &self.options; - let sides = tree::sides(lhs, rhs)?; - let Some((lhs, rhs)) = before_and_after_ids(&sides) else { + let Some((lhs, rhs)) = before_and_after_ids(sides) else { return Ok(Vec::new()); }; // A leaf needs a first, a middle, and a last line. @@ -60,7 +53,7 @@ impl Plugin for RemovedRuns { Gates::default(), &mut leaves, ); - let mut draft = Draft::new(&sides); + let mut draft = Draft::new(sides); for (id, len) in leaves { let middle = draft.cut_lines(id, 1, len - 1)?; draft.collapse(middle, format!("{} lines removed", len - 2))?; diff --git a/plugins/summarize/src/lib.rs b/plugins/summarize/src/lib.rs index 32a0beba6..c02f49df3 100644 --- a/plugins/summarize/src/lib.rs +++ b/plugins/summarize/src/lib.rs @@ -7,10 +7,9 @@ //! and at most `max_concurrency` requests are in flight at once across //! files. use diffr_plugin_sdk::anyhow::{self, anyhow, Context as _}; -use diffr_plugin_sdk::types::Source as SourceRecord; use diffr_plugin_sdk::{ - docstring_of, export, has_tag, is_fold, line_count, one_sided, tree, walk, Draft, FileEntry, - Move, Node, OtherSide, Pairing, Plugin, Region, Source, + docstring_of, export, has_tag, is_fold, line_count, one_sided, walk, Draft, FileEntry, Move, + Node, OtherSide, Pairing, Plugin, Region, Source, }; use serde::Deserialize; use serde_json::json; @@ -395,14 +394,8 @@ impl Plugin for Summarize { Ok(Vec::new()) } - fn mutate( - &self, - file: &FileEntry, - lhs: Option<&SourceRecord>, - rhs: Option<&SourceRecord>, - ) -> anyhow::Result<Vec<Move>> { - let sides = tree::sides(lhs, rhs)?; - let selected = select(&sides, self.options.min_lines); + fn mutate(&self, file: &FileEntry, sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { + let selected = select(sides, self.options.min_lines); let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { return Ok(Vec::new()); }; @@ -433,7 +426,7 @@ impl Plugin for Summarize { }); // Collapse every summarized body first, then link each to its // docstring: a summary and its docstring are one thing. - let mut draft = Draft::new(&sides); + let mut draft = Draft::new(sides); let mut links = Vec::new(); for (id, summary) in texts { let text = match &summary.quote { diff --git a/plugins/test-bodies/src/lib.rs b/plugins/test-bodies/src/lib.rs index a1ae4e3e2..bd69a9b55 100644 --- a/plugins/test-bodies/src/lib.rs +++ b/plugins/test-bodies/src/lib.rs @@ -1,8 +1,7 @@ //! Collapse test bodies and test modules. -use diffr_plugin_sdk::types::Source; use diffr_plugin_sdk::{ - anyhow, docstring_of, export, has_tag, is_fold, line_count, tree, walk, Draft, FileEntry, Move, - Plugin, + anyhow, docstring_of, export, has_tag, is_fold, line_count, walk, Draft, FileEntry, Move, + Pairing, Plugin, Source, }; use serde::Deserialize; use std::collections::BTreeMap; @@ -40,14 +39,8 @@ impl Plugin for TestBodies { Ok(Vec::new()) } - fn mutate( - &self, - _file: &FileEntry, - lhs: Option<&Source>, - rhs: Option<&Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _file: &FileEntry, sides: &Pairing<Source>) -> anyhow::Result<Vec<Move>> { let options = &self.options; - let sides = tree::sides(lhs, rhs)?; // Every fold has its own id, so each side's fold is its own target // and takes its own label. A test body's docstring, when it has one, // is linked after it collapses. @@ -66,7 +59,7 @@ impl Plugin for TestBodies { } }); } - let mut draft = Draft::new(&sides); + let mut draft = Draft::new(sides); for (id, (label, docstring)) in labels { draft.collapse(id, label.to_owned())?; if let Some(docstring) = docstring { diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 1e225493e..0f5ed09e1 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -61,8 +61,7 @@ pub(crate) trait Runner: Send + Sync { &self, host: Host, file: &types::FileEntry, - lhs: Option<&types::Source>, - rhs: Option<&types::Source>, + sides: &types::SourceSides, ) -> anyhow::Result<Vec<types::Move>>; } @@ -202,11 +201,10 @@ impl Pipeline { let mut visibility = types::Visibility::default(); for plugin in &self.plugins { let started = Instant::now(); - let lhs = trees.lhs().map(tree::Source::to_record); - let rhs = trees.rhs().map(tree::Source::to_record); + let records = source_sides(&trees); let moves = plugin .runner - .mutate(self.host(&plugin.name), &entry, lhs.as_ref(), rhs.as_ref()) + .mutate(self.host(&plugin.name), &entry, &records) .with_context(|| MutationFailed(plugin.name.to_string()))?; log::debug!( "plugin {}: mutate {} took {:?}", @@ -268,6 +266,17 @@ pub(crate) fn file_entry(file: &FileChange) -> types::FileEntry { } } +/// The trees a plugin is given, as the contract's records. +fn source_sides(trees: &tree::Pairing<tree::Source>) -> types::SourceSides { + match trees { + tree::Pairing::Both { lhs, rhs } => { + types::SourceSides::Both((lhs.to_record(), rhs.to_record())) + } + tree::Pairing::LeftOnly { lhs } => types::SourceSides::LeftOnly(lhs.to_record()), + tree::Pairing::RightOnly { rhs } => types::SourceSides::RightOnly(rhs.to_record()), + } +} + /// One side of the wire as the tree the applier works on. pub(crate) fn to_tree(side: &protocol::Source) -> tree::Source { fn regions(list: &[protocol::Region]) -> Vec<tree::Region> { diff --git a/src/plugin/native.rs b/src/plugin/native.rs index 2c61cbdc5..8c62b215b 100644 --- a/src/plugin/native.rs +++ b/src/plugin/native.rs @@ -5,8 +5,8 @@ use super::host::Host; use super::Runner; use anyhow::anyhow; -use diffr_plugin_sdk::types::{FileEntry, Move, Source}; -use diffr_plugin_sdk::Plugin; +use diffr_plugin_sdk::types::{FileEntry, Move, SourceSides}; +use diffr_plugin_sdk::{tree, Plugin}; use std::rc::Rc; /// Makes a native plugin's instance from its options string. @@ -71,9 +71,11 @@ impl<P: Plugin + Send + Sync> Runner for Native<P> { &self, host: Host, file: &FileEntry, - lhs: Option<&Source>, - rhs: Option<&Source>, + sides: &SourceSides, ) -> anyhow::Result<Vec<Move>> { - call(host, || self.0.mutate(file, lhs, rhs)) + call(host, || { + let sides = tree::sides(sides)?; + self.0.mutate(file, &sides) + }) } } diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs index 944900876..8f82e99a9 100644 --- a/src/plugin/tests/mod.rs +++ b/src/plugin/tests/mod.rs @@ -187,17 +187,10 @@ pub(crate) fn moves( let [plugin] = &pipeline.plugins[..] else { panic!("one plugin"); }; - let sides = trees(sides); - let (lhs, rhs) = ( - sides.lhs().map(tree::Source::to_record), - sides.rhs().map(tree::Source::to_record), - ); - plugin.runner.mutate( - pipeline.host(&plugin.name), - &file_entry(file), - lhs.as_ref(), - rhs.as_ref(), - ) + let records = super::source_sides(&trees(sides)); + plugin + .runner + .mutate(pipeline.host(&plugin.name), &file_entry(file), &records) } /// For each `deleted-bodies:function` body on the after side, the first line @@ -328,12 +321,7 @@ impl Plugin for Bad { Ok(Vec::new()) } - fn mutate( - &self, - _: &FileEntry, - _: Option<&types::Source>, - _: Option<&types::Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _: &FileEntry, _: &tree::Pairing<tree::Source>) -> anyhow::Result<Vec<Move>> { Ok(vec![Move::SetCollapsed((99_999, true))]) } } @@ -355,12 +343,7 @@ impl Plugin for TagsAZ { Ok(vec!["a".to_owned(), "z".to_owned()]) } - fn mutate( - &self, - _: &FileEntry, - _: Option<&types::Source>, - _: Option<&types::Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _: &FileEntry, _: &tree::Pairing<tree::Source>) -> anyhow::Result<Vec<Move>> { Ok(Vec::new()) } } @@ -377,12 +360,7 @@ impl Plugin for TagsB { Ok(vec!["b".to_owned()]) } - fn mutate( - &self, - _: &FileEntry, - _: Option<&types::Source>, - _: Option<&types::Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _: &FileEntry, _: &tree::Pairing<tree::Source>) -> anyhow::Result<Vec<Move>> { Ok(Vec::new()) } } @@ -398,12 +376,7 @@ impl Plugin for NotATag { Ok(vec!["Not A Tag".to_owned()]) } - fn mutate( - &self, - _: &FileEntry, - _: Option<&types::Source>, - _: Option<&types::Source>, - ) -> anyhow::Result<Vec<Move>> { + fn mutate(&self, _: &FileEntry, _: &tree::Pairing<tree::Source>) -> anyhow::Result<Vec<Move>> { Ok(Vec::new()) } } diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 08eff1fbb..20daa1339 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -277,10 +277,9 @@ impl Runner for WasmInstance { &self, host: Host, file: &contract::FileEntry, - lhs: Option<&contract::Source>, - rhs: Option<&contract::Source>, + sides: &contract::SourceSides, ) -> anyhow::Result<Vec<contract::Move>> { - let (lhs, rhs) = (lhs.map(source), rhs.map(source)); + let sides = source_sides(sides); let instance = &mut *self.enter(host); let started = Instant::now(); let moves = instance @@ -291,8 +290,7 @@ impl Runner for WasmInstance { &mut instance.store, instance.plugin, &file_entry(file), - lhs.as_ref(), - rhs.as_ref(), + &sides, )? .map_err(anyhow::Error::msg)?; log::debug!("called in {:?}", started.elapsed()); @@ -326,6 +324,16 @@ fn file_entry(file: &contract::FileEntry) -> types::FileEntry { } } +fn source_sides(sides: &contract::SourceSides) -> types::SourceSides { + match sides { + contract::SourceSides::Both((lhs, rhs)) => { + types::SourceSides::Both((source(lhs), source(rhs))) + } + contract::SourceSides::LeftOnly(lhs) => types::SourceSides::LeftOnly(source(lhs)), + contract::SourceSides::RightOnly(rhs) => types::SourceSides::RightOnly(source(rhs)), + } +} + fn source(side: &contract::Source) -> types::Source { let position = |position: contract::Position| types::Position { line: position.line, diff --git a/wit/plugin.wit b/wit/plugin.wit index 6247d7120..bd535de25 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -128,6 +128,13 @@ interface types { regions: list<region>, } + /// The sides a diffed file has, the same sides as its entry's `file`. + variant source-sides { + both(tuple<source, source>), + left-only(source), + right-only(source), + } + record cut { /// A leaf's id. region: u32, @@ -191,7 +198,7 @@ interface host { /// What every plugin exports: the plugin itself, as a resource. interface guest { - use types.{file-entry, source, move}; + use types.{file-entry, source-sides, move}; resource plugin { /// Make the plugin from `options`, the plugin's entry in @@ -209,10 +216,10 @@ interface guest { /// before the stream starts. classify: func(file: file-entry) -> result<list<string>, string>; - /// The moves that shape how the diffed file starts out. `lhs` and - /// `rhs` are the sides the file has. An error aborts the run with - /// `mutation_failed`. - mutate: func(file: file-entry, lhs: option<source>, rhs: option<source>) -> result<list<move>, string>; + /// The moves that shape how the diffed file starts out. `sides` are + /// the sides the file has, the same sides as its entry's `file`. An + /// error aborts the run with `mutation_failed`. + mutate: func(file: file-entry, sides: source-sides) -> result<list<move>, string>; } } From e32bb3d18975109669c77c900269892129b779a2 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 13:28:22 -0700 Subject: [PATCH 17/20] Fix the typo check and the doc warnings The Linguist port names esy's lockfile, which the typo check reads as a misspelling of "easy"; allow the word, as the other real names here are allowed. Three doc comments in the context plugin link to private items, and one in the plugin config writes `<title>` as bare HTML rustdoc cannot close. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f --- .typos.toml | 2 ++ plugins/context/src/lib.rs | 6 +++--- src/plugin/config.rs | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.typos.toml b/.typos.toml index e61e7321e..6bd14b854 100644 --- a/.typos.toml +++ b/.typos.toml @@ -13,6 +13,8 @@ extend-exclude = [ [default.extend-words] ba = "ba" edn = "edn" +# esy is the OCaml/Reason package manager, whose lockfile Linguist lists. +esy = "esy" mak = "mak" ND = "ND" diff --git a/plugins/context/src/lib.rs b/plugins/context/src/lib.rs index a3454646a..cfc877450 100644 --- a/plugins/context/src/lib.rs +++ b/plugins/context/src/lib.rs @@ -3,15 +3,15 @@ //! A line stays visible when it is within `lines` of a changed line on its //! side, when it is paired with such a line, or when it opens or closes a //! scope that holds a change on its side. Scopes are the constructs this -//! plugin's queries tag `context:scope` (see [`scope_rows`]), each covering +//! plugin's queries tag `context:scope` (see `scope_rows`), each covering //! its signature line through the line that closes it; a file the //! diff did not parse has none. Every other stretch of unchanged -//! paired lines that is at least [`MIN_GAP`] lines long collapses, labelled +//! paired lines that is at least `MIN_GAP` lines long collapses, labelled //! with its line count; a file with no change collapses whole, however //! short. //! //! A stretch is cut at region edges. The part in one list of siblings -//! collapses when it is at least [`MIN_GAP`] lines long or is the whole +//! collapses when it is at least `MIN_GAP` lines long or is the whole //! stretch; a shorter sliver stays open. When a part spans several //! siblings, each collapses and a group wraps them in one row on each side, //! provided the two sides' siblings match one for one: leaves sharing an diff --git a/src/plugin/config.rs b/src/plugin/config.rs index 429421fcb..d553ab226 100644 --- a/src/plugin/config.rs +++ b/src/plugin/config.rs @@ -52,8 +52,8 @@ pub(crate) struct Manifest { #[serde(default)] pub(crate) description: String, /// How settings screens show the `enabled` switch, and whether the - /// plugin is on by default. Without it the switch is titled "Run - /// <title>" and the plugin is on. + /// plugin is on by default. Without it the switch is titled + /// `Run <title>` and the plugin is on. #[serde(default)] pub(crate) enabled: Option<Switch>, /// Each option's JSON Schema, in the order settings screens list them. From 09a6921636b0c0fb7c958fb23c7880d278bc60a7 Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 13:58:04 -0700 Subject: [PATCH 18/20] Make the new tests run everywhere CI runs them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the test suite assumed about the machine running it. It assumed `wasm32-wasip2` was installed: `tests/wasm.rs` builds every plugin as a component, and no workflow installs that target, so all three tests failed on every platform. Component loading is host behaviour, not per-target, so it is tested once, natively, on a Linux job that installs the target — behind `wasm-plugin-tests` rather than skipped silently when the target is missing. It assumed it could execute the binary it built. Under `cross` the binary is built for another architecture and only cargo's runner can run it, which `tests/cli.rs` has always handled and the newer tests did not. That helper is now `tests/support`, shared by all four. It assumed paths are written with `/`, so three assertions failed on Windows, where the same real paths carry `\`. The product was right and the tests were wrong: they now compare separators the one way they spell them. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f --- .github/workflows/test.yml | 14 ++++++++++++++ Cargo.toml | 8 ++++++++ docs/plugins.md | 4 +++- src/config.rs | 3 ++- src/plugin/queries.rs | 9 +++++++-- tests/cli.rs | 31 +++---------------------------- tests/support/mod.rs | 34 ++++++++++++++++++++++++++++++++++ tests/tags.rs | 7 +++++-- tests/view.rs | 7 +++++-- tests/wasm.rs | 5 ++++- 10 files changed, 85 insertions(+), 37 deletions(-) create mode 100644 tests/support/mod.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf1f3e2f6..96d5f103f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,6 +50,20 @@ jobs: shell: bash run: $BUILD_CMD test --target ${{ matrix.job.target }} + test_wasm_plugins: + name: Test WASM component plugins + runs-on: ubuntu-22.04 + steps: + # v4.2.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dtolnay/rust-toolchain@1.85.0 + with: + # The tests build every bundled plugin as a component. + targets: wasm32-wasip2 + # Component loading is host behaviour, so it is tested once, natively, + # rather than on every target the matrix above cross-compiles for. + - run: cargo test --features wasm-plugin-tests --test wasm + test_mime_db: name: Test with MIME database runs-on: ubuntu-22.04 diff --git a/Cargo.toml b/Cargo.toml index 9a9e2775e..51d5a03d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -214,4 +214,12 @@ assets = [ [ "CHANGELOG.md", "usr/share/doc/difftastic/", "644" ], ] +# The component tests build every plugin for wasm32-wasip2, so they run only +# where that target is installed: `cargo test --features wasm-plugin-tests`, +# and the Linux job in CI. +[[test]] +name = "wasm" +required-features = ["wasm-plugin-tests"] + [features] +wasm-plugin-tests = [] diff --git a/docs/plugins.md b/docs/plugins.md index 656755a7f..c3c48916b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -292,7 +292,9 @@ next run. bundled plugins, which build as components too. Pointing their entries at the bundled folders once the script has built them runs the components in place of the native code, and produces the same stream (`tests/wasm.rs` - checks it): + checks it, with `cargo test --features wasm-plugin-tests --test wasm`: the + tests build every plugin for `wasm32-wasip2`, so they are behind a feature + and run where that target is installed): ```toml [plugins.deleted-bodies] diff --git a/src/config.rs b/src/config.rs index c2e984fe9..fbceea81c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -622,7 +622,8 @@ mod tag_tests { ¶ms, ) .expect_err("a conflict"); - let message = conflict.to_string(); + // The message names real paths, which Windows writes with `\`. + let message = conflict.to_string().replace('\\', "/"); assert!(message.starts_with("src/lib.rs:1: "), "{message}"); assert!( message.contains("queries/rust/removed-runs.scm and ") diff --git a/src/plugin/queries.rs b/src/plugin/queries.rs index 5f59b6dfa..1765d5fd2 100644 --- a/src/plugin/queries.rs +++ b/src/plugin/queries.rs @@ -201,8 +201,13 @@ mod tests { path.display().to_string() } - fn names(sources: &[QuerySource]) -> Vec<&str> { - sources.iter().map(|source| source.name.as_str()).collect() + /// The sources' names, with separators written the one way these tests + /// spell them: a name built from a real path uses `\` on Windows. + fn names(sources: &[QuerySource]) -> Vec<String> { + sources + .iter() + .map(|source| source.name.replace('\\', "/")) + .collect() } #[test] diff --git a/tests/cli.rs b/tests/cli.rs index 85df00b15..050e84979 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,36 +1,11 @@ +mod support; + use std::process::Command; +use support::diffr_command as get_base_command; use assert_cmd::prelude::*; use predicates::prelude::*; -fn find_runner() -> Option<String> { - for (key, value) in std::env::vars() { - if key.starts_with("CARGO_TARGET_") && key.ends_with("_RUNNER") && !value.is_empty() { - return Some(value); - } - } - None -} - -// Sample code from -// https://github.com/assert-rs/assert_cmd/issues/139, supports -// cross-compiled binaries. -fn get_base_command() -> Command { - let mut cmd; - let path = assert_cmd::cargo_bin!("diffr"); - if let Some(runner) = find_runner() { - let mut runner = runner.split_whitespace(); - cmd = Command::new(runner.next().unwrap()); - for arg in runner { - cmd.arg(arg); - } - cmd.arg(path); - } else { - cmd = Command::new(path); - } - cmd -} - fn debug_command() -> Command { let mut cmd = get_base_command(); cmd.arg("debug"); diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 000000000..38b0f1cb2 --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,34 @@ +//! Shared test helpers. +//! +//! Spawning the built `diffr` needs care: under `cross`, the binary is built +//! for another architecture and only the runner cargo names in +//! `CARGO_TARGET_<TARGET>_RUNNER` can execute it. Every test that runs diffr +//! goes through [`diffr_command`] so it works there as well as natively. +use std::process::Command; + +fn find_runner() -> Option<String> { + for (key, value) in std::env::vars() { + if key.starts_with("CARGO_TARGET_") && key.ends_with("_RUNNER") && !value.is_empty() { + return Some(value); + } + } + None +} + +/// A command that runs the built `diffr`, through cargo's runner when there +/// is one. +/// +/// Sample code from <https://github.com/assert-rs/assert_cmd/issues/139>. +pub fn diffr_command() -> Command { + let path = assert_cmd::cargo_bin!("diffr"); + let Some(runner) = find_runner() else { + return Command::new(path); + }; + let mut runner = runner.split_whitespace(); + let mut cmd = Command::new(runner.next().expect("a runner names a program")); + for arg in runner { + cmd.arg(arg); + } + cmd.arg(path); + cmd +} diff --git a/tests/tags.rs b/tests/tags.rs index a573ce1c4..3573b4e3e 100644 --- a/tests/tags.rs +++ b/tests/tags.rs @@ -1,8 +1,11 @@ //! File tags on the stream manifest, from bundled rules and git attributes. +mod support; + use git2::{IndexAddOption, Repository, Signature, Time}; use serde_json::Value; use std::fs; -use std::process::{Command, Output}; +use std::process::Output; +use support::diffr_command; use tempfile::TempDir; /// A repository with a working tree, plus a home directory holding git's @@ -62,7 +65,7 @@ impl Fixture { fn run(&self, base: &str, head: &str) -> Output { let home = self.dir.path().join("home"); - Command::new(assert_cmd::cargo_bin!("diffr")) + diffr_command() .arg("--repo") .arg(self.dir.path().join("repo")) .args([base, head, "--format", "ndjson"]) diff --git a/tests/view.rs b/tests/view.rs index dbabd4f6e..418c46526 100644 --- a/tests/view.rs +++ b/tests/view.rs @@ -1,10 +1,13 @@ //! The plugins on the stream: hidden files, collapsed and linked regions, and //! a run cut short by a failing plugin. +mod support; + use git2::{IndexAddOption, Repository, Signature, Time}; use serde_json::Value; use std::fs; use std::path::PathBuf; -use std::process::{Command, Output}; +use std::process::Output; +use support::diffr_command; use tempfile::TempDir; struct Fixture { @@ -74,7 +77,7 @@ impl Fixture { } fn diffr(&self, args: &[&str]) -> Output { - Command::new(assert_cmd::cargo_bin!("diffr")) + diffr_command() .arg("--repo") .arg(self.dir.path().join("repo")) .args(args) diff --git a/tests/wasm.rs b/tests/wasm.rs index 6513881b5..c77d0b9d8 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -1,12 +1,15 @@ //! WASM component plugins: the bundled plugins built as components shape //! files exactly as the same source compiled into diffr does, and the //! fixtures example classifies, reads a file, runs git and moves regions. +mod support; + use git2::{IndexAddOption, Repository, Signature, Time}; use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::Once; +use support::diffr_command; use tempfile::TempDir; /// Build every guest plugin into its folder's `plugin.wasm`, once per test @@ -96,7 +99,7 @@ impl Fixture { fn run(&self, config: &Path, base: &str, head: &str) -> Output { let home = self.dir.path().join("home"); - Command::new(assert_cmd::cargo_bin!("diffr")) + diffr_command() .arg("--repo") .arg(self.dir.path().join("repo")) .arg("--config") From 737a6791bafe53c5e64f5e25491486029e15146e Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 14:04:22 -0700 Subject: [PATCH 19/20] Drop the crates.io packaging check `cargo package` tarballs the crate for crates.io, and cannot: diffr depends on `diffr-plugin-sdk` and the seven bundled plugin crates by path, and a dependency without a version is refused, while a version sends cargo looking for crates that were never published. Nothing local fixes that. The check came from upstream difftastic, which does publish. This fork builds a `diffr` binary and does not, so it was testing something that does not happen. The jobs that build and test diffr are untouched. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f --- .github/workflows/test.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 96d5f103f..347be42f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,15 +83,6 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: cargo +stable test - package: - name: Check Linux Packaging - runs-on: ubuntu-22.04 - steps: - # v4.2.2 - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: dtolnay/rust-toolchain@1.85.0 - - run: cargo package --allow-dirty - fmt: name: Rustfmt runs-on: ubuntu-22.04 From 2e136e5f6d10e4fcb349d193a0bc59292677557e Mon Sep 17 00:00:00 2001 From: Sidharth Menon <menon.sid.k@gmail.com> Date: Wed, 16 Sep 2026 14:06:49 -0700 Subject: [PATCH 20/20] Move the cross runner helper without rewriting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper that runs the built binary under cargo's runner is upstream's, and has sat in tests/cli.rs since before this branch. Sharing it, I restyled it on the way — renamed it, rewrote its branch as a let-else, replaced its unwrap, dropped its comment — so a move read as new code, and the reason any of it exists went with the comment. Move it as it was. The only change it needed was `pub`. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f --- tests/cli.rs | 2 +- tests/support/mod.rs | 34 +++++++++++++++------------------- tests/tags.rs | 4 ++-- tests/view.rs | 4 ++-- tests/wasm.rs | 4 ++-- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/tests/cli.rs b/tests/cli.rs index 050e84979..3a4c20850 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,7 +1,7 @@ mod support; use std::process::Command; -use support::diffr_command as get_base_command; +use support::get_base_command; use assert_cmd::prelude::*; use predicates::prelude::*; diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 38b0f1cb2..702200b02 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,9 +1,4 @@ -//! Shared test helpers. -//! -//! Spawning the built `diffr` needs care: under `cross`, the binary is built -//! for another architecture and only the runner cargo names in -//! `CARGO_TARGET_<TARGET>_RUNNER` can execute it. Every test that runs diffr -//! goes through [`diffr_command`] so it works there as well as natively. +//! Helpers shared by the integration tests. use std::process::Command; fn find_runner() -> Option<String> { @@ -15,20 +10,21 @@ fn find_runner() -> Option<String> { None } -/// A command that runs the built `diffr`, through cargo's runner when there -/// is one. -/// -/// Sample code from <https://github.com/assert-rs/assert_cmd/issues/139>. -pub fn diffr_command() -> Command { +// Sample code from +// https://github.com/assert-rs/assert_cmd/issues/139, supports +// cross-compiled binaries. +pub fn get_base_command() -> Command { + let mut cmd; let path = assert_cmd::cargo_bin!("diffr"); - let Some(runner) = find_runner() else { - return Command::new(path); - }; - let mut runner = runner.split_whitespace(); - let mut cmd = Command::new(runner.next().expect("a runner names a program")); - for arg in runner { - cmd.arg(arg); + if let Some(runner) = find_runner() { + let mut runner = runner.split_whitespace(); + cmd = Command::new(runner.next().unwrap()); + for arg in runner { + cmd.arg(arg); + } + cmd.arg(path); + } else { + cmd = Command::new(path); } - cmd.arg(path); cmd } diff --git a/tests/tags.rs b/tests/tags.rs index 3573b4e3e..469c9f8a2 100644 --- a/tests/tags.rs +++ b/tests/tags.rs @@ -5,7 +5,7 @@ use git2::{IndexAddOption, Repository, Signature, Time}; use serde_json::Value; use std::fs; use std::process::Output; -use support::diffr_command; +use support::get_base_command; use tempfile::TempDir; /// A repository with a working tree, plus a home directory holding git's @@ -65,7 +65,7 @@ impl Fixture { fn run(&self, base: &str, head: &str) -> Output { let home = self.dir.path().join("home"); - diffr_command() + get_base_command() .arg("--repo") .arg(self.dir.path().join("repo")) .args([base, head, "--format", "ndjson"]) diff --git a/tests/view.rs b/tests/view.rs index 418c46526..12016de79 100644 --- a/tests/view.rs +++ b/tests/view.rs @@ -7,7 +7,7 @@ use serde_json::Value; use std::fs; use std::path::PathBuf; use std::process::Output; -use support::diffr_command; +use support::get_base_command; use tempfile::TempDir; struct Fixture { @@ -77,7 +77,7 @@ impl Fixture { } fn diffr(&self, args: &[&str]) -> Output { - diffr_command() + get_base_command() .arg("--repo") .arg(self.dir.path().join("repo")) .args(args) diff --git a/tests/wasm.rs b/tests/wasm.rs index c77d0b9d8..51949ae22 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -9,7 +9,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::Once; -use support::diffr_command; +use support::get_base_command; use tempfile::TempDir; /// Build every guest plugin into its folder's `plugin.wasm`, once per test @@ -99,7 +99,7 @@ impl Fixture { fn run(&self, config: &Path, base: &str, head: &str) -> Output { let home = self.dir.path().join("home"); - diffr_command() + get_base_command() .arg("--repo") .arg(self.dir.path().join("repo")) .arg("--config")