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.Services;
8using CascadeIDE.Services.Lsp;
9using Microsoft.CodeAnalysis;
10using CascadeIDE.Contracts;
11
12#nullable enable
13
14namespace CascadeIDE.Features.Lsp.DataAcquisition;
15
16/// <summary>
17/// Один процесс Markdown LSP (stdio), по умолчанию Marksman: корень workspace = каталог решения,
18/// <see cref="textDocument/publishDiagnostics"/> для <c>*.md</c>. Транспорт — <see cref="LspStdioSession"/>.
19/// </summary>
20[IoBoundary("lsp-markdown-stdio")]
21[UiThreadMarshal("diagnostics coalesce → UI Post")]
22public sealed class MarkdownLspDiagnosticsHost : ILspDiagnosticSource
23{
24 private readonly ConcurrentDictionary<string, List<EditorDiagnosticStrip>> _strips = new(StringComparer.OrdinalIgnoreCase);
25 private readonly ConcurrentDictionary<string, string> _syncedText = new(StringComparer.OrdinalIgnoreCase);
26 private readonly HashSet<string> _openedNormalized = new(StringComparer.OrdinalIgnoreCase);
27 private readonly ConcurrentDictionary<string, CancellationTokenSource> _debounceByPath = new(StringComparer.OrdinalIgnoreCase);
28 private readonly object _gate = new();
29 private LspStdioSession? _session;
30 private CancellationTokenSource? _runCts;
31 private int _versionCounter;
32 private volatile bool _handshakeDone;
33 private bool _disposed;
34 private readonly object _diagNotifyLock = new();
35 private bool _diagNotifyPosted;
36
37 public bool IsActive => _handshakeDone && _session?.Process is { HasExited: false };
38
39 public event Action? DiagnosticsChanged;
40
41 public IReadOnlyList<EditorDiagnosticStrip> GetStripsForFile(string? filePath)
42 {
43 if (string.IsNullOrEmpty(filePath))
44 return [];
45 var key = LspFileUri.NormalizePath(filePath);
46 return _strips.TryGetValue(key, out var list) ? list : [];
47 }
48
49 /// <summary>
50 /// Запуск Marksman (или custom exe), <c>initialize</c> с <paramref name="solutionPath"/> → корень workspace,
51 /// затем <c>initialized</c>.
52 /// </summary>
53 public async Task<bool> TryStartAsync(string providerId, string? solutionPath, string? userExecutable, string? userArguments, CancellationToken ct)
54 {
55 DisposeProcess();
56
57 if (string.Equals(providerId, MarkdownLspProviderIds.Off, StringComparison.OrdinalIgnoreCase))
58 return false;
59
60 if (string.IsNullOrWhiteSpace(solutionPath) || !File.Exists(solutionPath))
61 return false;
62
63 var (exe, args) = MarkdownLspProviderIds.ResolveProcessArgs(providerId, userExecutable, userArguments);
64 if (string.IsNullOrWhiteSpace(exe))
65 return false;
66
67 var workspaceRoot = BreakpointsFileService.GetWorkspaceRoot(solutionPath);
68 if (string.IsNullOrEmpty(workspaceRoot) || !Directory.Exists(workspaceRoot))
69 return false;
70
71 var psi = new ProcessStartInfo
72 {
73 FileName = exe,
74 Arguments = args,
75 WorkingDirectory = workspaceRoot,
76 UseShellExecute = false,
77 RedirectStandardInput = true,
78 RedirectStandardOutput = true,
79 RedirectStandardError = true,
80 CreateNoWindow = true,
81 StandardInputEncoding = new UTF8Encoding(false),
82 StandardOutputEncoding = new UTF8Encoding(false),
83 };
84
85 Process? proc;
86 try
87 {
88 proc = Process.Start(psi);
89 }
90 catch
91 {
92 return false;
93 }
94
95 if (proc is null)
96 return false;
97
98 _runCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
99 var runToken = _runCts.Token;
100
101 _session = new LspStdioSession(proc, proc.StandardInput.BaseStream, OnLspNotification);
102 _session.StartReadLoop(runToken);
103
104 try
105 {
106 var rootUri = LspFileUri.PathToFileUri(workspaceRoot);
107 var initId = _session.AllocateRequestId();
108
109 JsonDocument initResult;
110 try
111 {
112 var doc = await SendInitializeAsync(initId, rootUri, runToken).ConfigureAwait(false);
113 if (doc is null)
114 return false;
115 initResult = doc;
116 }
117 catch
118 {
119 return false;
120 }
121
122 using (initResult)
123 {
124 var root = initResult.RootElement;
125 if (root.TryGetProperty("error", out _))
126 return false;
127 if (!root.TryGetProperty("result", out _))
128 return false;
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 ["publishDiagnostics"] = new JsonObject { ["relatedInformation"] = false }
168 },
169 ["workspace"] = new JsonObject { ["workspaceFolders"] = true }
170 },
171 ["workspaceFolders"] = new JsonArray
172 {
173 new JsonObject { ["uri"] = rootUri, ["name"] = "root" }
174 }
175 }
176 };
177 return await _session.SendRequestAsync(msg, id, TimeSpan.FromSeconds(60), ct).ConfigureAwait(false);
178 }
179
180 public void EnsureOpened(string filePath, string text)
181 {
182 if (!IsActive || string.IsNullOrEmpty(filePath) || !IsMarkdownFile(filePath))
183 return;
184 var key = LspFileUri.NormalizePath(filePath);
185 lock (_gate)
186 {
187 if (!_openedNormalized.Add(key))
188 return;
189 }
190
191 _syncedText[key] = text;
192 _ = SendDidOpenAsync(filePath, text, CancellationToken.None);
193 }
194
195 public void ScheduleDocumentSync(string filePath, string text)
196 {
197 if (!IsActive || string.IsNullOrEmpty(filePath) || !IsMarkdownFile(filePath))
198 return;
199 var key = LspFileUri.NormalizePath(filePath);
200
201 if (_debounceByPath.TryGetValue(key, out var old))
202 {
203 try { old.Cancel(); } catch { }
204 old.Dispose();
205 }
206
207 var cts = new CancellationTokenSource();
208 _debounceByPath[key] = cts;
209 _ = DebouncedSyncAsync(key, filePath, text, cts.Token);
210 }
211
212 private static bool IsMarkdownFile(string path) =>
213 path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
214 || path.EndsWith(".markdown", StringComparison.OrdinalIgnoreCase);
215
216 private async Task DebouncedSyncAsync(string key, string filePath, string text, CancellationToken ct)
217 {
218 try
219 {
220 await Task.Delay(400, ct).ConfigureAwait(false);
221 }
222 catch
223 {
224 return;
225 }
226
227 _syncedText[key] = text;
228 if (!_openedNormalized.Contains(key))
229 EnsureOpened(filePath, text);
230 else
231 await SendDidChangeFullAsync(filePath, text, ct).ConfigureAwait(false);
232 }
233
234 private async Task SendInitializedNotificationAsync(CancellationToken ct)
235 {
236 if (_session is null)
237 return;
238 const string payload = """{"jsonrpc":"2.0","method":"initialized","params":{}}""";
239 var bytes = Encoding.UTF8.GetBytes(payload);
240 await _session.SendRawUtf8Async(bytes, ct).ConfigureAwait(false);
241 }
242
243 private async Task SendDidOpenAsync(string filePath, string text, CancellationToken ct)
244 {
245 if (_session is null)
246 return;
247 var uri = LspFileUri.PathToFileUri(CanonicalFilePath.Normalize(filePath));
248 var ver = Interlocked.Increment(ref _versionCounter);
249 var msg = new JsonObject
250 {
251 ["jsonrpc"] = "2.0",
252 ["method"] = "textDocument/didOpen",
253 ["params"] = new JsonObject
254 {
255 ["textDocument"] = new JsonObject
256 {
257 ["uri"] = uri,
258 ["languageId"] = "markdown",
259 ["version"] = ver,
260 ["text"] = text
261 }
262 }
263 };
264 await _session.SendEnvelopeAsync(msg, ct).ConfigureAwait(false);
265 }
266
267 private async Task SendDidChangeFullAsync(string filePath, string text, CancellationToken ct)
268 {
269 if (_session is null)
270 return;
271 var uri = LspFileUri.PathToFileUri(CanonicalFilePath.Normalize(filePath));
272 var ver = Interlocked.Increment(ref _versionCounter);
273 var msg = new JsonObject
274 {
275 ["jsonrpc"] = "2.0",
276 ["method"] = "textDocument/didChange",
277 ["params"] = new JsonObject
278 {
279 ["textDocument"] = new JsonObject { ["uri"] = uri, ["version"] = ver },
280 ["contentChanges"] = new JsonArray { new JsonObject { ["text"] = text } }
281 }
282 };
283 await _session.SendEnvelopeAsync(msg, ct).ConfigureAwait(false);
284 }
285
286 private void HandlePublishDiagnostics(JsonElement @params)
287 {
288 if (!@params.TryGetProperty("uri", out var uriEl))
289 return;
290 var uri = uriEl.GetString();
291 if (string.IsNullOrEmpty(uri))
292 return;
293 if (!LspFileUri.TryUriToPath(uri, out var path))
294 return;
295 if (!IsMarkdownFile(path))
296 return;
297 var key = LspFileUri.NormalizePath(path);
298
299 if (!@params.TryGetProperty("diagnostics", out var arr) || arr.ValueKind != JsonValueKind.Array)
300 return;
301
302 _syncedText.TryGetValue(key, out var sourceText);
303 sourceText ??= "";
304
305 var list = new List<EditorDiagnosticStrip>();
306 foreach (var d in arr.EnumerateArray())
307 {
308 if (!d.TryGetProperty("range", out var range))
309 continue;
310 if (!TryGetRangeOffsets(sourceText, range, out var start, out var len, out var line1, out var col1))
311 continue;
312 var sev = MapSeverity(d);
313 if (sev is null)
314 continue;
315 var id = FormatCode(d);
316 var msg = d.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
317 list.Add(new EditorDiagnosticStrip(start, len, sev.Value, id, msg, line1, col1));
318 }
319
320 _strips[key] = list;
321 ScheduleDiagnosticsNotify();
322 }
323
324 private void ScheduleDiagnosticsNotify()
325 {
326 lock (_diagNotifyLock)
327 {
328 if (_diagNotifyPosted)
329 return;
330 _diagNotifyPosted = true;
331 }
332
333 UiScheduler.Default.Post(() =>
334 {
335 lock (_diagNotifyLock)
336 {
337 _diagNotifyPosted = false;
338 }
339
340 DiagnosticsChanged?.Invoke();
341 }, DispatcherPriority.Background);
342 }
343
344 private static DiagnosticSeverity? MapSeverity(JsonElement d)
345 {
346 if (!d.TryGetProperty("severity", out var s) || s.ValueKind != JsonValueKind.Number)
347 return DiagnosticSeverity.Warning;
348 return s.GetInt32() switch
349 {
350 1 => DiagnosticSeverity.Error,
351 2 => DiagnosticSeverity.Warning,
352 _ => (DiagnosticSeverity?)null
353 };
354 }
355
356 private static string FormatCode(JsonElement d)
357 {
358 if (!d.TryGetProperty("code", out var c))
359 return "lsp-md";
360 return c.ValueKind switch
361 {
362 JsonValueKind.String => c.GetString() ?? "lsp-md",
363 JsonValueKind.Number => c.GetRawText(),
364 JsonValueKind.Object when c.TryGetProperty("value", out var v) => v.GetString() ?? v.GetRawText(),
365 _ => "lsp-md"
366 };
367 }
368
369 private static bool TryGetRangeOffsets(string text, JsonElement range, out int start, out int length, out int line1, out int col1)
370 {
371 start = 0;
372 length = 1;
373 line1 = 1;
374 col1 = 1;
375 if (!range.TryGetProperty("start", out var startEl) || !range.TryGetProperty("end", out var endEl))
376 return false;
377 if (!TryGetPosition(startEl, out var sl, out var sc))
378 return false;
379 if (!TryGetPosition(endEl, out var el, out var ec))
380 return false;
381 line1 = sl + 1;
382 col1 = sc + 1;
383 var sOff = GetOffset(text, sl, sc);
384 var eOff = GetOffset(text, el, ec);
385 if (eOff < sOff)
386 eOff = sOff;
387 start = sOff;
388 length = Math.Max(1, eOff - sOff);
389 return start <= text.Length;
390 }
391
392 private static bool TryGetPosition(JsonElement pos, out int line0, out int char0)
393 {
394 line0 = 0;
395 char0 = 0;
396 if (!pos.TryGetProperty("line", out var l) || l.ValueKind != JsonValueKind.Number)
397 return false;
398 if (!pos.TryGetProperty("character", out var c) || c.ValueKind != JsonValueKind.Number)
399 return false;
400 line0 = l.GetInt32();
401 char0 = c.GetInt32();
402 return true;
403 }
404
405 private static int GetOffset(string text, int line0, int char0)
406 {
407 var line = 0;
408 var i = 0;
409 while (i < text.Length && line < line0)
410 {
411 if (text[i] == '\n')
412 line++;
413 i++;
414 }
415
416 if (line != line0)
417 return Math.Min(i, text.Length);
418
419 var col = 0;
420 while (i < text.Length && col < char0)
421 {
422 var ch = text[i];
423 if (ch == '\r')
424 {
425 i++;
426 continue;
427 }
428
429 if (ch == '\n')
430 break;
431 if (char.IsSurrogatePair(text, i))
432 {
433 i += 2;
434 col++;
435 }
436 else
437 {
438 i++;
439 col++;
440 }
441 }
442
443 return i;
444 }
445
446 private void DisposeProcess()
447 {
448 _handshakeDone = false;
449 foreach (var kv in _debounceByPath)
450 {
451 if (_debounceByPath.TryRemove(kv.Key, out var c))
452 {
453 try { c.Cancel(); } catch { }
454 c.Dispose();
455 }
456 }
457
458 lock (_gate)
459 {
460 _openedNormalized.Clear();
461 }
462
463 _syncedText.Clear();
464 _strips.Clear();
465
466 try
467 {
468 _runCts?.Cancel();
469 }
470 catch { }
471
472 _session?.Dispose();
473 _session = null;
474
475 _runCts?.Dispose();
476 _runCts = null;
477 }
478
479 public void Dispose()
480 {
481 if (_disposed)
482 return;
483 _disposed = true;
484 DisposeProcess();
485 }
486}
487
View only · write via MCP/CIDE