From 829f1b8baa2ae6e427cbf3dc6340e99c84fddbad Mon Sep 17 00:00:00 2001 From: ketan0 Date: Fri, 18 Sep 2026 22:39:25 -0700 Subject: [PATCH 1/2] Expose fold-independent structural change ranges Collect complete and initially visible line coverage together after plugins. Export coalesced base/head ranges while preserving existing visible counts. AI-assisted implementation and review by Codex. --- docs/streaming.md | 29 +++++-- src/protocol/mod.rs | 29 +++++++ src/protocol/project.rs | 2 + src/protocol/stream.rs | 163 ++++++++++++++++++++++++++++++---------- 4 files changed, 180 insertions(+), 43 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index 94de5c62f..8b6781587 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -133,6 +133,7 @@ any file failed or the run aborted. {"type": "text", "lhs": {"text": "…", "syntax": [...], "regions": [...]}, "rhs": {"text": "…", "syntax": [...], "regions": [...]}, + "structural_changes": {"base": [[8, 9]], "head": [[10, 14]]}, "stats": {"textual": {"added": 4, "removed": 1}, "visible": {"added": 2, "removed": 1}}} // + "fallback": {code, message} on a line diff ``` @@ -147,14 +148,31 @@ tree-sitter highlight capture name (`keyword`, `function.method`, …). Spans ar per line, sorted, and non-overlapping; where captures nest, the innermost wins. Files that fell back to a line diff have no syntax. +`structural_changes` records all structurally changed source lines, including +those inside collapsed regions. `base` refers to `lhs`, and `head` to `rhs`. +Each array contains zero-based, half-open `[start, end)` line ranges, sorted, +nonempty, non-overlapping, and merged when adjacent. Both arrays are present; +a missing or unchanged side has an empty array. + +A paired leaf contributes the distinct lines carrying `changed` spans. An +unpaired leaf contributes every line, including blank lines. Formatting-only +lines with no changed spans in paired leaves do not contribute. Fallback +files use the same rule over their line-diff leaves; `stats.fallback` still +identifies them. Binary diffs have no structural coverage. + +Consumers can intersect these ranges with a code selection and subtract viewed +ranges to measure remaining work. Folding must not affect that calculation. +The sum of range lengths gives complete structural counts, which may exceed +`stats.visible` and differ from `stats.textual`. + `stats.textual` counts lines with any byte change. `stats.visible` counts the changed lines still on screen under the default visibility: a line that carries a `changed` span, or any line of a leaf that exists on one side only, unless it sits inside a region that starts collapsed. It is computed after the plugins -run, so configuration changes it, and with nothing collapsed it matches -`textual` up to the blank lines of a paired changed run. A frontend that lets the -reader fold and unfold recomputes the same rule locally; the wire value is the -starting point. +run, so configuration changes it. It is an initial-visibility measurement, +not the complete structural total. A frontend can keep it fixed (as the TUI +does), or recompute visible counts as folds toggle. For fold-independent +progress, use `structural_changes` instead. `stats.fallback` is present when the AST match did not run: `unsupported_language`, `too_large`, `too_complex`, `parse_error`, or `generated` for a file tagged @@ -327,7 +345,8 @@ Each file goes through one pipeline before its record is written: starts collapsed yet. 4. The enabled plugins run in `plugins.order` on the finished trees, starting with `context`. -5. `stats.visible` is recounted, and the record is written. +5. Complete `structural_changes` and initially visible coverage are collected + together. The latter supplies `stats.visible`, and the record is written. Queries decide which regions exist; plugins decide how they start out. No plugin matches one side to the other: pairing is the projection's alone. A diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index b3ff1bc04..c3ad45705 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -155,6 +155,8 @@ pub enum Diff { #[serde(flatten)] sides: Pairing, stats: Stats, + /// All structurally changed lines, including content hidden by folds. + structural_changes: StructuralChanges, }, /// Either side being binary makes the whole diff binary. Binary { @@ -274,6 +276,28 @@ pub struct SourcePos { pub column: u32, } +/// Zero-based, half-open source line interval: `[start, end)` on the wire. +pub type LineRange = [u32; 2]; + +/// Structural change coverage, independent of visibility. Ranges are sorted, +/// nonempty, disjoint, and coalesced when adjacent. A missing side has no ranges. +/// `base` refers to `lhs`; `head` refers to `rhs`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct StructuralChanges { + pub base: Vec, + pub head: Vec, +} + +impl StructuralChanges { + pub fn counts(&self) -> LineCounts { + let count = |ranges: &[LineRange]| ranges.iter().map(|[start, end]| end - start).sum(); + LineCounts { + added: count(&self.head), + removed: count(&self.base), + } + } +} + /// Line counts for one file. `fallback` is present exactly when the AST /// match did not run and the alignment is a line diff, carrying why: /// `too_complex`, `too_large`, `unsupported_language`, `parse_error`, @@ -376,6 +400,10 @@ mod tests { }], ), }, + structural_changes: StructuralChanges { + base: vec![], + head: vec![[1, 2]], + }, stats: Stats { textual: LineCounts { added: 1, @@ -421,6 +449,7 @@ mod tests { "rhs": {"text": "fn f() {\n 1 + 2\n}\n", "regions": [region(5, json!([{"line": 1, "start_column": 5, "end_column": 9}]))]}, "stats": {"textual": {"added": 1, "removed": 1}, "visible": {"added": 1, "removed": 1}}, + "structural_changes": {"base": [], "head": [[1, 2]]}, }, }); assert_eq!(serde_json::to_value(example_file()).unwrap(), expected); diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 8df7200aa..9ae85a034 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -85,6 +85,8 @@ pub(crate) fn diff(result: &DiffResult, inputs: Inputs<'_>) -> Diff { Diff::Text { sides, stats: stats(result, lhs_src, rhs_src), + // Filled together with visible counts after plugins shape the trees. + structural_changes: Default::default(), } } diff --git a/src/protocol/stream.rs b/src/protocol/stream.rs index e7382a4a4..ca5f50531 100644 --- a/src/protocol/stream.rs +++ b/src/protocol/stream.rs @@ -4,8 +4,8 @@ //! before its record is written; `stats.visible` is recounted after them. use super::project::{self, Inputs}; use super::{ - Diff, Event, FileChange, LineCounts, Node, Outcome, Problem, Region, Snapshot, Source, - SyntaxSpan, Visibility, VERSION, + Diff, Event, FileChange, LineRange, Node, Outcome, Problem, Region, Snapshot, Source, + StructuralChanges, SyntaxSpan, Visibility, VERSION, }; use crate::engine::QueryConflict; use crate::git::{DiffSession, FileError, LoadedFile}; @@ -225,10 +225,19 @@ fn shape( Diff::Text { mut sides, mut stats, + .. } => { let visibility = pipeline.run(entry, &mut sides)?; - stats.visible = visible_counts(&sides); - Ok((visibility, Diff::Text { sides, stats })) + let coverage = change_coverage(&sides); + stats.visible = coverage.initially_visible.counts(); + Ok(( + visibility, + Diff::Text { + sides, + stats, + structural_changes: coverage.all, + }, + )) } Diff::Binary { sides } => { let mut empty = sides.clone().map(|_| Source { @@ -351,12 +360,15 @@ pub(crate) fn write_file( Ok(ended) } -/// Changed lines that start on screen. A line counts when it carries a -/// `changed` span, or when no leaf on the other side shares its -/// `alignment_id` (every line of -/// a one-sided leaf is new or removed, blank ones included). Lines inside a -/// collapsed region, or under one, are not counted. -pub(crate) fn visible_counts(sides: &Pairing) -> LineCounts { +/// Collect complete and default-visible coverage together. A paired leaf counts +/// only lines carrying changed spans; every line of an unpaired leaf counts, +/// including blank lines. Visibility never removes lines from `all`. +struct ChangeCoverage { + all: StructuralChanges, + initially_visible: StructuralChanges, +} + +fn change_coverage(sides: &Pairing) -> ChangeCoverage { fn alignments(regions: &[Region], out: &mut DftHashSet) { for region in regions { match ®ion.node { @@ -367,8 +379,13 @@ pub(crate) fn visible_counts(sides: &Pairing) -> LineCounts { } } } - fn count(regions: &[Region], other: &DftHashSet, hidden: bool) -> u32 { - let mut total = 0; + fn collect( + regions: &[Region], + other: &DftHashSet, + hidden: bool, + all: &mut Vec, + visible: &mut Vec, + ) { for region in regions { let hidden = hidden || region.visibility.collapsed; match ®ion.node { @@ -376,42 +393,67 @@ pub(crate) fn visible_counts(sides: &Pairing) -> LineCounts { alignment_id, changed, } => { - if hidden { - continue; - } + let start = all.len(); if other.contains(alignment_id) { - let lines: DftHashSet = changed.iter().map(|span| span.line).collect(); - total += lines.len() as u32; + all.extend(changed.iter().map(|span| [span.line, span.line + 1])); } else { - total += region.range.lines().len() as u32; + let lines = region.range.lines(); + all.push([lines.start, lines.end]); + } + if !hidden { + visible.extend_from_slice(&all[start..]); } } - Node::Fold { children } => total += count(children, other, hidden), + Node::Fold { children } => collect(children, other, hidden, all, visible), } } - total } - let side_alignments = |source: &Source| { - let mut out = DftHashSet::default(); - alignments(&source.regions, &mut out); - out + fn side(source: Option<&Source>, other: Option<&Source>) -> (Vec, Vec) { + let mut paired = DftHashSet::default(); + if let Some(other) = other { + alignments(&other.regions, &mut paired); + } + let (mut all, mut visible) = (Vec::new(), Vec::new()); + if let Some(source) = source { + collect(&source.regions, &paired, false, &mut all, &mut visible); + } + (coalesce(all), coalesce(visible)) + } + let (lhs, rhs) = match sides { + Pairing::Both { lhs, rhs } => (Some(lhs), Some(rhs)), + Pairing::LeftOnly { lhs } => (Some(lhs), None), + Pairing::RightOnly { rhs } => (None, Some(rhs)), }; - match sides { - Pairing::Both { lhs, rhs } => LineCounts { - added: count(&rhs.regions, &side_alignments(lhs), false), - removed: count(&lhs.regions, &side_alignments(rhs), false), - }, - Pairing::LeftOnly { lhs } => LineCounts { - added: 0, - removed: count(&lhs.regions, &DftHashSet::default(), false), - }, - Pairing::RightOnly { rhs } => LineCounts { - added: count(&rhs.regions, &DftHashSet::default(), false), - removed: 0, + let (base, visible_base) = side(lhs, rhs); + let (head, visible_head) = side(rhs, lhs); + ChangeCoverage { + all: StructuralChanges { base, head }, + initially_visible: StructuralChanges { + base: visible_base, + head: visible_head, }, } } +/// Compact spans and whole-leaf ranges without allocating one entry per source line. +fn coalesce(mut ranges: Vec) -> Vec { + ranges.sort_unstable(); + let mut merged: Vec = Vec::new(); + for [start, end] in ranges { + if start >= end { + continue; + } + if let Some(last) = merged.last_mut() { + if start <= last[1] { + last[1] = last[1].max(end); + continue; + } + } + merged.push([start, end]); + } + merged +} + #[cfg(test)] mod visible_tests { use super::*; @@ -505,15 +547,60 @@ mod visible_tests { leaf(9, 9, (3, 4), &[], false), leaf(10, 8, (4, 7), &[4, 5], true), ]); - let counts = visible_counts(&Pairing::Both { lhs, rhs }); + let coverage = change_coverage(&Pairing::Both { lhs, rhs }); + assert_eq!(coverage.all.head, vec![[0, 2], [4, 15]]); + assert_eq!(coverage.all.base, vec![[0, 1], [4, 7]]); + let counts = coverage.initially_visible.counts(); assert_eq!(counts.added, 2 + 2 + 3); assert_eq!(counts.removed, 1); } + #[test] + fn changing_fold_visibility_never_changes_complete_coverage() { + let lhs = source(vec![leaf(1, 7, (0, 3), &[], false)]); + let rhs = source(vec![fold( + 2, + (0, 3), + true, + vec![fold( + 3, + (0, 3), + false, + vec![leaf(4, 7, (0, 3), &[2, 0, 0], false)], + )], + )]); + let mut sides = Pairing::Both { lhs, rhs }; + let hidden = change_coverage(&sides); + assert_eq!(hidden.all.head, vec![[0, 1], [2, 3]]); + assert!(hidden.all.base.is_empty()); // Added tokens do not imply removed tokens. + assert_eq!(hidden.initially_visible.counts().added, 0); + if let Pairing::Both { rhs, .. } = &mut sides { + rhs.regions[0].visibility.collapsed = false; + } + let opened = change_coverage(&sides); + assert_eq!(opened.all, hidden.all); + assert_eq!(opened.initially_visible, opened.all); + } + + #[test] + fn deleted_blank_lines_and_empty_files_have_complete_coverage() { + let lhs = source(vec![leaf(1, 0, (0, 2), &[], true)]); + let deleted = change_coverage(&Pairing::LeftOnly { lhs }); + assert_eq!(deleted.all.base, vec![[0, 2]]); + assert!(deleted.all.head.is_empty()); + assert_eq!(deleted.initially_visible.counts().removed, 0); + let empty = change_coverage(&Pairing::RightOnly { + rhs: source(vec![]), + }); + assert_eq!(empty.all, StructuralChanges::default()); + } + #[test] fn a_missing_side_counts_nothing() { let rhs = source(vec![leaf(0, 1, (0, 1), &[0], false)]); - let counts = visible_counts(&Pairing::RightOnly { rhs }); + let counts = change_coverage(&Pairing::RightOnly { rhs }) + .initially_visible + .counts(); assert_eq!(counts.added, 1); assert_eq!(counts.removed, 0); } From bc9654086f31e11fd60c661d7210c8347de340a3 Mon Sep 17 00:00:00 2001 From: ketan0 Date: Sat, 19 Sep 2026 14:07:12 -0700 Subject: [PATCH 2/2] Fix cross-platform plugin test prerequisites and repository paths Canonicalize the repository root before relative path calculation on Windows. Treat Wasmtime disk caching as optional when the cache directory is unavailable. Install the WASI target for coverage integration tests. AI-assisted by Codex. --- .github/workflows/coverage.yml | 8 +++++++- .github/workflows/test.yml | 3 ++- src/cli.rs | 4 +++- src/plugin/wasm.rs | 7 ++++--- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 61a465bba..bba261b1e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -10,11 +10,17 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Install Rust - run: rustup toolchain install stable --component llvm-tools-preview + run: rustup toolchain install stable --component llvm-tools-preview --target wasm32-wasip2 - name: Install cargo-llvm-cov uses: taiki-e/install-action@ff869c26e4a7acc3087d9d6c1163a80d3957bca7 # cargo-llvm-cov + # WASI guests cannot use the host LLVM coverage runtime. + - name: Build WASM plugins + run: cargo +stable xtask build-plugins + - name: Generate code coverage + env: + DIFFR_PLUGINS_BUILT: "1" run: cargo +stable llvm-cov --all-features --workspace --lcov --output-path lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba2a2cba2..4dedc0758 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,8 @@ jobs: - { target: x86_64-pc-windows-msvc, os: windows-latest } - { target: aarch64-pc-windows-msvc, os: windows-11-arm } - - { target: aarch64-unknown-linux-gnu, os: ubuntu-22.04, use-cross: true } + # Wasmtime needs native memfd support unavailable under QEMU user emulation. + - { target: aarch64-unknown-linux-gnu, os: ubuntu-24.04-arm } # mac-14 is an M1 ARM device. - { target: aarch64-apple-darwin, os: macos-14 } diff --git a/src/cli.rs b/src/cli.rs index d7f313da4..9ff3a7200 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -223,7 +223,9 @@ fn select( } } paths.extend(explicit_paths); - let prefix = location.strip_prefix(repo.workdir().unwrap_or(repo.path()))?; + // Match canonical path forms, including Windows verbatim path prefixes. + let root = std::fs::canonicalize(repo.workdir().unwrap_or(repo.path()))?; + let prefix = location.strip_prefix(&root)?; let paths = paths .into_iter() .map(|path| normalize_path(prefix, &path)) diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 90530d043..e36f42740 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -47,9 +47,10 @@ use bindings::diffr::plugin::{host, types}; pub(crate) fn engine() -> anyhow::Result { let mut config = Config::new(); config.wasm_component_model(true); - config.cache(Some( - Cache::new(CacheConfig::new()).context("wasmtime's compilation cache")?, - )); + // The disk cache is an optimization; read-only homes must still run plugins. + if let Ok(cache) = Cache::new(CacheConfig::new()) { + config.cache(Some(cache)); + } Engine::new(&config) }