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/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/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) } 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); }