UbisamBase 플랫폼 위에서 도는 플러그인. 지금까지 만든 것을 그대로 담는다. - 폴더 비교 / 파일 비교(부분 수용·워드랩·찾기) / 3-Way 병합 / Git 조작 - 비교 엔진: histogram 정렬 · 이동 블록 감지 · 무시 규칙 · 13개 언어 토크나이저 - doc/ 에 작업 지시서와 진행 기록 빌드 산출물(bin/obj)은 추적하지 않는다 - 플랫폼 런타임이 빌드 때마다 D:\UbisamPlatform\bin 에서 복사되어 들어오므로 저장소에 담을 이유가 없다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
7.3 KiB
C#
198 lines
7.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace MergeHub.Services
|
|
{
|
|
/// <summary>옮겨진 덩어리 하나. 지워진 자리와 새로 나타난 자리를 짝으로 가리킨다.</summary>
|
|
public sealed class MovedBlock
|
|
{
|
|
public MovedBlock(int removedStart, int addedStart, int length)
|
|
{
|
|
RemovedStart = removedStart;
|
|
AddedStart = addedStart;
|
|
Length = length;
|
|
}
|
|
|
|
/// <summary>정렬 결과에서 "지워진 것으로 보이던" 구간의 시작 행.</summary>
|
|
public int RemovedStart { get; }
|
|
|
|
/// <summary>같은 내용이 "새로 추가된 것으로 보이던" 구간의 시작 행.</summary>
|
|
public int AddedStart { get; }
|
|
|
|
public int Length { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 정렬 결과에서 <b>자리만 옮긴 덩어리</b>를 찾아낸다.
|
|
///
|
|
/// 메서드 순서만 바꿔도 정렬기는 "여기서 12줄 삭제 · 저기서 12줄 추가"로 본다.
|
|
/// 사람 눈에는 옮긴 것 하나인데 차이가 둘로 보이니 검토가 번거롭다.
|
|
/// 지워진 구간과 추가된 구간의 내용이 똑같으면 <b>이동</b>으로 묶어 표시한다.
|
|
///
|
|
/// 한 줄짜리 <c>{</c>, <c>}</c>, 빈 줄까지 이동으로 보면 화면이 온통 이동 표시가 되므로
|
|
/// "알맹이가 있는 줄"이 하나라도 있어야 이동으로 친다.
|
|
/// </summary>
|
|
public static class MoveDetector
|
|
{
|
|
/// <summary>이 길이 이상 이어져야 이동으로 본다. 한 줄짜리는 우연히 같을 때가 많다.</summary>
|
|
private const int MinimumLines = 2;
|
|
|
|
/// <summary>두 구간을 견줄 때 만드는 표의 최대 칸 수. 넘으면 이동 찾기를 건너뛴다.</summary>
|
|
private const long MaxCompareCells = 250_000;
|
|
|
|
public static IReadOnlyList<MovedBlock> Find(IReadOnlyList<AlignedRow> rows)
|
|
{
|
|
// 한쪽에만 있는 구간을 각각 모은다.
|
|
var removed = CollectRuns(rows, deleted: true);
|
|
var added = CollectRuns(rows, deleted: false);
|
|
|
|
var moves = new List<MovedBlock>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 두 구간에서 <b>줄이 이어지는 채로</b> 똑같은 가장 긴 부분을 찾는다.
|
|
///
|
|
/// 구간 전체를 통째로 견주지 않는 이유가 있다. 정렬기는 <c>}</c> 나 빈 줄 같은 공통 줄을
|
|
/// 한쪽에 붙여 놓기 때문에, 같은 메서드를 옮겼어도 구간의 시작과 끝이 한두 줄씩 어긋난다.
|
|
/// 그래서 겹치는 알맹이만 골라낸다.
|
|
/// </summary>
|
|
private static (int TakeAt, int GiveAt, int Length) LongestCommonRun(
|
|
IReadOnlyList<AlignedRow> 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; }
|
|
}
|
|
|
|
/// <summary>한쪽에만 있는 줄이 이어지는 구간들. 알맹이가 없는 구간은 걸러낸다.</summary>
|
|
private static List<Run> CollectRuns(IReadOnlyList<AlignedRow> rows, bool deleted)
|
|
{
|
|
var runs = new List<Run>();
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 중괄호·빈 줄만 있는 구간은 이동으로 치지 않는다.
|
|
/// 어디에나 있는 모양이라 우연히 겹칠 뿐, 옮겼다고 알려 줄 값어치가 없다.
|
|
/// </summary>
|
|
private static bool HasSubstance(IReadOnlyList<AlignedRow> 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;
|
|
}
|
|
|
|
}
|
|
}
|