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