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; } } }