diff --git a/crates/diffr-plugin-sdk/src/apply.rs b/crates/diffr-plugin-sdk/src/apply.rs index bfbd0f1df..81a963257 100644 --- a/crates/diffr-plugin-sdk/src/apply.rs +++ b/crates/diffr-plugin-sdk/src/apply.rs @@ -12,7 +12,7 @@ //! side keeps its leaf's ids. The second piece takes a fresh `id` on each //! side and a fresh `alignment_id` shared by the two, and its //! `fold_state_id` is the `id` of the lhs piece, or of its only piece. -//! Pieces keep the leaf's tags and visibility and the `changed` spans on +//! Pieces keep the leaf's tags and visibility and both change/search spans on //! their lines. A fold, or the file, cannot be cut. //! - `JoinFolds { regions }` needs two or more region ids. Each side wraps the //! ones it holds, which must be two or more consecutive siblings under one @@ -24,10 +24,12 @@ //! so is an id no side holds. //! - `LinkFoldState { regions }` needs two or more region ids. Every region //! in any of their fold states, on either side, takes the first region's -//! `fold_state_id` and whether it starts collapsed. +//! `fold_state_id` and whether it starts collapsed. A linked state containing +//! search highlights stays open. //! - `SetCollapsed { region, collapsed }` sets whether every region sharing //! the region's `fold_state_id` starts collapsed, on both sides: they open //! and close together. On [`ROOT`] it sets whether the file starts hidden. +//! Collapse requests that would conceal search highlights leave it open. //! - `SetLabel { region, label }` sets the label of that region alone, or of //! the file on [`ROOT`]; `None` clears it. //! - `SetTags { region, tags }` replaces that region's tags. The file's tags @@ -38,7 +40,9 @@ //! [`ROOT`]) when a plugin's moves begin, and fresh `alignment_id`s above the //! largest leaf `alignment_id`; each is handed out in the order the moves //! need them, lhs before rhs. -use crate::tree::{walk, walk_mut, Node, Pairing, Region, Source}; +use crate::tree::{ + has_search_highlights, highlights_in_states, walk, walk_mut, Node, Pairing, Region, Source, +}; use crate::types::{Cut, Move, Position, Range, Visibility, ROOT}; use anyhow::{bail, ensure}; use std::collections::BTreeSet; @@ -68,11 +72,16 @@ impl Applier { Move::JoinFolds(regions) => join(sides, ®ions, &mut self.fresh), Move::LinkFoldState(regions) => link(sides, ®ions), Move::SetCollapsed((ROOT, collapsed)) => { - file.collapsed = collapsed; + file.collapsed = collapsed + && !sides + .sides() + .iter() + .any(|source| source.regions.iter().any(has_search_highlights)); Ok(()) } Move::SetCollapsed((region, collapsed)) => { let state = region_of(sides, region)?.fold_state_id; + let collapsed = collapsed && !highlights_in_states(sides, &[region]); for tree in trees(sides) { walk_mut(tree, &mut |region| { if region.fold_state_id == state { @@ -116,6 +125,7 @@ pub fn apply( fn trees(sides: &mut Pairing) -> Vec<&mut Vec> { match sides { + Pairing::Same { source } => vec![&mut source.regions], Pairing::Both { lhs, rhs } => vec![&mut lhs.regions, &mut rhs.regions], Pairing::LeftOnly { lhs } => vec![&mut lhs.regions], Pairing::RightOnly { rhs } => vec![&mut rhs.regions], @@ -124,6 +134,7 @@ fn trees(sides: &mut Pairing) -> Vec<&mut Vec> { pub fn trees_ref(sides: &Pairing) -> Vec<&[Region]> { match sides { + Pairing::Same { source } => vec![&source.regions], Pairing::Both { lhs, rhs } => vec![&lhs.regions, &rhs.regions], Pairing::LeftOnly { lhs } => vec![&lhs.regions], Pairing::RightOnly { rhs } => vec![&rhs.regions], @@ -306,7 +317,12 @@ fn cut(sides: &mut Pairing, id: u32, offset: u32, fresh: &mut Fresh) -> /// A leaf split at relative line `offset`. The second piece takes `id`, /// `alignment_id` and `fold_state_id`. fn split(leaf: Region, offset: u32, id: u32, alignment_id: u32, fold_state_id: u32) -> [Region; 2] { - let Node::Leaf { changed, .. } = &leaf.node else { + let Node::Leaf { + changed, + search_highlights, + .. + } = &leaf.node + else { unreachable!("only leaves are cut"); }; let boundary = Position { @@ -328,6 +344,11 @@ fn split(leaf: Region, offset: u32, id: u32, alignment_id: u32, fold_state_id: u .copied() .filter(|span| lines.contains(&span.line)) .collect(), + search_highlights: search_highlights + .iter() + .copied() + .filter(|span| lines.contains(&span.line)) + .collect(), }, } }; @@ -366,7 +387,10 @@ fn check_regions(ids: &[u32], what: &str) -> anyhow::Result<()> { fn link(sides: &mut Pairing, ids: &[u32]) -> anyhow::Result<()> { check_regions(ids, "a link")?; let first = region_of(sides, ids[0])?; - let (state, collapsed) = (first.fold_state_id, first.visibility.collapsed); + let (state, collapsed) = ( + first.fold_state_id, + first.visibility.collapsed && !highlights_in_states(sides, ids), + ); let states = ids .iter() .map(|id| Ok(region_of(sides, *id)?.fold_state_id)) @@ -462,6 +486,7 @@ mod tests { visibility: Visibility::default(), node: Node::Leaf { alignment_id: alignment, + search_highlights: Vec::new(), changed: changed .iter() .map(|&line| Span { @@ -807,4 +832,78 @@ mod tests { assert_eq!(lhs.regions[0].visibility, Visibility::default()); assert_eq!(rhs.regions[0].tags, ["mine:tag"]); } + #[test] + fn cuts_preserve_each_sides_search_spans_through_record_roundtrips() { + let mut left = leaf(1, 7, 10, 16, &[11]); + let right = in_state(leaf(2, 7, 20, 26, &[]), 1); + if let Node::Leaf { + search_highlights, .. + } = &mut left.node + { + *search_highlights = vec![ + Span { + line: 11, + start_column: 2, + end_column: 7, + }, + Span { + line: 13, + start_column: 0, + end_column: 5, + }, + ]; + } + let mut sides = both(vec![left], vec![right]); + run(vec![Move::Cut(Cut { region: 1, at: 3 })], &mut sides).unwrap(); + let (lhs, rhs) = sides_of(&sides); + let spans = |region: &Region| match ®ion.node { + Node::Leaf { + search_highlights, .. + } => search_highlights.clone(), + _ => panic!("expected leaf"), + }; + assert_eq!(spans(&lhs.regions[0])[0].line, 11); + assert_eq!(spans(&lhs.regions[1])[0].line, 13); + assert!(rhs.regions.iter().all(|r| spans(r).is_empty())); + assert_eq!(Source::from_record(&lhs.to_record()).unwrap(), *lhs); + assert_eq!(Source::from_record(&rhs.to_record()).unwrap(), *rhs); + } + + #[test] + fn later_moves_cannot_hide_highlights_through_files_links_or_ancestors() { + let mut matched = leaf(1, 0, 0, 2, &[]); + if let Node::Leaf { + search_highlights, .. + } = &mut matched.node + { + search_highlights.push(Span { + line: 1, + start_column: 0, + end_column: 3, + }); + } + let mut sides = both( + vec![fold(3, false, vec![matched]), leaf(4, 2, 2, 4, &[2])], + vec![], + ); + let visibility = run( + vec![ + Move::SetCollapsed((4, true)), + Move::LinkFoldState(vec![4, 3]), + Move::SetCollapsed((4, true)), + Move::SetCollapsed((ROOT, true)), + Move::JoinFolds(vec![3, 4]), + Move::SetCollapsed((5, true)), + Move::SetLabel((5, Some("summary".into()))), + Move::SetTags((5, vec!["test:tag".into()])), + ], + &mut sides, + ) + .unwrap(); + assert!(!visibility.collapsed); + walk(&sides.lhs().unwrap().regions, &mut |region| { + assert!(!region.visibility.collapsed) + }); + assert!(has_search_highlights(&sides.lhs().unwrap().regions[0])); + } } diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs index 66fab82f7..d7d68cfd5 100644 --- a/crates/diffr-plugin-sdk/src/lib.rs +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -38,8 +38,9 @@ pub use anyhow; pub use draft::Draft; use serde::de::DeserializeOwned; pub use tree::{ - before_and_after_ids, docstring_of, has_tag, is_fold, line_count, one_sided, path_to, - siblings_of, sides_with_other_ids, walk, walk_mut, Node, OtherSide, Pairing, Region, Source, + before_and_after_ids, docstring_of, has_search_highlights, has_tag, highlights_in_states, + is_fold, line_count, one_sided, path_to, siblings_of, sides_with_other_ids, walk, walk_mut, + Node, OtherSide, Pairing, Region, Source, }; pub use types::{ FileEntry, FileRef, FileSides, FileStatus, Move, Position, QuerySource, Range, Side, Span, diff --git a/crates/diffr-plugin-sdk/src/tree.rs b/crates/diffr-plugin-sdk/src/tree.rs index 9cdabed7e..b22a46150 100644 --- a/crates/diffr-plugin-sdk/src/tree.rs +++ b/crates/diffr-plugin-sdk/src/tree.rs @@ -6,6 +6,8 @@ use std::collections::BTreeSet; /// Which sides a thing exists on. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Pairing { + /// One unchanged source, with highlights on its regions. Never an addition or deletion. + Same { source: T }, Both { lhs: T, rhs: T }, LeftOnly { lhs: T }, RightOnly { rhs: T }, @@ -19,6 +21,7 @@ impl Pairing { lhs: f(lhs), rhs: f(rhs), }, + Self::Same { source } => Pairing::Same { source: f(source) }, Self::LeftOnly { lhs } => Pairing::LeftOnly { lhs: f(lhs) }, Self::RightOnly { rhs } => Pairing::RightOnly { rhs: f(rhs) }, } @@ -26,6 +29,7 @@ impl Pairing { pub fn lhs(&self) -> Option<&T> { match self { + Self::Same { source } => Some(source), Self::Both { lhs, .. } | Self::LeftOnly { lhs } => Some(lhs), Self::RightOnly { .. } => None, } @@ -33,6 +37,7 @@ impl Pairing { pub fn rhs(&self) -> Option<&T> { match self { + Self::Same { source } => Some(source), Self::Both { rhs, .. } | Self::RightOnly { rhs } => Some(rhs), Self::LeftOnly { .. } => None, } @@ -41,6 +46,7 @@ impl Pairing { /// Every side that exists, lhs first. pub fn sides(&self) -> Vec<&T> { match self { + Self::Same { source } => vec![source], Self::Both { lhs, rhs } => vec![lhs, rhs], Self::LeftOnly { lhs } => vec![lhs], Self::RightOnly { rhs } => vec![rhs], @@ -50,6 +56,7 @@ impl Pairing { /// Every side that exists, lhs first. pub fn sides_mut(&mut self) -> Vec<&mut T> { match self { + Self::Same { source } => vec![source], Self::Both { lhs, rhs } => vec![lhs, rhs], Self::LeftOnly { lhs } => vec![lhs], Self::RightOnly { rhs } => vec![rhs], @@ -73,6 +80,7 @@ pub enum Node { Leaf { alignment_id: u32, changed: Vec, + search_highlights: Vec, }, Fold { children: Vec, @@ -89,6 +97,36 @@ impl Region { } } +/// Whether collapsing this region would conceal a search match. +pub fn has_search_highlights(region: &Region) -> bool { + match ®ion.node { + Node::Leaf { + search_highlights, .. + } => !search_highlights.is_empty(), + Node::Fold { children } => children.iter().any(has_search_highlights), + } +} + +/// Whether any of these regions' linked collapse states contains a match. +/// Include a body's docstring when deciding whether it can be summarized. +pub fn highlights_in_states(sides: &Pairing, ids: &[u32]) -> bool { + let mut states = BTreeSet::new(); + for source in sides.sides() { + walk(&source.regions, &mut |region| { + if ids.contains(®ion.id) { + states.insert(region.fold_state_id); + } + }); + } + sides.sides().iter().any(|source| { + let mut found = false; + walk(&source.regions, &mut |region| { + found |= states.contains(®ion.fold_state_id) && has_search_highlights(region); + }); + found + }) +} + /// One side of the diffed file as a tree. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Source { @@ -110,6 +148,7 @@ impl Source { types::Kind::Leaf(leaf) => Node::Leaf { alignment_id: leaf.alignment_id, changed: leaf.changed.clone(), + search_highlights: leaf.search_highlights.clone(), }, types::Kind::Fold => Node::Fold { children: children(regions, region.id), @@ -149,9 +188,11 @@ impl Source { Node::Leaf { alignment_id, changed, + search_highlights, } => types::Kind::Leaf(types::Leaf { alignment_id: *alignment_id, changed: changed.clone(), + search_highlights: search_highlights.clone(), }), Node::Fold { .. } => types::Kind::Fold, }; @@ -182,6 +223,7 @@ impl Source { /// on the way in, so a plugin is handed the pairing rather than the record. pub fn sides(sides: &types::SourceSides) -> anyhow::Result> { Ok(match sides { + types::SourceSides::Same(source) => Pairing::Same { source: Source::from_record(source)? }, types::SourceSides::Both((lhs, rhs)) => Pairing::Both { lhs: Source::from_record(lhs)?, rhs: Source::from_record(rhs)?, @@ -376,7 +418,7 @@ pub fn before_and_after_ids(sides: &Pairing) -> Option<(&Source, OtherSi match sides { Pairing::Both { lhs, rhs } => Some((lhs, OtherSide::of(&rhs.regions))), Pairing::LeftOnly { lhs } => Some((lhs, OtherSide::default())), - Pairing::RightOnly { .. } => None, + Pairing::Same { .. } | Pairing::RightOnly { .. } => None, } } @@ -387,6 +429,7 @@ pub fn sides_with_other_ids(sides: &Pairing) -> Vec<(&Source, OtherSide) (lhs, OtherSide::of(&rhs.regions)), (rhs, OtherSide::of(&lhs.regions)), ], + Pairing::Same { source } => vec![(source, OtherSide::of(&source.regions))], Pairing::LeftOnly { lhs } => vec![(lhs, OtherSide::default())], Pairing::RightOnly { rhs } => vec![(rhs, OtherSide::default())], } @@ -415,6 +458,7 @@ mod tests { visibility: Visibility::default(), node: Node::Leaf { alignment_id: alignment, + search_highlights: Vec::new(), changed: vec![], }, }; @@ -428,6 +472,7 @@ mod tests { fold_state_id: id + 100, node: Node::Leaf { alignment_id: alignment + 100, + search_highlights: Vec::new(), changed: vec![], }, ..leaf.clone() diff --git a/examples/plugins/fixtures/src/lib.rs b/examples/plugins/fixtures/src/lib.rs index 1455ca79f..a25efd77e 100644 --- a/examples/plugins/fixtures/src/lib.rs +++ b/examples/plugins/fixtures/src/lib.rs @@ -88,6 +88,7 @@ impl Plugin for Fixtures { // piece on this leaf's side takes the first fresh id either way. let side = match &sides { Pairing::Both { lhs, .. } | Pairing::LeftOnly { lhs } => lhs, + Pairing::Same { source } => source, Pairing::RightOnly { rhs } => rhs, }; let leaf = side diff --git a/plugins/context/queries/javascript.scm b/plugins/context/queries/javascript.scm index 0037606c3..6357f7343 100644 --- a/plugins/context/queries/javascript.scm +++ b/plugins/context/queries/javascript.scm @@ -22,3 +22,11 @@ (#set! tag "context:scope")) ((return_statement) @fold (#set! tag "context:scope")) + +; Preserve both the try body boundary (`} catch`) and the end of the whole +; try/catch construct when its opening line is visible in context. +((try_statement) @fold + (#set! tag "context:scope")) +(try_statement + body: (statement_block "{" @fold.open "}" @fold.close) @fold + (#set! tag "context:body")) diff --git a/plugins/context/src/lib.rs b/plugins/context/src/lib.rs index d3df92baa..57dc00788 100644 --- a/plugins/context/src/lib.rs +++ b/plugins/context/src/lib.rs @@ -2,9 +2,10 @@ //! //! A line stays visible when it is within `lines` of a changed line on its //! side, when it is paired with such a line, or when it opens or closes a -//! scope that holds a change on its side. Scopes are the constructs this -//! plugin's queries tag `context:scope` (see `scope_rows`), each covering -//! its signature line through the line that closes it; a file the +//! scope that holds a change on its side or has a visible boundary. Scopes +//! are the constructs this plugin's queries tag `context:scope` (see +//! `scope_rows`), each covering its signature through its closing line. +//! A `context:body` fold instead excludes both delimiter lines. A file the //! diff did not parse has none. Every other stretch of unchanged //! paired lines that is at least `MIN_GAP` lines long collapses, labelled //! with its line count; a file with no change collapses whole, however @@ -33,6 +34,9 @@ const MIN_GAP: u32 = 3; /// A fold the queries mark as a scope: a function, a type, a block. const SCOPE: &str = "context:scope"; +/// A body fold excludes its delimiter lines, unlike a whole-construct scope. +const BODY: &str = "context:body"; + pub struct Context { options: Options, } @@ -45,25 +49,42 @@ pub struct Options { } /// The first and last line of every scope on `source` that holds a changed -/// line. +/// line or has an opening or closing line already visible in the context window. /// /// A scope region is a whole construct: its first line is the line its /// signature or header starts on and its last is the line that closes it, /// since the construct's own node is what the queries tag. Keeping both /// always shows where a scope opens and where it ends, whatever sits inside. -fn scope_rows(source: &Source, changed: &BTreeSet) -> BTreeSet { - let mut rows = BTreeSet::new(); - walk(&source.regions, &mut |region| { - if !is_fold(region) || !has_tag(region, SCOPE) { - return; - } - let span = region.range.lines(); - if changed.range(span.clone()).next().is_none() { - return; +/// Body folds use the immediately adjacent lines for their delimiters. +fn scope_rows(source: &Source, changed: &BTreeSet, visible: &BTreeSet) -> BTreeSet { + let mut rows = visible.clone(); + loop { + let previous = rows.len(); + walk(&source.regions, &mut |region| { + if !is_fold(region) || !(has_tag(region, SCOPE) || has_tag(region, BODY)) { + return; + } + let span = region.range.lines(); + let (first, last) = if has_tag(region, BODY) { + (span.start.saturating_sub(1), span.end) + } else { + (span.start, span.end - 1) + }; + if changed.range(span.clone()).next().is_none() + && !rows.contains(&first) + && !rows.contains(&last) + { + return; + } + rows.insert(first); + rows.insert(last); + }); + // Shared boundaries connect constructs such as try and catch. Repeat + // so neither side of such a boundary leaves an unmatched delimiter. + if rows.len() == previous { + break; } - rows.insert(span.start); - rows.insert(span.end - 1); - }); + } rows } @@ -77,6 +98,7 @@ struct Leaf { paired: bool, /// Whether this side paints any byte of it as changed. spans: bool, + highlights: BTreeSet, } fn leaves(regions: &[Region], other: &BTreeSet) -> Vec { @@ -85,6 +107,7 @@ fn leaves(regions: &[Region], other: &BTreeSet) -> Vec { if let Node::Leaf { alignment_id, changed, + search_highlights, } = ®ion.node { out.push(Leaf { @@ -94,6 +117,7 @@ fn leaves(regions: &[Region], other: &BTreeSet) -> Vec { end: region.range.end.line, paired: other.contains(alignment_id), spans: !changed.is_empty(), + highlights: search_highlights.iter().map(|span| span.line).collect(), }); } }); @@ -204,6 +228,80 @@ fn segments(regions: &[Region], start: u32, end: u32, out: &mut Vec> } } +// Same processes one unchanged tree. Genuine one-sided diffs retain their +// existing behavior when there are no search highlights. +fn single_side_context(sides: &Pairing, context: u32) -> anyhow::Result> { + let source = sides.sides()[0]; + if !matches!(sides, Pairing::Same { .. }) + && !source + .regions + .iter() + .any(diffr_plugin_sdk::has_search_highlights) + { + return Ok(Vec::new()); + } + let mut anchors = BTreeSet::new(); + walk(&source.regions, &mut |region| { + if let Node::Leaf { + changed, + search_highlights, + .. + } = ®ion.node + { + if !changed.is_empty() { + anchors.extend(region.range.lines()); + } + anchors.extend(search_highlights.iter().map(|span| span.line)); + } + }); + let count = source.text.split_terminator('\n').count() as u32; + let mut shown = BTreeSet::new(); + for line in &anchors { + shown.extend( + line.saturating_sub(context)..line.saturating_add(context).saturating_add(1).min(count), + ); + } + shown.extend(scope_rows(source, &anchors, &shown)); + let mut gaps: Vec<(u32, u32)> = Vec::new(); + for line in (0..count).filter(|line| !shown.contains(line)) { + if let Some((_, end)) = gaps.last_mut().filter(|(_, end)| *end == line) { + *end += 1; + } else { + gaps.push((line, line + 1)); + } + } + let mut draft = Draft::new(sides); + for (start, end) in gaps.into_iter().rev() { + if end - start < MIN_GAP && !matches!(sides, Pairing::Same { .. }) { + continue; + } + let mut parts = Vec::new(); + segments(&source.regions, start, end, &mut parts); + for part in parts.into_iter().rev() { + let length: u32 = part.iter().map(Member::lines).sum(); + if length < MIN_GAP && length != end - start { + continue; + } + let mut members = Vec::new(); + for member in part.into_iter().rev() { + let (id, lines) = match member { + Member::Whole { id, lines, .. } => (id, lines), + Member::Part { id, start, end, .. } => { + (draft.cut_lines(id, start, end)?, end - start) + } + }; + draft.collapse(id, unchanged_label(lines))?; + members.push(id); + } + members.reverse(); + if members.len() > 1 { + draft.group(members, unchanged_label(length))?; + } + } + } + Ok(draft.into_moves()) +} + impl Plugin for Context { type Options = Options; @@ -258,8 +356,7 @@ impl Plugin for Context { fn mutate(&self, _file: &FileEntry, sides: &Pairing) -> anyhow::Result> { let options = &self.options; let Pairing::Both { lhs, rhs } = &sides else { - // A one-sided file is all changed lines. - return Ok(Vec::new()); + return single_side_context(sides, options.lines); }; let lhs_leaves = leaves(&lhs.regions, &leaf_alignments(&rhs.regions)); let rhs_leaves = leaves(&rhs.regions, &leaf_alignments(&lhs.regions)); @@ -295,6 +392,22 @@ impl Plugin for Context { unchanged.push((leaf.start, partner.start, leaf.end - leaf.start)); } } + // Search matches seed their actual rows, not the whole containing leaf. + // Keep unchanged leaves eligible for cutting around those rows. + for (side, own, other) in [(0, &lhs_leaves, &rhs_leaves), (1, &rhs_leaves, &lhs_leaves)] { + for leaf in own { + novel[side].extend(&leaf.highlights); + seeds[side].extend(&leaf.highlights); + if let Some(partner) = other.iter().find(|other| other.alignment == leaf.alignment) + { + seeds[1 - side].extend( + leaf.highlights + .iter() + .map(|line| partner.start + line - leaf.start), + ); + } + } + } let changed = !seeds[0].is_empty() || !seeds[1].is_empty(); let counts = [lhs, rhs].map(|source| source.text.split_terminator('\n').count() as u32); @@ -310,7 +423,8 @@ impl Plugin for Context { if changed { for (side, source) in [lhs, rhs].into_iter().enumerate() { // Context adds unchanged rows only; changed rows are shown anyway. - shown[side].extend(scope_rows(source, &novel[side])); + let boundaries = scope_rows(source, &novel[side], &shown[side]); + shown[side].extend(boundaries); } } diff --git a/plugins/group/src/lib.rs b/plugins/group/src/lib.rs index 543e9c88c..3ff280d1e 100644 --- a/plugins/group/src/lib.rs +++ b/plugins/group/src/lib.rs @@ -22,8 +22,8 @@ //! run whose regions are paired across sides some other way stays as it //! is. use diffr_plugin_sdk::{ - anyhow, export, is_fold, line_count, sides_with_other_ids, Draft, FileEntry, Move, Node, - Pairing, Plugin, Region, Source, + anyhow, export, has_search_highlights, is_fold, line_count, sides_with_other_ids, Draft, + FileEntry, Move, Node, Pairing, Plugin, Region, Source, }; use serde::Deserialize; use std::collections::BTreeSet; @@ -57,7 +57,11 @@ impl Plugin for Group { } fn mutate(&self, _file: &FileEntry, sides: &Pairing) -> anyhow::Result> { - let per_side: Vec<_> = sides_with_other_ids(sides) + let sources = match sides { + Pairing::Same { source } => vec![(source, diffr_plugin_sdk::OtherSide::default())], + _ => sides_with_other_ids(sides), + }; + let per_side: Vec<_> = sources .into_iter() .map(|(source, other_ids)| (runs(&source.regions), other_ids)) .collect(); @@ -176,6 +180,11 @@ fn collapsed_runs<'a>(regions: &'a [Region], out: &mut Vec>) { let mut separator: Vec<&Region> = Vec::new(); let mut gap = 0; for region in regions { + if has_search_highlights(region) { + push_run(&mut run, &mut own); + separator.clear(); + continue; + } match shape(region) { // A run starts with a collapsed row: a group never hides open // lines above its first one. diff --git a/plugins/removed-runs/src/lib.rs b/plugins/removed-runs/src/lib.rs index de952648e..cd3b2d893 100644 --- a/plugins/removed-runs/src/lib.rs +++ b/plugins/removed-runs/src/lib.rs @@ -1,7 +1,7 @@ //! Collapse the middle of long removed stretches. use diffr_plugin_sdk::{ - anyhow, before_and_after_ids, export, has_tag, line_count, one_sided, Draft, FileEntry, Move, - Node, OtherSide, Pairing, Plugin, Region, Source, + anyhow, before_and_after_ids, export, has_search_highlights, has_tag, line_count, one_sided, + Draft, FileEntry, Move, Node, OtherSide, Pairing, Plugin, Region, Source, }; use serde::Deserialize; @@ -149,7 +149,12 @@ fn visit( } Node::Leaf { .. } => { let len = line_count(region); - if !collapsed && gates.open() && !rhs.pairs(region) && len >= threshold { + if !collapsed + && !has_search_highlights(region) + && gates.open() + && !rhs.pairs(region) + && len >= threshold + { leaves.push((region.id, len as u32)); } } diff --git a/plugins/summarize/plugin.toml b/plugins/summarize/plugin.toml index 50a073898..fd5e7a551 100644 --- a/plugins/summarize/plugin.toml +++ b/plugins/summarize/plugin.toml @@ -76,7 +76,7 @@ verbatim from that doc; otherwise leave it empty. Answer with a JSON array of \ [options.tests] type = "boolean" title = "Summarize tests" -description = "Summarize right-side test bodies, including modified and unchanged tests in diffed files." +description = "Summarize only added, unpaired right-side test bodies." default = true [options.test_min_lines] diff --git a/plugins/summarize/plugin.wasm b/plugins/summarize/plugin.wasm index 542201db1..e486a1b1b 100644 Binary files a/plugins/summarize/plugin.wasm and b/plugins/summarize/plugin.wasm differ diff --git a/plugins/summarize/src/lib.rs b/plugins/summarize/src/lib.rs index 18aafbf89..acddcb825 100644 --- a/plugins/summarize/src/lib.rs +++ b/plugins/summarize/src/lib.rs @@ -16,7 +16,7 @@ use std::time::Duration; mod http; /// The plugin's name, and the tags its queries set: a function body, and a -/// test body, which can be summarized independently of whether it is new. +/// test body. Both must be added and unpaired in the original comparison. const PLUGIN: &str = "summarize"; const FUNCTION: &str = "summarize:function"; const TEST: &str = "summarize:test"; @@ -214,7 +214,7 @@ impl Summarize { } /// Select new right-side function bodies and, when a threshold is supplied, -/// right-side tests regardless of newness or initial collapsed state. Descend +/// new right-side tests, including bodies already collapsed by the test plugin. Descend /// through suites/modules so each test gets its own summary. Only the outermost /// eligible body is selected; docstrings are linked after selection. pub fn select( @@ -225,7 +225,7 @@ pub fn select( let (lhs, rhs) = match sides { Pairing::Both { lhs, rhs } => (OtherSide::of(&lhs.regions), rhs), Pairing::RightOnly { rhs } => (OtherSide::default(), rhs), - Pairing::LeftOnly { .. } => return Vec::new(), + Pairing::Same { .. } | Pairing::LeftOnly { .. } => return Vec::new(), }; let mut selected = Vec::new(); fn visit( @@ -238,7 +238,8 @@ pub fn select( ) { for region in regions { let eligible = if has_tag(region, TEST) { - test_min_lines.is_some_and(|minimum| line_count(region) >= minimum) + one_sided(region, lhs) + && test_min_lines.is_some_and(|minimum| line_count(region) >= minimum) } else { has_tag(region, FUNCTION) && !region.visibility.collapsed @@ -422,6 +423,11 @@ impl Plugin for Summarize { }; let folds: Vec = selected .into_iter() + .filter(|(id, _, _, docstring)| { + let mut ids = vec![*id]; + ids.extend(*docstring); + !diffr_plugin_sdk::highlights_in_states(sides, &ids) + }) .map(|(id, first_line, last_line, docstring)| Request { id, first_line, diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 93e02ed5d..7f5b399e4 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -201,15 +201,13 @@ impl Pipeline { Ok(entry.tags) } - /// Run every plugin on one file's sides, returning the file's own - /// visibility. - pub(crate) fn run( + /// Ordinary diff protocol retains its three-way source representation. + pub(crate) fn run_diff( &self, file: &FileChange, sides: &mut Pairing, ) -> anyhow::Result { - let entry = file_entry(file); - let mut trees = match &*sides { + let trees = match &*sides { Pairing::Both { lhs, rhs } => tree::Pairing::Both { lhs: to_tree(lhs), rhs: to_tree(rhs), @@ -217,23 +215,7 @@ impl Pipeline { Pairing::LeftOnly { lhs } => tree::Pairing::LeftOnly { lhs: to_tree(lhs) }, Pairing::RightOnly { rhs } => tree::Pairing::RightOnly { rhs: to_tree(rhs) }, }; - let mut visibility = types::Visibility::default(); - for plugin in &self.plugins { - let started = Instant::now(); - let records = source_sides(&trees); - let moves = plugin - .runner - .mutate(self.host(&plugin.name), &entry, &records) - .with_context(|| MutationFailed(plugin.name.to_string()))?; - log::debug!( - "plugin {}: mutate {} took {:?}", - plugin.name, - entry.path(), - started.elapsed() - ); - apply::apply(moves, &mut trees, &mut visibility) - .with_context(|| MutationFailed(plugin.name.to_string()))?; - } + let (trees, visibility) = self.mutate(file, trees)?; match (sides, trees) { ( Pairing::Both { lhs, rhs }, @@ -246,17 +228,95 @@ impl Pipeline { rhs.regions = from_tree(right.regions); } (Pairing::LeftOnly { lhs }, tree::Pairing::LeftOnly { lhs: left }) => { - lhs.regions = from_tree(left.regions); + lhs.regions = from_tree(left.regions) } (Pairing::RightOnly { rhs }, tree::Pairing::RightOnly { rhs: right }) => { + rhs.regions = from_tree(right.regions) + } + _ => unreachable!("moves never add or remove a side"), + } + Ok(visibility) + } + + /// Run every plugin on one file's sides, returning the file's own + /// visibility. + pub(crate) fn run( + &self, + file: &FileChange, + sides: &mut crate::pairing::Comparison, + ) -> anyhow::Result { + use crate::pairing::Comparison; + let trees = match &*sides { + Comparison::Same { source, .. } => tree::Pairing::Same { + source: to_tree(source), + }, + Comparison::Both { lhs, rhs, .. } => tree::Pairing::Both { + lhs: to_tree(lhs), + rhs: to_tree(rhs), + }, + Comparison::LeftOnly { source, .. } => tree::Pairing::LeftOnly { + lhs: to_tree(source), + }, + Comparison::RightOnly { source, .. } => tree::Pairing::RightOnly { + rhs: to_tree(source), + }, + }; + let (trees, visibility) = self.mutate(file, trees)?; + match (sides, trees) { + (Comparison::Same { source, .. }, tree::Pairing::Same { source: processed }) => { + source.regions = from_tree(processed.regions) + } + ( + Comparison::Both { lhs, rhs, .. }, + tree::Pairing::Both { + lhs: left, + rhs: right, + }, + ) => { + lhs.regions = from_tree(left.regions); rhs.regions = from_tree(right.regions); } + (Comparison::LeftOnly { source, .. }, tree::Pairing::LeftOnly { lhs }) => { + source.regions = from_tree(lhs.regions) + } + (Comparison::RightOnly { source, .. }, tree::Pairing::RightOnly { rhs }) => { + source.regions = from_tree(rhs.regions) + } _ => unreachable!("moves never add or remove a side"), } - Ok(protocol::Visibility { - collapsed: visibility.collapsed, - label: visibility.label, - }) + Ok(visibility) + } + + fn mutate( + &self, + file: &FileChange, + mut trees: tree::Pairing, + ) -> anyhow::Result<(tree::Pairing, protocol::Visibility)> { + let entry = file_entry(file); + let mut visibility = types::Visibility::default(); + for plugin in &self.plugins { + let started = Instant::now(); + let records = source_sides(&trees); + let moves = plugin + .runner + .mutate(self.host(&plugin.name), &entry, &records) + .with_context(|| MutationFailed(plugin.name.to_string()))?; + log::debug!( + "plugin {}: mutate {} took {:?}", + plugin.name, + entry.path(), + started.elapsed() + ); + apply::apply(moves, &mut trees, &mut visibility) + .with_context(|| MutationFailed(plugin.name.to_string()))?; + } + Ok(( + trees, + protocol::Visibility { + collapsed: visibility.collapsed, + label: visibility.label, + }, + )) } } @@ -274,6 +334,7 @@ pub(crate) fn file_entry(file: &FileChange) -> types::FileEntry { Pairing::RightOnly { rhs } => types::FileSides::RightOnly(file_ref(rhs)), }, status: match file.status { + FileStatus::Unchanged => types::FileStatus::Unchanged, FileStatus::Added => types::FileStatus::Added, FileStatus::Deleted => types::FileStatus::Deleted, FileStatus::Modified => types::FileStatus::Modified, @@ -288,6 +349,7 @@ pub(crate) fn file_entry(file: &FileChange) -> types::FileEntry { /// The trees a plugin is given, as the contract's records. fn source_sides(trees: &tree::Pairing) -> types::SourceSides { match trees { + tree::Pairing::Same { source } => types::SourceSides::Same(source.to_record()), tree::Pairing::Both { lhs, rhs } => { types::SourceSides::Both((lhs.to_record(), rhs.to_record())) } @@ -322,6 +384,7 @@ pub(crate) fn to_tree(side: &protocol::Source) -> tree::Source { protocol::Node::Leaf { alignment_id, changed, + search_highlights, } => tree::Node::Leaf { alignment_id: *alignment_id, changed: changed @@ -332,6 +395,14 @@ pub(crate) fn to_tree(side: &protocol::Source) -> tree::Source { end_column: span.end_column, }) .collect(), + search_highlights: search_highlights + .iter() + .map(|span| types::Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), }, protocol::Node::Fold { children } => tree::Node::Fold { children: regions(children), @@ -372,6 +443,7 @@ fn from_tree(regions: Vec) -> Vec { tree::Node::Leaf { alignment_id, changed, + search_highlights, } => protocol::Node::Leaf { alignment_id, changed: changed @@ -382,6 +454,14 @@ fn from_tree(regions: Vec) -> Vec { end_column: span.end_column, }) .collect(), + search_highlights: search_highlights + .into_iter() + .map(|span| protocol::Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), }, tree::Node::Fold { children } => protocol::Node::Fold { children: from_tree(children), diff --git a/src/plugin/tests/context.rs b/src/plugin/tests/context.rs index beae09445..e26da0e3a 100644 --- a/src/plugin/tests/context.rs +++ b/src/plugin/tests/context.rs @@ -311,6 +311,7 @@ fn a_fold_whose_matched_partner_holds_changes_stays_open() { visibility: types::Visibility::default(), node: tree::Node::Leaf { alignment_id: alignment, + search_highlights: Vec::new(), changed: (start..end) .filter(|_| changed) .map(|line| types::Span { @@ -566,3 +567,22 @@ fn a_scope_keeps_the_line_it_closes_on() { ); } } + +#[test] +fn visible_try_and_for_headers_keep_their_closing_boundaries() { + let before = include_str!("../../../tests/code-mode/fixtures/base/retry.js"); + let after = include_str!("../../../tests/code-mode/fixtures/head/retry.js"); + // The changed line is above the loop; context reaches its try header. + let sides = shaped("retry.js", before, after, 4); + for source in [lhs(&sides), rhs(&sides)] { + let visible = open_lines(&source.regions); + for line in [4, 5, 12, 14, 15, 17] { + assert!( + visible.contains(&line), + "missing boundary on line {}", + line + 1 + ); + } + assert!(!visible.contains(&7), "the try body should still collapse"); + } +} diff --git a/src/plugin/tests/deleted_bodies.rs b/src/plugin/tests/deleted_bodies.rs index f83f873e6..a8ebc372e 100644 --- a/src/plugin/tests/deleted_bodies.rs +++ b/src/plugin/tests/deleted_bodies.rs @@ -24,6 +24,7 @@ fn leaf(id: u32, alignment: u32, start: u32, end: u32) -> tree::Region { visibility: types::Visibility::default(), node: tree::Node::Leaf { alignment_id: alignment, + search_highlights: Vec::new(), changed: vec![], }, } diff --git a/src/plugin/tests/group.rs b/src/plugin/tests/group.rs index 3a47758cc..8c4118e50 100644 --- a/src/plugin/tests/group.rs +++ b/src/plugin/tests/group.rs @@ -24,6 +24,7 @@ fn leaf(id: u32, alignment: u32, start: u32, end: u32) -> tree::Region { visibility: types::Visibility::default(), node: tree::Node::Leaf { alignment_id: alignment, + search_highlights: Vec::new(), changed: vec![], }, } @@ -230,3 +231,44 @@ fn a_single_collapsed_fold_is_left_alone() { .unwrap() .is_empty()); } + +#[test] +fn unchanged_source_groups_once_and_keeps_shared_fold_state() { + let sides = tree::Pairing::Same { + source: source(vec![ + fold(1, 1, 0, 5, true, "5 unchanged lines"), + fold(2, 2, 5, 10, true, "5 unchanged lines"), + ]), + }; + let file = manifest("same.py", &sides, FileStatus::Unchanged); + let tree::Pairing::Same { source } = sides else { + unreachable!() + }; + let Pairing::Both { + lhs: lhs_file, + rhs: rhs_file, + } = file.file.clone() + else { + unreachable!() + }; + let mut comparison = crate::pairing::Comparison::Same { + lhs_file, + rhs_file, + source: protocol::Source { + text: source.text, + syntax: vec![], + regions: from_tree(source.regions), + }, + }; + bundled("group", json!({})) + .run(&file, &mut comparison) + .unwrap(); + let crate::pairing::Comparison::Same { source, .. } = comparison else { + panic!("shared tree retained") + }; + assert_eq!(source.regions.len(), 1); + assert_eq!( + source.regions[0].visibility.label, + "2 collapsed regions ยท 10 lines" + ); +} diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs index a29e288b8..b20757023 100644 --- a/src/plugin/tests/mod.rs +++ b/src/plugin/tests/mod.rs @@ -85,7 +85,7 @@ pub(crate) fn manifest(path: &str, sides: &tree::Pairing, status: FileStat }; FileChange { file: match sides { - tree::Pairing::Both { .. } => Pairing::Both { + tree::Pairing::Same { .. } | tree::Pairing::Both { .. } => Pairing::Both { lhs: file_ref(), rhs: file_ref(), }, @@ -117,6 +117,10 @@ pub(crate) fn wire(sides: tree::Pairing) -> Pairing Pairing::Both { + lhs: source(same.clone()), + rhs: source(same), + }, tree::Pairing::Both { lhs, rhs } => Pairing::Both { lhs: source(lhs), rhs: source(rhs), @@ -173,7 +177,7 @@ pub(crate) fn run( file: &FileChange, sides: &mut Pairing, ) { - bundled(name, overrides).run(file, sides).unwrap(); + bundled(name, overrides).run_diff(file, sides).unwrap(); } /// Run the bundled plugin `name` with `overrides` on trees built by hand, @@ -421,7 +425,7 @@ fn classifying_plugins_add_tags_in_order_and_a_bad_tag_is_an_error() { fn a_move_that_cannot_be_carried_out_fails_naming_the_plugin() { let (file, mut sides) = project("a.rs", "fn a() {}\n", "fn b() {}\n"); let bad = pipeline(vec![("bad", native::native::)]); - let error = bad.run(&file, &mut sides).unwrap_err(); + let error = bad.run_diff(&file, &mut sides).unwrap_err(); assert!(error.downcast_ref::().is_some()); assert_eq!(format!("{error:#}"), "mutation bad: no region 99999"); } diff --git a/src/plugin/tests/removed_runs.rs b/src/plugin/tests/removed_runs.rs index 79801cead..dd24175bb 100644 --- a/src/plugin/tests/removed_runs.rs +++ b/src/plugin/tests/removed_runs.rs @@ -22,6 +22,7 @@ fn removed_leaf(id: u32, alignment: u32, start: u32, end: u32, changed: &[u32]) visibility: types::Visibility::default(), node: tree::Node::Leaf { alignment_id: alignment, + search_highlights: Vec::new(), changed: changed .iter() .map(|&line| types::Span { @@ -76,6 +77,7 @@ fn shape(regions: &[tree::Region]) -> Vec { let tree::Node::Leaf { alignment_id, changed, + .. } = ®ion.node else { panic!("leaf expected"); diff --git a/src/plugin/tests/summarize.rs b/src/plugin/tests/summarize.rs index 83d369202..d2176bcdb 100644 --- a/src/plugin/tests/summarize.rs +++ b/src/plugin/tests/summarize.rs @@ -167,7 +167,9 @@ fn long_summaries_are_discarded_and_the_body_stays_open() { let (file, mut sides) = project("a.py", "", LARGE); let id = select(&trees(&sides), 3, None)[0].0; let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "a()\nb()\nc()")]))]); - summarizer(&endpoint, 0).run(&file, &mut sides).unwrap(); + summarizer(&endpoint, 0) + .run_diff(&file, &mut sides) + .unwrap(); let sides = trees(&sides); server.join().unwrap(); let mut folds = Vec::new(); @@ -185,7 +187,9 @@ fn summaries_collapse_selected_folds_behind_pseudocode() { let (file, mut sides) = project("a.py", "", LARGE); let id = select(&trees(&sides), 3, None)[0].0; let (endpoint, server) = serve(vec![(200, gemini_answer(&[(id, "call a, b, c")]))]); - summarizer(&endpoint, 0).run(&file, &mut sides).unwrap(); + summarizer(&endpoint, 0) + .run_diff(&file, &mut sides) + .unwrap(); let sides = trees(&sides); let bodies = server.join().unwrap(); assert!(bodies[0].contains("thinkingBudget")); @@ -222,7 +226,9 @@ fn a_docstring_is_sent_and_only_a_verbatim_sentence_from_it_is_kept() { let (file, mut sides) = project("a.rs", "", after); let id = select(&trees(&sides), 3, None)[0].0; let (endpoint, server) = serve(vec![(200, answer(id, "Sums three numbers."))]); - summarizer(&endpoint, 0).run(&file, &mut sides).unwrap(); + summarizer(&endpoint, 0) + .run_diff(&file, &mut sides) + .unwrap(); let sides = trees(&sides); let bodies = server.join().unwrap(); assert!( @@ -236,7 +242,9 @@ fn a_docstring_is_sent_and_only_a_verbatim_sentence_from_it_is_kept() { // A sentence the docstring does not contain is dropped. let (file, mut sides) = project("a.rs", "", after); let (endpoint, server) = serve(vec![(200, answer(id, "Adds things up."))]); - summarizer(&endpoint, 0).run(&file, &mut sides).unwrap(); + summarizer(&endpoint, 0) + .run_diff(&file, &mut sides) + .unwrap(); let sides = trees(&sides); server.join().unwrap(); assert_eq!(body_label(&sides), "return a + b + c"); @@ -324,7 +332,7 @@ fn the_system_prompt_is_the_configured_one() { overrides["api_key"] = json!("test-key"); overrides["endpoint"] = json!(endpoint); overrides["min_lines"] = json!(3); - summarizer_with(overrides).run(&file, sides).unwrap(); + summarizer_with(overrides).run_diff(&file, sides).unwrap(); let bodies = server.join().unwrap(); let body: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap(); ( @@ -360,7 +368,9 @@ fn transient_failures_are_retried_then_succeed() { (429, "{}".to_owned()), (200, gemini_answer(&[(id, "retry ok")])), ]); - summarizer(&endpoint, 3).run(&file, &mut sides).unwrap(); + summarizer(&endpoint, 3) + .run_diff(&file, &mut sides) + .unwrap(); let sides = trees(&sides); assert_eq!(server.join().unwrap().len(), 3); let label = fold_label(&sides); @@ -372,7 +382,7 @@ fn hard_failures_and_exhausted_retries_are_run_failures() { let (file, sides) = project("a.py", "", LARGE); let (endpoint, server) = serve(vec![(400, "{\"error\": \"bad key\"}".to_owned())]); let error = summarizer(&endpoint, 3) - .run(&file, &mut sides.clone()) + .run_diff(&file, &mut sides.clone()) .unwrap_err(); server.join().unwrap(); assert!(error.downcast_ref::().is_some()); @@ -380,7 +390,7 @@ fn hard_failures_and_exhausted_retries_are_run_failures() { assert!(format!("{error:#}").contains("HTTP 400"), "{error:#}"); let (endpoint, server) = serve(vec![(500, "{}".to_owned()), (500, "{}".to_owned())]); let error = summarizer(&endpoint, 1) - .run(&file, &mut sides.clone()) + .run_diff(&file, &mut sides.clone()) .unwrap_err(); server.join().unwrap(); assert!( @@ -434,13 +444,13 @@ fn external_component_summarizes_over_http() { &|host, options| plugin.create(host, options), ) .unwrap(); - pipeline.run(&file, &mut sides).unwrap(); + pipeline.run_diff(&file, &mut sides).unwrap(); assert_eq!(fold_label(&trees(&sides)), "call a, b, c"); assert_eq!(server.join().unwrap().len(), 2); } #[test] -fn tests_are_selected_when_added_modified_unchanged_or_already_collapsed() { +fn only_unpaired_added_tests_are_selected_even_when_already_collapsed() { for (path, before, after) in [ ( "a.py", @@ -469,11 +479,18 @@ fn tests_are_selected_when_added_modified_unchanged_or_already_collapsed() { let comment = if path.ends_with(".py") { "#" } else { "//" }; let after = format!("{after}\n{comment} changed elsewhere\n"); let (file, mut sides) = project(path, old, &after); - assert_eq!(select(&trees(&sides), 3, Some(3)).len(), 1, "{path}: {old}"); + assert_eq!( + select(&trees(&sides), 3, Some(3)).len(), + usize::from(old.is_empty()), + "{path}: {old}" + ); assert!(select(&trees(&sides), 3, None).is_empty()); assert!(select(&trees(&sides), 3, Some(30)).is_empty()); run("test-bodies", json!({"min_lines": 3}), &file, &mut sides); - assert_eq!(select(&trees(&sides), 3, Some(3)).len(), 1); + assert_eq!( + select(&trees(&sides), 3, Some(3)).len(), + usize::from(old.is_empty()) + ); } } } @@ -488,7 +505,7 @@ fn suites_select_individual_tests_and_preserve_nested_summary_folds() { let selected = select(&trees(&sides), 3, Some(3)); assert_eq!(selected.len(), 2, "{path}: {selected:?}"); let (endpoint, server) = serve(vec![(200, gemini_answer(&[(selected[0].0, "setup; act; check one"), (selected[1].0, "setup; act; check two")]))]); - summarizer_with(json!({"api_key": "test", "endpoint": endpoint, "test_min_lines": 3})).run(&file, &mut sides).unwrap(); + summarizer_with(json!({"api_key": "test", "endpoint": endpoint, "test_min_lines": 3})).run_diff(&file, &mut sides).unwrap(); run("test-bodies", json!({"min_lines": 3}), &file, &mut sides); server.join().unwrap(); run("group", json!({}), &file, &mut sides); @@ -527,7 +544,7 @@ fn bundled_wasm_summarizer_streams_large_prompts() { "api_key": "test", "endpoint": endpoint, "test_min_lines": 3, "retries": 0, })); assert!(builtin::component("summarize").is_some()); - wasm.run(&file, &mut sides).unwrap(); + wasm.run_diff(&file, &mut sides).unwrap(); assert_eq!(fold_label(&trees(&sides)), "setup; act; check"); let requests = server.join().unwrap(); assert_eq!(requests.len(), 1); diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 90530d043..33c1531d4 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -344,6 +344,7 @@ fn file_entry(file: &contract::FileEntry) -> types::FileEntry { contract::FileSides::RightOnly(rhs) => types::FileSides::RightOnly(file_ref(rhs)), }, status: match file.status { + contract::FileStatus::Unchanged => types::FileStatus::Unchanged, contract::FileStatus::Added => types::FileStatus::Added, contract::FileStatus::Deleted => types::FileStatus::Deleted, contract::FileStatus::Modified => types::FileStatus::Modified, @@ -357,6 +358,7 @@ fn file_entry(file: &contract::FileEntry) -> types::FileEntry { fn source_sides(sides: &contract::SourceSides) -> types::SourceSides { match sides { + contract::SourceSides::Same(same) => types::SourceSides::Same(source(same)), contract::SourceSides::Both((lhs, rhs)) => { types::SourceSides::Both((source(lhs), source(rhs))) } @@ -400,6 +402,15 @@ fn source(side: &contract::Source) -> types::Source { end_column: span.end_column, }) .collect(), + search_highlights: leaf + .search_highlights + .iter() + .map(|span| types::Span { + line: span.line, + start_column: span.start_column, + end_column: span.end_column, + }) + .collect(), }), contract::Kind::Fold => types::Kind::Fold, }, diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index b3ff1bc04..5ddcc9d71 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -118,6 +118,7 @@ pub enum FileStatus { Renamed, Copied, TypeChanged, + Unchanged, } /// One side of a git delta. @@ -236,6 +237,8 @@ pub enum Node { alignment_id: u32, #[serde(default, skip_serializing_if = "Vec::is_empty")] changed: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + search_highlights: Vec, }, /// A foldable region. Its range is the hull of its children. Fold { children: Vec }, @@ -322,6 +325,7 @@ mod tests { visibility: Visibility::default(), node: Node::Leaf { alignment_id: index, + search_highlights: Vec::new(), changed, }, } diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 8df7200aa..17f0ffb0e 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -570,6 +570,7 @@ fn leaf_region( visibility: Visibility::default(), node: Node::Leaf { alignment_id, + search_highlights: Vec::new(), changed, }, } diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index e7382a4a4..914e816d9 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -226,7 +226,7 @@ fn shape( mut sides, mut stats, } => { - let visibility = pipeline.run(entry, &mut sides)?; + let visibility = pipeline.run_diff(entry, &mut sides)?; stats.visible = visible_counts(&sides); Ok((visibility, Diff::Text { sides, stats })) } @@ -236,7 +236,7 @@ fn shape( syntax: Vec::new(), regions: Vec::new(), }); - let visibility = pipeline.run(entry, &mut empty)?; + let visibility = pipeline.run_diff(entry, &mut empty)?; Ok((visibility, Diff::Binary { sides })) } } @@ -375,6 +375,7 @@ pub(crate) fn visible_counts(sides: &Pairing) -> LineCounts { Node::Leaf { alignment_id, changed, + .. } => { if hidden { continue; @@ -442,6 +443,7 @@ mod visible_tests { }, node: Node::Leaf { alignment_id: alignment, + search_highlights: Vec::new(), changed: changed .iter() .map(|&line| Span { diff --git a/wit/plugin.wit b/wit/plugin.wit index afc494227..5ca75a338 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -41,6 +41,7 @@ interface types { renamed, copied, type-changed, + unchanged, } /// One side of a git delta, as the manifest names it. @@ -104,6 +105,7 @@ interface types { /// lines up with this one. alignment-id: u32, changed: list, + search-highlights: list, } /// A leaf tiles the file; a fold's range is the hull of its children. @@ -136,8 +138,10 @@ interface types { regions: list, } - /// The sides a diffed file has, the same sides as its entry's `file`. + /// Original comparison, independent of display-side selection. `same` + /// represents identical sources once; its file entry still names both refs. variant source-sides { + same(source), both(tuple), left-only(source), right-only(source),