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/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/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, 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/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/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..a34bbb5db --- /dev/null +++ b/src/search.rs @@ -0,0 +1,471 @@ +//! 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 command; +pub(crate) mod input; +pub(crate) mod pprint; +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); +} 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/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()); + } +} 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;")); +}