using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using MergeHub.Views.Controls; using System.Threading; using System.Threading.Tasks; namespace MergeHub.Services { /// 폴더 비교에서 한 항목이 어떤 상태인지. public enum FolderEntryStatus { /// 양쪽이 같다. Same, /// 양쪽에 있고 내용이 다르다. Different, /// 왼쪽에만 있다. LeftOnly, /// 오른쪽에만 있다. RightOnly, /// 내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다. Binary, /// /// 코드는 같고 주석·공백만 다르다. /// /// 따로 두는 이유 - 봐야 할 성격이 다르다. 주석만 손댄 파일은 눈으로 훑고 넘길 수 있는데 /// 다른 것과 같은 색으로 섞여 있으면 전부 열어 봐야 한다. /// CommentOnly } /// /// 바이트는 다른데 설정 때문에 "같음" 으로 본 까닭(10차 F-2). /// /// 설정 [C# 코드 스타일 맞춤] 이 켜져 있으면 중괄호 자리만 다른 파일, [비교 · 병합에서 머지 주석 무시] 가 켜져 있으면 /// 머지 주석 줄만 다른 파일을 같음으로 본다(6차 B-5 · E-1). 그런데 화면에는 그냥 "같음" 이라 크기가 다른 파일이 /// "같은 것만" 목록에 섞여 내용이 다른 파일처럼 보였다. 같음은 그대로 두고 왜 같은지를 따로 적는다(사용자 결정). /// public enum SameReason { /// 바이트가 같다(또는 빠른 비교에서 크기 · 수정일이 같다). None, /// C# 중괄호 자리만 다르다. Style, /// 머지 주석 줄(또는 줄 끝 머지 주석)만 다르다. MergeNotes, /// 둘 다를 맞춰야 같다. StyleAndMergeNotes } /// 폴더 비교 목록의 상태 글자. 화면과 검사가 같은 글을 쓴다(10차 F-1 · F-2). public static class FolderStatusText { public static string Of(FolderEntryStatus status, SameReason reason) => status switch { FolderEntryStatus.Same => reason switch { SameReason.Style => "같음(스타일만 다름)", SameReason.MergeNotes => "같음(머지 주석만 다름)", SameReason.StyleAndMergeNotes => "같음(스타일 · 머지 주석만 다름)", _ => "같음" }, FolderEntryStatus.Different => "다름", FolderEntryStatus.LeftOnly => "왼쪽만", FolderEntryStatus.RightOnly => "오른쪽만", FolderEntryStatus.Binary => "다름(이진)", FolderEntryStatus.CommentOnly => "주석만 다름", _ => string.Empty }; /// /// "같은 것만 · 왼쪽에만 · 오른쪽에만" 으로 걸러 볼 때 폴더 줄에 적을 글. 걸린 파일 수를 적는다. /// 폴더 자신의 상태(안에 다른 것이 하나라도 있으면 "다름")를 적으면 "같은 것만" 목록에 주황 "다름" 줄이 섞여 /// 내용이 다른 것이 보이는 것처럼 읽혔다(10차 F-1). /// public static string FilteredFolder(FolderEntryStatus kind, int count) => kind switch { FolderEntryStatus.Same => $"같음 {count}개", FolderEntryStatus.LeftOnly => $"왼쪽만 {count}개", FolderEntryStatus.RightOnly => $"오른쪽만 {count}개", _ => $"{count}개" }; } /// 폴더 비교 규칙. 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; /// /// 머지 주석(// [이름] 날짜)만 다른 파일을 같은 파일로 볼지(6차 B-5). /// 주석 줄을 빼고 줄 끝 주석을 뗀 글이 같으면 "같음" 이다. 줄끝 · 인코딩 차이는 가리지 않는다. /// public bool IgnoreMergeNotes { get; set; } /// /// C# 파일을 이 스타일로 맞춘 뒤 견줄지(6차 추가). 중괄호 줄바꿈만 다른 파일은 "같음" 이다. /// 줄끝 · 인코딩 차이는 가리지 않는다. /// public CSharpBraceStyle CSharpStyle { get; set; } /// /// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(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; } /// 설정 때문에 같음으로 본 까닭. 같음이 아니거나 바이트가 같으면 None(10차 F-2). public SameReason SameReason { 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 pending = new List(); var count = 0; Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result.Items, pending, ref count, cancel); ClassifyAll(pending, options, cancel); Tally(result.Items, result); result.Truncated = count >= MaxEntries; return result; } /// /// 여기부터 여러 갈래로 나눈다. 그 아래는 나눠 봐야 갈래를 세우는 값이 더 든다. /// private const int ParallelThreshold = 64; /// 아직 무엇인지 가리지 않은 파일 하나. 경로를 다시 만들지 않으려고 같이 들고 다닌다. private readonly struct Pending { public Pending(FolderCompareItem item, string leftPath, string rightPath) { Item = item; LeftPath = leftPath; RightPath = rightPath; } public FolderCompareItem Item { get; } public string LeftPath { get; } public string RightPath { get; } } /// 모아 둔 파일을 가린다. 몇 개 안 되면 그냥 차례로 한다. private static void ClassifyAll(List pending, FolderCompareOptions options, CancellationToken cancel) { if (pending.Count < ParallelThreshold) { foreach (var p in pending) { cancel.ThrowIfCancellationRequested(); p.Item.Status = Classify(p.Item, p.LeftPath, p.RightPath, options); } return; } var parallel = new ParallelOptions { CancellationToken = cancel }; Parallel.ForEach(pending, parallel, p => p.Item.Status = Classify(p.Item, p.LeftPath, p.RightPath, options)); } /// /// 트리를 차례대로 돌며 폴더 상태를 정하고 개수를 센다. /// 세는 일은 반드시 한 갈래로 한다 - 여러 갈래로 더하면 값이 어긋난다. /// private static void Tally(List items, FolderCompareResult result) { foreach (var item in items) { if (item.IsDirectory) { Tally(item.Children, result); item.Status = FolderStatus(item); continue; } 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; } } } /// /// 폴더 하나를 훑어 트리를 세운다. 파일은 아직 가리지 않고 에 모아 둔다. /// /// 이름만 얻고 크기·시각을 다시 묻는 대신 로 훑는다. /// 목록을 얻을 때 크기와 수정 시각이 딸려 오기 때문이다. 예전에는 파일 하나마다 /// File.Exists 한 번과 new FileInfo(...) 한 번을 양쪽에 물었다 - /// 4,500개면 그것만 만 팔천 번이고, 실제로 훑는 시간의 열에 아홉이 여기였다. /// private static void Walk(string leftRoot, string rightRoot, string relative, FolderCompareOptions options, Regex? include, Regex? exclude, List into, List pending, ref int count, CancellationToken cancel) { cancel.ThrowIfCancellationRequested(); if (count >= MaxEntries) return; var leftDir = Path.Combine(leftRoot, relative); var rightDir = Path.Combine(rightRoot, relative); // ---- 파일 ---- var files = new SortedDictionary(StringComparer.OrdinalIgnoreCase); foreach (var info in SafeFiles(leftDir)) { files[info.Name] = new SidePair(info, null); } foreach (var info in SafeFiles(rightDir)) { files[info.Name] = files.TryGetValue(info.Name, out var got) ? new SidePair(got.Left, info) : new SidePair(null, info); } foreach (var pair in files) { cancel.ThrowIfCancellationRequested(); if (count >= MaxEntries) return; var name = pair.Key; var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name); if (IsFilteredOut(name, childRelative, include, exclude)) continue; var item = new FolderCompareItem(name, childRelative, isDirectory: false) { LeftExists = pair.Value.Left is not null, RightExists = pair.Value.Right is not null, }; if (pair.Value.Left is { } left) { item.LeftSize = left.Length; item.LeftModified = left.LastWriteTime; } if (pair.Value.Right is { } right) { item.RightSize = right.Length; item.RightModified = right.LastWriteTime; } pending.Add(new Pending(item, Path.Combine(leftDir, name), Path.Combine(rightDir, name))); into.Add(item); count++; } if (!options.IncludeSubfolders) return; // ---- 하위 폴더 ---- var dirNames = new SortedSet(StringComparer.OrdinalIgnoreCase); foreach (var info in SafeDirs(leftDir)) dirNames.Add(info.Name); foreach (var info in SafeDirs(rightDir)) dirNames.Add(info.Name); 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, item.Children, pending, ref count, cancel); // 알맹이가 하나도 없는 폴더는 목록만 어지럽힌다. 한쪽에만 있는 폴더는 남긴다. // 폴더 상태는 아래 것들이 다 가려진 뒤에 정한다(Tally). if (item.Children.Count == 0 && item.LeftExists && item.RightExists) continue; into.Add(item); count++; } } /// 같은 이름의 양쪽 파일. 없는 쪽은 null. private readonly struct SidePair { public SidePair(FileInfo? left, FileInfo? right) { Left = left; Right = right; } public FileInfo? Left { get; } public FileInfo? Right { get; } } /// 폴더 상태는 아래에 담긴 것들로 정한다. 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(item, 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(item, leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options); } return SameContent(leftPath, rightPath) ? FolderEntryStatus.Same : DifferentKind(item, leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options); } /// /// 다른 파일을 어떤 "다름"으로 볼지. /// /// 이진이면 줄 단위 비교 화면을 열어도 볼 것이 없으니 따로 표시한다. /// 글 파일이면 코드는 같고 주석만 다른지를 한 번 더 본다. /// private static FolderEntryStatus DifferentKind(FolderCompareItem item, string leftPath, string rightPath, long size, FolderCompareOptions options) { if (TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath)) return FolderEntryStatus.Binary; if ((options.IgnoreMergeNotes || options.CSharpStyle != CSharpBraceStyle.None) && OnlyMergeNotesDiffer(leftPath, rightPath, size, options)) { item.SameReason = WhySame(leftPath, rightPath, size, options); return FolderEntryStatus.Same; } 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); } /// /// 머지 주석만 다른가(6차 B-5). 주석만 있는 줄은 빼고, 줄 끝 주석은 떼고 견준다. /// /// 어느 쪽에서도 주석을 뗀 것이 없으면 볼 것도 없다 - 바이트가 다른 까닭이 주석이 아니다. /// 줄끝(\r)은 그대로 남겨 견준다. 주석을 핑계로 줄끝 바꿈까지 "같음" 이 되면 안 된다. /// /// /// 설정으로 같음이 된 까닭(10차 F-2). 한 가지만 켜서도 같은지 차례로 본다 - 스타일만으로 같으면 스타일, /// 머지 주석만으로 같으면 머지 주석, 둘 다 맞춰야 같으면 둘 다. 같음이 된 파일만 이 값을 치르므로 비교 시간에는 거의 안 든다. /// private static SameReason WhySame(string leftPath, string rightPath, long size, FolderCompareOptions options) { var styleOnly = new FolderCompareOptions { CSharpStyle = options.CSharpStyle, IgnoreMergeNotes = false, FullCompareLimitBytes = options.FullCompareLimitBytes }; if (options.CSharpStyle != CSharpBraceStyle.None && OnlyMergeNotesDiffer(leftPath, rightPath, size, styleOnly)) return SameReason.Style; var notesOnly = new FolderCompareOptions { CSharpStyle = CSharpBraceStyle.None, IgnoreMergeNotes = true, FullCompareLimitBytes = options.FullCompareLimitBytes }; if (options.IgnoreMergeNotes && OnlyMergeNotesDiffer(leftPath, rightPath, size, notesOnly)) return SameReason.MergeNotes; return options.IgnoreMergeNotes && options.CSharpStyle != CSharpBraceStyle.None ? SameReason.StyleAndMergeNotes : options.IgnoreMergeNotes ? SameReason.MergeNotes : SameReason.Style; } private static bool OnlyMergeNotesDiffer(string leftPath, string rightPath, long size, FolderCompareOptions options) { if (size > options.FullCompareLimitBytes) return false; var left = WithoutMergeNotes(leftPath, options, out var leftCut); if (left is null) return false; var right = WithoutMergeNotes(rightPath, options, out var rightCut); if (right is null) return false; return (leftCut || rightCut) && string.Equals(left, right, StringComparison.Ordinal); } /// /// 머지 주석을 걷어내고(켰으면) C# 스타일을 맞춘(골랐으면) 글. 읽지 못하면 null. /// 하나라도 바꾼 것이 있으면 이 참. /// 스타일 맞춤은 줄끝을 그 파일의 줄끝으로 다시 이으므로, 양쪽 줄끝이 다르면 여전히 다르게 나온다. /// private static string? WithoutMergeNotes(string path, FolderCompareOptions options, out bool cut) { cut = false; try { var original = TextFile.Read(path); var text = Services.CSharpStyle.Applies(path, options.CSharpStyle) ? Services.CSharpStyle.Format(original, options.CSharpStyle) : original; cut = !ReferenceEquals(text, original); if (!options.IgnoreMergeNotes) return text; var builder = new System.Text.StringBuilder(text.Length); foreach (var raw in text.Split('\n')) { var hasCr = raw.EndsWith("\r", StringComparison.Ordinal); var line = hasCr ? raw.Substring(0, raw.Length - 1) : raw; if (MergeNote.LooksLikeNote(line, path)) { cut = true; continue; } var at = MergeNote.TailStart(line, path); if (at > 0) { line = line.Substring(0, at); cut = true; } builder.Append(line).Append(hasCr ? "\r\n" : "\n"); } return builder.ToString(); } catch (IOException) { return null; } catch (UnauthorizedAccessException) { return null; } } /// 주석과 공백을 걷어낸 글. 읽지 못하면 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; } } /// /// 크기·수정 시각까지 한 번에 얻는다. Directory.GetFiles 는 이름만 주므로 /// 나중에 파일마다 한 번씩 더 물어야 한다 - 그것이 훑는 시간의 대부분이었다. /// private static FileInfo[] SafeFiles(string dir) { try { var info = new DirectoryInfo(dir); return info.Exists ? info.GetFiles() : Array.Empty(); } catch (UnauthorizedAccessException) { return Array.Empty(); } catch (IOException) { return Array.Empty(); } } private static DirectoryInfo[] SafeDirs(string dir) { try { var info = new DirectoryInfo(dir); return info.Exists ? info.GetDirectories() : Array.Empty(); } catch (UnauthorizedAccessException) { return Array.Empty(); } catch (IOException) { return Array.Empty(); } } } }