Files
mostalive cdadafe676 em 04: codegen — generate the refactoring, round-trip check, unified-diff preview
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').
2026-09-12 21:15:28 +01:00

188 lines
6.0 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}