using System; using System.Collections.Generic; namespace MergeHub.Services { /// 옮겨진 덩어리 하나. 지워진 자리와 새로 나타난 자리를 짝으로 가리킨다. public sealed class MovedBlock { public MovedBlock(int removedStart, int addedStart, int length) { RemovedStart = removedStart; AddedStart = addedStart; Length = length; } /// 정렬 결과에서 "지워진 것으로 보이던" 구간의 시작 행. public int RemovedStart { get; } /// 같은 내용이 "새로 추가된 것으로 보이던" 구간의 시작 행. public int AddedStart { get; } public int Length { get; } } /// /// 정렬 결과에서 자리만 옮긴 덩어리를 찾아낸다. /// /// 메서드 순서만 바꿔도 정렬기는 "여기서 12줄 삭제 · 저기서 12줄 추가"로 본다. /// 사람 눈에는 옮긴 것 하나인데 차이가 둘로 보이니 검토가 번거롭다. /// 지워진 구간과 추가된 구간의 내용이 똑같으면 이동으로 묶어 표시한다. /// /// 한 줄짜리 {, }, 빈 줄까지 이동으로 보면 화면이 온통 이동 표시가 되므로 /// "알맹이가 있는 줄"이 하나라도 있어야 이동으로 친다. /// public static class MoveDetector { /// 이 길이 이상 이어져야 이동으로 본다. 한 줄짜리는 우연히 같을 때가 많다. private const int MinimumLines = 2; /// 두 구간을 견줄 때 만드는 표의 최대 칸 수. 넘으면 이동 찾기를 건너뛴다. private const long MaxCompareCells = 250_000; public static IReadOnlyList Find(IReadOnlyList rows) { // 한쪽에만 있는 구간을 각각 모은다. var removed = CollectRuns(rows, deleted: true); var added = CollectRuns(rows, deleted: false); var moves = new List(); var usedAdded = new bool[added.Count]; foreach (var take in removed) { var bestGive = -1; var bestTakeAt = 0; var bestGiveAt = 0; var bestLength = 0; for (var i = 0; i < added.Count; i++) { if (usedAdded[i]) continue; var (takeAt, giveAt, length) = LongestCommonRun(rows, take, added[i]); if (length <= bestLength) continue; bestGive = i; bestTakeAt = takeAt; bestGiveAt = giveAt; bestLength = length; } if (bestGive < 0 || bestLength < MinimumLines) continue; var removedStart = take.Start + bestTakeAt; var addedStart = added[bestGive].Start + bestGiveAt; if (!HasSubstance(rows, removedStart, bestLength, deleted: true)) continue; usedAdded[bestGive] = true; moves.Add(new MovedBlock(removedStart, addedStart, bestLength)); } return moves; } /// /// 두 구간에서 줄이 이어지는 채로 똑같은 가장 긴 부분을 찾는다. /// /// 구간 전체를 통째로 견주지 않는 이유가 있다. 정렬기는 } 나 빈 줄 같은 공통 줄을 /// 한쪽에 붙여 놓기 때문에, 같은 메서드를 옮겼어도 구간의 시작과 끝이 한두 줄씩 어긋난다. /// 그래서 겹치는 알맹이만 골라낸다. /// private static (int TakeAt, int GiveAt, int Length) LongestCommonRun( IReadOnlyList rows, Run take, Run give) { // 구간이 지나치게 크면 굳이 따지지 않는다 - 이동 표시는 편의 기능이다. if ((long)take.Length * give.Length > MaxCompareCells) return (0, 0, 0); var table = new int[take.Length + 1, give.Length + 1]; var best = 0; var bestTakeAt = 0; var bestGiveAt = 0; for (var i = 1; i <= take.Length; i++) { for (var j = 1; j <= give.Length; j++) { var before = TextOf(rows[take.Start + i - 1], deleted: true); var after = TextOf(rows[give.Start + j - 1], deleted: false); if (!string.Equals(before, after, StringComparison.Ordinal)) continue; table[i, j] = table[i - 1, j - 1] + 1; if (table[i, j] > best) { best = table[i, j]; bestTakeAt = i - best; bestGiveAt = j - best; } } } return (bestTakeAt, bestGiveAt, best); } private readonly struct Run { public Run(int start, int length) { Start = start; Length = length; } public int Start { get; } public int Length { get; } } /// 한쪽에만 있는 줄이 이어지는 구간들. 알맹이가 없는 구간은 걸러낸다. private static List CollectRuns(IReadOnlyList rows, bool deleted) { var runs = new List(); var start = -1; for (var i = 0; i <= rows.Count; i++) { var isTarget = i < rows.Count && IsOneSided(rows[i], deleted); if (isTarget) { if (start < 0) start = i; continue; } if (start >= 0) { var length = i - start; if (length >= MinimumLines && HasSubstance(rows, start, length, deleted)) runs.Add(new Run(start, length)); start = -1; } } return runs; } private static bool IsOneSided(AlignedRow row, bool deleted) => deleted ? row.BaseText is not null && row.LocalText is null : row.BaseText is null && row.LocalText is not null; private static string TextOf(AlignedRow row, bool deleted) => (deleted ? row.BaseText : row.LocalText) ?? string.Empty; /// /// 중괄호·빈 줄만 있는 구간은 이동으로 치지 않는다. /// 어디에나 있는 모양이라 우연히 겹칠 뿐, 옮겼다고 알려 줄 값어치가 없다. /// private static bool HasSubstance(IReadOnlyList rows, int start, int length, bool deleted) { for (var i = start; i < start + length; i++) { var meaningful = 0; foreach (var c in TextOf(rows[i], deleted)) { if (char.IsLetterOrDigit(c) || c == '_' || c > 127) meaningful++; if (meaningful >= 3) return true; } } return false; } } }