Forge
csharpdeeb25a2
1using System.Collections.Concurrent;
2using System.Diagnostics;
3using System.Text;
4using System.Text.Json;
5using System.Text.Json.Nodes;
6using Avalonia.Threading;
7using CascadeIDE.Features.Editor.Application.Monaco;
8using CascadeIDE.Services.Lsp;
9using Microsoft.CodeAnalysis;
10using CascadeIDE.Contracts;
11
12#nullable enable
13
14namespace CascadeIDE.Features.Lsp.DataAcquisition;
15
16/// <summary>
17/// Один процесс C# LSP (stdio): <see cref="textDocument/publishDiagnostics"/> → полосы в UI,
18/// <see cref="textDocument/hover"/> → Quick Info в тултипе при активном процессе.
19/// didOpen / didChange (full sync) для открытых .cs.
20/// Транспорт и JSON-RPC — <see cref="LspStdioSession"/>.
21/// </summary>
22[IoBoundary("lsp-csharp-stdio")]
23[UiThreadMarshal("diagnostics coalesce → UI Post")]
24public sealed partial class CSharpLspDiagnosticsHost : ILspDiagnosticSource, ICideEditorLspIntelligence
25{
26 private readonly ConcurrentDictionary<string, List<EditorDiagnosticStrip>> _strips = new(StringComparer.OrdinalIgnoreCase);
27 private readonly ConcurrentDictionary<string, string> _syncedText = new(StringComparer.OrdinalIgnoreCase);
28 private readonly HashSet<string> _openedNormalized = new(StringComparer.OrdinalIgnoreCase);
29 private readonly ConcurrentDictionary<string, CancellationTokenSource> _debounceByPath = new(StringComparer.OrdinalIgnoreCase);
30 private readonly object _gate = new();
31 private LspStdioSession? _session;
32 private CancellationTokenSource? _runCts;
33 private int _versionCounter;
34 private volatile bool _handshakeDone;
35 private bool _disposed;
36 private readonly object _diagNotifyLock = new();
37 private bool _diagNotifyPosted;
38
39 public bool IsActive => _handshakeDone && _session?.Process is { HasExited: false };
40
41 public event Action? DiagnosticsChanged;
42
43 public IReadOnlyList<EditorDiagnosticStrip> GetStripsForFile(string? filePath)
44 {
45 if (string.IsNullOrEmpty(filePath))
46 return [];
47 var key = LspFileUri.NormalizePath(filePath);
48 return _strips.TryGetValue(key, out var list) ? list : [];
49 }
50
51 /// <summary>Запуск процесса, initialize / initialized. Возвращает false при ошибке.</summary>
52 public async Task<bool> TryStartAsync(string providerId, string? solutionPath, string? userExecutable, string? userArguments, CancellationToken ct)
53 {
54 DisposeProcess();
55
56 if (string.IsNullOrWhiteSpace(solutionPath) || !File.Exists(solutionPath))
57 return false;
58
59 if (string.Equals(providerId, CSharpLspProviderIds.ParseOnly, StringComparison.OrdinalIgnoreCase))
60 return false;
61
62 var (exe, args) = CSharpLspProviderIds.ResolveProcessArgs(providerId, solutionPath, userExecutable, userArguments);
63 if (string.IsNullOrWhiteSpace(exe))
64 return false;
65
66 var solutionDir = Path.GetDirectoryName(CanonicalFilePath.Normalize(solutionPath));
67 if (string.IsNullOrEmpty(solutionDir))
68 return false;
69
70 var psi = new ProcessStartInfo
71 {
72 FileName = exe,
73 Arguments = args,
74 WorkingDirectory = solutionDir,
75 UseShellExecute = false,
76 RedirectStandardInput = true,
77 RedirectStandardOutput = true,
78 RedirectStandardError = true,
79 CreateNoWindow = true,
80 StandardInputEncoding = new UTF8Encoding(false),
81 StandardOutputEncoding = new UTF8Encoding(false),
82 };
83
84 Process? proc;
85 try
86 {
87 proc = Process.Start(psi);
88 }
89 catch
90 {
91 return false;
92 }
93
94 if (proc is null)
95 return false;
96
97 _runCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
98 var runToken = _runCts.Token;
99
100 _session = new LspStdioSession(proc, proc.StandardInput.BaseStream, OnLspNotification);
101 _session.StartReadLoop(runToken);
102
103 try
104 {
105 var rootUri = LspFileUri.PathToFileUri(solutionDir);
106 var initId = _session.AllocateRequestId();
107
108 JsonDocument initResult;
109 try
110 {
111 var doc = await SendInitializeAsync(initId, rootUri, runToken).ConfigureAwait(false);
112 if (doc is null)
113 return false;
114 initResult = doc;
115 }
116 catch
117 {
118 return false;
119 }
120
121 using (initResult)
122 {
123 var root = initResult.RootElement;
124 if (root.TryGetProperty("error", out _))
125 return false;
126 if (!root.TryGetProperty("result", out var resultEl))
127 return false;
128 ApplyServerCapabilities(resultEl);
129 }
130
131 await SendInitializedNotificationAsync(runToken).ConfigureAwait(false);
132 _handshakeDone = true;
133 return true;
134 }
135 catch
136 {
137 DisposeProcess();
138 return false;
139 }
140 }
141
142 private void OnLspNotification(string method, JsonElement @params)
143 {
144 if (string.Equals(method, "textDocument/publishDiagnostics", StringComparison.Ordinal))
145 HandlePublishDiagnostics(@params);
146 }
147
148 private async Task<JsonDocument?> SendInitializeAsync(int id, string rootUri, CancellationToken ct)
149 {
150 if (_session is null)
151 return null;
152 var msg = new JsonObject
153 {
154 ["jsonrpc"] = "2.0",
155 ["id"] = id,
156 ["method"] = "initialize",
157 ["params"] = new JsonObject
158 {
159 ["processId"] = Environment.ProcessId,
160 ["clientInfo"] = new JsonObject { ["name"] = "CascadeIDE", ["version"] = "1" },
161 ["rootUri"] = rootUri,
162 ["capabilities"] = new JsonObject
163 {
164 ["textDocument"] = new JsonObject
165 {
166 ["synchronization"] = new JsonObject { ["dynamicRegistration"] = false },
167 ["hover"] = new JsonObject { ["dynamicRegistration"] = false },
168 ["semanticTokens"] = new JsonObject
169 {
170 ["requests"] = new JsonObject
171 {
172 ["full"] = new JsonObject { ["delta"] = false },
173 },
174 ["tokenTypes"] = new JsonArray(),
175 ["formats"] = new JsonArray { "relative" },
176 ["multilineTokenSupport"] = false,
177 },
178 },
179 ["workspace"] = new JsonObject { ["workspaceFolders"] = true }
180 },
181 ["workspaceFolders"] = new JsonArray
182 {
183 new JsonObject { ["uri"] = rootUri, ["name"] = "root" }
184 }
185 }
186 };
187 return await _session.SendRequestAsync(msg, id, TimeSpan.FromSeconds(60), ct).ConfigureAwait(false);
188 }
189
190 public void EnsureOpened(string filePath, string text)
191 {
192 if (!IsActive || string.IsNullOrEmpty(filePath) || !filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
193 return;
194 var key = LspFileUri.NormalizePath(filePath);
195 lock (_gate)
196 {
197 if (!_openedNormalized.Add(key))
198 return;
199 }
200
201 _syncedText[key] = text;
202 _ = SendDidOpenAsync(filePath, text, CancellationToken.None);
203 }
204
205 public void ScheduleDocumentSync(string filePath, string text)
206 {
207 if (!IsActive || string.IsNullOrEmpty(filePath) || !filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
208 return;
209 var key = LspFileUri.NormalizePath(filePath);
210
211 if (_debounceByPath.TryGetValue(key, out var old))
212 {
213 try { old.Cancel(); } catch { }
214 old.Dispose();
215 }
216
217 var cts = new CancellationTokenSource();
218 _debounceByPath[key] = cts;
219 _ = DebouncedSyncAsync(key, filePath, text, cts.Token);
220 }
221
222 /// <summary>
223 /// Полная синхронизация текста и <c>textDocument/hover</c> (позиция в редакторе — 1-based line/column).
224 /// Перед запросом буфер отправляется в LSP (без debounce), чтобы подсказка соответствовала открытому тексту.
225 /// </summary>
226 public async Task<string?> RequestHoverAsync(string filePath, string text, int line1, int col1, CancellationToken ct)
227 {
228 if (!IsActive || string.IsNullOrEmpty(filePath) || !filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
229 return null;
230 if (line1 < 1 || col1 < 1)
231 return null;
232 if (_session is null)
233 return null;
234
235 await SyncFullTextForRequestAsync(filePath, text, ct).ConfigureAwait(false);
236
237 var uri = LspFileUri.PathToFileUri(CanonicalFilePath.Normalize(filePath));
238 var line0 = line1 - 1;
239 var char0 = col1 - 1;
240
241 var id = _session.AllocateRequestId();
242 var msg = new JsonObject
243 {
244 ["jsonrpc"] = "2.0",
245 ["id"] = id,
246 ["method"] = "textDocument/hover",
247 ["params"] = new JsonObject
248 {
249 ["textDocument"] = new JsonObject { ["uri"] = uri },
250 ["position"] = new JsonObject { ["line"] = line0, ["character"] = char0 }
251 }
252 };
253
254 try
255 {
256 using var doc = await _session.SendRequestAsync(msg, id, TimeSpan.FromSeconds(8), ct).ConfigureAwait(false);
257 return ParseHoverResponse(doc);
258 }
259 catch
260 {
261 return null;
262 }
263 }
264
265 /// <remarks>
266 /// Порядок сообщений с <see cref="EnsureOpened"/> без общей очереди на stdin теоретически может перепутаться;
267 /// на практике после первого debounce-синка состояние стабильно.
268 /// </remarks>
269 private async Task SyncFullTextForRequestAsync(string filePath, string text, CancellationToken ct)
270 {
271 var key = LspFileUri.NormalizePath(filePath);
272 if (_syncedText.TryGetValue(key, out var synced) && string.Equals(synced, text, StringComparison.Ordinal))
273 return;
274
275 _syncedText[key] = text;
276 bool needOpen;
277 lock (_gate)
278 {
279 needOpen = !_openedNormalized.Contains(key);
280 if (needOpen)
281 _openedNormalized.Add(key);
282 }
283
284 if (needOpen)
285 await SendDidOpenAsync(filePath, text, ct).ConfigureAwait(false);
286 else
287 await SendDidChangeFullAsync(filePath, text, ct).ConfigureAwait(false);
288 }
289
290 private static string? ParseHoverResponse(JsonDocument? doc)
291 {
292 if (doc is null)
293 return null;
294 var root = doc.RootElement;
295 if (root.TryGetProperty("error", out _))
296 return null;
297 if (!root.TryGetProperty("result", out var result))
298 return null;
299 if (result.ValueKind == JsonValueKind.Null)
300 return null;
301 if (!result.TryGetProperty("contents", out var contents))
302 return null;
303 return LspHoverContentFormatter.Format(contents);
304 }
305
306 private async Task DebouncedSyncAsync(string key, string filePath, string text, CancellationToken ct)
307 {
308 try
309 {
310 await Task.Delay(400, ct).ConfigureAwait(false);
311 }
312 catch
313 {
314 return;
315 }
316
317 _syncedText[key] = text;
318 if (!_openedNormalized.Contains(key))
319 EnsureOpened(filePath, text);
320 else
321 await SendDidChangeFullAsync(filePath, text, ct).ConfigureAwait(false);
322 }
323
324 private async Task SendInitializedNotificationAsync(CancellationToken ct)
325 {
326 if (_session is null)
327 return;
328 const string payload = """{"jsonrpc":"2.0","method":"initialized","params":{}}""";
329 var bytes = Encoding.UTF8.GetBytes(payload);
330 await _session.SendRawUtf8Async(bytes, ct).ConfigureAwait(false);
331 }
332
333 private async Task SendDidOpenAsync(string filePath, string text, CancellationToken ct)
334 {
335 if (_session is null)
336 return;
337 var uri = LspFileUri.PathToFileUri(CanonicalFilePath.Normalize(filePath));
338 var ver = Interlocked.Increment(ref _versionCounter);
339 var msg = new JsonObject
340 {
341 ["jsonrpc"] = "2.0",
342 ["method"] = "textDocument/didOpen",
343 ["params"] = new JsonObject
344 {
345 ["textDocument"] = new JsonObject
346 {
347 ["uri"] = uri,
348 ["languageId"] = "csharp",
349 ["version"] = ver,
350 ["text"] = text
351 }
352 }
353 };
354 await _session.SendEnvelopeAsync(msg, ct).ConfigureAwait(false);
355 }
356
357 private async Task SendDidChangeFullAsync(string filePath, string text, CancellationToken ct)
358 {
359 if (_session is null)
360 return;
361 var uri = LspFileUri.PathToFileUri(CanonicalFilePath.Normalize(filePath));
362 var ver = Interlocked.Increment(ref _versionCounter);
363 var msg = new JsonObject
364 {
365 ["jsonrpc"] = "2.0",
366 ["method"] = "textDocument/didChange",
367 ["params"] = new JsonObject
368 {
369 ["textDocument"] = new JsonObject { ["uri"] = uri, ["version"] = ver },
370 ["contentChanges"] = new JsonArray { new JsonObject { ["text"] = text } }
371 }
372 };
373 await _session.SendEnvelopeAsync(msg, ct).ConfigureAwait(false);
374 }
375
376 private void HandlePublishDiagnostics(JsonElement @params)
377 {
378 if (!@params.TryGetProperty("uri", out var uriEl))
379 return;
380 var uri = uriEl.GetString();
381 if (string.IsNullOrEmpty(uri))
382 return;
383 if (!LspFileUri.TryUriToPath(uri, out var path))
384 return;
385 var key = LspFileUri.NormalizePath(path);
386
387 if (!@params.TryGetProperty("diagnostics", out var arr) || arr.ValueKind != JsonValueKind.Array)
388 return;
389
390 _syncedText.TryGetValue(key, out var sourceText);
391 sourceText ??= "";
392
393 var list = new List<EditorDiagnosticStrip>();
394 foreach (var d in arr.EnumerateArray())
395 {
396 if (!d.TryGetProperty("range", out var range))
397 continue;
398 if (!TryGetRangeOffsets(sourceText, range, out var start, out var len, out var line1, out var col1))
399 continue;
400 var sev = MapSeverity(d);
401 if (sev is null)
402 continue;
403 var id = FormatCode(d);
404 var msg = d.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
405 list.Add(new EditorDiagnosticStrip(start, len, sev.Value, id, msg, line1, col1));
406 }
407
408 _strips[key] = list;
409 ScheduleDiagnosticsNotify();
410 }
411
412 /// <summary>
413 /// Коалесит <see cref="DiagnosticsChanged"/> в один <see cref="IUiScheduler.Post"/> на серию
414 /// <c>textDocument/publishDiagnostics</c> (как <see cref="global::CascadeIDE.Features.Build.BuildOutputPanelViewModel.Append"/>).
415 /// </summary>
416 private void ScheduleDiagnosticsNotify()
417 {
418 lock (_diagNotifyLock)
419 {
420 if (_diagNotifyPosted)
421 return;
422 _diagNotifyPosted = true;
423 }
424
425 UiScheduler.Default.Post(() =>
426 {
427 lock (_diagNotifyLock)
428 {
429 _diagNotifyPosted = false;
430 }
431
432 DiagnosticsChanged?.Invoke();
433 }, DispatcherPriority.Background);
434 }
435
436 private static DiagnosticSeverity? MapSeverity(JsonElement d)
437 {
438 if (!d.TryGetProperty("severity", out var s) || s.ValueKind != JsonValueKind.Number)
439 return DiagnosticSeverity.Warning;
440 return s.GetInt32() switch
441 {
442 1 => DiagnosticSeverity.Error,
443 2 => DiagnosticSeverity.Warning,
444 _ => (DiagnosticSeverity?)null
445 };
446 }
447
448 private static string FormatCode(JsonElement d)
449 {
450 if (!d.TryGetProperty("code", out var c))
451 return "lsp";
452 return c.ValueKind switch
453 {
454 JsonValueKind.String => c.GetString() ?? "lsp",
455 JsonValueKind.Number => c.GetRawText(),
456 JsonValueKind.Object when c.TryGetProperty("value", out var v) => v.GetString() ?? v.GetRawText(),
457 _ => "lsp"
458 };
459 }
460
461 private static bool TryGetRangeOffsets(string text, JsonElement range, out int start, out int length, out int line1, out int col1)
462 {
463 start = 0;
464 length = 1;
465 line1 = 1;
466 col1 = 1;
467 if (!range.TryGetProperty("start", out var startEl) || !range.TryGetProperty("end", out var endEl))
468 return false;
469 if (!TryGetPosition(startEl, out var sl, out var sc))
470 return false;
471 if (!TryGetPosition(endEl, out var el, out var ec))
472 return false;
473 line1 = sl + 1;
474 col1 = sc + 1;
475 var sOff = GetOffset(text, sl, sc);
476 var eOff = GetOffset(text, el, ec);
477 if (eOff < sOff)
478 eOff = sOff;
479 start = sOff;
480 length = Math.Max(1, eOff - sOff);
481 return start <= text.Length;
482 }
483
484 private static bool TryGetPosition(JsonElement pos, out int line0, out int char0)
485 {
486 line0 = 0;
487 char0 = 0;
488 if (!pos.TryGetProperty("line", out var l) || l.ValueKind != JsonValueKind.Number)
489 return false;
490 if (!pos.TryGetProperty("character", out var c) || c.ValueKind != JsonValueKind.Number)
491 return false;
492 line0 = l.GetInt32();
493 char0 = c.GetInt32();
494 return true;
495 }
496
497 private static int GetOffset(string text, int line0, int char0)
498 {
499 var line = 0;
500 var i = 0;
501 while (i < text.Length && line < line0)
502 {
503 if (text[i] == '\n')
504 line++;
505 i++;
506 }
507
508 if (line != line0)
509 return Math.Min(i, text.Length);
510
511 var col = 0;
512 while (i < text.Length && col < char0)
513 {
514 var ch = text[i];
515 if (ch == '\r')
516 {
517 i++;
518 continue;
519 }
520
521 if (ch == '\n')
522 break;
523 if (char.IsSurrogatePair(text, i))
524 {
525 i += 2;
526 col++;
527 }
528 else
529 {
530 i++;
531 col++;
532 }
533 }
534
535 return i;
536 }
537
538 private void DisposeProcess()
539 {
540 _handshakeDone = false;
541 ClearSemanticTokensState();
542 foreach (var kv in _debounceByPath)
543 {
544 if (_debounceByPath.TryRemove(kv.Key, out var c))
545 {
546 try { c.Cancel(); } catch { }
547 c.Dispose();
548 }
549 }
550
551 lock (_gate)
552 {
553 _openedNormalized.Clear();
554 }
555
556 _syncedText.Clear();
557 _strips.Clear();
558
559 try
560 {
561 _runCts?.Cancel();
562 }
563 catch { }
564
565 _session?.Dispose();
566 _session = null;
567
568 _runCts?.Dispose();
569 _runCts = null;
570 }
571
572 public void Dispose()
573 {
574 if (_disposed)
575 return;
576 _disposed = true;
577 DisposeProcess();
578 }
579}
580
View only · write via MCP/CIDE