RefactoringGenerator emits the new method (promoted signature, parameter
names = source symbol names) plus the rewritten call site for every
em 02/03 bucket shape (return slot declared inside/outside, tuple
deconstruction, ref write-back, return-call when the selection ends a
non-void method) and refuses loudly where v1 cannot be sound (composite
trailing return, return not ending the method).
The transformed tree must compile with zero diagnostics (the yak's
round-trip check, enforced in both the CLI and the tests).
Findings that shaped the implementation, all enforced by tests:
- NormalizeWhitespace on the WHOLE root corrupts doc-comment trivia so
Roslyn's XML-doc writer fails with CS1569 ('count (-3) must be
non-negative') — and it would reformat the entire file, killing the
diff. Only generated nodes are normalized; original trivia is kept and
indentation/end-of-line is grafted onto the inserted statements/method.
- ParseTypeName("void") yields a node rejected as a method return type
(CS1547) — a void signature must be PredefinedType(Token(VoidKeyword)).
- NormalizeWhitespace drops the space after the contextual keyword 'var'
before '(' — the parsed 'var (x, y) = ...' deconstruction skeleton is
used verbatim; builder-made statements are normalized.
- The new method is normalized inside a throwaway class wrapper: the
normalizer computes indentation from nesting depth, and a bare method
has none.
v1 decision: print a unified-diff PREVIEW to stdout (hand-rolled LCS
unified diff), never an in-place rewrite. Also fixes the hunk header
off-by-one from the phantom trailing empty line of Split('\n').
188 lines
6.0 KiB
C#
188 lines
6.0 KiB
C#
using System.Text;
|
||
|
||
namespace ExtractMethod.Tooling;
|
||
|
||
/// <summary>
|
||
/// Line-based unified diff (v1, hand-rolled, no dependencies): an LCS over
|
||
/// lines is backtracked into equal/delete/insert runs, and the runs are
|
||
/// printed as unified hunks with 3 lines of context (hunks closer than
|
||
/// 2×context lines apart merge). The shape is the familiar
|
||
/// <c>---/+++/@@</c> one — enough for a human to review the generated
|
||
/// refactoring; no patch-file semantics are promised.
|
||
/// </summary>
|
||
public static class UnifiedDiff
|
||
{
|
||
private const int Context = 3;
|
||
|
||
public static string Diff(string original, string modified, string originalLabel, string modifiedLabel)
|
||
{
|
||
var a = SplitLines(original);
|
||
var b = SplitLines(modified);
|
||
|
||
var sb = new StringBuilder($"--- {originalLabel}\n+++ {modifiedLabel}\n");
|
||
foreach (var hunk in BuildHunks(a, b))
|
||
{
|
||
sb.Append(hunk);
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Split on newlines, dropping a trailing carriage return (CRLF tolerance).
|
||
/// A trailing newline does NOT start a new line: "a\n" is ONE line, so the
|
||
/// phantom empty element a naive Split('\n') leaves at the end is dropped
|
||
/// (it would inflate every hunk header by one). The empty string is zero
|
||
/// lines.
|
||
/// </summary>
|
||
private static string[] SplitLines(string text)
|
||
{
|
||
if (text.Length == 0)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
var lines = text.Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
|
||
return lines[^1].Length == 0 ? lines[..^1] : lines;
|
||
}
|
||
|
||
private enum Op { Equal, Del, Ins }
|
||
|
||
/// <summary>
|
||
/// LCS + backtracking + hunk assembly. <c>lcs[i, j]</c> is the length of
|
||
/// the longest common suffix of <c>a[i..]</c> and <c>b[j..]</c>.
|
||
/// </summary>
|
||
private static IReadOnlyList<string> BuildHunks(string[] a, string[] b)
|
||
{
|
||
var n = a.Length;
|
||
var m = b.Length;
|
||
|
||
var lcs = new int[n + 1, m + 1];
|
||
for (var row = n - 1; row >= 0; row--)
|
||
{
|
||
for (var col = m - 1; col >= 0; col--)
|
||
{
|
||
lcs[row, col] = a[row] == b[col]
|
||
? lcs[row + 1, col + 1] + 1
|
||
: Math.Max(lcs[row + 1, col], lcs[row, col + 1]);
|
||
}
|
||
}
|
||
|
||
// Backtrack: equal lines advance both; on a mismatch prefer a deletion
|
||
// (ties go to delete, which keeps the printed block old-then-new).
|
||
var ops = new List<(Op Tag, int A, int B)>();
|
||
var i = 0;
|
||
var j = 0;
|
||
while (i < n && j < m)
|
||
{
|
||
if (a[i] == b[j])
|
||
{
|
||
ops.Add((Op.Equal, i, j));
|
||
i++;
|
||
j++;
|
||
}
|
||
else if (lcs[i + 1, j] >= lcs[i, j + 1])
|
||
{
|
||
ops.Add((Op.Del, i, j));
|
||
i++;
|
||
}
|
||
else
|
||
{
|
||
ops.Add((Op.Ins, i, j));
|
||
j++;
|
||
}
|
||
}
|
||
|
||
while (i < n)
|
||
{
|
||
ops.Add((Op.Del, i, j));
|
||
i++;
|
||
}
|
||
|
||
while (j < m)
|
||
{
|
||
ops.Add((Op.Ins, i, j));
|
||
j++;
|
||
}
|
||
|
||
// A "region" is a maximal run of non-equal ops: [AStart, AEnd) ×
|
||
// [BStart, BEnd) in the two line arrays.
|
||
var regions = new List<(int AStart, int AEnd, int BStart, int BEnd)>();
|
||
var index = 0;
|
||
while (index < ops.Count)
|
||
{
|
||
if (ops[index].Tag == Op.Equal)
|
||
{
|
||
index++;
|
||
continue;
|
||
}
|
||
|
||
var first = ops[index];
|
||
var regionAStart = first.Tag == Op.Del ? first.A : (index > 0 ? ops[index - 1].A + 1 : 0);
|
||
var regionBStart = first.B;
|
||
while (index < ops.Count && ops[index].Tag != Op.Equal)
|
||
{
|
||
index++;
|
||
}
|
||
|
||
var last = ops[index - 1];
|
||
regions.Add((regionAStart,
|
||
last.Tag == Op.Del ? last.A + 1 : last.A,
|
||
regionBStart,
|
||
last.Tag == Op.Ins ? last.B + 1 : last.B));
|
||
}
|
||
|
||
// Extend every region by Context lines (clamped), then merge regions
|
||
// whose extended ranges overlap.
|
||
var hunks = new List<(int AStart, int AEnd, int BStart, int BEnd)>();
|
||
foreach (var (aStart, aEnd, bStart, bEnd) in regions)
|
||
{
|
||
var (haStart, haEnd) = (Math.Max(0, aStart - Context), Math.Min(n, aEnd + Context));
|
||
var (hbStart, hbEnd) = (Math.Max(0, bStart - Context), Math.Min(m, bEnd + Context));
|
||
if (hunks.Count > 0)
|
||
{
|
||
var prev = hunks[^1];
|
||
if (haStart <= prev.AEnd && hbStart <= prev.BEnd)
|
||
{
|
||
hunks[^1] = (prev.AStart, Math.Max(prev.AEnd, haEnd), prev.BStart, Math.Max(prev.BEnd, hbEnd));
|
||
continue;
|
||
}
|
||
}
|
||
|
||
hunks.Add((haStart, haEnd, hbStart, hbEnd));
|
||
}
|
||
|
||
return hunks.Select(hunk => RenderHunk(ops, a, b, hunk.AStart, hunk.AEnd, hunk.BStart, hunk.BEnd)).ToList();
|
||
}
|
||
|
||
private static string RenderHunk(
|
||
IReadOnlyList<(Op Tag, int A, int B)> ops,
|
||
string[] a,
|
||
string[] b,
|
||
int aStart,
|
||
int aEnd,
|
||
int bStart,
|
||
int bEnd)
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine($"@@ -{aStart + 1},{aEnd - aStart} +{bStart + 1},{bEnd - bStart} @@");
|
||
foreach (var (tag, aIndex, bIndex) in ops)
|
||
{
|
||
switch (tag)
|
||
{
|
||
case Op.Equal when aStart <= aIndex && aIndex < aEnd:
|
||
sb.Append(' ').AppendLine(a[aIndex]);
|
||
break;
|
||
case Op.Del when aStart <= aIndex && aIndex < aEnd:
|
||
sb.Append('-').AppendLine(a[aIndex]);
|
||
break;
|
||
case Op.Ins when bStart <= bIndex && bIndex < bEnd:
|
||
sb.Append('+').AppendLine(b[bIndex]);
|
||
break;
|
||
}
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
}
|