From 53ee0f67bf4f93f61c7efd94d7dd7cc6ee9230cb Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 3 Oct 2026 14:35:15 -0700 Subject: [PATCH 1/4] Fold identical sources for search context DiffResult::from_sources_with_context parses an identical pair once and gives both sides matching folds, so context can run on unchanged files. Ordinary diffs keep the identical-source fast path. AI assistance: developed with OpenAI Codex and Claude Code. Agent-Session: 01a10126-800c-7922-a418-bb477c280cbd Agent-Session: dca03de1-4350-4b24-b376-cb54c86fcc2d Agent-Session: 9a22412a-b2b1-4e3a-a35d-e310f6846f29 Agent-Session: 82924cb5-dcfa-42e4-abb0-ef40393249f8 Agent-Session: 01a103af-15d8-79c3-9da1-1dce5fa8bdc7 Agent-Session: 01a1061e-c424-7fa2-8db0-b8e0c93f5d16 Agent-Session: b7f96806-2bcb-4ee8-aca4-c5bedad0939a Agent-Session: 01a10ad4-5daa-74b2-9479-9dca5e8b2572 Agent-Session: 01a10dbe-5dcc-74a3-a06b-f810c6d3af7e Agent-Session: 01a10e7a-1dfa-7bd1-a6f4-8449c22813e2 Agent-Session: 5700e9f2-ed1a-41d0-825c-bda83f9e8aa0 Agent-Session: 01a10f90-e4ce-70f2-84d1-26d64820eed3 Agent-Session: 01a11898-f278-7b30-b366-ea64bfc7ff5e Agent-Session: 01a118dd-0ded-7b60-a820-60e4f5797db5 --- crates/diffr-core/src/engine.rs | 47 ++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/diffr-core/src/engine.rs b/crates/diffr-core/src/engine.rs index 5041e385e..c3436e73b 100644 --- a/crates/diffr-core/src/engine.rs +++ b/crates/diffr-core/src/engine.rs @@ -15,7 +15,7 @@ use crate::parse::syntax::{self, init_next_prev}; use crate::parse::tree_sitter_parser as tsp; use crate::summary::{DiffResult, FallbackCause, FileContent, FileFormat, Highlight}; use humansize::{format_size, FormatSizeOptions, BINARY}; -use std::{env, fmt, path::Path}; +use std::{env, fmt, num::NonZeroU32, path::Path}; use typed_arena::Arena; /// The fallback reason for a file diffed by line because of what it is. @@ -97,6 +97,51 @@ impl DiffResult { ) -> Result { diff_file_content(params, path, lhs, rhs, options, &[]) } + /// Like `from_sources_with_options`, but also parses identical sources so + /// search can show their folds. + pub fn from_sources_with_context( + path: &str, + lhs: &str, + rhs: &str, + params: &Params, + options: &DiffOptions, + ) -> Result { + let mut diff = Self::from_sources_with_options(path, lhs, rhs, params, options)?; + if lhs == rhs && options.by_line.is_none() && lhs.len() <= options.byte_limit { + if let Some(language) = guess(Path::new(path), lhs, &[]) { + let config = params.language(language); + let tree = tsp::to_tree(lhs, config.parser); + let arena = Arena::new(); + let (nodes, _) = + tsp::to_syntax(&tree, lhs, &arena, config, false).map_err(|conflict| { + QueryConflict { + path: path.to_owned(), + side: Side::Left, + conflict, + } + })?; + folds::unmatched(&nodes, &mut diff.lhs_folds); + let count = diff.lhs_folds.len(); + for (i, fold) in diff.lhs_folds.iter_mut().enumerate() { + let lhs = NonZeroU32::new(i as u32 + 1).unwrap(); + let rhs = NonZeroU32::new((count + i) as u32 + 1).unwrap(); + fold.syntax_id = lhs; + fold.match_kind = folds::FoldMatch::Matched { opposite: rhs }; + diff.rhs_folds.push(folds::Fold { + tags: fold.tags.clone(), + relations: fold.relations.clone(), + range: fold.range, + indent: fold.indent, + syntax: fold.syntax, + syntax_id: rhs, + match_kind: folds::FoldMatch::Matched { opposite: lhs }, + placeholder: String::new(), + }); + } + } + } + Ok(diff) + } } pub fn diff_file_content( params: &Params, From 8d360c9e0d97bcdfcdec6d13e17425c5d2f2e2be Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 3 Oct 2026 14:35:15 -0700 Subject: [PATCH 2/4] Cache computed diffs as files Search keeps each query-independent diff as one JSON file, named by a hash of its analysis settings and source identities and written atomically. [storage] path sets the directory, .cache/diffr in the repository by default. AI assistance: developed with OpenAI Codex and Claude Code. Agent-Session: 01a10126-800c-7922-a418-bb477c280cbd Agent-Session: dca03de1-4350-4b24-b376-cb54c86fcc2d Agent-Session: 9a22412a-b2b1-4e3a-a35d-e310f6846f29 Agent-Session: 82924cb5-dcfa-42e4-abb0-ef40393249f8 Agent-Session: 01a103af-15d8-79c3-9da1-1dce5fa8bdc7 Agent-Session: 01a1061e-c424-7fa2-8db0-b8e0c93f5d16 Agent-Session: b7f96806-2bcb-4ee8-aca4-c5bedad0939a Agent-Session: 01a10ad4-5daa-74b2-9479-9dca5e8b2572 Agent-Session: 01a10dbe-5dcc-74a3-a06b-f810c6d3af7e Agent-Session: 01a10e7a-1dfa-7bd1-a6f4-8449c22813e2 Agent-Session: 5700e9f2-ed1a-41d0-825c-bda83f9e8aa0 Agent-Session: 01a10f90-e4ce-70f2-84d1-26d64820eed3 Agent-Session: 01a11898-f278-7b30-b366-ea64bfc7ff5e Agent-Session: 01a118dd-0ded-7b60-a820-60e4f5797db5 --- Cargo.lock | 1 + Cargo.toml | 2 + crates/diffr-core/src/config.rs | 4 + crates/diffr-core/src/config/default.toml | 3 + crates/diffr-core/src/config/storage.rs | 18 +++ src/search/store.rs | 164 ++++++++++++++++++++++ 6 files changed, 192 insertions(+) create mode 100644 crates/diffr-core/src/config/storage.rs create mode 100644 src/search/store.rs diff --git a/Cargo.lock b/Cargo.lock index ab411fe7c..a1af811ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -669,6 +669,7 @@ dependencies = [ "rayon", "serde", "serde_json", + "sha2", "strum", "tempfile", "terminal_size", diff --git a/Cargo.toml b/Cargo.toml index 635607d10..9b720fec5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ include = [ [dependencies] diffr-core = { path = "crates/diffr-core", version = "0.1.16" } +sha2 = "0.10" +tempfile = "3.27.0" gix = { version = "0.88", default-features = false, features = ["parallel", "revision", "status", "blob-diff", "attributes", "sha1"] } clap = { version = "4.0.0", features = ["cargo", "derive", "env", "wrap_help", "string"] } typed-arena = "2.0.2" diff --git a/crates/diffr-core/src/config.rs b/crates/diffr-core/src/config.rs index 680df48b6..a608b8963 100644 --- a/crates/diffr-core/src/config.rs +++ b/crates/diffr-core/src/config.rs @@ -20,6 +20,7 @@ pub mod migrate; pub(crate) mod prune; pub(crate) mod query; +pub mod storage; pub mod store; use crate::hash::DftHashMap; use crate::options::DiffOptions; @@ -56,6 +57,9 @@ pub struct Config { /// Limits on the structural comparison itself. #[serde(default)] pub diff: DiffConfig, + /// Cache for computed diffs. + #[serde(default)] + pub storage: storage::StoreConfig, } impl Default for Config { diff --git a/crates/diffr-core/src/config/default.toml b/crates/diffr-core/src/config/default.toml index bd15d1194..1fe8ee1fe 100644 --- a/crates/diffr-core/src/config/default.toml +++ b/crates/diffr-core/src/config/default.toml @@ -42,3 +42,6 @@ name = "default-dark" byte_limit = 1000000 graph_limit = 3000000 parse_error_limit = 0 + +[storage] +path = ".cache/diffr" diff --git a/crates/diffr-core/src/config/storage.rs b/crates/diffr-core/src/config/storage.rs new file mode 100644 index 000000000..371d01260 --- /dev/null +++ b/crates/diffr-core/src/config/storage.rs @@ -0,0 +1,18 @@ +//! Where search keeps computed diffs. +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct StoreConfig { + /// Directory for computed diffs, relative to the source repository unless absolute. + #[schemars(title = "Diff storage directory", extend("x-group" = "Storage"))] + pub path: PathBuf, +} +impl Default for StoreConfig { + fn default() -> Self { + Self { + path: ".cache/diffr".into(), + } + } +} diff --git a/src/search/store.rs b/src/search/store.rs new file mode 100644 index 000000000..cdfcb46b2 --- /dev/null +++ b/src/search/store.rs @@ -0,0 +1,164 @@ +//! Computed diffs, cached as one JSON file per key. +use crate::{ + pairing::Pairing, + protocol::{FileChange, Source, Stats}, +}; +use anyhow::{ensure, Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fs, io::Write, path::PathBuf}; + +/// Bump when diff algorithms, parsers, or serialized tree semantics change. +const CACHE_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), ":search-diff-v6"); + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub(crate) struct DiffKey(String); +impl DiffKey { + /// Hash a canonical JSON description of analysis settings and source identities. + pub(crate) fn new(identity: &impl Serialize) -> Result { + let mut canonical = serde_json::to_value((CACHE_VERSION, identity))?; + canonical.sort_all_objects(); + Ok(Self(format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&canonical)?) + ))) + } + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for DiffKey { + type Error = anyhow::Error; + fn try_from(value: String) -> Result { + ensure!( + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "invalid diff key" + ); + Ok(Self(value)) + } +} +impl From for String { + fn from(key: DiffKey) -> Self { + key.0 + } +} + +/// A computed diff, without search highlights. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct StoredDiff { + pub(crate) entry: FileChange, + pub(crate) sources: Pairing, + pub(crate) stats: Stats, + pub(crate) hidden: Option, +} + +pub(crate) struct FileStore { + directory: PathBuf, +} +#[derive(Serialize, Deserialize)] +struct Record { + key: DiffKey, + diff: StoredDiff, +} +impl FileStore { + pub(crate) fn open(directory: impl Into) -> Result { + let directory = directory.into(); + fs::create_dir_all(&directory).context("create diff store directory")?; + Ok(Self { directory }) + } + fn path(&self, key: &DiffKey) -> PathBuf { + self.directory.join(format!("{}.json", key.as_str())) + } + pub(crate) fn get(&self, key: &DiffKey) -> Result> { + let bytes = match fs::read(self.path(key)) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("read diff store entry"), + }; + let record: Record = serde_json::from_slice(&bytes).context("decode diff store entry")?; + ensure!(&record.key == key, "diff store key mismatch"); + Ok(Some(record.diff)) + } + /// Write through a temporary file, so a reader never sees half an entry. + pub(crate) fn put(&self, key: &DiffKey, diff: &StoredDiff) -> Result<()> { + let mut file = tempfile::NamedTempFile::new_in(&self.directory)?; + serde_json::to_writer( + &mut file, + &Record { + key: key.clone(), + diff: diff.clone(), + }, + )?; + file.flush()?; + file.as_file().sync_all()?; + file.persist(self.path(key)) + .context("publish diff store entry")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{FileRef, FileStatus, LineCounts, Region}; + + fn sample(text: &str) -> StoredDiff { + StoredDiff { + hidden: None, + stats: Stats { + textual: LineCounts { + added: 1, + removed: 0, + }, + visible: LineCounts { + added: 1, + removed: 0, + }, + fallback: None, + }, + entry: FileChange { + file: Pairing::RightOnly { + rhs: FileRef { + path: "x.rs".into(), + oid: "abc".into(), + mode: "100644".into(), + }, + }, + status: FileStatus::Added, + tags: vec![], + }, + sources: Pairing::RightOnly { + rhs: Source { + text: text.into(), + syntax: vec![], + root: Region::root(1, vec![]), + }, + }, + } + } + fn text(diff: Option) -> String { + let Pairing::RightOnly { rhs } = diff.unwrap().sources else { + panic!("one side"); + }; + rhs.text + } + + #[test] + fn entries_survive_a_reopen_and_corruption_is_an_error() { + let directory = tempfile::tempdir().unwrap(); + let key = DiffKey::new(&("a.rs", "blob", "config")).unwrap(); + let store = FileStore::open(directory.path()).unwrap(); + assert!(store.get(&key).unwrap().is_none()); + store.put(&key, &sample("saved")).unwrap(); + store.put(&key, &sample("replaced")).unwrap(); + let reopened = FileStore::open(directory.path()).unwrap(); + assert_eq!(text(reopened.get(&key).unwrap()), "replaced"); + fs::write(reopened.path(&key), b"broken JSON").unwrap(); + assert!(reopened.get(&key).is_err()); + } +} From 414f3b6ac74abf5598130e3fd46ca6843614854d Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 3 Oct 2026 14:35:24 -0700 Subject: [PATCH 3/4] Search grep hits against pinned commits A search Session pins a repository to two commits and classifies their manifest. For each hit it loads or computes the file's cached diff and marks the hit's line on the matching leaf, then runs the context plugin on every file with a hit, unless the caller overrides the plugins. AI assistance: developed with OpenAI Codex and Claude Code. Agent-Session: 01a10126-800c-7922-a418-bb477c280cbd Agent-Session: dca03de1-4350-4b24-b376-cb54c86fcc2d Agent-Session: 9a22412a-b2b1-4e3a-a35d-e310f6846f29 Agent-Session: 82924cb5-dcfa-42e4-abb0-ef40393249f8 Agent-Session: 01a103af-15d8-79c3-9da1-1dce5fa8bdc7 Agent-Session: 01a1061e-c424-7fa2-8db0-b8e0c93f5d16 Agent-Session: b7f96806-2bcb-4ee8-aca4-c5bedad0939a Agent-Session: 01a10ad4-5daa-74b2-9479-9dca5e8b2572 Agent-Session: 01a10dbe-5dcc-74a3-a06b-f810c6d3af7e Agent-Session: 01a10e7a-1dfa-7bd1-a6f4-8449c22813e2 Agent-Session: 5700e9f2-ed1a-41d0-825c-bda83f9e8aa0 Agent-Session: 01a10f90-e4ce-70f2-84d1-26d64820eed3 Agent-Session: 01a11898-f278-7b30-b366-ea64bfc7ff5e Agent-Session: 01a118dd-0ded-7b60-a820-60e4f5797db5 --- crates/diffr-core/src/pairing.rs | 5 +- crates/diffr-core/src/plugin/config.rs | 4 +- crates/diffr-core/src/plugin/queries.rs | 20 +- src/main.rs | 1 + src/plugin/mod.rs | 2 +- src/search.rs | 468 ++++++++++++++++++++++++ 6 files changed, 494 insertions(+), 6 deletions(-) create mode 100644 src/search.rs diff --git a/crates/diffr-core/src/pairing.rs b/crates/diffr-core/src/pairing.rs index cea0d8a11..0cd1511cd 100644 --- a/crates/diffr-core/src/pairing.rs +++ b/crates/diffr-core/src/pairing.rs @@ -16,7 +16,7 @@ pub enum Pairing { impl Pairing { /// The same sides, each value transformed by `f`. - pub(crate) fn map(self, mut f: impl FnMut(T) -> U) -> Pairing { + pub fn map(self, mut f: impl FnMut(T) -> U) -> Pairing { match self { Self::Both { lhs, rhs } => Pairing::Both { lhs: f(lhs), @@ -42,7 +42,7 @@ impl Pairing { } /// Every side that exists, lhs first. - pub(crate) fn sides(&self) -> Vec<&T> { + pub fn sides(&self) -> Vec<&T> { match self { Self::Both { lhs, rhs } => vec![lhs, rhs], Self::LeftOnly { lhs } => vec![lhs], @@ -52,6 +52,7 @@ impl Pairing { } #[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] struct PairingRepr { #[serde(skip_serializing_if = "Option::is_none")] lhs: Option, diff --git a/crates/diffr-core/src/plugin/config.rs b/crates/diffr-core/src/plugin/config.rs index f6e11ca6c..990bbb47f 100644 --- a/crates/diffr-core/src/plugin/config.rs +++ b/crates/diffr-core/src/plugin/config.rs @@ -360,7 +360,7 @@ impl PluginsConfig { pub struct ShapeConfig { /// 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, + pub order: Vec, /// Every entry, by reference: `bundled.` or the whole custom name. pub entries: BTreeMap, } @@ -736,7 +736,7 @@ impl Default for ShapeConfig { impl ShapeConfig { /// Resolve each selected entry, validate its options, fill their defaults, /// and require every entry to appear in the explicit order exactly once. - pub(crate) fn resolve(&mut self, base: &Path) -> Result<(), ConfigError> { + pub fn resolve(&mut self, base: &Path) -> Result<(), ConfigError> { let mut identities = BTreeSet::new(); for (name, entry) in &mut self.entries { let folder = match (name.strip_prefix("bundled."), &entry.path) { diff --git a/crates/diffr-core/src/plugin/queries.rs b/crates/diffr-core/src/plugin/queries.rs index 5946c847d..c4c33fe4b 100644 --- a/crates/diffr-core/src/plugin/queries.rs +++ b/crates/diffr-core/src/plugin/queries.rs @@ -18,7 +18,7 @@ pub struct PluginQuery { } /// The query files one plugin declares. -pub(crate) type Queries = Vec; +pub type Queries = Vec; const BUILTIN_PREFIX: &str = "builtin:"; @@ -192,6 +192,24 @@ impl Assembly { } } +/// Query sources per language, imports included. +pub fn sources( + plugins: &[(String, Queries)], +) -> Result>, ConfigError> { + Ok(assemble(plugins)? + .into_iter() + .map(|(language, sources)| { + ( + language, + sources + .into_iter() + .map(|source| (source.name, source.text)) + .collect(), + ) + }) + .collect()) +} + /// Assemble in plugin order, with imports before their first importer. pub(crate) fn assemble( plugins: &[(String, Queries)], diff --git a/src/main.rs b/src/main.rs index 3a1e3819d..20b278476 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,7 @@ mod options; mod plugin; mod pprint; mod run; +mod search; mod tags; mod version; diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index d5ba004fb..f8ba2efb6 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -12,7 +12,7 @@ pub(crate) mod wasm; pub(crate) use classify::Classifier; #[cfg(test)] pub(crate) use diffr_core::plugin::builtin; -pub(crate) use diffr_core::plugin::{config, cursor, MutationFailed}; +pub(crate) use diffr_core::plugin::{config, cursor, queries, MutationFailed}; pub(crate) use wasm::Pipeline; #[cfg(test)] diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 000000000..a8462adb8 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,468 @@ +//! Hydrate grep hits against pinned commits and run the search plugins. +use crate::{ + config::{Config, Params}, + git, + options::DiffOptions, + pairing::Pairing, + plugin::{config::ShapeConfig, Classifier, Pipeline}, + present::present, + protocol::{project, Diff, FileChange, FileRef, FileStatus, Node, Region, Source, Span}, + summary::{DiffResult, FallbackCause}, + tags::GENERATED, +}; +use anyhow::{anyhow, bail, ensure, Context}; +use gix::{ObjectId as Oid, Repository}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + num::NonZeroUsize, + path::{Component, Path, PathBuf}, + slice, + sync::Mutex, +}; +pub(crate) mod store; +use store::{DiffKey, FileStore, StoredDiff}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Scope { + pub(crate) repo: PathBuf, + pub(crate) base: String, + pub(crate) head: String, +} +impl Scope { + /// Resolve revisions to commit IDs once. + pub(crate) fn resolve(repo: &Path, base: &str, head: &str) -> anyhow::Result { + let repo = gix::discover(repo)?; + Ok(Self { + repo: repo.workdir().unwrap_or(repo.git_dir()).canonicalize()?, + base: commit(&repo, base)?.to_string(), + head: commit(&repo, head)?.to_string(), + }) + } +} +pub(crate) struct Hit { + pub(crate) revision: String, + pub(crate) file: PathBuf, + pub(crate) lines: Vec, +} +pub(crate) struct HitLine { + pub(crate) line: u32, + pub(crate) text: String, +} +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Display { + Both, + Lhs, + Rhs, +} +/// The one plugin search runs by default. +const CONTEXT: &str = "bundled.context"; + +/// One file's search result: the stream's file record (`file` and `diff`), +/// with the side to show and its scope. An unchanged file has two identical +/// sides. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct SearchResult { + display: Display, + pub(crate) scope: Scope, + file: Pairing, + diff: Diff, +} +impl SearchResult { + /// The text diff's sources, which must exist on the file's sides. + fn sources(&self) -> anyhow::Result<&Pairing> { + let Diff::Text { sides, .. } = &self.diff else { + bail!("search results are text diffs"); + }; + sides_agree(&self.file, sides)?; + Ok(sides) + } + fn sources_mut(&mut self) -> anyhow::Result<&mut Pairing> { + let Diff::Text { sides, .. } = &mut self.diff else { + bail!("search results are text diffs"); + }; + sides_agree(&self.file, sides)?; + Ok(sides) + } +} +fn sides_agree(file: &Pairing, sources: &Pairing) -> anyhow::Result<()> { + ensure!( + file.lhs().is_some() == sources.lhs().is_some() + && file.rhs().is_some() == sources.rhs().is_some(), + "file and source sides must agree" + ); + Ok(()) +} + +/// Two pinned commits and their plugin pipeline. +pub(crate) struct Session { + store: FileStore, + scope: Scope, + params: Params, + pipeline: Pipeline, + manifest: Vec<(FileChange, Option)>, + classifier: Mutex, + analysis: DiffKey, +} + +/// Per-call plugin overrides. +#[derive(Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub(crate) struct Options { + plugins: Option, +} + +/// Create a session for `scope`, caching computed diffs under the configured path. +pub(crate) fn configured_session(scope: Scope, options: Options) -> anyhow::Result { + let mut config = Config::load()?; + let store = FileStore::open(scope.repo.join(&config.storage.path))?; + let defaults = search_plugins(&config, &scope.repo)?; + // Always load context queries, even if ordinary diffs disable context. + let shape = &mut config.plugins.shape; + shape + .entries + .insert(CONTEXT.to_owned(), defaults.entries[CONTEXT].clone()); + if !shape.order.iter().any(|name| name == CONTEXT) { + shape.order.insert(0, CONTEXT.to_owned()); + } + let mut processing = config.clone(); + processing.plugins.shape = options.plugins.unwrap_or(defaults); + processing.plugins.shape.resolve(&scope.repo)?; + let pipeline = Pipeline::from_config(&processing, &scope.repo, NonZeroUsize::MIN)?; + validate(&scope)?; + let queries = config.plugins.shape.queries()?; + let resolved = crate::plugin::queries::sources(&queries)?; + let analysis = DiffKey::new(&( + serde_json::to_value(config.diff)?, + serde_json::to_value(&config.plugins)?, + resolved, + ))?; + let params = config.compile_queries(queries)?; + let listing = git::list( + &scope.repo, + git::Comparison { + before: git::Operand::revision(&scope.base), + after: git::Operand::revision(&scope.head), + }, + &git::FileParams::default(), + ) + .map_err(|e| anyhow!("{e}"))?; + let entries: Vec<_> = listing + .files + .iter() + .map(|f| f.change.manifest_entry()) + .collect(); + let mut classifier = Classifier::from_config(&config, &scope.repo)?; + let classified = classifier.classify(&entries)?; + let manifest = entries + .into_iter() + .zip(classified) + .map(|(mut entry, classification)| { + entry.tags = classification.tags; + (entry, classification.hidden) + }) + .collect(); + Ok(Session { + store, + scope, + params, + pipeline, + manifest, + classifier: Mutex::new(classifier), + analysis, + }) +} + +fn search_plugins(config: &Config, repo: &Path) -> anyhow::Result { + let mut plugins = ShapeConfig { + order: vec![CONTEXT.into()], + ..Default::default() + }; + plugins + .entries + .retain(|name, _| plugins.order.contains(name)); + let context = plugins + .entries + .get_mut(CONTEXT) + .context("the bundled context plugin is missing")?; + if let Some(lines) = config + .plugins + .shape + .entries + .get(CONTEXT) + .and_then(|entry| entry.options.get("lines")) + { + context.options.insert("lines".into(), lines.clone()); + } + plugins.resolve(repo)?; + Ok(plugins) +} + +impl Session { + /// Mark each hit on its file's diff at the pinned commits, then shape every + /// file around its hits with the search plugins. + pub(crate) fn search( + &self, + hits: Vec, + runtime: &tokio::runtime::Runtime, + ) -> anyhow::Result> { + let scope = &self.scope; + let repo = gix::open(&scope.repo)?; + let base = oid(&scope.base)?; + let head = oid(&scope.head)?; + let mut revisions = + BTreeMap::from([(scope.base.clone(), base), (scope.head.clone(), head)]); + let mut selected: BTreeMap = BTreeMap::new(); + for hit in hits { + ensure!( + !hit.file.as_os_str().is_empty() + && hit + .file + .components() + .all(|c| matches!(c, Component::Normal(_))), + "hit paths must be repository-relative without parent traversal" + ); + let path = hit.file.to_str().context("non-UTF8 hit path")?; + let oid = match revisions.get(&hit.revision) { + Some(oid) => *oid, + None => { + let oid = commit(&repo, &hit.revision)?; + revisions.insert(hit.revision.clone(), oid); + oid + } + }; + let right = oid == head; + ensure!( + right || oid == base, + "hit revision is outside scoped commits" + ); + let cached = self.resolve(right, path)?; + let (_, result) = selected.entry(key(&cached.entry.file)).or_insert_with(|| { + let result = SearchResult { + display: Display::Both, + scope: scope.clone(), + file: cached.entry.file.clone(), + diff: Diff::Text { + sides: cached.sources.clone(), + stats: cached.stats.clone(), + }, + }; + (cached, result) + }); + let output = side_mut(result.sources_mut()?, right).context("hit side absent")?; + let mut spans = Vec::new(); + for line in hit.lines { + ensure!(line.line > 0, "hit line numbers are 1-based"); + let text = output + .text + .split_terminator('\n') + .nth((line.line - 1) as usize) + .context("hit line is out of bounds")?; + let text = text.strip_suffix('\r').unwrap_or(text); + ensure!( + text == line.text, + "hit text differs from pinned source at {path}:{}", + line.line + ); + spans.push(Span { + line: line.line - 1, + start_column: 0, + end_column: u32::try_from(text.len())?, + }); + } + add_highlights(slice::from_mut(&mut output.root), spans); + } + let mut results = Vec::new(); + for (cached, mut result) in selected.into_values() { + // A hidden file runs no plugin, so its hits stay on show. + if cached.hidden.is_none() { + result.diff = runtime.block_on(present(None, result.diff, async |sides| { + self.pipeline.run(&cached.entry, sides).await + }))?; + } + results.push(result); + } + Ok(results) + } + + fn resolve(&self, right: bool, path: &str) -> anyhow::Result { + let scope = &self.scope; + let (entry, hidden) = if let Some(entry) = self.manifest.iter().find(|(entry, _)| { + sides(&entry.file) + .iter() + .any(|(side, file)| *side == right && file.path == path) + }) { + entry.clone() + } else { + let repo = gix::open(&scope.repo)?; + let file = |commit: &str| -> anyhow::Result { + let tree = repo.find_commit(oid(commit)?)?.tree()?; + let entry = tree + .lookup_entry_by_path(path)? + .context("hit does not resolve to a file in that commit")?; + ensure!( + entry.mode().is_blob() && !entry.mode().is_link(), + "search requires regular text files" + ); + Ok(FileRef { + path: path.to_owned(), + oid: entry.object_id().to_string(), + mode: format!("{:o}", entry.mode().value()), + }) + }; + let lhs = file(&scope.base)?; + let rhs = file(&scope.head)?; + ensure!( + lhs.oid == rhs.oid, + "changed file missing from comparison manifest" + ); + let mut entry = FileChange { + file: Pairing::Both { lhs, rhs }, + status: FileStatus::Unchanged, + tags: Vec::new(), + }; + let classification = self + .classifier + .lock() + .map_err(|_| anyhow!("classifier lock poisoned"))? + .classify(slice::from_ref(&entry))? + .remove(0); + entry.tags = classification.tags; + (entry, classification.hidden) + }; + let cache_key = DiffKey::new(&(&self.analysis, path, &entry, &hidden))?; + if let Some(diff) = self.store.get(&cache_key)? { + return Ok(diff); + } + let repo = gix::open(&scope.repo)?; + let read = |file: &FileRef| -> anyhow::Result { + ensure!( + file.mode == "100644" || file.mode == "100755", + "search requires regular text files" + ); + let blob = repo.find_blob(oid(&file.oid)?)?; + ensure!(!blob.data.contains(&0), "search requires text files"); + Ok(std::str::from_utf8(&blob.data)?.to_owned()) + }; + let mut text = [String::new(), String::new()]; + for (right, file) in sides(&entry.file) { + text[usize::from(right)] = read(file)?; + } + let options = DiffOptions { + by_line: if entry.tags.iter().any(|tag| tag == GENERATED) { + Some(FallbackCause::Generated) + } else if hidden.is_some() { + Some(FallbackCause::Hidden) + } else { + None + }, + syntax: true, + ..self.params.diff.options(false) + }; + let diff = DiffResult::from_sources_with_context( + path, + &text[0], + &text[1], + &self.params, + &options, + )?; + let Diff::Text { sides, stats } = project::diff( + &diff, + project::Inputs { + file: &entry.file, + sizes: (text[0].len() as u64, text[1].len() as u64), + }, + ) else { + bail!("search requires text files") + }; + let stored = StoredDiff { + entry, + sources: sides, + stats, + hidden, + }; + self.store.put(&cache_key, &stored)?; + Ok(stored) + } +} + +fn sides(pair: &Pairing) -> Vec<(bool, &T)> { + match pair { + Pairing::Both { lhs, rhs } => vec![(false, lhs), (true, rhs)], + Pairing::LeftOnly { lhs } => vec![(false, lhs)], + Pairing::RightOnly { rhs } => vec![(true, rhs)], + } +} +fn key(file: &Pairing) -> String { + serde_json::to_string(file).expect("file refs serialize") +} +fn oid(hex: &str) -> anyhow::Result { + Oid::from_hex(hex.as_bytes()).map_err(|error| anyhow!("{error}")) +} +fn validate(scope: &Scope) -> anyhow::Result<()> { + ensure!( + scope.repo.is_absolute(), + "scope repository must be absolute" + ); + let repo = gix::open(&scope.repo)?; + for revision in [&scope.base, &scope.head] { + let pinned = oid(revision)?; + ensure!( + pinned.to_string() == *revision, + "scope revisions must be full commit IDs" + ); + repo.find_commit(pinned)?; + } + Ok(()) +} +fn commit(repo: &Repository, revision: &str) -> anyhow::Result { + Ok(repo + .rev_parse_single(revision) + .with_context(|| format!("invalid hit/comparison revision {revision:?}"))? + .object()? + .peel_to_commit()? + .id() + .detach()) +} +fn side_mut(pair: &mut Pairing, right: bool) -> Option<&mut T> { + match (pair, right) { + (Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs }, false) => Some(lhs), + (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }, true) => Some(rhs), + _ => None, + } +} +fn attach(regions: &mut [Region], spans: &[Span]) { + for region in regions { + match &mut region.node { + Node::Leaf { + search_highlights, .. + } => { + *search_highlights = spans + .iter() + .copied() + .filter(|s| region.range.lines().contains(&s.line)) + .collect(); + } + Node::Fold { children, .. } => attach(children, spans), + } + } +} +fn collect(regions: &[Region], spans: &mut Vec) { + for region in regions { + match ®ion.node { + Node::Leaf { + search_highlights, .. + } => spans.extend(search_highlights), + Node::Fold { children, .. } => collect(children, spans), + } + } +} +fn add_highlights(regions: &mut [Region], new: Vec) { + let mut spans = new; + collect(regions, &mut spans); + spans.sort_by_key(|s| (s.line, s.start_column, s.end_column)); + spans.dedup(); + attach(regions, &spans); +} From 8f2c11b782cd44e7c61b3b4a5b094c1790f2ac7d Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 3 Oct 2026 14:35:34 -0700 Subject: [PATCH 4/4] Add the diffr search command diffr search BASE HEAD reads revision-qualified git grep -n or -nz hits from stdin and writes one JSON result per file with a hit, with --options for plugin overrides. diffr search pprint renders results without reading the repository. tests/search.rs drives both against a fixture repository. AI assistance: developed with OpenAI Codex and Claude Code. Agent-Session: 01a10126-800c-7922-a418-bb477c280cbd Agent-Session: dca03de1-4350-4b24-b376-cb54c86fcc2d Agent-Session: 9a22412a-b2b1-4e3a-a35d-e310f6846f29 Agent-Session: 82924cb5-dcfa-42e4-abb0-ef40393249f8 Agent-Session: 01a103af-15d8-79c3-9da1-1dce5fa8bdc7 Agent-Session: 11f062a8-d0e9-4c39-a0a5-d32e2ca60fb7 Agent-Session: 01a1061e-c424-7fa2-8db0-b8e0c93f5d16 Agent-Session: b7f96806-2bcb-4ee8-aca4-c5bedad0939a Agent-Session: 01a10ad4-5daa-74b2-9479-9dca5e8b2572 Agent-Session: ea775653-fef8-47be-96ea-16815bdff22c Agent-Session: d72470a3-1f93-4201-8e6d-2730c798e92b Agent-Session: 01a10dbe-bfd3-7750-b69a-1c24fb1211cb Agent-Session: 01a10dbe-5dcc-74a3-a06b-f810c6d3af7e Agent-Session: 01a10e7a-1dfa-7bd1-a6f4-8449c22813e2 Agent-Session: 5700e9f2-ed1a-41d0-825c-bda83f9e8aa0 Agent-Session: 01a10f90-e4ce-70f2-84d1-26d64820eed3 Agent-Session: 01a11898-f278-7b30-b366-ea64bfc7ff5e Agent-Session: 01a118dd-0ded-7b60-a820-60e4f5797db5 --- .gitignore | 3 + src/cli.rs | 11 + src/search.rs | 3 + src/search/command.rs | 85 +++++ src/search/input.rs | 114 +++++++ src/search/pprint.rs | 285 ++++++++++++++++ tests/fixtures/search/base/removed.js | 18 + tests/fixtures/search/base/retry.js | 23 ++ tests/fixtures/search/base/unchanged.js | 7 + tests/fixtures/search/head/added.js | 3 + tests/fixtures/search/head/retry.js | 23 ++ tests/fixtures/search/head/unchanged.js | 7 + tests/search.rs | 433 ++++++++++++++++++++++++ 13 files changed, 1015 insertions(+) create mode 100644 src/search/command.rs create mode 100644 src/search/input.rs create mode 100644 src/search/pprint.rs create mode 100644 tests/fixtures/search/base/removed.js create mode 100644 tests/fixtures/search/base/retry.js create mode 100644 tests/fixtures/search/base/unchanged.js create mode 100644 tests/fixtures/search/head/added.js create mode 100644 tests/fixtures/search/head/retry.js create mode 100644 tests/fixtures/search/head/unchanged.js create mode 100644 tests/search.rs diff --git a/.gitignore b/.gitignore index f9102b365..1b80f6c07 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ plugin.wasm !/plugins/*/plugin.wasm /crates/*/target /plugins/workspace/target + +# Computed search diffs. +/.cache/diffr/ diff --git a/src/cli.rs b/src/cli.rs index 0ab017687..6d022fa6c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,6 +4,7 @@ use crate::git::{self, Comparison, FileParams, Operand, Result}; use crate::options::DebugArgs; use crate::plugin::{Classifier, Pipeline}; use crate::run; +use crate::search::command as search; use clap::{ error::ErrorKind, parser::ValueSource, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, @@ -126,6 +127,8 @@ enum Format { enum Command { /// Print saved NDJSON without a repository or terminal frontend Pprint(PprintArgs), + /// Shape Git-grep hits from stdin as diffs, or pretty-print the results + Search(search::SearchCommand), /// Show, edit, or open the settings screen for diffr's configuration Config(ConfigArgs), #[command( @@ -193,6 +196,13 @@ enum ConfigCommand { }, } +/// A search command's exit code, with its error chain as the message. +fn exit(result: anyhow::Result<()>) -> Result { + result + .map(|()| 0) + .map_err(|error| format!("{error:#}").into()) +} + pub(crate) fn run(runtime: &tokio::runtime::Runtime) -> Result { let frontend_args: Vec = std::env::args_os().skip(1).collect(); // Git treats an argument before `--` as a revision even when a file shares @@ -203,6 +213,7 @@ pub(crate) fn run(runtime: &tokio::runtime::Runtime) -> Result { let args = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()); match &args.command { Some(Command::Pprint(args)) => return run_pprint(args), + Some(Command::Search(command)) => return exit(search::run(command, runtime)), Some(Command::Config(config)) => return run_config(config), Some(Command::Debug(debug)) => { crate::run_debug(debug.mode(), &Config::default().compile()?); diff --git a/src/search.rs b/src/search.rs index a8462adb8..a34bbb5db 100644 --- a/src/search.rs +++ b/src/search.rs @@ -20,6 +20,9 @@ use std::{ slice, sync::Mutex, }; +pub(crate) mod command; +pub(crate) mod input; +pub(crate) mod pprint; pub(crate) mod store; use store::{DiffKey, FileStore, StoredDiff}; diff --git a/src/search/command.rs b/src/search/command.rs new file mode 100644 index 000000000..4d0f13b54 --- /dev/null +++ b/src/search/command.rs @@ -0,0 +1,85 @@ +use super::{configured_session, input, pprint, Options, Scope, SearchResult}; +use anyhow::{bail, Context}; +use clap::{Args, Subcommand}; +use std::{ + io::{self, Read, Write}, + path::PathBuf, +}; + +/// `diffr search BASE HEAD`, or `diffr search pprint`. +#[derive(Args)] +#[command(args_conflicts_with_subcommands = true, subcommand_negates_reqs = true)] +pub(crate) struct SearchCommand { + #[command(subcommand)] + step: Option, + /// Repository containing both commits; defaults to the current directory + #[arg(long, default_value = ".")] + repo: PathBuf, + /// Base commit or revision + #[arg(required = true)] + base: Option, + /// Head commit or revision + #[arg(required = true)] + head: Option, + /// JSON file containing per-call plugin overrides, e.g. {"plugins":{"order":[]}} + #[arg(long)] + options: Option, +} + +#[derive(Subcommand)] +enum Step { + /// Pretty-print search results from stdin without repository access + Pprint, +} + +pub(crate) fn run( + command: &SearchCommand, + runtime: &tokio::runtime::Runtime, +) -> anyhow::Result<()> { + match (&command.step, &command.base, &command.head) { + (Some(Step::Pprint), ..) => pprint(), + (None, Some(base), Some(head)) => search(command, base, head, runtime), + (None, ..) => bail!("give BASE and HEAD, or pprint"), + } +} + +/// Read revision-qualified Git-grep hits from stdin and write search results. +fn search( + command: &SearchCommand, + base: &str, + head: &str, + runtime: &tokio::runtime::Runtime, +) -> anyhow::Result<()> { + let mut bytes = Vec::new(); + io::stdin().lock().read_to_end(&mut bytes)?; + let hits = input::parse(&bytes)?; + let scope = Scope::resolve(&command.repo, base, head)?; + let options = match &command.options { + Some(path) => serde_json::from_reader(std::fs::File::open(path)?) + .context("invalid search options JSON")?, + None => Options::default(), + }; + let results = if hits.is_empty() { + Vec::new() + } else { + configured_session(scope, options)?.search(hits, runtime)? + }; + let mut stdout = io::stdout().lock(); + serde_json::to_writer(&mut stdout, &results)?; + writeln!(stdout)?; + Ok(()) +} + +fn pprint() -> anyhow::Result<()> { + let results: Vec = serde_json::from_reader(io::stdin().lock()) + .context("expected a JSON array of search results")?; + let rendered = results + .iter() + .map(pprint::render) + .collect::>>()?; + let mut stdout = io::stdout().lock(); + if !rendered.is_empty() { + writeln!(stdout, "{}", rendered.join("\n\n"))?; + } + Ok(()) +} diff --git a/src/search/input.rs b/src/search/input.rs new file mode 100644 index 000000000..2d27efd2b --- /dev/null +++ b/src/search/input.rs @@ -0,0 +1,114 @@ +//! Revision-qualified `git grep -n` and `git grep -nz` records. +use super::{Hit, HitLine}; +use anyhow::{ensure, Context}; +use std::collections::BTreeMap; + +pub(crate) fn parse(input: &[u8]) -> anyhow::Result> { + let input = std::str::from_utf8(input).context("Git-grep input must be UTF-8")?; + let null = input.contains('\0'); + let mut remaining = input; + let mut hits: BTreeMap<(String, String), BTreeMap> = BTreeMap::new(); + while !remaining.is_empty() { + let (name, line, text, rest) = if null { + let (name, rest) = remaining + .split_once('\0') + .context("missing Git-grep filename NUL")?; + let (line, rest) = rest + .split_once('\0') + .context("missing Git-grep line-number NUL")?; + let (text, rest) = record(rest); + (name, line, text, rest) + } else { + let (row, rest) = record(remaining); + // Filenames containing `::` need `-z`. + let (revision, tail) = row + .split_once(':') + .context("expected revision:path:line:text")?; + let mut offset = None; + for (i, _) in tail.match_indices(':') { + if let Some((number, _)) = tail[i + 1..].split_once(':') { + if !number.is_empty() && number.bytes().all(|c| c.is_ascii_digit()) { + offset = Some((i, number.len())); + break; + } + } + } + let (i, len) = offset.context("expected revision:path:line:text; use git grep -n")?; + let name_len = revision.len() + 1 + i; + ( + &row[..name_len], + &tail[i + 1..i + 1 + len], + &tail[i + len + 2..], + rest, + ) + }; + remaining = rest; + let (revision, file) = name + .split_once(':') + .context("Git-grep hits must name a revision")?; + ensure!( + !revision.is_empty() && !file.is_empty(), + "Git-grep revision and path must be nonempty" + ); + ensure!( + !line.is_empty() && line.bytes().all(|c| c.is_ascii_digit()), + "invalid Git-grep line number" + ); + let line: u32 = line.parse().context("Git-grep line number exceeds u32")?; + ensure!(line > 0, "hit line numbers are 1-based"); + // Git emits the source CR before its own LF for CRLF files. + let text = text.strip_suffix('\r').unwrap_or(text); + let lines = hits + .entry((revision.to_owned(), file.to_owned())) + .or_default(); + if let Some(previous) = lines.insert(line, text.to_owned()) { + ensure!( + previous == text, + "conflicting text for {revision}:{file}:{line}" + ); + } + } + Ok(hits + .into_iter() + .map(|((revision, file), lines)| Hit { + revision, + file: file.into(), + lines: lines + .into_iter() + .map(|(line, text)| HitLine { line, text }) + .collect(), + }) + .collect()) +} + +fn record(input: &str) -> (&str, &str) { + input.split_once('\n').unwrap_or((input, "")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_text_and_deduplicates_hits() { + let hits = + parse(b"HEAD:src/a.js:9: value:12: stays \nHEAD:src/a.js:9: value:12: stays \n") + .unwrap(); + assert_eq!(hits[0].lines.len(), 1); + assert_eq!(hits[0].lines[0].text, " value:12: stays "); + assert!(parse(b"HEAD:a:1:first\nHEAD:a:1:second\n").is_err()); + } + + #[test] + fn rejects_unqualified_or_malformed_records() { + for input in [ + "HEAD:a:4294967296:text", + "\n", + "HEAD:a\0x\0text\n", + "HEAD:a\0", + "HEAD:a:1:text\nHEAD:a\0", + ] { + assert!(parse(input.as_bytes()).is_err(), "{input:?}"); + } + } +} diff --git a/src/search/pprint.rs b/src/search/pprint.rs new file mode 100644 index 000000000..3b3fb145c --- /dev/null +++ b/src/search/pprint.rs @@ -0,0 +1,285 @@ +//! Render saved results without the repository, configuration or plugins. +use super::{Display, SearchResult}; +use crate::{ + pairing::Pairing, + protocol::{Node, Region, Source}, +}; +use anyhow::{ensure, Context}; +use std::collections::BTreeMap; + +enum Item<'a> { + Row { + alignment: u32, + offset: u32, + line: u32, + text: &'a str, + changed: bool, + }, + Fold { + id: u32, + first: u32, + last: u32, + }, +} +impl Item<'_> { + fn key(&self) -> (bool, u32, u32) { + match self { + Self::Row { + alignment, offset, .. + } => (false, *alignment, *offset), + Self::Fold { id, .. } => (true, *id, 0), + } + } + fn is_fold(&self) -> bool { + matches!(self, Self::Fold { .. }) + } +} + +fn visible(source: &Source) -> anyhow::Result>> { + let lines: Vec<_> = source.text.split('\n').collect(); + fn visit<'a>( + regions: &[Region], + lines: &[&'a str], + items: &mut Vec>, + hidden: bool, + ) -> anyhow::Result<()> { + for region in regions { + let first = region.range.start.line; + let end = region + .range + .end + .line + .checked_add(u32::from(region.range.end.column > 0)) + .context("region line range overflow")?; + ensure!( + first <= end && end as usize <= lines.len(), + "region line range out of bounds" + ); + ensure!( + (region.range.start.line, region.range.start.column) + <= (region.range.end.line, region.range.end.column), + "reversed region range" + ); + for pos in [region.range.start, region.range.end] { + if let Some(line) = lines.get(pos.line as usize) { + ensure!( + pos.column as usize <= line.len() + && line.is_char_boundary(pos.column as usize), + "region column out of bounds" + ); + } else { + ensure!( + pos.line as usize == lines.len() && pos.column == 0, + "region position out of bounds" + ); + } + } + let collapsed = region.visibility.collapsed; + if !hidden && collapsed && end > first { + items.push(Item::Fold { + id: region.fold_state_id, + first, + last: end - 1, + }); + } + match ®ion.node { + Node::Fold { children, .. } => visit(children, lines, items, hidden || collapsed)?, + Node::Leaf { + alignment_id, + changed, + .. + } if !hidden && !collapsed => { + for line in first..end { + let text = lines[line as usize]; + items.push(Item::Row { + alignment: *alignment_id, + offset: line - first, + line, + text: text.strip_suffix('\r').unwrap_or(text), + changed: changed.iter().any(|span| span.line == line), + }); + } + } + _ => {} + } + } + Ok(()) + } + let mut items = Vec::new(); + visit( + std::slice::from_ref(&source.root), + &lines, + &mut items, + false, + )?; + Ok(items) +} + +/// Pair the two sides' rows by alignment and fold IDs. +fn pair<'a, 's>( + left: &'a [Item<'s>], + right: &'a [Item<'s>], +) -> Vec<(Option<&'a Item<'s>>, Option<&'a Item<'s>>)> { + let mut rows = Vec::new(); + let mut positions: BTreeMap<_, Vec<_>> = BTreeMap::new(); + for (index, item) in right.iter().enumerate() { + positions.entry(item.key()).or_default().push(index); + } + let mut cursor = 0; + for l in left { + let matching = positions + .get(&l.key()) + .and_then(|indices| indices.get(indices.partition_point(|index| *index < cursor))); + if let Some(&matched) = matching { + rows.extend(right[cursor..matched].iter().map(|r| (None, Some(r)))); + rows.push((Some(l), Some(&right[matched]))); + cursor = matched + 1; + } else { + rows.push((Some(l), None)); + } + } + rows.extend(right[cursor..].iter().map(|r| (None, Some(r)))); + rows +} + +fn fold_row(left: Option<&Item<'_>>, right: Option<&Item<'_>>) -> String { + let mut ranges = Vec::new(); + let mut identity = 0; + for (item, side) in [(left, "base"), (right, "head")] { + if let Some(Item::Fold { id, first, last }) = item { + identity = *id; + ranges.push(format!("{side} {}–{}", first + 1, last + 1)); + } + } + format!( + " … {} collapsed [fold_state_id={identity}] …", + ranges.join(" / ") + ) +} + +pub(crate) fn render(result: &SearchResult) -> anyhow::Result { + let sources = result.sources()?; + let lhs = if matches!(result.display, Display::Rhs) { + None + } else { + sources.lhs() + }; + let rhs = if matches!(result.display, Display::Lhs) { + None + } else { + sources.rhs() + }; + ensure!( + lhs.is_some() || rhs.is_some(), + "display requests an absent side" + ); + let name = match &result.file { + Pairing::Both { lhs, rhs } if lhs.path != rhs.path => { + format!("{} → {}", lhs.path, rhs.path) + } + Pairing::Both { rhs: file, .. } + | Pairing::LeftOnly { lhs: file } + | Pairing::RightOnly { rhs: file } => file.path.clone(), + }; + let combined = lhs.is_some() && rhs.is_some(); + let label = if combined { + "base → head" + } else if lhs.is_some() { + "base" + } else { + "head" + }; + let columns = if combined { + " base head" + } else if lhs.is_some() { + " base" + } else { + " head" + }; + let mut output = vec![format!("{name} — {label}"), columns.to_owned()]; + // Validate every supplied source, even if display selects only one side. + let left = sources.lhs().map(visible).transpose()?.unwrap_or_default(); + let right = sources.rhs().map(visible).transpose()?.unwrap_or_default(); + let mut folded = false; + if combined { + for (l, r) in pair(&left, &right) { + if l.is_some_and(Item::is_fold) || r.is_some_and(Item::is_fold) { + output.push(fold_row(l, r)); + folded = true; + continue; + } + match (l, r) { + ( + Some(Item::Row { + line: ll, + text: lt, + changed: false, + .. + }), + Some(Item::Row { + line: rl, + text: rt, + changed: false, + .. + }), + ) if lt == rt => { + output.push( + format!("{:>5} {:>5} {rt}", ll + 1, rl + 1) + .trim_end() + .to_owned(), + ); + } + _ => { + if let Some(Item::Row { line, text, .. }) = l { + output.push( + format!("{:>5} - {text}", line + 1) + .trim_end() + .to_owned(), + ); + } + if let Some(Item::Row { line, text, .. }) = r { + output.push( + format!(" {:>5} + {text}", line + 1) + .trim_end() + .to_owned(), + ); + } + } + } + } + } else { + let items = if lhs.is_some() { &left } else { &right }; + for item in items { + match item { + Item::Fold { .. } => { + output.push(fold_row(lhs.map(|_| item), rhs.map(|_| item))); + folded = true; + } + Item::Row { + line, + text, + changed, + .. + } => { + let marker = if !changed { + " " + } else if lhs.is_some() { + "-" + } else { + "+" + }; + output.push( + format!("{:>5} {marker} {text}", line + 1) + .trim_end() + .to_owned(), + ); + } + } + } + } + if folded { + output.push(String::new()); + output.push("[More context: set visibility.collapsed=false for the indicated fold_state_id\nin the saved JSON, then run diffr pprint again. Full text and children are present.]".to_owned()); + } + Ok(output.join("\n")) +} diff --git a/tests/fixtures/search/base/removed.js b/tests/fixtures/search/base/removed.js new file mode 100644 index 000000000..0e18c7c6e --- /dev/null +++ b/tests/fixtures/search/base/removed.js @@ -0,0 +1,18 @@ +export function legacyRetry(request) { + const attempts = 5; + const history = []; + let lastError; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const response = request(); + history.push(response.status); + if (response.ok) { + return response; + } + lastError = new Error("search_token: removed failure path"); + } catch (error) { + lastError = error; + } + } + throw lastError; +} diff --git a/tests/fixtures/search/base/retry.js b/tests/fixtures/search/base/retry.js new file mode 100644 index 000000000..9a2aad2d5 --- /dev/null +++ b/tests/fixtures/search/base/retry.js @@ -0,0 +1,23 @@ +export function retry(request, options) { + const attempts = 3; // search_token: changed configuration + const history = []; // search_token: unchanged line inside changed function + let lastError; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const response = request(); + history.push(response.status); + if (response.ok) { + return response; + } + lastError = new Error(response.statusText); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +export function describeRetry() { + const label = "search_token: unchanged explanation"; + return label; +} diff --git a/tests/fixtures/search/base/unchanged.js b/tests/fixtures/search/base/unchanged.js new file mode 100644 index 000000000..2d56ca948 --- /dev/null +++ b/tests/fixtures/search/base/unchanged.js @@ -0,0 +1,7 @@ +export function describeCache(cache) { + if (cache.enabled) { + const label = "search_token: wholly unchanged file"; + return `${label}: ${cache.size}`; + } + return "cache disabled"; +} diff --git a/tests/fixtures/search/head/added.js b/tests/fixtures/search/head/added.js new file mode 100644 index 000000000..0f9d90813 --- /dev/null +++ b/tests/fixtures/search/head/added.js @@ -0,0 +1,3 @@ +export function retryLabel() { + return "search_token: newly added file"; +} diff --git a/tests/fixtures/search/head/retry.js b/tests/fixtures/search/head/retry.js new file mode 100644 index 000000000..8e89693cf --- /dev/null +++ b/tests/fixtures/search/head/retry.js @@ -0,0 +1,23 @@ +export function retry(request, options) { + const attempts = options.attempts ?? 3; // search_token: changed configuration + const history = []; // search_token: unchanged line inside changed function + let lastError; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const response = request(); + history.push(response.status); + if (response.ok) { + return response; + } + lastError = new Error(response.statusText); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +export function describeRetry() { + const label = "search_token: unchanged explanation"; + return label; +} diff --git a/tests/fixtures/search/head/unchanged.js b/tests/fixtures/search/head/unchanged.js new file mode 100644 index 000000000..2d56ca948 --- /dev/null +++ b/tests/fixtures/search/head/unchanged.js @@ -0,0 +1,7 @@ +export function describeCache(cache) { + if (cache.enabled) { + const label = "search_token: wholly unchanged file"; + return `${label}: ${cache.size}`; + } + return "cache disabled"; +} diff --git a/tests/search.rs b/tests/search.rs new file mode 100644 index 000000000..e6864b406 --- /dev/null +++ b/tests/search.rs @@ -0,0 +1,433 @@ +//! End-to-end tests for `diffr search` and its steps. +mod git_fixture; +mod support; + +use gix::Repository; +use serde_json::{json, Value}; +use std::{ + fs, + io::Write, + path::PathBuf, + process::{Command, Output, Stdio}, +}; +use tempfile::TempDir; + +struct Fixture { + dir: TempDir, + repo: Repository, + base: String, + head: String, +} +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let repo = gix::init(dir.path().join("repo")).unwrap(); + git_fixture::git( + repo.workdir().unwrap(), + &["config", "core.autocrlf", "false"], + ); + fs::create_dir_all(dir.path().join("config/diffr")).unwrap(); + let mut fixture = Self { + dir, + repo, + base: String::new(), + head: String::new(), + }; + fixture.copy("base"); + fixture.base = fixture.commit(); + for entry in fs::read_dir(fixture.root()).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + fs::remove_file(path).unwrap(); + } + } + fixture.copy("head"); + fixture.head = fixture.commit(); + fixture + } + fn root(&self) -> PathBuf { + self.dir.path().join("repo") + } + fn copy(&self, side: &str) { + let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/search") + .join(side); + for entry in fs::read_dir(fixtures).unwrap() { + let entry = entry.unwrap(); + fs::copy(entry.path(), self.root().join(entry.file_name())).unwrap(); + } + } + fn commit(&self) -> String { + git_fixture::commit(&self.repo, "search fixture") + } + fn grep(&self, null: bool, path: Option<&str>) -> Vec { + let mut command = Command::new("git"); + command + .current_dir(self.root()) + .env_remove("GIT_DIR") + .args([ + "--no-pager", + "grep", + "--no-color", + "--no-heading", + "--no-break", + "--full-name", + "-n", + "-F", + ]); + if null { + command.arg("-z"); + } + command.args(["-e", "search_token", &self.base, &self.head, "--"]); + if let Some(path) = path { + command.arg(path); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout + } + fn run(&self, args: &[&str], input: &[u8]) -> Output { + let mut child = support::get_base_command() + .args(args) + .current_dir(if !matches!(args, ["search", "pprint", ..]) { + self.root() + } else { + self.dir.path().to_path_buf() + }) + .env("XDG_CONFIG_HOME", self.dir.path().join("config")) + .env_remove("GIT_DIR") + .env_remove("GEMINI_API_KEY") + .env_remove("GOOGLE_API_KEY") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Write concurrently: a rejected invocation can exit without reading stdin. + let mut stdin = child.stdin.take().unwrap(); + let bytes = input.to_vec(); + let writer = std::thread::spawn(move || { + let _ = stdin.write_all(&bytes); + }); + let output = child.wait_with_output().unwrap(); + writer.join().unwrap(); + output + } + fn search(&self, input: &[u8]) -> Value { + value(self.run(&["search", &self.base, &self.head], input)) + } + fn json(&self, args: &[&str], input: &Value) -> Output { + self.run(args, &serde_json::to_vec(input).unwrap()) + } +} +fn success(output: Output) -> Vec { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout +} +fn value(output: Output) -> Value { + serde_json::from_slice(&success(output)).unwrap() +} +fn failure(output: Output, message: &str) { + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr).contains(message), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} +fn find<'a>(results: &'a Value, side: &str, path: &str) -> &'a Value { + results + .as_array() + .unwrap() + .iter() + .find(|r| r["file"][side]["path"] == path) + .unwrap() +} +fn walk(regions: &mut Value, visit: &mut impl FnMut(&mut Value)) { + for region in regions.as_array_mut().unwrap() { + visit(region); + if region["kind"] == "fold" { + walk(&mut region["children"], visit); + } + } +} + +#[test] +fn real_git_grep_hydrates_both_commits_and_deleted_files_without_worktrees() { + let fixture = Fixture::new(); + let normal = fixture.search(&fixture.grep(false, None)); + assert_eq!(normal, fixture.search(&fixture.grep(true, None))); + let retry = find(&normal, "rhs", "retry.js"); + assert!(retry["diff"]["lhs"]["text"] + .as_str() + .unwrap() + .contains("const attempts = 3;")); + assert!(retry["diff"]["rhs"]["text"] + .as_str() + .unwrap() + .contains("options.attempts")); + assert!(find(&normal, "lhs", "removed.js")["diff"]["rhs"].is_null()); + assert!(find(&normal, "rhs", "added.js")["diff"]["lhs"].is_null()); + assert_eq!(fixture.search(b""), json!([])); +} + +#[test] +fn validates_hit_origin_coordinates_and_full_line_text() { + let fixture = Fixture::new(); + for (hit, message) in [ + ("HEAD:retry.js:1:stale\n", "hit text differs"), + ("HEAD:retry.js:0:\n", "1-based"), + ("HEAD:retry.js:999:\n", "out of bounds"), + ("HEAD:../retry.js:1:text\n", "repository-relative"), + ("HEAD:/retry.js:1:text\n", "repository-relative"), + ("HEAD:missing.js:1:text\n", "file in that commit"), + ("retry.js:1:text\n", "revision:path:line:text"), + ] { + failure( + fixture.run(&["search", &fixture.base, &fixture.head], hit.as_bytes()), + message, + ); + } + fs::write(fixture.root().join("third.js"), "third commit").unwrap(); + fixture.commit(); + failure( + fixture.run( + &["search", &fixture.base, &fixture.head], + b"HEAD:retry.js:1:text\n", + ), + "outside scoped commits", + ); + failure( + fixture.run(&["search", "not-a-revision", &fixture.head], b""), + "revision", + ); +} + +#[test] +fn plugin_overrides_replace_the_search_plugins() { + let fixture = Fixture::new(); + let options = fixture.root().join("options.json"); + fs::write(&options, r#"{"plugins":{"order":[]}}"#).unwrap(); + let processed = value(fixture.run( + &[ + "search", + &fixture.base, + &fixture.head, + "--options", + options.to_str().unwrap(), + ], + &fixture.grep(true, Some("retry.js")), + )); + let preview = + String::from_utf8(success(fixture.json(&["search", "pprint"], &processed))).unwrap(); + assert!(preview.contains("const response = request();")); + assert!(!preview.contains("collapsed")); +} + +#[test] +fn pprint_rejects_results_whose_file_and_sides_disagree() { + let fixture = Fixture::new(); + let mut incomplete = fixture.search(&fixture.grep(true, Some("retry.js"))); + incomplete[0]["file"].as_object_mut().unwrap().remove("lhs"); + failure( + fixture.json(&["search", "pprint"], &incomplete), + "file and source sides", + ); +} + +#[test] +fn pprint_expands_saved_folds_without_repository_or_configuration_access() { + let fixture = Fixture::new(); + let mut selected = fixture.search(&fixture.grep(true, Some("retry.js"))); + let before = + String::from_utf8(success(fixture.json(&["search", "pprint"], &selected))).unwrap(); + assert!(!before.contains("const response = request();")); + for side in ["lhs", "rhs"] { + walk( + &mut selected[0]["diff"][side]["root"]["children"], + &mut |r| { + r["visibility"]["collapsed"] = json!(false); + }, + ); + } + selected[0]["scope"]["repo"] = json!(fixture.dir.path().join("no-repository")); + fs::write( + fixture.dir.path().join("config/diffr/config.toml"), + "invalid [ config", + ) + .unwrap(); + let after = String::from_utf8(success(fixture.json(&["search", "pprint"], &selected))).unwrap(); + assert!(after.contains("const response = request();")); + assert!(!after.contains("collapsed")); + assert!(after.contains("const attempts = 3;")); + assert!(after.contains("options.attempts")); +} + +#[test] +fn rename_correspondence_pairs_old_and_new_paths() { + let mut fixture = Fixture::new(); + fs::rename( + fixture.root().join("retry.js"), + fixture.root().join("renamed.js"), + ) + .unwrap(); + fixture.head = fixture.commit(); + let results = fixture.search(&fixture.grep(true, None)); + let result = find(&results, "rhs", "renamed.js"); + assert_eq!(result["file"]["lhs"]["path"], "retry.js"); + let preview = String::from_utf8(success( + fixture.json(&["search", "pprint"], &json!([result])), + )) + .unwrap(); + assert!(preview.contains("retry.js → renamed.js")); +} + +#[test] +fn aliases_crlf_and_utf8_preserve_blob_text_and_byte_highlights() { + let mut fixture = Fixture::new(); + let text = "const café = 'search_token: 🦀';\r\n"; + fs::write(fixture.root().join("unicode.js"), text).unwrap(); + fixture.head = fixture.commit(); + let hit = format!("HEAD:unicode.js:1:{text}"); + let selected = value(fixture.run(&["search", "HEAD~2", "HEAD"], hit.as_bytes())); + assert_eq!(selected[0]["scope"]["base"], fixture.base); + assert_eq!(selected[0]["scope"]["head"], fixture.head); + assert_eq!(selected[0]["diff"]["rhs"]["text"], text); + let mut source = selected[0]["diff"]["rhs"].clone(); + let mut spans = Vec::new(); + walk(&mut source["root"]["children"], &mut |r| { + if let Some(highlights) = r["search_highlights"].as_array() { + spans.extend(highlights.clone()); + } + }); + assert_eq!( + spans, + vec![ + json!({"line":0,"start_column":0,"end_column":text.trim_end_matches(['\r','\n']).len()}) + ] + ); +} + +#[cfg(unix)] +#[test] +fn nul_framed_git_grep_handles_ambiguous_filenames() { + let mut fixture = Fixture::new(); + let name = "odd:12:name\n.js"; + fs::write(fixture.root().join(name), "const token = 'search_token';\n").unwrap(); + fixture.head = fixture.commit(); + let selected = fixture.search(&fixture.grep(true, Some(name))); + assert_eq!(selected[0]["file"]["rhs"]["path"], name); + assert_eq!( + selected[0]["diff"]["rhs"]["text"], + "const token = 'search_token';\n" + ); +} + +#[test] +fn shared_unchanged_content_keeps_search_context_and_collapses_distant_lines() { + let mut fixture = Fixture::new(); + let before: String = (0..20) + .map(|i| format!(" const before{i} = {i};\n")) + .collect(); + let after: String = (0..20) + .map(|i| format!(" const after{i} = {i};\n")) + .collect(); + fs::write( + fixture.root().join("long.js"), + format!("function unchanged() {{\n{before} const search_token = 42;\n{after}}}\n"), + ) + .unwrap(); + fixture.base = fixture.commit(); + fixture.head = fixture.commit(); + let processed = fixture.search(&fixture.grep(true, Some("long.js"))); + let rendered = + String::from_utf8(success(fixture.json(&["search", "pprint"], &processed))).unwrap(); + assert!(rendered.contains("function unchanged()")); + assert!(rendered.contains("const search_token = 42;")); + assert!(!rendered.contains("const before5 = 5;")); + assert!(!rendered.contains("const after15 = 15;")); +} + +#[test] +fn search_defaults_use_highlights_and_keep_the_opposite_edit_and_complete_header() { + let mut fixture = Fixture::new(); + let header = "fn visitor(\n value: u32,\n) {\n"; + let body: String = (0..20).map(|i| format!(" let a{i} = {i};\n")).collect(); + let path = fixture.root().join("visitor.rs"); + fs::write( + &path, + format!("const UNRELATED: u32 = 1;\n{header}{body} old_reference();\n}}\n"), + ) + .unwrap(); + fixture.base = fixture.commit(); + fs::write( + &path, + format!("const UNRELATED: u32 = 2;\n{header}{body} search_token();\n}}\n"), + ) + .unwrap(); + fixture.head = fixture.commit(); + fs::write( + fixture.dir.path().join("config/diffr/config.toml"), + "[plugins.shape.bundled.context]\nlines = 1\n", + ) + .unwrap(); + let processed = fixture.search(&fixture.grep(true, Some("visitor.rs"))); + let text = String::from_utf8(success(fixture.json(&["search", "pprint"], &processed))).unwrap(); + for line in header.lines() { + assert!(text.contains(line), "missing {line}:\n{text}"); + } + assert!( + text.contains("old_reference();"), + "opposite edit missing:\n{text}" + ); + assert!(text.contains("search_token();")); + assert!(text.contains("let a19 = 19;")); + assert!( + !text.contains("let a18 = 18;"), + "configured width must be one:\n{text}" + ); + assert!( + !text.contains("const UNRELATED"), + "unrelated edit must fold:\n{text}" + ); +} + +#[test] +fn documented_rust_results_keep_headers_and_native_relations_through_json_and_cache() { + let mut fixture = Fixture::new(); + let header = "/// Visit each region.\n/// Keep its context.\nfn visit(\n regions: &[Region],\n rhs: &OtherSide,\n threshold: usize,\n under_collapsed: bool,\n gates: Gates,\n leaves: &mut Vec<(u32, u32)>,\n) {\n for region in regions {\n"; + let body: String = (0..20) + .map(|i| format!(" let a{i} = {i};\n")) + .collect(); + let path = fixture.root().join("visitor.rs"); + fs::write( + &path, + format!("{header}{body} search_token(false);\n }}\n}}\n"), + ) + .unwrap(); + fixture.base = fixture.commit(); + fs::write( + &path, + format!("{header}{body} search_token(true);\n }}\n}}\n"), + ) + .unwrap(); + fixture.head = fixture.commit(); + let hits = fixture.grep(true, Some("visitor.rs")); + // The first search computes the diff; the second reads the cache. + let processed = fixture.search(&hits); + assert_eq!(processed, fixture.search(&hits)); + let text = String::from_utf8(success(fixture.json(&["search", "pprint"], &processed))).unwrap(); + for line in header.lines() { + assert!(text.contains(line), "missing {line}:\n{text}"); + } + assert!(!text.contains("let a5 = 5;")); +}