Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
29 changes: 24 additions & 5 deletions docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 4 additions & 3 deletions src/plugin/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ use bindings::diffr::plugin::{host, types};
pub(crate) fn engine() -> anyhow::Result<Engine> {
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)
}

Expand Down
29 changes: 29 additions & 0 deletions src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ pub enum Diff {
#[serde(flatten)]
sides: Pairing<Source>,
stats: Stats,
/// All structurally changed lines, including content hidden by folds.
structural_changes: StructuralChanges,
},
/// Either side being binary makes the whole diff binary.
Binary {
Expand Down Expand Up @@ -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<LineRange>,
pub head: Vec<LineRange>,
}

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`,
Expand Down Expand Up @@ -376,6 +400,10 @@ mod tests {
}],
),
},
structural_changes: StructuralChanges {
base: vec![],
head: vec![[1, 2]],
},
stats: Stats {
textual: LineCounts {
added: 1,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/protocol/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down
163 changes: 125 additions & 38 deletions src/protocol/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Source>) -> 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<Source>) -> ChangeCoverage {
fn alignments(regions: &[Region], out: &mut DftHashSet<u32>) {
for region in regions {
match &region.node {
Expand All @@ -367,51 +379,81 @@ pub(crate) fn visible_counts(sides: &Pairing<Source>) -> LineCounts {
}
}
}
fn count(regions: &[Region], other: &DftHashSet<u32>, hidden: bool) -> u32 {
let mut total = 0;
fn collect(
regions: &[Region],
other: &DftHashSet<u32>,
hidden: bool,
all: &mut Vec<LineRange>,
visible: &mut Vec<LineRange>,
) {
for region in regions {
let hidden = hidden || region.visibility.collapsed;
match &region.node {
Node::Leaf {
alignment_id,
changed,
} => {
if hidden {
continue;
}
let start = all.len();
if other.contains(alignment_id) {
let lines: DftHashSet<u32> = 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<LineRange>, Vec<LineRange>) {
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<LineRange>) -> Vec<LineRange> {
ranges.sort_unstable();
let mut merged: Vec<LineRange> = 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::*;
Expand Down Expand Up @@ -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);
}
Expand Down
Loading