diff --git a/crates/diffr-plugin-sdk/src/guest.rs b/crates/diffr-plugin-sdk/src/guest.rs index 0e8088c48..dd614327d 100644 --- a/crates/diffr-plugin-sdk/src/guest.rs +++ b/crates/diffr-plugin-sdk/src/guest.rs @@ -18,6 +18,16 @@ impl guest::GuestPlugin for Instance

{ Ok(guest::Plugin::new(Instance(plugin))) } + fn enrich( + &self, + file: FileEntry, + sides: SourceSides, + ) -> Result, String> { + let sides = tree::sides(&sides).map_err(|error| format!("{error:#}"))?; + self.0 + .enrich(&file, &sides) + .map_err(|error| format!("{error:#}")) + } fn queries(&self) -> Result, String> { self.0.queries().map_err(|error| format!("{error:#}")) } diff --git a/crates/diffr-plugin-sdk/src/lib.rs b/crates/diffr-plugin-sdk/src/lib.rs index 66fab82f7..271ce2f99 100644 --- a/crates/diffr-plugin-sdk/src/lib.rs +++ b/crates/diffr-plugin-sdk/src/lib.rs @@ -42,8 +42,8 @@ pub use tree::{ 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, - Visibility, ROOT, + Annotation, FileEntry, FileRef, FileSides, FileStatus, Move, Position, QuerySource, Range, + Side, Span, Visibility, ROOT, }; /// A diffr plugin: the `plugin` resource of `wit/plugin.wit`. diffr makes one @@ -71,6 +71,15 @@ pub trait Plugin: Sized { /// The moves that shape how the diffed file starts out. `sides` are the /// sides the file has, already rebuilt as trees. fn mutate(&self, file: &FileEntry, sides: &Pairing) -> anyhow::Result>; + + /// Deferred labels for stable region IDs, after all initial mutations. + fn enrich( + &self, + _file: &FileEntry, + _sides: &Pairing, + ) -> anyhow::Result> { + Ok(Vec::new()) + } } /// The contract generated from `wit/plugin.wit`. Its records are plain Rust diff --git a/crates/diffr-plugin-sdk/src/native.rs b/crates/diffr-plugin-sdk/src/native.rs index 3261808de..5f6e8770d 100644 --- a/crates/diffr-plugin-sdk/src/native.rs +++ b/crates/diffr-plugin-sdk/src/native.rs @@ -5,6 +5,11 @@ use crate::{tree, FileEntry, Move, Plugin, QuerySource}; /// The object-safe form of a plugin, after its options have been deserialized. pub trait Instance: Send + Sync { fn queries(&self) -> anyhow::Result>; + fn enrich( + &self, + file: &FileEntry, + sides: &SourceSides, + ) -> anyhow::Result>; fn classify(&self, file: &FileEntry) -> anyhow::Result>; fn mutate(&self, file: &FileEntry, sides: &SourceSides) -> anyhow::Result>; } @@ -27,6 +32,13 @@ pub fn create( struct Adapter

(P); impl Instance for Adapter

{ + fn enrich( + &self, + file: &FileEntry, + sides: &SourceSides, + ) -> anyhow::Result> { + self.0.enrich(file, &tree::sides(sides)?) + } fn queries(&self) -> anyhow::Result> { self.0.queries() } diff --git a/crates/diffr-plugin-sdk/src/types.rs b/crates/diffr-plugin-sdk/src/types.rs index 39a5da4a9..80a68f43c 100644 --- a/crates/diffr-plugin-sdk/src/types.rs +++ b/crates/diffr-plugin-sdk/src/types.rs @@ -6,8 +6,8 @@ //! This module adds only what the WIT cannot say: [`ROOT`], the id that names //! the file rather than a region, and the impls below. pub use crate::bindings::diffr::plugin::types::{ - Cut, FileEntry, FileRef, FileSides, FileStatus, Kind, Leaf, Move, Position, QuerySource, Range, - Region, Side, Source, SourceSides, Span, Visibility, + Annotation, Cut, FileEntry, FileRef, FileSides, FileStatus, Kind, Leaf, Move, Position, + QuerySource, Range, Region, Side, Source, SourceSides, Span, Visibility, }; use crate::tree::Pairing; diff --git a/docs/plugins.md b/docs/plugins.md index b24bd3dc8..468166f95 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -86,7 +86,7 @@ external plugin never requires rebuilding diffr. ## The contract [`wit/plugin.wit`](../wit/plugin.wit) is the contract: the package -`diffr:plugin@0.1.0`, world `plugin`. A plugin exports the interface `guest`, +`diffr:plugin@0.2.0`, world `plugin`. A plugin exports the interface `guest`, which holds one resource, the plugin itself (the component model has no optional exports; a plugin that does not classify returns an empty list): @@ -358,3 +358,18 @@ Opening a module or aggregate fold reveals each test's pseudocode; opening an individual body reveals its source. JS/TS `describe` suites remain containers, with summaries on their individual `it`/`test` callbacks. An explicit custom `plugins.order` is respected; update it to this order to match the defaults. + +## Deferred annotations + +The 0.2 plugin ABI adds `enrich(file, sides) -> list`. Rebuild +external components against the updated SDK (`cargo xtask build-plugins` rebuilds +bundled components). SDK plugins default to returning no annotations. + +`mutate` performs initial presentation only. After all mutations finish, `enrich` +may do slow work and return `{region-id, label}` records. IDs refer to the final +region trees and are validated by the host. Annotations cannot change ranges, +alignment, fold-state IDs, or collapsed state. The summarizer reserves its folds +and links docstrings during mutation; enrichment only attaches pseudocode. +Rejected or absent summaries leave the initial fold available to expand. + +The ordinary stream still applies both phases before emitting a file. diff --git a/plugins/summarize/plugin.wasm b/plugins/summarize/plugin.wasm index 542201db1..1a575b1c3 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..f4abd5118 100644 --- a/plugins/summarize/src/lib.rs +++ b/plugins/summarize/src/lib.rs @@ -6,8 +6,8 @@ //! Requests use WASI HTTP. The host calls one file at a time per instance. use diffr_plugin_sdk::anyhow::{self, anyhow, Context as _}; use diffr_plugin_sdk::{ - docstring_of, export, has_tag, is_fold, line_count, one_sided, walk, Draft, FileEntry, Move, - Node, OtherSide, Pairing, Plugin, Region, Source, + docstring_of, export, has_tag, is_fold, line_count, one_sided, walk, Annotation, Draft, + FileEntry, Move, Node, OtherSide, Pairing, Plugin, Region, Source, }; use serde::Deserialize; use serde_json::json; @@ -61,7 +61,6 @@ struct Request { id: u32, first_line: u32, last_line: u32, - docstring: Option, doc: Option, } @@ -320,7 +319,7 @@ fn quoted(doc: Option<&str>, summary: &str) -> Option { /// Pseudocode earns its place only when it is clearly shorter than the /// code: a summary with more than half the body's non-blank lines is -/// dropped and the body stays open. +/// dropped; the initially folded body remains expandable. fn compresses(summary: &str, body: &[&str]) -> bool { let summary_lines = summary .lines() @@ -411,12 +410,46 @@ impl Plugin for Summarize { Ok(Vec::new()) } - fn mutate(&self, file: &FileEntry, sides: &Pairing) -> anyhow::Result> { + fn mutate(&self, _file: &FileEntry, sides: &Pairing) -> anyhow::Result> { let selected = select( sides, self.options.min_lines, self.options.tests.then_some(self.options.test_min_lines), ); + let mut draft = Draft::new(sides); + let mut tags = BTreeMap::new(); + if let Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs } = sides { + walk(&rhs.regions, &mut |region| { + tags.insert(region.id, region.tags.clone()); + }); + } + for (id, _, _, docstring) in selected { + let mut region_tags = tags.remove(&id).expect("selected region exists"); + region_tags.push("summarize:pending".into()); + draft.push(Move::SetTags((id, region_tags)))?; + draft.collapse(id, String::new())?; + if let Some(docstring) = docstring { + draft.link(&[id, docstring])?; + } + } + Ok(draft.into_moves()) + } + + fn enrich(&self, file: &FileEntry, sides: &Pairing) -> anyhow::Result> { + let mut selected = Vec::new(); + if let Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs } = sides { + walk(&rhs.regions, &mut |region| { + if has_tag(region, "summarize:pending") { + let lines = region.range.lines(); + selected.push(( + region.id, + lines.start + 1, + lines.end, + docstring_of(rhs, region, PLUGIN), + )); + } + }); + } let (Pairing::Both { rhs, .. } | Pairing::RightOnly { rhs }) = &sides else { return Ok(Vec::new()); }; @@ -426,7 +459,6 @@ impl Plugin for Summarize { id, first_line, last_line, - docstring, doc: docstring.and_then(|docstring| documentation(rhs, docstring)), }) .collect(); @@ -445,28 +477,16 @@ impl Plugin for Summarize { let body = &lines[fold.first_line as usize - 1..fold.last_line as usize]; compresses(&summary.pseudocode, body) }); - // Collapse every summarized body first, then link each to its - // docstring: a summary and its docstring are one thing. - let mut draft = Draft::new(sides); - let mut links = Vec::new(); - for (id, summary) in texts { - let text = match &summary.quote { - Some(quote) => format!("{quote}\n{}", summary.pseudocode), - None => summary.pseudocode.clone(), - }; - draft.collapse(id, text)?; - let fold = folds - .iter() - .find(|fold| fold.id == id) - .expect("answered fold"); - if let Some(docstring) = fold.docstring { - links.push([id, docstring]); - } - } - for link in links { - draft.link(&link)?; - } - Ok(draft.into_moves()) + Ok(texts + .into_iter() + .map(|(region_id, summary)| Annotation { + region_id, + label: match summary.quote { + Some(quote) => format!("{quote}\n{}", summary.pseudocode), + None => summary.pseudocode, + }, + }) + .collect()) } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 93e02ed5d..6a48f908a 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -55,6 +55,15 @@ use std::time::Instant; /// its options, behind the contract's two calls on a file. Each call gets /// the host for that call. pub(crate) trait Runner: Send + Sync { + fn enrich( + &self, + _host: Host, + _file: &types::FileEntry, + _sides: &types::SourceSides, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + fn queries(&self, host: Host) -> anyhow::Result>; fn classify(&self, host: Host, file: &types::FileEntry) -> anyhow::Result>; @@ -201,9 +210,88 @@ impl Pipeline { Ok(entry.tags) } + /// Compatibility path: return the fully enriched file in one operation. + pub(crate) fn run( + &self, + file: &FileChange, + sides: &mut Pairing, + ) -> anyhow::Result { + let visibility = self.prepare(file, sides)?; + let annotations = self.enrich(file, sides)?; + Self::apply_annotations(sides, &annotations)?; + Ok(visibility) + } + + /// Deferred plugins may only attach labels to existing regions. + pub(crate) fn enrich( + &self, + file: &FileChange, + sides: &Pairing, + ) -> anyhow::Result> { + let trees = match sides { + Pairing::Both { lhs, rhs } => tree::Pairing::Both { + lhs: to_tree(lhs), + rhs: to_tree(rhs), + }, + Pairing::LeftOnly { lhs } => tree::Pairing::LeftOnly { lhs: to_tree(lhs) }, + Pairing::RightOnly { rhs } => tree::Pairing::RightOnly { rhs: to_tree(rhs) }, + }; + let records = source_sides(&trees); + let entry = file_entry(file); + let mut annotations = Vec::new(); + for plugin in &self.plugins { + let labels = plugin + .runner + .enrich(self.host(&plugin.name), &entry, &records) + .with_context(|| MutationFailed(plugin.name.to_string()))?; + annotations.extend(labels.into_iter().map(|label| protocol::Annotation { + region_id: label.region_id, + label: label.label, + })); + } + // Validate the complete batch before it can leave the host. + Self::apply_annotations(&mut sides.clone(), &annotations)?; + Ok(annotations) + } + + pub(crate) fn apply_annotations( + sides: &mut Pairing, + annotations: &[protocol::Annotation], + ) -> anyhow::Result<()> { + fn find(regions: &mut [protocol::Region], id: u32) -> Option<&mut protocol::Region> { + for region in regions { + if region.id == id { + return Some(region); + } + if let protocol::Node::Fold { children } = &mut region.node { + if let Some(found) = find(children, id) { + return Some(found); + } + } + } + None + } + for annotation in annotations { + let region = match sides { + Pairing::Both { lhs, rhs } => find(&mut lhs.regions, annotation.region_id) + .or_else(|| find(&mut rhs.regions, annotation.region_id)), + Pairing::LeftOnly { lhs } => find(&mut lhs.regions, annotation.region_id), + Pairing::RightOnly { rhs } => find(&mut rhs.regions, annotation.region_id), + } + .ok_or_else(|| { + anyhow!( + "annotation refers to missing region {}", + annotation.region_id + ) + })?; + region.visibility.label = annotation.label.clone(); + } + Ok(()) + } + /// Run every plugin on one file's sides, returning the file's own /// visibility. - pub(crate) fn run( + pub(crate) fn prepare( &self, file: &FileChange, sides: &mut Pairing, diff --git a/src/plugin/native.rs b/src/plugin/native.rs index c7b016f52..3c8c4daa8 100644 --- a/src/plugin/native.rs +++ b/src/plugin/native.rs @@ -62,6 +62,14 @@ fn call(host: Host, call: impl FnOnce() -> anyhow::Result) -> anyhow::Resu struct Native(Box); impl Runner for Native { + fn enrich( + &self, + host: Host, + file: &FileEntry, + sides: &SourceSides, + ) -> anyhow::Result> { + call(host, || self.0.enrich(file, sides)) + } fn queries(&self, host: Host) -> anyhow::Result> { call(host, || self.0.queries()) } diff --git a/src/plugin/tests/summarize.rs b/src/plugin/tests/summarize.rs index 83d369202..7b5e8f96a 100644 --- a/src/plugin/tests/summarize.rs +++ b/src/plugin/tests/summarize.rs @@ -163,7 +163,7 @@ fn selection_skips_test_bodies_and_collapsed_folds() { } #[test] -fn long_summaries_are_discarded_and_the_body_stays_open() { +fn long_summaries_are_discarded_without_changing_initial_folding() { 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()")]))]); @@ -176,8 +176,8 @@ fn long_summaries_are_discarded_and_the_body_stays_open() { folds.push((region.visibility.collapsed, region.visibility.label.clone())); } }); - // No plugin collapsed it, so it has no label. - assert_eq!(folds, vec![(false, String::new())]); + // Enrichment cannot reopen a fold after the initial file was displayed. + assert_eq!(folds, vec![(true, String::new())]); } #[test] @@ -536,3 +536,48 @@ fn bundled_wasm_summarizer_streams_large_prompts() { assert!(prompt.contains(&"context ".repeat(16_384))); assert!(prompt.contains(&format!("fold {id}: lines 2-4"))); } + +#[test] +fn deferred_summary_preserves_user_fold_state_and_region_identity() { + 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")]))]); + let pipeline = summarizer(&endpoint, 0); + pipeline.prepare(&file, &mut sides).unwrap(); + // No HTTP is required for prepare. The selected fold already exists. + assert_eq!(fold_label(&trees(&sides)), ""); + let annotations = pipeline.enrich(&file, &sides).unwrap(); + fn open(regions: &mut [protocol::Region]) { + for region in regions { + region.visibility.collapsed = false; + if let protocol::Node::Fold { children } = &mut region.node { + open(children); + } + } + } + match &mut sides { + Pairing::Both { lhs, rhs } => { + open(&mut lhs.regions); + open(&mut rhs.regions); + } + Pairing::RightOnly { rhs } => open(&mut rhs.regions), + _ => panic!("right side required"), + } + let before = sides.clone(); + Pipeline::apply_annotations(&mut sides, &annotations).unwrap(); + assert_eq!(fold_label(&trees(&sides)), "call a, b, c"); + walk(&rhs(&trees(&sides)).regions, &mut |region| { + assert!(!region.visibility.collapsed); + }); + // Removing only the added label recovers the exact initial tree. + Pipeline::apply_annotations( + &mut sides, + &[protocol::Annotation { + region_id: id, + label: String::new(), + }], + ) + .unwrap(); + assert_eq!(sides, before); + server.join().unwrap(); +} diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index e36f42740..b2d3cc545 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -277,6 +277,32 @@ impl WasmInstance { } impl Runner for WasmInstance { + fn enrich( + &self, + host: Host, + file: &contract::FileEntry, + sides: &contract::SourceSides, + ) -> anyhow::Result> { + let instance = &mut *self.enter(host); + let labels = instance + .exports + .diffr_plugin_guest() + .plugin() + .call_enrich( + &mut instance.store, + instance.plugin, + &file_entry(file), + &source_sides(sides), + )? + .map_err(anyhow::Error::msg)?; + Ok(labels + .into_iter() + .map(|label| contract::Annotation { + region_id: label.region_id, + label: label.label, + }) + .collect()) + } fn queries(&self, host: Host) -> anyhow::Result> { let instance = &mut *self.enter(host); let sources = instance diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index c3ad45705..d0dcf57a9 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -24,6 +24,13 @@ pub(crate) mod stream; /// The current wire version. Changes within a version are additive. pub const VERSION: u32 = 3; +/// Deferred content for an existing region; never changes fold state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Annotation { + pub region_id: u32, + pub label: String, +} + // ── stream ──────────────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/wit/plugin.wit b/wit/plugin.wit index afc494227..c6843a6c3 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -15,9 +15,16 @@ /// /// diffr makes one instance of each enabled plugin per run and calls it for /// every file, so what a plugin keeps from `new` lasts the whole run. -package diffr:plugin@0.1.0; +package diffr:plugin@0.2.0; interface types { + /// Deferred presentation content for an existing region. This cannot + /// change topology, alignment, fold identity, or collapsed state. + record annotation { + region-id: u32, + label: string, + } + /// A named query source. diffr compiles all enabled sources per language. /// Names identify sources for imports and diagnostics; shared names must /// have identical text. Relative imports resolve against this name. @@ -206,7 +213,7 @@ interface host { /// What every plugin exports: the plugin itself, as a resource. interface guest { - use types.{file-entry, source-sides, move, query-source}; + use types.{file-entry, source-sides, move, query-source, annotation}; resource plugin { /// Make the plugin from `options`, its bundled or external config @@ -232,6 +239,11 @@ interface guest { /// the sides the file has, the same sides as its entry's `file`. An /// error aborts the run with `mutation_failed`. mutate: func(file: file-entry, sides: source-sides) -> result, string>; + + /// Optional slow enrichment after every plugin has shaped the file. + /// Errors leave the initial diff usable. Return no annotations when + /// this plugin has no deferred work. + enrich: func(file: file-entry, sides: source-sides) -> result, string>; } }