feat(diff): highlight changed words inside paired diff lines

This commit is contained in:
mika kuns
2026-08-07 09:17:26 +02:00
parent 3cd0f0879e
commit ebbee6005d
2 changed files with 192 additions and 2 deletions
@@ -32,6 +32,12 @@ public static class DiffAlignment
/// Marker text for a skipped region between two hunks.
public const string GapText = "⋯";
/// Word diff is O(n·m) in tokens and is noise on lines that were rewritten wholesale,
/// so it is skipped on very long lines, on token-heavy lines, and on dissimilar pairs.
public const int MaxWordDiffChars = 2000;
public const int MaxWordDiffTokens = 400;
public const double MinWordDiffSimilarity = 0.5;
internal static readonly IReadOnlyList<TextSpan> NoSpans = Array.Empty<TextSpan>();
public static AlignedDiff Build(IReadOnlyList<DiffLineViewModel>? lines)
@@ -126,7 +132,100 @@ public static class DiffAlignment
k < paired ? spans[k].Right : NoSpans));
}
/// Placeholder until Task 2 fills in the token LCS.
/// Changed character ranges on each side of a 1:1 line pair, or no spans when the pair
/// is too long, too token-heavy or too dissimilar for per-word highlighting to help.
internal static (IReadOnlyList<TextSpan> Left, IReadOnlyList<TextSpan> Right) WordDiff(
string left, string right) => (NoSpans, NoSpans);
string left, string right)
{
if (left.Length == 0 || right.Length == 0 || string.Equals(left, right, StringComparison.Ordinal))
return (NoSpans, NoSpans);
if (left.Length > MaxWordDiffChars || right.Length > MaxWordDiffChars)
return (NoSpans, NoSpans);
var a = Tokenize(left);
var b = Tokenize(right);
if (a.Count > MaxWordDiffTokens || b.Count > MaxWordDiffTokens)
return (NoSpans, NoSpans);
var (keepA, keepB, common) = LongestCommonSubsequence(a, b);
var similarity = common / (double)Math.Max(a.Count, b.Count);
if (similarity < MinWordDiffSimilarity) return (NoSpans, NoSpans);
return (SpansForUnmatched(a, keepA), SpansForUnmatched(b, keepB));
}
/// Splits a line into runs of word characters, runs of whitespace, and single
/// punctuation characters — the granularity that makes an identifier rename read as
/// one changed token rather than a string of changed characters.
private static List<Token> Tokenize(string s)
{
var tokens = new List<Token>();
var i = 0;
while (i < s.Length)
{
var start = i;
if (IsWordChar(s[i]))
while (i < s.Length && IsWordChar(s[i])) i++;
else if (char.IsWhiteSpace(s[i]))
while (i < s.Length && char.IsWhiteSpace(s[i])) i++;
else
i++;
tokens.Add(new Token(start, s[start..i]));
}
return tokens;
}
private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c == '_';
private static (bool[] KeepA, bool[] KeepB, int Common) LongestCommonSubsequence(
List<Token> a, List<Token> b)
{
var n = a.Count;
var m = b.Count;
var dp = new int[n + 1, m + 1];
for (var i = n - 1; i >= 0; i--)
for (var j = m - 1; j >= 0; j--)
dp[i, j] = string.Equals(a[i].Text, b[j].Text, StringComparison.Ordinal)
? dp[i + 1, j + 1] + 1
: Math.Max(dp[i + 1, j], dp[i, j + 1]);
var keepA = new bool[n];
var keepB = new bool[m];
int x = 0, y = 0;
while (x < n && y < m)
{
if (string.Equals(a[x].Text, b[y].Text, StringComparison.Ordinal))
{
keepA[x] = keepB[y] = true;
x++; y++;
}
else if (dp[x + 1, y] >= dp[x, y + 1]) x++;
else y++;
}
return (keepA, keepB, dp[0, 0]);
}
/// Merges runs of consecutive unmatched tokens so "zz" is one span, not two.
private static IReadOnlyList<TextSpan> SpansForUnmatched(List<Token> tokens, bool[] keep)
{
var spans = new List<TextSpan>();
var i = 0;
while (i < tokens.Count)
{
if (keep[i]) { i++; continue; }
var start = tokens[i].Start;
var end = start + tokens[i].Text.Length;
i++;
while (i < tokens.Count && !keep[i])
{
end = tokens[i].Start + tokens[i].Text.Length;
i++;
}
spans.Add(new TextSpan(start, end - start));
}
return spans.Count == 0 ? NoSpans : spans;
}
/// A tokenized slice of a line, carrying its offset so spans map back to characters.
private readonly record struct Token(int Start, string Text);
}
@@ -164,4 +164,95 @@ public class DiffAlignmentTests
Assert.Single(result.SplitRows);
Assert.Equal("a", result.SplitRows[0].LeftText);
}
[Fact]
public void WordDiff_HighlightsOnlyTheChangedToken()
{
var result = DiffAlignment.Build(new[]
{
Del(1, " var x = 1;"),
Add(1, " var x = 2;"),
});
var row = Assert.Single(result.SplitRows);
var left = Assert.Single(row.LeftSpans);
var right = Assert.Single(row.RightSpans);
Assert.Equal(new TextSpan(12, 1), left);
Assert.Equal(new TextSpan(12, 1), right);
Assert.Equal("1", row.LeftText.Substring(left.Start, left.Length));
Assert.Equal("2", row.RightText.Substring(right.Start, right.Length));
}
[Fact]
public void WordDiff_MergesAdjacentChangedTokensIntoOneSpan()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "call(a, b);"),
Add(1, "call(zz, b);"),
});
var row = Assert.Single(result.SplitRows);
var right = Assert.Single(row.RightSpans);
Assert.Equal("zz", row.RightText.Substring(right.Start, right.Length));
}
[Fact]
public void WordDiff_SkippedWhenTheLinesAreUnrelated()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "public void Alpha()"),
Add(1, "return 42;"),
});
var row = Assert.Single(result.SplitRows);
Assert.Empty(row.LeftSpans);
Assert.Empty(row.RightSpans);
}
[Fact]
public void WordDiff_SkippedOnVeryLongLines()
{
var left = new string('a', DiffAlignment.MaxWordDiffChars + 1);
var result = DiffAlignment.Build(new[] { Del(1, left), Add(1, left + "b") });
var row = Assert.Single(result.SplitRows);
Assert.Empty(row.LeftSpans);
Assert.Empty(row.RightSpans);
}
[Fact]
public void WordDiff_SkippedWhenTokenCountExceedsTheCap()
{
var many = string.Join(" ", Enumerable.Range(0, DiffAlignment.MaxWordDiffTokens + 10).Select(n => $"t{n}"));
var result = DiffAlignment.Build(new[] { Del(1, many), Add(1, many + " x") });
var row = Assert.Single(result.SplitRows);
Assert.Empty(row.LeftSpans);
Assert.Empty(row.RightSpans);
}
[Fact]
public void WordDiff_IdenticalTextYieldsNoSpans()
{
var result = DiffAlignment.Build(new[] { Del(1, "same"), Add(1, "same") });
var row = Assert.Single(result.SplitRows);
Assert.Empty(row.LeftSpans);
Assert.Empty(row.RightSpans);
}
[Fact]
public void WordDiff_UnpairedOverhangRowsHaveNoSpans()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "var x = 1;"),
Add(1, "var x = 2;"), Add(2, "var y = 3;"),
});
Assert.NotEmpty(result.SplitRows[0].RightSpans);
Assert.Empty(result.SplitRows[1].RightSpans);
}
}