Forge
csharp4405de34
1using System.Collections.Concurrent;
2using System.Collections.Specialized;
3using Avalonia.Threading;
4using CascadeIDE.Services.Lsp;
5using CascadeIDE.ViewModels;
6using Microsoft.CodeAnalysis;
7
8namespace CascadeIDE.Services;
9
10/// <summary>
11/// Диагностики: Roslyn по открытым <c>.cs</c> (без C# LSP), внешний C# LSP, внешний Markdown LSP (Marksman) для <c>*.md</c>;
12/// debounce, кэш, панель Problems, событие для HUD/вкладок.
13/// </summary>
14public sealed class WorkspaceDiagnosticsCoordinator : IDisposable
15{
16 private readonly CSharpLanguageService _language;
17 private readonly ProblemsPanelViewModel _problems;
18 private readonly TimeSpan _debounce = TimeSpan.FromMilliseconds(400);
19 private readonly ConcurrentDictionary<string, byte> _watchedPaths = new(StringComparer.OrdinalIgnoreCase);
20 private readonly object _cacheLock = new();
21 private readonly Dictionary<string, List<EditorDiagnosticStrip>> _byPath = new(StringComparer.OrdinalIgnoreCase);
22 private readonly ConcurrentDictionary<string, CancellationTokenSource> _pending = new(StringComparer.OrdinalIgnoreCase);
23
24 private MainWindowViewModel? _vm;
25 private CSharpLspDiagnosticsHost? _lspHost;
26 private MarkdownLspDiagnosticsHost? _markdownLspHost;
27 private bool _disposed;
28
29 public WorkspaceDiagnosticsCoordinator(CSharpLanguageService language, ProblemsPanelViewModel problems)
30 {
31 _language = language;
32 _problems = problems;
33 }
34
35 public event Action? DiagnosticsChanged;
36
37 public void Attach(MainWindowViewModel vm)
38 {
39 _vm = vm;
40 vm.Documents.OpenDocuments.CollectionChanged += OnOpenDocumentsChanged;
41 foreach (var d in vm.Documents.OpenDocuments)
42 WatchDocument(d);
43 foreach (var d in vm.Documents.OpenDocuments)
44 TrySchedule(d.FilePath, d.Content);
45 }
46
47 /// <summary>Подключить один C# LSP-процесс (или null — только парсер Roslyn).</summary>
48 public void SetLspDiagnosticsHost(CSharpLspDiagnosticsHost? host)
49 {
50 if (ReferenceEquals(_lspHost, host))
51 return;
52 if (_lspHost is not null)
53 _lspHost.DiagnosticsChanged -= OnLspHostDiagnosticsChanged;
54 _lspHost = host;
55 if (_lspHost is not null)
56 _lspHost.DiagnosticsChanged += OnLspHostDiagnosticsChanged;
57
58 if (_lspHost is { IsActive: true })
59 {
60 foreach (var kv in _pending)
61 {
62 if (_pending.TryRemove(kv.Key, out var cts))
63 {
64 cts.Cancel();
65 cts.Dispose();
66 }
67 }
68 }
69 else if (_vm is not null)
70 {
71 foreach (var d in _vm.Documents.OpenDocuments)
72 TrySchedule(d.FilePath, d.Content);
73 }
74
75 RebuildProblemsList();
76 DiagnosticsChanged?.Invoke();
77 }
78
79 /// <summary>Подключить Markdown LSP (Marksman) или <c>null</c>.</summary>
80 public void SetMarkdownLspDiagnosticsHost(MarkdownLspDiagnosticsHost? host)
81 {
82 if (ReferenceEquals(_markdownLspHost, host))
83 return;
84 if (_markdownLspHost is not null)
85 _markdownLspHost.DiagnosticsChanged -= OnMarkdownLspHostDiagnosticsChanged;
86 _markdownLspHost = host;
87 if (_markdownLspHost is not null)
88 _markdownLspHost.DiagnosticsChanged += OnMarkdownLspHostDiagnosticsChanged;
89
90 RebuildProblemsList();
91 DiagnosticsChanged?.Invoke();
92 }
93
94 private void OnMarkdownLspHostDiagnosticsChanged()
95 {
96 RebuildProblemsList();
97 DiagnosticsChanged?.Invoke();
98 }
99
100 private void OnLspHostDiagnosticsChanged()
101 {
102 RebuildProblemsList();
103 DiagnosticsChanged?.Invoke();
104 }
105
106 /// <summary>Явная постановка в очередь (Monaco: текст из WebView может опережать <see cref="OpenDocumentViewModel.Content"/>).</summary>
107 public void ScheduleDocumentText(string filePath, string text) => TrySchedule(filePath, text);
108
109 public IReadOnlyList<EditorDiagnosticStrip> GetStripsForFile(string? filePath)
110 {
111 if (string.IsNullOrEmpty(filePath))
112 return [];
113 if (_lspHost is { IsActive: true } && filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
114 return _lspHost.GetStripsForFile(filePath);
115 if (_markdownLspHost is { IsActive: true } && IsMarkdownPath(filePath))
116 return _markdownLspHost.GetStripsForFile(filePath);
117 var key = NormalizePath(filePath);
118 lock (_cacheLock)
119 {
120 return _byPath.TryGetValue(key, out var list) ? list : [];
121 }
122 }
123
124 /// <summary>Найти диагностику, покрывающую offset в документе (для tooltip).</summary>
125 public static EditorDiagnosticStrip? HitTest(IReadOnlyList<EditorDiagnosticStrip> strips, int offset)
126 {
127 foreach (var s in strips)
128 {
129 var end = s.Start + s.Length;
130 if (offset >= s.Start && offset < end)
131 return s;
132 }
133
134 return null;
135 }
136
137 /// <summary>
138 /// Как <see cref="HitTest"/>, плюс устойчивость к курсору <b>сразу после</b> диапазона (AvaloniaEdit часто
139 /// даёт offset на следующей позиции) и сопоставление по строке/колонке, если LSP-диапазон и
140 /// смещение в документе (GetOffset) редко расходятся.
141 /// </summary>
142 public static EditorDiagnosticStrip? HitTestForToolTip(
143 IReadOnlyList<EditorDiagnosticStrip> strips,
144 int offset,
145 int line1,
146 int col1,
147 string? documentText)
148 {
149 var exact = HitTest(strips, offset);
150 if (exact is not null)
151 return exact;
152
153 // Курсор сразу за последним UTF-16-кодпоинтом подчёркивания — тоже показать тот же тултип
154 foreach (var s in strips)
155 {
156 if (s.Length == 0)
157 continue;
158 if (offset == s.Start + s.Length)
159 return s;
160 }
161
162 // (line, col) 1-based: только для однострочного сегмента, иначе col+Length врёт
163 if (!string.IsNullOrEmpty(documentText))
164 {
165 foreach (var s in strips)
166 {
167 if (s.Line1 != line1)
168 continue;
169 if (s.Length == 0 || s.Start < 0 || s.Start + s.Length > documentText.Length)
170 continue;
171 if (documentText.AsSpan(s.Start, s.Length).IndexOf('\n') >= 0)
172 continue;
173 if (col1 >= s.Column1 && col1 < s.Column1 + s.Length)
174 return s;
175 }
176 }
177
178 return null;
179 }
180
181 public void Dispose()
182 {
183 if (_disposed)
184 return;
185 _disposed = true;
186 if (_vm is not null)
187 _vm.Documents.OpenDocuments.CollectionChanged -= OnOpenDocumentsChanged;
188 foreach (var cts in _pending.Values)
189 cts.Cancel();
190 _pending.Clear();
191 }
192
193 private void OnOpenDocumentsChanged(object? sender, NotifyCollectionChangedEventArgs e)
194 {
195 if (e.NewItems is not null)
196 {
197 foreach (OpenDocumentViewModel d in e.NewItems)
198 WatchDocument(d);
199 }
200
201 if (e.OldItems is not null)
202 {
203 foreach (OpenDocumentViewModel d in e.OldItems)
204 {
205 UnwatchDocument(d);
206 var key = NormalizePath(d.FilePath);
207 lock (_cacheLock)
208 {
209 _byPath.Remove(key);
210 }
211
212 if (_pending.TryRemove(key, out var cts))
213 cts.Cancel();
214 }
215 }
216
217 RebuildProblemsList();
218 DiagnosticsChanged?.Invoke();
219 }
220
221 private void WatchDocument(OpenDocumentViewModel doc)
222 {
223 if (!_watchedPaths.TryAdd(doc.FilePath, 0))
224 return;
225 doc.PropertyChanged += OnDocumentPropertyChanged;
226 TrySchedule(doc.FilePath, doc.Content);
227 }
228
229 private void UnwatchDocument(OpenDocumentViewModel doc)
230 {
231 if (!_watchedPaths.TryRemove(doc.FilePath, out _))
232 return;
233 doc.PropertyChanged -= OnDocumentPropertyChanged;
234 }
235
236 private void OnDocumentPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
237 {
238 if (e.PropertyName != nameof(OpenDocumentViewModel.Content))
239 return;
240 if (sender is not OpenDocumentViewModel doc)
241 return;
242 TrySchedule(doc.FilePath, doc.Content);
243 }
244
245 private void TrySchedule(string filePath, string text)
246 {
247 if (string.IsNullOrEmpty(filePath))
248 return;
249
250 if (filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
251 {
252 if (_lspHost is { IsActive: true })
253 {
254 _lspHost.ScheduleDocumentSync(filePath, text);
255 return;
256 }
257 }
258 else if (IsMarkdownPath(filePath))
259 {
260 if (_markdownLspHost is { IsActive: true })
261 {
262 _markdownLspHost.ScheduleDocumentSync(filePath, text);
263 return;
264 }
265
266 return;
267 }
268 else
269 return;
270
271 var key = NormalizePath(filePath);
272 if (_pending.TryGetValue(key, out var oldCts))
273 {
274 oldCts.Cancel();
275 oldCts.Dispose();
276 }
277
278 var cts = new CancellationTokenSource();
279 _pending[key] = cts;
280 var token = cts.Token;
281 var snapshot = text ?? "";
282
283 _ = DebouncedComputeAsync(key, filePath, snapshot, token);
284 }
285
286 private async Task DebouncedComputeAsync(string key, string filePath, string textSnapshot, CancellationToken ct)
287 {
288 try
289 {
290 await Task.Delay(_debounce, ct).ConfigureAwait(false);
291 }
292 catch
293 {
294 return;
295 }
296
297 IReadOnlyList<Diagnostic> raw;
298 try
299 {
300 raw = await Task.Run(() => _language.GetDiagnosticsForFile(filePath, textSnapshot, ct), ct).ConfigureAwait(false);
301 }
302 catch
303 {
304 return;
305 }
306
307 if (ct.IsCancellationRequested)
308 return;
309
310 var len = textSnapshot.Length;
311 var strips = new List<EditorDiagnosticStrip>(raw.Count);
312 foreach (var d in raw)
313 {
314 if (d.Severity is not (DiagnosticSeverity.Error or DiagnosticSeverity.Warning))
315 continue;
316 if (!d.Location.IsInSource)
317 continue;
318 var s = d.Location.SourceSpan;
319 if (s.Start < 0 || s.Start >= len)
320 continue;
321 var spanLen = s.Length <= 0 ? 1 : s.Length;
322 if (s.Start + spanLen > len)
323 spanLen = len - s.Start;
324 if (spanLen <= 0)
325 continue;
326 var (line1, col1) = RoslynLinePositionMapper.ToEditorLineColumn(d.Location.GetLineSpan().StartLinePosition);
327 strips.Add(new EditorDiagnosticStrip(
328 s.Start,
329 spanLen,
330 d.Severity,
331 d.Id,
332 d.GetMessage(),
333 line1.Value,
334 col1.Value));
335 }
336
337 UiScheduler.Default.Post(() =>
338 {
339 if (ct.IsCancellationRequested)
340 return;
341 var doc = _vm?.Documents.OpenDocuments.FirstOrDefault(d => NormalizePath(d.FilePath) == key);
342 if (doc is not null && !string.Equals(doc.Content, textSnapshot, StringComparison.Ordinal))
343 return;
344
345 lock (_cacheLock)
346 {
347 _byPath[key] = strips;
348 }
349
350 RebuildProblemsList();
351 DiagnosticsChanged?.Invoke();
352 }, DispatcherPriority.Background);
353 }
354
355 private void RebuildProblemsList()
356 {
357 if (_vm is null)
358 return;
359 var rows = new List<ProblemListItem>();
360 foreach (var doc in _vm.Documents.OpenDocuments)
361 {
362 IReadOnlyList<EditorDiagnosticStrip> list;
363 if (doc.FilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
364 {
365 if (_lspHost is { IsActive: true })
366 list = _lspHost.GetStripsForFile(doc.FilePath);
367 else
368 {
369 lock (_cacheLock)
370 {
371 if (!_byPath.TryGetValue(NormalizePath(doc.FilePath), out var roslynList))
372 continue;
373 list = roslynList;
374 }
375 }
376 }
377 else if (IsMarkdownPath(doc.FilePath))
378 {
379 if (_markdownLspHost is not { IsActive: true })
380 continue;
381 list = _markdownLspHost.GetStripsForFile(doc.FilePath);
382 }
383 else
384 continue;
385
386 foreach (var s in list)
387 {
388 rows.Add(new ProblemListItem(
389 doc.FilePath,
390 s.Line1,
391 s.Column1,
392 s.Severity == DiagnosticSeverity.Error ? "error" : "warning",
393 s.Id,
394 s.Message));
395 }
396 }
397
398 rows.Sort((a, b) =>
399 {
400 var c = string.Compare(a.FilePath, b.FilePath, StringComparison.OrdinalIgnoreCase);
401 if (c != 0)
402 return c;
403 c = a.Line.CompareTo(b.Line);
404 return c != 0 ? c : a.Column.CompareTo(b.Column);
405 });
406
407 _problems.ReplaceItems(rows);
408 }
409
410 private static string NormalizePath(string path) =>
411 CanonicalFilePath.Normalize(path);
412
413 private static bool IsMarkdownPath(string filePath) =>
414 filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
415 || filePath.EndsWith(".markdown", StringComparison.OrdinalIgnoreCase);
416}
417
View only · write via MCP/CIDE