using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using MergeHub.Views.Controls;
using System.Threading;
namespace MergeHub.Services
{
/// 폴더 비교에서 한 항목이 어떤 상태인지.
public enum FolderEntryStatus
{
/// 양쪽이 같다.
Same,
/// 양쪽에 있고 내용이 다르다.
Different,
/// 왼쪽에만 있다.
LeftOnly,
/// 오른쪽에만 있다.
RightOnly,
/// 내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다.
Binary,
///
/// 코드는 같고 주석·공백만 다르다.
///
/// 따로 두는 이유 - 봐야 할 성격이 다르다. 주석만 손댄 파일은 눈으로 훑고 넘길 수 있는데
/// 다른 것과 같은 색으로 섞여 있으면 전부 열어 봐야 한다.
///
CommentOnly
}
/// 폴더 비교 규칙.
public sealed class FolderCompareOptions
{
///
/// 빠른 비교. 내용을 읽지 않고 크기와 수정 시각만 본다.
/// 네트워크 드라이브나 수만 개짜리 폴더에서 몇 분이 몇 초로 줄어든다.
///
public bool QuickCompare { get; set; }
///
/// 이 크기를 넘는 파일은 빠른 비교로 돌린다.
/// 전체 비교를 골랐더라도 수백 MB 짜리를 바이트로 훑는 것은 득보다 실이 크다.
///
public long FullCompareLimitBytes { get; set; } = 8L * 1024 * 1024;
/// 포함할 파일 정규식. 비우면 전부 포함.
public string IncludePattern { get; set; } = string.Empty;
/// 제외할 파일·폴더 정규식. 비우면 제외 없음. 포함 규칙보다 우선한다.
public string ExcludePattern { get; set; } = string.Empty;
/// 하위 폴더까지 훑을지.
public bool IncludeSubfolders { get; set; } = true;
///
/// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를
/// 기대하면 안 된다.
///
public TimeSpan TimeTolerance { get; set; } = TimeSpan.FromSeconds(2);
}
/// 비교 결과 한 항목. 폴더면 Children 이 채워진다.
public sealed class FolderCompareItem
{
public FolderCompareItem(string name, string relativePath, bool isDirectory)
{
Name = name;
RelativePath = relativePath;
IsDirectory = isDirectory;
}
public string Name { get; }
/// 두 뿌리 폴더 기준의 상대 경로. 양쪽 실제 경로를 만들 때 쓴다.
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 Children { get; } = new List();
public bool IsDifferent => Status != FolderEntryStatus.Same;
}
public sealed class FolderCompareResult
{
public List Items { get; } = new List();
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; }
/// 그중 코드는 같고 주석만 다른 것. "다름" 안에 든 수다.
public int CommentOnlyCount { get; set; }
/// 항목 상한에 걸려 도중에 멈췄는지.
public bool Truncated { get; set; }
public string Summary =>
$"전체 {TotalFiles}개 · 다름 {DifferentCount}"
+ (CommentOnlyCount > 0 ? $"(주석만 {CommentOnlyCount})" : string.Empty)
+ $" · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}"
+ (Truncated ? " (너무 많아 일부만 봄)" : string.Empty);
}
///
/// 폴더 두 개를 재귀로 견준다.
///
/// 파일 하나하나를 여는 것이 비용의 전부라서, 열기 전에 걸러낼 수 있는 것은 모두 먼저 거른다 -
/// 크기가 다르면 내용을 볼 필요가 없고, 빠른 비교면 아예 열지 않는다.
///
public static class FolderComparer
{
/// 이 개수를 넘으면 멈춘다. 실수로 C:\ 를 고른 경우에 화면이 굳는 것을 막는다.
private const int MaxEntries = 50_000;
///
/// 큰 폴더를 잘못 골랐을 때 기다리는 수밖에 없으면 안 된다.
/// 항목 하나를 볼 때마다 확인하므로 누르는 즉시 멈춘다.
///
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 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(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(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++;
}
}
/// 폴더 상태는 아래에 담긴 것들로 정한다.
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);
}
///
/// 다른 파일을 어떤 "다름"으로 볼지.
///
/// 이진이면 줄 단위 비교 화면을 열어도 볼 것이 없으니 따로 표시한다.
/// 글 파일이면 코드는 같고 주석만 다른지를 한 번 더 본다.
///
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;
}
///
/// 코드는 같고 주석·공백만 다른가.
///
/// 판단은 주석 토큰을 걷어내고 공백을 지운 뒤 견주는 것이다. 주석 기호는 언어마다 다르므로
/// 확장자로 고른 언어표를 따른다. 언어를 모르는 파일(평문·표 등)은 이 판단을 하지 않는다 -
/// 주석이라는 것이 없다.
///
/// 줄 구분은 코드가 있는 줄에만 남긴다. 그래야 주석 한 줄을 통째로 지운 경우에도
/// 같다고 볼 수 있고, 반대로 코드를 다른 줄로 옮긴 것은 다르다고 본다.
///
/// 큰 파일은 하지 않는다. 전체 비교에서 이미 걸러 둔 한도를 그대로 쓴다 -
/// 폴더 하나에 수천 개가 있을 때 이 판단만으로 몇 분이 더 들면 안 된다.
///
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);
}
/// 주석과 공백을 걷어낸 글. 읽지 못하면 null.
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;
}
}
/// 내용을 앞에서부터 덩어리째 견준다. 다른 곳을 만나면 바로 멈춘다.
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;
}
/// 규칙이 깨져 있으면 규칙이 없는 것으로 본다. 화면이 예외로 죽는 것보다 낫다.
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 SafeFiles(string dir)
{
if (!Directory.Exists(dir)) return Array.Empty();
try { return Directory.GetFiles(dir); }
catch (UnauthorizedAccessException) { return Array.Empty(); }
catch (IOException) { return Array.Empty(); }
}
private static IEnumerable SafeDirs(string dir)
{
if (!Directory.Exists(dir)) return Array.Empty();
try { return Directory.GetDirectories(dir); }
catch (UnauthorizedAccessException) { return Array.Empty(); }
catch (IOException) { return Array.Empty(); }
}
}
}