using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// 정렬된 한 행. 한쪽에만 있는 줄은 반대쪽이 null 이다.
public sealed class AlignedRow
{
public AlignedRow(string? baseText, int? baseNumber, string? localText, int? localNumber)
{
BaseText = baseText;
BaseNumber = baseNumber;
LocalText = localText;
LocalNumber = localNumber;
}
public string? BaseText { get; }
public int? BaseNumber { get; }
public string? LocalText { get; }
public int? LocalNumber { get; }
}
///
/// 두 줄 목록을 "내용이 같은 줄끼리" 맞춰 나란히 놓는다.
///
/// 줄 번호로 짝지으면 한쪽에 줄이 하나만 늘어나도 그 아래가 통째로 밀려
/// 똑같은 코드가 서로 다른 줄과 비교된다. 그래서 공통 줄을 먼저 고정하고
/// 그 사이에 낀 것만 추가/삭제/수정으로 본다.
///
/// 공통 줄을 고르는 방식이 정렬 품질을 좌우한다. 예전에는 최장 공통 부분수열(LCS)을
/// 썼는데, LCS 는 "가장 많이 겹치는" 답을 고르다 보니 {, }, 빈 줄처럼
/// 짧고 자주 나오는 줄을 엉뚱한 짝에 붙여 정렬이 지저분해진다.
///
/// 지금은 히스토그램 방식이다. 원리는 이렇다.
/// 1. BASE 구간에 어떤 줄이 몇 번 나오는지 센다(= 히스토그램).
/// 2. 양쪽에 다 있으면서 드물게 나오는 줄을 앵커로 삼는다.
/// 드문 줄일수록 "그 줄이 맞다"는 확신이 크기 때문이다.
/// 3. 앵커에서 위아래로 같은 줄이 이어지는 만큼 구간을 넓힌다.
/// 4. 가장 긴(같으면 더 드문) 구간을 확정하고, 그 앞뒤를 같은 방법으로 다시 나눈다.
///
/// git 이 기본으로 쓰는 방식이며, LCS 와 달리 표를 만들지 않아 큰 파일에서도 가볍다.
///
public static class LineAligner
{
///
/// 한 줄이 이보다 자주 나오면 앵커로 쓰지 않는다.
/// 여닫는 중괄호나 빈 줄처럼 어디에나 있는 줄은 위치를 특정해 주지 못한다.
/// git 이 쓰는 값과 같다.
///
private const int MaxOccurrences = 64;
///
/// 한 번에 정렬을 시도할 구간의 최대 줄 수. 넘으면 정확한 정렬을 포기하고
/// 통째로 수정 취급한다 - 화면은 뜨게 하려는 안전선이다.
///
/// LCS 때는 표 크기(가로×세로)로 막아야 했지만 히스토그램은 표를 만들지 않는다.
/// 중복이 극심한 입력에서 구간 확장 비용이 커지는 것만 막으면 되므로 줄 수로 센다.
///
private const int MaxRegionLines = 20_000;
///
/// 두 줄 목록을 맞춰 놓는다.
///
/// / 를 주면 "같은 줄인가"를
/// 그쪽으로 판단한다. 공백·대소문자를 무시할 때 쓴다 - 원문은 그대로 돌려주고
/// 비교만 다르게 하려는 것이다. 안 주면 원문끼리 비교한다.
///
public static IReadOnlyList Align(IReadOnlyList baseLines,
IReadOnlyList localLines,
IReadOnlyList? baseKeys = null,
IReadOnlyList? localKeys = null)
{
var a = baseKeys ?? baseLines;
var b = localKeys ?? localLines;
var rows = new List(Math.Max(baseLines.Count, localLines.Count));
// 1) 앞에서부터 같은 줄은 그대로 붙인다.
var prefix = 0;
while (prefix < baseLines.Count && prefix < localLines.Count &&
string.Equals(a[prefix], b[prefix], StringComparison.Ordinal))
{
rows.Add(new AlignedRow(baseLines[prefix], prefix + 1, localLines[prefix], prefix + 1));
prefix++;
}
// 2) 뒤에서부터 같은 줄도 찾아둔다(앞 구간과 겹치지 않게).
var suffix = 0;
while (suffix < baseLines.Count - prefix && suffix < localLines.Count - prefix &&
string.Equals(a[baseLines.Count - 1 - suffix],
b[localLines.Count - 1 - suffix], StringComparison.Ordinal))
{
suffix++;
}
// 3) 가운데만 히스토그램으로 정렬한다.
AlignRegion(baseLines, localLines, a, b,
prefix, baseLines.Count - suffix,
prefix, localLines.Count - suffix,
rows);
// 4) 뒤쪽 공통 구간.
for (var k = suffix; k > 0; k--)
{
var bi = baseLines.Count - k;
var li = localLines.Count - k;
rows.Add(new AlignedRow(baseLines[bi], bi + 1, localLines[li], li + 1));
}
return rows;
}
/// 정렬할 구간 하나. IsCommon 이면 이미 짝이 확정된 공통 구간이다.
private readonly struct Region
{
public Region(int aStart, int aEnd, int bStart, int bEnd, bool isCommon)
{
AStart = aStart; AEnd = aEnd;
BStart = bStart; BEnd = bEnd;
IsCommon = isCommon;
}
public int AStart { get; }
public int AEnd { get; }
public int BStart { get; }
public int BEnd { get; }
public bool IsCommon { get; }
}
///
/// 앵커를 찾아 구간을 셋(앞 / 앵커 / 뒤)으로 쪼개기를 반복한다.
///
/// 재귀 대신 스택을 쓴다. 변경 덩어리가 많은 파일은 쪼개는 횟수가 줄 수에 비례해
/// 재귀로 짜면 깊이가 수천이 되어 스택이 넘칠 수 있다.
///
private static void AlignRegion(IReadOnlyList baseText, IReadOnlyList localText,
IReadOnlyList a, IReadOnlyList b,
int aStart, int aEnd, int bStart, int bEnd,
List rows)
{
var work = new Stack();
work.Push(new Region(aStart, aEnd, bStart, bEnd, false));
while (work.Count > 0)
{
var r = work.Pop();
if (r.IsCommon)
{
for (var k = 0; k < r.AEnd - r.AStart; k++)
{
rows.Add(new AlignedRow(baseText[r.AStart + k], r.AStart + k + 1,
localText[r.BStart + k], r.BStart + k + 1));
}
continue;
}
if (r.AStart >= r.AEnd && r.BStart >= r.BEnd) continue;
var tooBig = r.AEnd - r.AStart > MaxRegionLines || r.BEnd - r.BStart > MaxRegionLines;
// 한쪽이 비었거나, 너무 크거나, 공통 줄이 하나도 없으면 통째로 짝짓는다.
if (r.AStart >= r.AEnd || r.BStart >= r.BEnd || tooBig ||
!TryFindAnchor(a, b, r, out var aBegin, out var bBegin, out var length))
{
rows.AddRange(PairBlocks(baseText, localText, r.AStart, r.AEnd, r.BStart, r.BEnd));
continue;
}
// 꺼내는 순서가 앞 → 앵커 → 뒤 가 되도록 거꾸로 넣는다.
work.Push(new Region(aBegin + length, r.AEnd, bBegin + length, r.BEnd, false));
work.Push(new Region(aBegin, aBegin + length, bBegin, bBegin + length, true));
work.Push(new Region(r.AStart, aBegin, r.BStart, bBegin, false));
}
}
///
/// 구간 안에서 가장 믿을 만한 공통 구간(앵커)을 찾는다.
/// 길수록 좋고, 길이가 같으면 그 안에 흔한 줄이 적은 쪽이 낫다.
///
private static bool TryFindAnchor(IReadOnlyList a, IReadOnlyList b, Region r,
out int aBegin, out int bBegin, out int length)
{
aBegin = 0; bBegin = 0; length = 0;
var index = BuildHistogram(a, r.AStart, r.AEnd);
var found = false;
var bestCommonness = int.MaxValue;
var bs = r.BStart;
while (bs < r.BEnd)
{
if (!index.TryGetValue(b[bs], out var slots) || slots.Count > MaxOccurrences)
{
// BASE 에 없는 줄이거나, 너무 흔해서 위치를 특정 못 하는 줄이다.
bs++;
continue;
}
// 이 위치에서 찾아낸 가장 긴 구간의 끝 다음 - 같은 자리를 다시 훑지 않는다.
var next = bs + 1;
foreach (var candidate in slots)
{
var ai = candidate;
var bi = bs;
while (ai > r.AStart && bi > r.BStart &&
string.Equals(a[ai - 1], b[bi - 1], StringComparison.Ordinal))
{
ai--; bi--;
}
var aj = candidate;
var bj = bs;
while (aj + 1 < r.AEnd && bj + 1 < r.BEnd &&
string.Equals(a[aj + 1], b[bj + 1], StringComparison.Ordinal))
{
aj++; bj++;
}
if (bj + 1 > next) next = bj + 1;
var span = aj - ai + 1;
var commonness = MaxOccurrenceIn(index, a, ai, aj);
found = true;
if (span > length || (span == length && commonness < bestCommonness))
{
aBegin = ai;
bBegin = bi;
length = span;
bestCommonness = commonness;
}
}
bs = next;
}
return found;
}
///
/// 구간 안 줄별 등장 위치. MaxOccurrences 를 넘으면 더 담지 않는다 -
/// 흔한 줄은 어차피 앵커로 안 쓰므로 위치를 다 기억할 이유가 없다.
/// (담기를 멈춰도 Count 가 상한을 한 칸 넘으므로 "흔하다"는 판정은 그대로 된다.)
///
private static Dictionary> BuildHistogram(IReadOnlyList a, int start, int end)
{
var index = new Dictionary>(StringComparer.Ordinal);
for (var i = start; i < end; i++)
{
if (!index.TryGetValue(a[i], out var slots))
{
slots = new List(1);
index[a[i]] = slots;
}
if (slots.Count <= MaxOccurrences) slots.Add(i);
}
return index;
}
/// 구간 a[from..to] 안에서 가장 흔한 줄의 등장 횟수. 낮을수록 좋은 앵커다.
private static int MaxOccurrenceIn(Dictionary> index,
IReadOnlyList a, int from, int to)
{
var worst = 0;
for (var k = from; k <= to; k++)
{
if (index.TryGetValue(a[k], out var slots) && slots.Count > worst)
worst = slots.Count;
}
return worst;
}
///
/// 짝이 안 맞는 삭제·추가 덩어리를 위에서부터 1:1로 붙여 "수정된 줄"로 보여준다.
/// 남는 쪽은 한쪽만 있는 행이 된다.
///
private static List PairBlocks(IReadOnlyList a, IReadOnlyList b,
int aStart, int aEnd, int bStart, int bEnd)
{
var removed = Math.Max(0, aEnd - aStart);
var added = Math.Max(0, bEnd - bStart);
var rows = new List(Math.Max(removed, added));
var pairs = Math.Min(removed, added);
for (var k = 0; k < pairs; k++)
{
rows.Add(new AlignedRow(a[aStart + k], aStart + k + 1,
b[bStart + k], bStart + k + 1));
}
for (var k = pairs; k < removed; k++)
rows.Add(new AlignedRow(a[aStart + k], aStart + k + 1, null, null));
for (var k = pairs; k < added; k++)
rows.Add(new AlignedRow(null, null, b[bStart + k], bStart + k + 1));
return rows;
}
}
}