UbisamBase 플랫폼 위에서 도는 플러그인. 지금까지 만든 것을 그대로 담는다. - 폴더 비교 / 파일 비교(부분 수용·워드랩·찾기) / 3-Way 병합 / Git 조작 - 비교 엔진: histogram 정렬 · 이동 블록 감지 · 무시 규칙 · 13개 언어 토크나이저 - doc/ 에 작업 지시서와 진행 기록 빌드 산출물(bin/obj)은 추적하지 않는다 - 플랫폼 런타임이 빌드 때마다 D:\UbisamPlatform\bin 에서 복사되어 들어오므로 저장소에 담을 이유가 없다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
13 KiB
C#
311 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace MergeHub.Services
|
|
{
|
|
/// <summary>정렬된 한 행. 한쪽에만 있는 줄은 반대쪽이 null 이다.</summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 두 줄 목록을 "내용이 같은 줄끼리" 맞춰 나란히 놓는다.
|
|
///
|
|
/// 줄 번호로 짝지으면 한쪽에 줄이 하나만 늘어나도 그 아래가 통째로 밀려
|
|
/// 똑같은 코드가 서로 다른 줄과 비교된다. 그래서 공통 줄을 먼저 고정하고
|
|
/// 그 사이에 낀 것만 추가/삭제/수정으로 본다.
|
|
///
|
|
/// 공통 줄을 고르는 방식이 정렬 품질을 좌우한다. 예전에는 최장 공통 부분수열(LCS)을
|
|
/// 썼는데, LCS 는 "가장 많이 겹치는" 답을 고르다 보니 <c>{</c>, <c>}</c>, 빈 줄처럼
|
|
/// 짧고 자주 나오는 줄을 엉뚱한 짝에 붙여 정렬이 지저분해진다.
|
|
///
|
|
/// 지금은 <b>히스토그램 방식</b>이다. 원리는 이렇다.
|
|
/// 1. BASE 구간에 어떤 줄이 몇 번 나오는지 센다(= 히스토그램).
|
|
/// 2. 양쪽에 다 있으면서 <b>드물게 나오는 줄</b>을 앵커로 삼는다.
|
|
/// 드문 줄일수록 "그 줄이 맞다"는 확신이 크기 때문이다.
|
|
/// 3. 앵커에서 위아래로 같은 줄이 이어지는 만큼 구간을 넓힌다.
|
|
/// 4. 가장 긴(같으면 더 드문) 구간을 확정하고, 그 앞뒤를 같은 방법으로 다시 나눈다.
|
|
///
|
|
/// git 이 기본으로 쓰는 방식이며, LCS 와 달리 표를 만들지 않아 큰 파일에서도 가볍다.
|
|
/// </summary>
|
|
public static class LineAligner
|
|
{
|
|
/// <summary>
|
|
/// 한 줄이 이보다 자주 나오면 앵커로 쓰지 않는다.
|
|
/// 여닫는 중괄호나 빈 줄처럼 어디에나 있는 줄은 위치를 특정해 주지 못한다.
|
|
/// git 이 쓰는 값과 같다.
|
|
/// </summary>
|
|
private const int MaxOccurrences = 64;
|
|
|
|
/// <summary>
|
|
/// 한 번에 정렬을 시도할 구간의 최대 줄 수. 넘으면 정확한 정렬을 포기하고
|
|
/// 통째로 수정 취급한다 - 화면은 뜨게 하려는 안전선이다.
|
|
///
|
|
/// LCS 때는 표 크기(가로×세로)로 막아야 했지만 히스토그램은 표를 만들지 않는다.
|
|
/// 중복이 극심한 입력에서 구간 확장 비용이 커지는 것만 막으면 되므로 줄 수로 센다.
|
|
/// </summary>
|
|
private const int MaxRegionLines = 20_000;
|
|
|
|
/// <summary>
|
|
/// 두 줄 목록을 맞춰 놓는다.
|
|
///
|
|
/// <paramref name="baseKeys"/> / <paramref name="localKeys"/> 를 주면 "같은 줄인가"를
|
|
/// 그쪽으로 판단한다. 공백·대소문자를 무시할 때 쓴다 - 원문은 그대로 돌려주고
|
|
/// 비교만 다르게 하려는 것이다. 안 주면 원문끼리 비교한다.
|
|
/// </summary>
|
|
public static IReadOnlyList<AlignedRow> Align(IReadOnlyList<string> baseLines,
|
|
IReadOnlyList<string> localLines,
|
|
IReadOnlyList<string>? baseKeys = null,
|
|
IReadOnlyList<string>? localKeys = null)
|
|
{
|
|
var a = baseKeys ?? baseLines;
|
|
var b = localKeys ?? localLines;
|
|
|
|
var rows = new List<AlignedRow>(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;
|
|
}
|
|
|
|
/// <summary>정렬할 구간 하나. IsCommon 이면 이미 짝이 확정된 공통 구간이다.</summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 앵커를 찾아 구간을 셋(앞 / 앵커 / 뒤)으로 쪼개기를 반복한다.
|
|
///
|
|
/// 재귀 대신 스택을 쓴다. 변경 덩어리가 많은 파일은 쪼개는 횟수가 줄 수에 비례해
|
|
/// 재귀로 짜면 깊이가 수천이 되어 스택이 넘칠 수 있다.
|
|
/// </summary>
|
|
private static void AlignRegion(IReadOnlyList<string> baseText, IReadOnlyList<string> localText,
|
|
IReadOnlyList<string> a, IReadOnlyList<string> b,
|
|
int aStart, int aEnd, int bStart, int bEnd,
|
|
List<AlignedRow> rows)
|
|
{
|
|
var work = new Stack<Region>();
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 구간 안에서 가장 믿을 만한 공통 구간(앵커)을 찾는다.
|
|
/// 길수록 좋고, 길이가 같으면 그 안에 흔한 줄이 적은 쪽이 낫다.
|
|
/// </summary>
|
|
private static bool TryFindAnchor(IReadOnlyList<string> a, IReadOnlyList<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 구간 안 줄별 등장 위치. MaxOccurrences 를 넘으면 더 담지 않는다 -
|
|
/// 흔한 줄은 어차피 앵커로 안 쓰므로 위치를 다 기억할 이유가 없다.
|
|
/// (담기를 멈춰도 Count 가 상한을 한 칸 넘으므로 "흔하다"는 판정은 그대로 된다.)
|
|
/// </summary>
|
|
private static Dictionary<string, List<int>> BuildHistogram(IReadOnlyList<string> a, int start, int end)
|
|
{
|
|
var index = new Dictionary<string, List<int>>(StringComparer.Ordinal);
|
|
|
|
for (var i = start; i < end; i++)
|
|
{
|
|
if (!index.TryGetValue(a[i], out var slots))
|
|
{
|
|
slots = new List<int>(1);
|
|
index[a[i]] = slots;
|
|
}
|
|
|
|
if (slots.Count <= MaxOccurrences) slots.Add(i);
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
/// <summary>구간 a[from..to] 안에서 가장 흔한 줄의 등장 횟수. 낮을수록 좋은 앵커다.</summary>
|
|
private static int MaxOccurrenceIn(Dictionary<string, List<int>> index,
|
|
IReadOnlyList<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 짝이 안 맞는 삭제·추가 덩어리를 위에서부터 1:1로 붙여 "수정된 줄"로 보여준다.
|
|
/// 남는 쪽은 한쪽만 있는 행이 된다.
|
|
/// </summary>
|
|
private static List<AlignedRow> PairBlocks(IReadOnlyList<string> a, IReadOnlyList<string> 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<AlignedRow>(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;
|
|
}
|
|
}
|
|
}
|