실제 소스에 XAML 이 400개 42,477줄인데 여태 주석과 따옴표만 알아봐서 사실상 평문이었다. 마크업 전용 토크나이저를 따로 뒀다. 기존 토크나이저에 못 얹은 까닭은 규칙이 다르기 때문이다. 그쪽은 낱말을 읽어 예약어 표와 맞춰 보는데, 마크업은 같은 Content 가 꺾쇠 안에서는 속성 이름이고 밖에서는 그냥 글자다. 낱말이 무엇이냐가 아니라 어디에 있느냐로 갈린다. 줄 넘김 상태를 bool 하나에서 깃발 묶음(CarryOver)으로 넓혔다. XAML 은 여는 태그가 여러 줄에 걸치는 것이 보통이라 - 시험에 쓴 실제 파일은 첫 태그 하나가 14줄이다 - "지금 꺾쇠 안이다"를 안 넘기면 이어지는 줄의 속성이 그냥 글자로 보인다. 부르는 자리 다섯을 모두 바꿨다. 색은 새로 만들지 않고 이미 있는 갈래에 얹었다. 태그는 예약어 색, 속성 이름은 메서드 색, 값은 문자열 색, 앞가지는 타입 색이다. 태그 밖의 본문은 일부러 안 칠한다. 붙이는 중에 파일 비교 화면이 통째로 비어서 뜨는 일이 있었다. 의존 속성 타입을 bool 에서 CarryOver 로 바꾸면서 기본값 false 를 같이 안 고쳤다. 빌드도 통과하고 로그에도 안 남는다. 같은 주의를 코드 옆에 적어 뒀다. 실제 저장소 XAML 사본(638줄)으로 화면 확인. C# 색칠은 그대로. MarkupTest 65건. 전체 688건 통과(하네스 20벌). 경고 0 · 오류 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
479 lines
20 KiB
C#
479 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text.RegularExpressions;
|
|
using MergeHub.Views.Controls;
|
|
using System.Threading;
|
|
|
|
namespace MergeHub.Services
|
|
{
|
|
/// <summary>폴더 비교에서 한 항목이 어떤 상태인지.</summary>
|
|
public enum FolderEntryStatus
|
|
{
|
|
/// <summary>양쪽이 같다.</summary>
|
|
Same,
|
|
/// <summary>양쪽에 있고 내용이 다르다.</summary>
|
|
Different,
|
|
/// <summary>왼쪽에만 있다.</summary>
|
|
LeftOnly,
|
|
/// <summary>오른쪽에만 있다.</summary>
|
|
RightOnly,
|
|
/// <summary>내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다.</summary>
|
|
Binary,
|
|
|
|
/// <summary>
|
|
/// <b>코드는 같고 주석·공백만</b> 다르다.
|
|
///
|
|
/// 따로 두는 이유 - 봐야 할 성격이 다르다. 주석만 손댄 파일은 눈으로 훑고 넘길 수 있는데
|
|
/// 다른 것과 같은 색으로 섞여 있으면 전부 열어 봐야 한다.
|
|
/// </summary>
|
|
CommentOnly
|
|
}
|
|
|
|
/// <summary>폴더 비교 규칙.</summary>
|
|
public sealed class FolderCompareOptions
|
|
{
|
|
/// <summary>
|
|
/// 빠른 비교. 내용을 읽지 않고 <b>크기와 수정 시각</b>만 본다.
|
|
/// 네트워크 드라이브나 수만 개짜리 폴더에서 몇 분이 몇 초로 줄어든다.
|
|
/// </summary>
|
|
public bool QuickCompare { get; set; }
|
|
|
|
/// <summary>
|
|
/// 이 크기를 넘는 파일은 빠른 비교로 돌린다.
|
|
/// 전체 비교를 골랐더라도 수백 MB 짜리를 바이트로 훑는 것은 득보다 실이 크다.
|
|
/// </summary>
|
|
public long FullCompareLimitBytes { get; set; } = 8L * 1024 * 1024;
|
|
|
|
/// <summary>포함할 파일 정규식. 비우면 전부 포함.</summary>
|
|
public string IncludePattern { get; set; } = string.Empty;
|
|
|
|
/// <summary>제외할 파일·폴더 정규식. 비우면 제외 없음. 포함 규칙보다 우선한다.</summary>
|
|
public string ExcludePattern { get; set; } = string.Empty;
|
|
|
|
/// <summary>하위 폴더까지 훑을지.</summary>
|
|
public bool IncludeSubfolders { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를
|
|
/// 기대하면 안 된다.
|
|
/// </summary>
|
|
public TimeSpan TimeTolerance { get; set; } = TimeSpan.FromSeconds(2);
|
|
}
|
|
|
|
/// <summary>비교 결과 한 항목. 폴더면 Children 이 채워진다.</summary>
|
|
public sealed class FolderCompareItem
|
|
{
|
|
public FolderCompareItem(string name, string relativePath, bool isDirectory)
|
|
{
|
|
Name = name;
|
|
RelativePath = relativePath;
|
|
IsDirectory = isDirectory;
|
|
}
|
|
|
|
public string Name { get; }
|
|
|
|
/// <summary>두 뿌리 폴더 기준의 상대 경로. 양쪽 실제 경로를 만들 때 쓴다.</summary>
|
|
public string RelativePath { get; }
|
|
|
|
public bool IsDirectory { get; }
|
|
public FolderEntryStatus Status { get; set; }
|
|
|
|
public bool LeftExists { get; set; }
|
|
public bool RightExists { get; set; }
|
|
public long LeftSize { get; set; }
|
|
public long RightSize { get; set; }
|
|
public DateTime? LeftModified { get; set; }
|
|
public DateTime? RightModified { get; set; }
|
|
|
|
public List<FolderCompareItem> Children { get; } = new List<FolderCompareItem>();
|
|
|
|
public bool IsDifferent => Status != FolderEntryStatus.Same;
|
|
}
|
|
|
|
public sealed class FolderCompareResult
|
|
{
|
|
public List<FolderCompareItem> Items { get; } = new List<FolderCompareItem>();
|
|
|
|
public int TotalFiles { get; set; }
|
|
public int SameCount { get; set; }
|
|
public int DifferentCount { get; set; }
|
|
public int LeftOnlyCount { get; set; }
|
|
public int RightOnlyCount { get; set; }
|
|
|
|
/// <summary>그중 코드는 같고 주석만 다른 것. "다름" 안에 든 수다.</summary>
|
|
public int CommentOnlyCount { get; set; }
|
|
|
|
/// <summary>항목 상한에 걸려 도중에 멈췄는지.</summary>
|
|
public bool Truncated { get; set; }
|
|
|
|
public string Summary =>
|
|
$"전체 {TotalFiles}개 · 다름 {DifferentCount}"
|
|
+ (CommentOnlyCount > 0 ? $"(주석만 {CommentOnlyCount})" : string.Empty)
|
|
+ $" · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}"
|
|
+ (Truncated ? " (너무 많아 일부만 봄)" : string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 폴더 두 개를 재귀로 견준다.
|
|
///
|
|
/// 파일 하나하나를 여는 것이 비용의 전부라서, 열기 전에 걸러낼 수 있는 것은 모두 먼저 거른다 -
|
|
/// 크기가 다르면 내용을 볼 필요가 없고, 빠른 비교면 아예 열지 않는다.
|
|
/// </summary>
|
|
public static class FolderComparer
|
|
{
|
|
/// <summary>이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다.</summary>
|
|
private const int MaxEntries = 50_000;
|
|
|
|
/// <param name="cancel">
|
|
/// 큰 폴더를 잘못 골랐을 때 기다리는 수밖에 없으면 안 된다.
|
|
/// 항목 하나를 볼 때마다 확인하므로 누르는 즉시 멈춘다.
|
|
/// </param>
|
|
public static FolderCompareResult Compare(string leftRoot, string rightRoot, FolderCompareOptions options,
|
|
CancellationToken cancel = default)
|
|
{
|
|
var result = new FolderCompareResult();
|
|
|
|
if (string.IsNullOrWhiteSpace(leftRoot) || string.IsNullOrWhiteSpace(rightRoot)) return result;
|
|
if (!Directory.Exists(leftRoot) && !Directory.Exists(rightRoot)) return result;
|
|
|
|
var include = Compile(options.IncludePattern);
|
|
var exclude = Compile(options.ExcludePattern);
|
|
|
|
var count = 0;
|
|
Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result, result.Items, ref count, cancel);
|
|
|
|
result.Truncated = count >= MaxEntries;
|
|
return result;
|
|
}
|
|
|
|
private static void Walk(string leftRoot, string rightRoot, string relative,
|
|
FolderCompareOptions options, Regex? include, Regex? exclude,
|
|
FolderCompareResult result, List<FolderCompareItem> into, ref int count,
|
|
CancellationToken cancel)
|
|
{
|
|
cancel.ThrowIfCancellationRequested();
|
|
if (count >= MaxEntries) return;
|
|
|
|
var leftDir = Path.Combine(leftRoot, relative);
|
|
var rightDir = Path.Combine(rightRoot, relative);
|
|
|
|
// ---- 파일 ----
|
|
var names = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var path in SafeFiles(leftDir)) names.Add(Path.GetFileName(path));
|
|
foreach (var path in SafeFiles(rightDir)) names.Add(Path.GetFileName(path));
|
|
|
|
foreach (var name in names)
|
|
{
|
|
cancel.ThrowIfCancellationRequested();
|
|
if (count >= MaxEntries) return;
|
|
|
|
var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name);
|
|
if (IsFilteredOut(name, childRelative, include, exclude)) continue;
|
|
|
|
var leftPath = Path.Combine(leftDir, name);
|
|
var rightPath = Path.Combine(rightDir, name);
|
|
|
|
var item = new FolderCompareItem(name, childRelative, isDirectory: false)
|
|
{
|
|
LeftExists = File.Exists(leftPath),
|
|
RightExists = File.Exists(rightPath),
|
|
};
|
|
|
|
if (item.LeftExists)
|
|
{
|
|
var info = new FileInfo(leftPath);
|
|
item.LeftSize = info.Length;
|
|
item.LeftModified = info.LastWriteTime;
|
|
}
|
|
|
|
if (item.RightExists)
|
|
{
|
|
var info = new FileInfo(rightPath);
|
|
item.RightSize = info.Length;
|
|
item.RightModified = info.LastWriteTime;
|
|
}
|
|
|
|
item.Status = Classify(item, leftPath, rightPath, options);
|
|
|
|
into.Add(item);
|
|
count++;
|
|
|
|
result.TotalFiles++;
|
|
switch (item.Status)
|
|
{
|
|
case FolderEntryStatus.Same: result.SameCount++; break;
|
|
// "주석만 다름"도 다름의 한 갈래다. 둘 다 센다 - 요약이 "다름 3(주석만 1)" 로 읽힌다.
|
|
case FolderEntryStatus.CommentOnly:
|
|
result.DifferentCount++;
|
|
result.CommentOnlyCount++;
|
|
break;
|
|
case FolderEntryStatus.LeftOnly: result.LeftOnlyCount++; break;
|
|
case FolderEntryStatus.RightOnly: result.RightOnlyCount++; break;
|
|
default: result.DifferentCount++; break;
|
|
}
|
|
}
|
|
|
|
if (!options.IncludeSubfolders) return;
|
|
|
|
// ---- 하위 폴더 ----
|
|
var dirNames = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var path in SafeDirs(leftDir)) dirNames.Add(Path.GetFileName(path));
|
|
foreach (var path in SafeDirs(rightDir)) dirNames.Add(Path.GetFileName(path));
|
|
|
|
foreach (var name in dirNames)
|
|
{
|
|
if (count >= MaxEntries) return;
|
|
|
|
var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name);
|
|
|
|
// 폴더는 제외 규칙만 본다. 포함 규칙은 파일 이름에 거는 것이라 폴더에 걸면 트리가 통째로 사라진다.
|
|
if (exclude is not null && (exclude.IsMatch(name) || exclude.IsMatch(childRelative))) continue;
|
|
|
|
var item = new FolderCompareItem(name, childRelative, isDirectory: true)
|
|
{
|
|
LeftExists = Directory.Exists(Path.Combine(leftDir, name)),
|
|
RightExists = Directory.Exists(Path.Combine(rightDir, name)),
|
|
};
|
|
|
|
Walk(leftRoot, rightRoot, childRelative, options, include, exclude, result, item.Children, ref count, cancel);
|
|
|
|
item.Status = FolderStatus(item);
|
|
|
|
// 알맹이가 하나도 없는 폴더는 목록만 어지럽힌다. 한쪽에만 있는 폴더는 남긴다.
|
|
if (item.Children.Count == 0 && item.LeftExists && item.RightExists) continue;
|
|
|
|
into.Add(item);
|
|
count++;
|
|
}
|
|
}
|
|
|
|
/// <summary>폴더 상태는 아래에 담긴 것들로 정한다.</summary>
|
|
private static FolderEntryStatus FolderStatus(FolderCompareItem folder)
|
|
{
|
|
if (!folder.RightExists) return FolderEntryStatus.LeftOnly;
|
|
if (!folder.LeftExists) return FolderEntryStatus.RightOnly;
|
|
|
|
foreach (var child in folder.Children)
|
|
{
|
|
if (child.Status != FolderEntryStatus.Same) return FolderEntryStatus.Different;
|
|
}
|
|
|
|
return FolderEntryStatus.Same;
|
|
}
|
|
|
|
private static FolderEntryStatus Classify(FolderCompareItem item, string leftPath, string rightPath,
|
|
FolderCompareOptions options)
|
|
{
|
|
if (!item.RightExists) return FolderEntryStatus.LeftOnly;
|
|
if (!item.LeftExists) return FolderEntryStatus.RightOnly;
|
|
|
|
// 크기가 다르면 내용을 볼 필요가 없다.
|
|
if (item.LeftSize != item.RightSize) return DifferentKind(leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
|
|
|
|
var quick = options.QuickCompare || item.LeftSize > options.FullCompareLimitBytes;
|
|
|
|
if (quick)
|
|
{
|
|
var left = item.LeftModified ?? DateTime.MinValue;
|
|
var right = item.RightModified ?? DateTime.MinValue;
|
|
|
|
var gap = left > right ? left - right : right - left;
|
|
return gap <= options.TimeTolerance
|
|
? FolderEntryStatus.Same
|
|
: DifferentKind(leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
|
|
}
|
|
|
|
return SameContent(leftPath, rightPath)
|
|
? FolderEntryStatus.Same
|
|
: DifferentKind(leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 다른 파일을 어떤 "다름"으로 볼지.
|
|
///
|
|
/// 이진이면 줄 단위 비교 화면을 열어도 볼 것이 없으니 따로 표시한다.
|
|
/// 글 파일이면 <b>코드는 같고 주석만 다른지</b>를 한 번 더 본다.
|
|
/// </summary>
|
|
private static FolderEntryStatus DifferentKind(string leftPath, string rightPath, long size,
|
|
FolderCompareOptions options)
|
|
{
|
|
if (TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath)) return FolderEntryStatus.Binary;
|
|
|
|
return OnlyCommentsDiffer(leftPath, rightPath, size, options)
|
|
? FolderEntryStatus.CommentOnly
|
|
: FolderEntryStatus.Different;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 코드는 같고 <b>주석·공백만</b> 다른가.
|
|
///
|
|
/// 판단은 주석 토큰을 걷어내고 공백을 지운 뒤 견주는 것이다. 주석 기호는 언어마다 다르므로
|
|
/// 확장자로 고른 언어표를 따른다. 언어를 모르는 파일(평문·표 등)은 이 판단을 하지 않는다 -
|
|
/// 주석이라는 것이 없다.
|
|
///
|
|
/// 줄 구분은 <b>코드가 있는 줄에만</b> 남긴다. 그래야 주석 한 줄을 통째로 지운 경우에도
|
|
/// 같다고 볼 수 있고, 반대로 코드를 다른 줄로 옮긴 것은 다르다고 본다.
|
|
///
|
|
/// 큰 파일은 하지 않는다. 전체 비교에서 이미 걸러 둔 한도를 그대로 쓴다 -
|
|
/// 폴더 하나에 수천 개가 있을 때 이 판단만으로 몇 분이 더 들면 안 된다.
|
|
/// </summary>
|
|
private static bool OnlyCommentsDiffer(string leftPath, string rightPath, long size,
|
|
FolderCompareOptions options)
|
|
{
|
|
if (size > options.FullCompareLimitBytes) return false;
|
|
|
|
var profile = LanguageCatalog.ForPath(leftPath);
|
|
if (!profile.IsCode) return false;
|
|
|
|
// 확장자가 서로 다르면 같은 언어로 볼 근거가 없다.
|
|
if (!ReferenceEquals(profile, LanguageCatalog.ForPath(rightPath))) return false;
|
|
|
|
var left = CodeOnly(leftPath, profile);
|
|
if (left is null) return false;
|
|
|
|
var right = CodeOnly(rightPath, profile);
|
|
if (right is null) return false;
|
|
|
|
return string.Equals(left, right, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>주석과 공백을 걷어낸 글. 읽지 못하면 null.</summary>
|
|
private static string? CodeOnly(string path, LanguageProfile profile)
|
|
{
|
|
try
|
|
{
|
|
var text = TextFile.Read(path);
|
|
var builder = new System.Text.StringBuilder(text.Length);
|
|
var carry = CarryOver.None;
|
|
|
|
foreach (var line in text.Split('\n'))
|
|
{
|
|
var body = line.TrimEnd('\r');
|
|
var tokens = CodeTokenizer.Tokenize(body, profile, carry, out carry);
|
|
|
|
var isComment = new bool[body.Length];
|
|
foreach (var token in tokens)
|
|
{
|
|
if (token.Kind != CodeTokenKind.Comment && token.Kind != CodeTokenKind.XmlDoc) continue;
|
|
|
|
var end = Math.Min(body.Length, token.Start + token.Length);
|
|
for (var i = Math.Max(0, token.Start); i < end; i++) isComment[i] = true;
|
|
}
|
|
|
|
var before = builder.Length;
|
|
for (var i = 0; i < body.Length; i++)
|
|
{
|
|
if (isComment[i] || char.IsWhiteSpace(body[i])) continue;
|
|
builder.Append(body[i]);
|
|
}
|
|
|
|
// 코드가 한 글자라도 있던 줄에만 줄 구분을 남긴다.
|
|
if (builder.Length > before) builder.Append('\n');
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
catch (IOException)
|
|
{
|
|
return null;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>내용을 앞에서부터 덩어리째 견준다. 다른 곳을 만나면 바로 멈춘다.</summary>
|
|
private static bool SameContent(string leftPath, string rightPath)
|
|
{
|
|
const int BufferSize = 64 * 1024;
|
|
|
|
try
|
|
{
|
|
using var left = File.OpenRead(leftPath);
|
|
using var right = File.OpenRead(rightPath);
|
|
|
|
if (left.Length != right.Length) return false;
|
|
|
|
var a = new byte[BufferSize];
|
|
var b = new byte[BufferSize];
|
|
|
|
while (true)
|
|
{
|
|
var readA = ReadBlock(left, a);
|
|
var readB = ReadBlock(right, b);
|
|
|
|
if (readA != readB) return false;
|
|
if (readA == 0) return true;
|
|
|
|
for (var i = 0; i < readA; i++)
|
|
{
|
|
if (a[i] != b[i]) return false;
|
|
}
|
|
}
|
|
}
|
|
catch (IOException)
|
|
{
|
|
return false;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static int ReadBlock(Stream stream, byte[] buffer)
|
|
{
|
|
var total = 0;
|
|
|
|
while (total < buffer.Length)
|
|
{
|
|
var read = stream.Read(buffer, total, buffer.Length - total);
|
|
if (read == 0) break;
|
|
total += read;
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
private static bool IsFilteredOut(string name, string relative, Regex? include, Regex? exclude)
|
|
{
|
|
if (exclude is not null && (exclude.IsMatch(name) || exclude.IsMatch(relative))) return true;
|
|
if (include is not null && !include.IsMatch(name) && !include.IsMatch(relative)) return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>규칙이 깨져 있으면 규칙이 없는 것으로 본다. 화면이 예외로 죽는 것보다 낫다.</summary>
|
|
private static Regex? Compile(string pattern)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(pattern)) return null;
|
|
|
|
try
|
|
{
|
|
return new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> SafeFiles(string dir)
|
|
{
|
|
if (!Directory.Exists(dir)) return Array.Empty<string>();
|
|
try { return Directory.GetFiles(dir); }
|
|
catch (UnauthorizedAccessException) { return Array.Empty<string>(); }
|
|
catch (IOException) { return Array.Empty<string>(); }
|
|
}
|
|
|
|
private static IEnumerable<string> SafeDirs(string dir)
|
|
{
|
|
if (!Directory.Exists(dir)) return Array.Empty<string>();
|
|
try { return Directory.GetDirectories(dir); }
|
|
catch (UnauthorizedAccessException) { return Array.Empty<string>(); }
|
|
catch (IOException) { return Array.Empty<string>(); }
|
|
}
|
|
}
|
|
}
|