Forge
csharp4405de34
1using System.Collections.Concurrent;
2using DotNet.Globbing;
3
4namespace CascadeIDE.Services;
5
6/// <summary>
7/// Правила из вложенных <c>.gitignore</c> и <c>.cascadeignore</c> (в каждом каталоге свой файл).
8/// Семантика близка к git: последнее совпадение побеждает; <c>!</c> снимает игнор.
9/// </summary>
10public sealed class WorkspaceIgnoreMatcher
11{
12 private static readonly ConcurrentDictionary<string, WorkspaceIgnoreMatcher> Cache = new(StringComparer.OrdinalIgnoreCase);
13
14 private readonly string _repositoryRoot;
15 private readonly CompiledRule[] _rules;
16
17 private WorkspaceIgnoreMatcher(string repositoryRoot, IReadOnlyList<CompiledRule> rules)
18 {
19 _repositoryRoot = repositoryRoot;
20 _rules = rules.Count > 0 ? rules.ToArray() : Array.Empty<CompiledRule>();
21 }
22
23 private sealed class CompiledRule
24 {
25 public required string BaseDir { get; init; }
26 public required Glob[] Globs { get; init; }
27 /// <summary>Строка была с префиксом <c>!</c> — совпадение отменяет игнор.</summary>
28 public required bool Negation { get; init; }
29
30 public bool Matches(string relativePathFromBaseDir)
31 {
32 relativePathFromBaseDir = relativePathFromBaseDir.Replace('\\', '/');
33 if (relativePathFromBaseDir.StartsWith("..", StringComparison.Ordinal))
34 return false;
35 foreach (var g in Globs)
36 {
37 if (g.IsMatch(relativePathFromBaseDir))
38 return true;
39 }
40
41 return false;
42 }
43 }
44
45 /// <summary>Корень репозитория Git или <paramref name="fallbackStartDirectory"/>.</summary>
46 public static string ResolveRepositoryRoot(string fallbackStartDirectory)
47 {
48 fallbackStartDirectory = fallbackStartDirectory.Trim();
49 if (fallbackStartDirectory.Length == 0)
50 return fallbackStartDirectory;
51
52 try
53 {
54 var dir = new DirectoryInfo(CanonicalFilePath.Normalize(fallbackStartDirectory));
55 while (dir is not null)
56 {
57 if (Directory.Exists(Path.Combine(dir.FullName, ".git")))
58 return dir.FullName;
59 dir = dir.Parent;
60 }
61 }
62 catch
63 {
64 return CanonicalFilePath.Normalize(fallbackStartDirectory);
65 }
66
67 try
68 {
69 return CanonicalFilePath.Normalize(fallbackStartDirectory);
70 }
71 catch
72 {
73 return fallbackStartDirectory;
74 }
75 }
76
77 /// <summary>Кэш по нормализованному корню репозитория.</summary>
78 public static WorkspaceIgnoreMatcher GetOrCreate(string repositoryRoot)
79 {
80 var key = CanonicalFilePath.Normalize(repositoryRoot.Trim());
81 return Cache.GetOrAdd(key, static k => Load(k));
82 }
83
84 private static WorkspaceIgnoreMatcher Load(string repositoryRoot)
85 {
86 var options = new GlobOptions { Evaluation = new EvaluationOptions { CaseInsensitive = true } };
87 var rules = new List<CompiledRule>();
88
89 foreach (var ignoreFile in EnumerateIgnoreFilesSorted(repositoryRoot))
90 AppendRulesFromIgnoreFile(ignoreFile, options, rules);
91
92 return new WorkspaceIgnoreMatcher(repositoryRoot, rules);
93 }
94
95 private static void AppendRulesFromIgnoreFile(string ignoreFile, GlobOptions options, List<CompiledRule> rules)
96 {
97 string text;
98 try
99 {
100 text = File.ReadAllText(ignoreFile);
101 }
102 catch
103 {
104 return;
105 }
106
107 var baseDir = Path.GetDirectoryName(ignoreFile);
108 if (string.IsNullOrEmpty(baseDir))
109 return;
110 try
111 {
112 baseDir = CanonicalFilePath.Normalize(baseDir);
113 }
114 catch
115 {
116 return;
117 }
118
119 foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
120 {
121 if (!TryParseGitIgnoreLine(line, out var negation, out var patternBodies))
122 continue;
123
124 foreach (var pl in patternBodies)
125 {
126 var globs = new List<Glob>();
127 foreach (var pattern in GitIgnoreLineToGlobPatterns(pl))
128 {
129 try
130 {
131 globs.Add(Glob.Parse(pattern, options));
132 }
133 catch
134 {
135 // пропускаем неподдерживаемые шаблоны
136 }
137 }
138
139 if (globs.Count > 0)
140 {
141 rules.Add(new CompiledRule
142 {
143 BaseDir = baseDir,
144 Globs = globs.ToArray(),
145 Negation = negation,
146 });
147 }
148 }
149 }
150 }
151
152 /// <summary>Все <c>.gitignore</c> и <c>.cascadeignore</c> под корнем, без захода в <c>.git</c>; в каталоге сначала gitignore, потом cascade.</summary>
153 internal static IReadOnlyList<string> EnumerateIgnoreFilesSorted(string repositoryRoot)
154 {
155 var list = new List<string>();
156 string root;
157 try
158 {
159 root = CanonicalFilePath.Normalize(repositoryRoot.Trim());
160 }
161 catch
162 {
163 return list;
164 }
165
166 if (!Directory.Exists(root))
167 return list;
168
169 CollectIgnoreFiles(root, list);
170 list.Sort(IgnoreFileComparer);
171
172 return list;
173 }
174
175 private static void CollectIgnoreFiles(string directory, List<string> list)
176 {
177 foreach (var name in new[] { ".gitignore", ".cascadeignore" })
178 {
179 var p = Path.Combine(directory, name);
180 if (File.Exists(p))
181 list.Add(CanonicalFilePath.Normalize(p));
182 }
183
184 string[] subs;
185 try
186 {
187 subs = Directory.GetDirectories(directory);
188 }
189 catch
190 {
191 return;
192 }
193
194 foreach (var sub in subs)
195 {
196 if (string.Equals(Path.GetFileName(sub), ".git", StringComparison.OrdinalIgnoreCase))
197 continue;
198 CollectIgnoreFiles(sub, list);
199 }
200 }
201
202 private static int IgnoreFileComparer(string a, string b)
203 {
204 var da = Path.GetDirectoryName(a) ?? "";
205 var db = Path.GetDirectoryName(b) ?? "";
206 var c = string.Compare(da, db, StringComparison.OrdinalIgnoreCase);
207 if (c != 0)
208 return c;
209
210 var fa = Path.GetFileName(a);
211 var fb = Path.GetFileName(b);
212 if (string.Equals(fa, ".gitignore", StringComparison.OrdinalIgnoreCase) &&
213 string.Equals(fb, ".cascadeignore", StringComparison.OrdinalIgnoreCase))
214 return -1;
215 if (string.Equals(fa, ".cascadeignore", StringComparison.OrdinalIgnoreCase) &&
216 string.Equals(fb, ".gitignore", StringComparison.OrdinalIgnoreCase))
217 return 1;
218
219 return string.Compare(a, b, StringComparison.OrdinalIgnoreCase);
220 }
221
222 /// <summary>Очистка кэша (тесты).</summary>
223 internal static void ClearCacheForTests() => Cache.Clear();
224
225 /// <param name="fullPath">Абсолютный путь к файлу или каталогу.</param>
226 public bool IsIgnored(string fullPath)
227 {
228 if (_rules.Length == 0)
229 return false;
230
231 try
232 {
233 fullPath = CanonicalFilePath.Normalize(fullPath);
234 }
235 catch
236 {
237 return false;
238 }
239
240 bool? lastIgnored = null;
241 foreach (var rule in _rules)
242 {
243 string rel;
244 try
245 {
246 rel = Path.GetRelativePath(rule.BaseDir, fullPath);
247 }
248 catch
249 {
250 continue;
251 }
252
253 rel = rel.Replace('\\', '/');
254 if (rel.StartsWith("..", StringComparison.Ordinal))
255 continue;
256
257 if (!rule.Matches(rel))
258 continue;
259
260 // Совпало: позитивное правило → игнор; negation → не игнор
261 lastIgnored = !rule.Negation;
262 }
263
264 return lastIgnored == true;
265 }
266
267 /// <summary>Разбор одной строки: комментарии и пустые — false.</summary>
268 internal static bool TryParseGitIgnoreLine(string rawLine, out bool negation, out IReadOnlyList<string> patternBodies)
269 {
270 negation = false;
271 patternBodies = Array.Empty<string>();
272 var line = rawLine.Trim();
273 if (line.Length == 0 || line[0] == '#')
274 return false;
275
276 if (line[0] == '!')
277 {
278 negation = true;
279 line = line[1..].Trim();
280 }
281
282 if (line.Length == 0 || line[0] == '#')
283 return false;
284
285 patternBodies = new[] { line };
286 return true;
287 }
288
289 /// <summary>Преобразование тела строки gitignore (без <c>!</c>) в glob-паттерны DotNet.Glob.</summary>
290 internal static IEnumerable<string> GitIgnoreLineToGlobPatterns(string lineAfterNegationStripped)
291 {
292 var line = lineAfterNegationStripped.Trim();
293 if (line.Length == 0 || line[0] == '#')
294 yield break;
295
296 var anchored = line[0] == '/';
297 if (anchored)
298 line = line[1..];
299
300 var dirOnly = line.EndsWith('/');
301 if (dirOnly && line.Length > 0)
302 line = line[..^1];
303
304 if (line.Length == 0)
305 yield break;
306
307 line = line.Replace('\\', '/');
308
309 if (line.Contains('/'))
310 {
311 if (dirOnly)
312 {
313 if (anchored)
314 {
315 yield return line + "/**";
316 yield return line;
317 }
318 else
319 {
320 yield return "**/" + line + "/**";
321 yield return "**/" + line;
322 }
323 }
324 else
325 {
326 if (anchored)
327 {
328 yield return line;
329 }
330 else
331 {
332 yield return "**/" + line;
333 }
334 }
335
336 yield break;
337 }
338
339 if (dirOnly)
340 {
341 yield return "**/" + line + "/**";
342 yield return "**/" + line;
343 }
344 else
345 {
346 yield return "**/" + line;
347 if (line.Contains('*'))
348 yield return line;
349 }
350 }
351}
352
View only · write via MCP/CIDE