Forge
csharpdeeb25a2
1#nullable enable
2
3using CascadeIDE.Features.Editor.Application.Monaco;
4using CascadeIDE.Services;
5using Microsoft.CodeAnalysis;
6using Xunit;
7using TestContext = Xunit.TestContext;
8
9namespace CascadeIDE.Tests.MonacoForward;
10
11[Trait("Category", "MonacoForward")]
12public sealed class CideEditorCapabilityRouterTests
13{
14 private readonly CideEditorCapabilityRouter _router = new();
15
16 [Fact]
17 public void CanHandle_capability_requests_and_codeLensClick()
18 {
19 Assert.True(_router.CanHandle(Inbound(CideEditorBusManifest.Capabilities.Completion, 1, 1, 1)));
20 Assert.True(_router.CanHandle(Inbound(CideEditorBusManifest.Capabilities.CodeLensClick, null, null, null)));
21 Assert.False(_router.CanHandle(Inbound(CideEditorBusManifest.Editor.DidChange, null, null, null)));
22 }
23
24 [Fact]
25 public async Task Completion_nonCs_returns_empty()
26 {
27 var host = new RecordingCapabilityHost();
28 var ctx = CreateContext(host, filePath: @"D:\x\readme.txt", text: "hello");
29
30 await _router.HandleAsync(
31 Inbound(CideEditorBusManifest.Capabilities.Completion, requestId: 1, line: 1, column: 1),
32 ctx,
33 TestContext.Current.CancellationToken);
34
35 var result = host.Completions.Single();
36 Assert.Equal(1, result.RequestId);
37 Assert.Empty(result.Items);
38 }
39
40 [Fact]
41 public async Task Completion_cs_uses_roslyn_when_lsp_inactive()
42 {
43 var host = new RecordingCapabilityHost();
44 const string path = @"D:\Fake\Complete.cs";
45 var text = """
46 namespace N;
47 public class C
48 {
49 public void M() { }
50 }
51 """;
52 var ctx = CreateContext(host, path, text);
53
54 await _router.HandleAsync(
55 Inbound(CideEditorBusManifest.Capabilities.Completion, requestId: 7, line: 4, column: 17),
56 ctx,
57 TestContext.Current.CancellationToken);
58
59 var result = host.Completions.Single();
60 Assert.Equal(7, result.RequestId);
61 Assert.NotEmpty(result.Items);
62 }
63
64 [Fact]
65 public async Task Completion_prefers_lsp_when_active_and_non_empty()
66 {
67 var host = new RecordingCapabilityHost();
68 var lsp = new FakeLspIntelligence
69 {
70 CompletionItems =
71 [
72 new CideEditorCompletionItem("LspItem", "lsp", "from lsp"),
73 ],
74 };
75 var ctx = CreateContext(host, @"D:\Fake\Lsp.cs", "class X { }", lsp);
76
77 await _router.HandleAsync(
78 Inbound(CideEditorBusManifest.Capabilities.Completion, requestId: 2, line: 1, column: 8),
79 ctx,
80 TestContext.Current.CancellationToken);
81
82 var result = host.Completions.Single();
83 Assert.Equal("LspItem", result.Items[0].Label);
84 Assert.Equal(1, lsp.CompletionCalls);
85 }
86
87 [Fact]
88 public async Task Completion_falls_back_to_roslyn_when_lsp_returns_empty()
89 {
90 var host = new RecordingCapabilityHost();
91 var lsp = new FakeLspIntelligence { CompletionItems = [] };
92 const string path = @"D:\Fake\Fallback.cs";
93 var text = "class X { public void M() { } }";
94 var ctx = CreateContext(host, path, text, lsp);
95
96 await _router.HandleAsync(
97 Inbound(CideEditorBusManifest.Capabilities.Completion, requestId: 3, line: 1, column: 20),
98 ctx,
99 TestContext.Current.CancellationToken);
100
101 Assert.Equal(1, lsp.CompletionCalls);
102 Assert.NotEmpty(host.Completions.Single().Items);
103 }
104
105 [Fact]
106 public async Task Hover_uses_quick_info_when_no_diagnostic_hit()
107 {
108 var host = new RecordingCapabilityHost();
109 var ctx = new MonacoEditorCapabilityContext
110 {
111 Host = host,
112 FilePath = @"D:\Fake\Hover.cs",
113 GetEditorText = () => "class X { }",
114 CSharpLanguage = new CSharpLanguageService(),
115 WorkspaceDiagnostics = CreateDiagnostics(),
116 ResolveQuickInfoAsync = (_, _, _, _, _) => Task.FromResult<string?>("**info**: ok"),
117 };
118
119 await _router.HandleAsync(
120 Inbound(CideEditorBusManifest.Capabilities.Hover, requestId: 4, line: 1, column: 8),
121 ctx,
122 TestContext.Current.CancellationToken);
123
124 Assert.Equal("**info**: ok", host.Hovers.Single().Markdown);
125 }
126
127 [Fact]
128 public void HitTestForToolTip_finds_diagnostic_on_line()
129 {
130 var strips = new List<EditorDiagnosticStrip>
131 {
132 new(0, 3, DiagnosticSeverity.Warning, "CS1234", "bad token", Line1: 1, Column1: 1),
133 };
134 var hit = WorkspaceDiagnosticsCoordinator.HitTestForToolTip(strips, offset: 1, line1: 1, col1: 2, documentText: "bad\n");
135 Assert.NotNull(hit);
136 Assert.Equal("CS1234", hit!.Id);
137 }
138
139 [Fact]
140 public async Task Signature_without_paren_returns_null()
141 {
142 var host = new RecordingCapabilityHost();
143 var ctx = CreateContext(host, @"D:\Fake\Sig.cs", "class X { }");
144
145 await _router.HandleAsync(
146 Inbound(CideEditorBusManifest.Capabilities.SignatureHelp, requestId: 5, line: 1, column: 8),
147 ctx,
148 TestContext.Current.CancellationToken);
149
150 Assert.Null(host.Signatures.Single().Signature);
151 }
152
153 [Fact]
154 public async Task Definition_cs_returns_location()
155 {
156 var host = new RecordingCapabilityHost();
157 const string path = @"D:\Fake\Def.cs";
158 var text = "class X { public void M() { M(); } }";
159 var ctx = CreateContext(host, path, text);
160
161 await _router.HandleAsync(
162 Inbound(CideEditorBusManifest.Capabilities.Definition, requestId: 6, line: 1, column: 30),
163 ctx,
164 TestContext.Current.CancellationToken);
165
166 var def = host.Definitions.Single().Location;
167 Assert.NotNull(def);
168 Assert.True(def!.Line >= 1);
169 }
170
171 [Fact]
172 public async Task CodeLensClick_invokes_navigate_delegate()
173 {
174 var host = new RecordingCapabilityHost();
175 string? clicked = null;
176 var ctx = new MonacoEditorCapabilityContext
177 {
178 Host = host,
179 FilePath = @"D:\Fake\Lens.cs",
180 GetEditorText = () => "class X { }",
181 CSharpLanguage = new CSharpLanguageService(),
182 WorkspaceDiagnostics = CreateDiagnostics(),
183 ResolveQuickInfoAsync = (_, _, _, _, _) => Task.FromResult<string?>(null),
184 TryNavigateCodeLens = id =>
185 {
186 clicked = id;
187 return true;
188 },
189 };
190
191 await _router.HandleAsync(
192 Inbound(CideEditorBusManifest.Capabilities.CodeLensClick, lensId: "lens-1"),
193 ctx,
194 TestContext.Current.CancellationToken);
195
196 Assert.Equal("lens-1", clicked);
197 Assert.Empty(host.Completions);
198 }
199
200 [Fact]
201 public async Task SemanticTokens_without_lsp_returns_empty()
202 {
203 var host = new RecordingCapabilityHost();
204 var ctx = CreateContext(host, @"D:\Fake\Tok.cs", "class X { }");
205
206 await _router.HandleAsync(
207 Inbound(CideEditorBusManifest.Capabilities.SemanticTokens, requestId: 8, line: 1, column: 1),
208 ctx,
209 TestContext.Current.CancellationToken);
210
211 Assert.Empty(host.SemanticTokens.Single().Data);
212 }
213
214 [Fact]
215 public async Task Format_returns_roslyn_formatted_text()
216 {
217 var host = new RecordingCapabilityHost();
218 var ctx = CreateContext(host, @"D:\Fake\Fmt.cs", "class C{void M(){}}");
219
220 await _router.HandleAsync(
221 Inbound(CideEditorBusManifest.Capabilities.Format, requestId: 10, line: 1, column: 1),
222 ctx,
223 TestContext.Current.CancellationToken);
224
225 var formatted = host.Formats.Single().Text;
226 Assert.False(string.IsNullOrWhiteSpace(formatted));
227 Assert.NotEqual("class C{void M(){}}", formatted);
228 }
229
230 [Fact]
231 public async Task References_falls_back_to_roslyn_in_file()
232 {
233 var host = new RecordingCapabilityHost();
234 const string path = @"D:\Fake\Refs.cs";
235 var text = """
236 class C { void M() { int count = 1; count = count + 1; } }
237 """;
238 var markerIndex = text.IndexOf("count + 1", StringComparison.Ordinal);
239 var pos = Microsoft.CodeAnalysis.Text.SourceText.From(text).Lines.GetLinePosition(markerIndex);
240 var ctx = CreateContext(host, path, text);
241
242 await _router.HandleAsync(
243 Inbound(CideEditorBusManifest.Capabilities.References, requestId: 11, line: pos.Line + 1, column: pos.Character + 1),
244 ctx,
245 TestContext.Current.CancellationToken);
246
247 Assert.True(host.References.Single().Locations.Count >= 2);
248 }
249
250 [Fact]
251 public async Task Definition_cross_file_invokes_navigate_and_null_result()
252 {
253 var host = new RecordingCapabilityHost();
254 CideEditorDefinitionLocation? navigated = null;
255 var lsp = new FakeLspIntelligence
256 {
257 Definition = new CideEditorDefinitionLocation(@"D:\Fake\Other.cs", 3, 5),
258 };
259 var ctx = new MonacoEditorCapabilityContext
260 {
261 Host = host,
262 FilePath = @"D:\Fake\Current.cs",
263 GetEditorText = () => "class A { void M() { Other(); } }",
264 CSharpLanguage = new CSharpLanguageService(),
265 WorkspaceDiagnostics = CreateDiagnostics(),
266 ResolveQuickInfoAsync = (_, _, _, _, _) => Task.FromResult<string?>(null),
267 CSharpLspHost = lsp,
268 NavigateToLocationAsync = loc =>
269 {
270 navigated = loc;
271 return Task.CompletedTask;
272 },
273 };
274
275 await _router.HandleAsync(
276 Inbound(CideEditorBusManifest.Capabilities.Definition, requestId: 12, line: 1, column: 20),
277 ctx,
278 TestContext.Current.CancellationToken);
279
280 Assert.NotNull(navigated);
281 Assert.Equal(@"D:\Fake\Other.cs", navigated!.FilePath);
282 Assert.Null(host.Definitions.Single().Location);
283 }
284
285 [Fact]
286 public async Task Legacy_requestCompletion_normalizes_to_completion()
287 {
288 var host = new RecordingCapabilityHost();
289 var ctx = CreateContext(host, @"D:\Fake\Legacy.txt", "x");
290
291 await _router.HandleAsync(
292 Inbound(CideEditorBusManifest.Legacy.RequestCompletion, requestId: 9, line: 1, column: 1),
293 ctx,
294 TestContext.Current.CancellationToken);
295
296 Assert.Single(host.Completions);
297 }
298
299 private static WorkspaceDiagnosticsCoordinator CreateDiagnostics() =>
300 new(new CSharpLanguageService(), new ViewModels.ProblemsPanelViewModel(_ => { }));
301
302 private static CideEditorInboundMessage Inbound(
303 string type,
304 int? requestId = null,
305 int? line = null,
306 int? column = null,
307 string? lensId = null) =>
308 new(type, null, null, null, null, null, requestId, line, column, null, lensId, null, null, null, null, null, null, null);
309
310 private static MonacoEditorCapabilityContext CreateContext(
311 RecordingCapabilityHost host,
312 string filePath,
313 string text,
314 ICideEditorLspIntelligence? lsp = null) =>
315 new()
316 {
317 Host = host,
318 FilePath = filePath,
319 GetEditorText = () => text,
320 CSharpLanguage = new CSharpLanguageService(),
321 WorkspaceDiagnostics = CreateDiagnostics(),
322 ResolveQuickInfoAsync = (_, _, _, _, _) => Task.FromResult<string?>(null),
323 CSharpLspHost = lsp,
324 };
325
326 private sealed class RecordingCapabilityHost : ICideEditorCapabilityHost
327 {
328 public List<(int RequestId, IReadOnlyList<CideEditorCompletionItem> Items)> Completions { get; } = [];
329 public List<(int RequestId, string? Markdown)> Hovers { get; } = [];
330 public List<(int RequestId, string? Signature)> Signatures { get; } = [];
331 public List<(int RequestId, CideEditorDefinitionLocation? Location)> Definitions { get; } = [];
332 public List<(int RequestId, IReadOnlyList<CideEditorReferenceLocation> Locations)> References { get; } = [];
333 public List<(int RequestId, string? Text)> Formats { get; } = [];
334 public List<(int RequestId, IReadOnlyList<CideEditorCodeActionItem> Actions)> CodeActions { get; } = [];
335 public List<(int RequestId, IReadOnlyList<CideEditorInlayHint> Hints)> InlayHints { get; } = [];
336 public List<(int RequestId, IReadOnlyList<CideEditorCodeLensItem> Lenses)> CodeLenses { get; } = [];
337 public List<(int RequestId, IReadOnlyList<uint> Data, string? ResultId)> SemanticTokens { get; } = [];
338
339 public Task PushCapabilityCompletionResultAsync(
340 int requestId,
341 IReadOnlyList<CideEditorCompletionItem> items,
342 CancellationToken cancellationToken = default)
343 {
344 Completions.Add((requestId, items));
345 return Task.CompletedTask;
346 }
347
348 public Task PushCapabilityHoverResultAsync(
349 int requestId,
350 string? markdown,
351 CancellationToken cancellationToken = default)
352 {
353 Hovers.Add((requestId, markdown));
354 return Task.CompletedTask;
355 }
356
357 public Task PushCapabilitySignatureResultAsync(
358 int requestId,
359 string? signature,
360 CancellationToken cancellationToken = default)
361 {
362 Signatures.Add((requestId, signature));
363 return Task.CompletedTask;
364 }
365
366 public Task PushCapabilityDefinitionResultAsync(
367 int requestId,
368 CideEditorDefinitionLocation? location,
369 CancellationToken cancellationToken = default)
370 {
371 Definitions.Add((requestId, location));
372 return Task.CompletedTask;
373 }
374
375 public Task PushCapabilityReferencesResultAsync(
376 int requestId,
377 IReadOnlyList<CideEditorReferenceLocation> locations,
378 CancellationToken cancellationToken = default)
379 {
380 References.Add((requestId, locations));
381 return Task.CompletedTask;
382 }
383
384 public Task PushCapabilityFormatResultAsync(
385 int requestId,
386 string? text,
387 CancellationToken cancellationToken = default)
388 {
389 Formats.Add((requestId, text));
390 return Task.CompletedTask;
391 }
392
393 public Task PushCapabilityCodeActionResultAsync(
394 int requestId,
395 IReadOnlyList<CideEditorCodeActionItem> actions,
396 CancellationToken cancellationToken = default)
397 {
398 CodeActions.Add((requestId, actions));
399 return Task.CompletedTask;
400 }
401
402 public Task PushCapabilityWorkspaceEditResultAsync(
403 int requestId,
404 bool ok,
405 string? error,
406 IReadOnlyList<CideEditorDocumentTextChange> changes,
407 CancellationToken cancellationToken = default) =>
408 Task.CompletedTask;
409
410 public Task PushCapabilityInlayHintsResultAsync(
411 int requestId,
412 IReadOnlyList<CideEditorInlayHint> hints,
413 CancellationToken cancellationToken = default)
414 {
415 InlayHints.Add((requestId, hints));
416 return Task.CompletedTask;
417 }
418
419 public Task PushCapabilityCodeLensResultAsync(
420 int requestId,
421 IReadOnlyList<CideEditorCodeLensItem> lenses,
422 CancellationToken cancellationToken = default)
423 {
424 CodeLenses.Add((requestId, lenses));
425 return Task.CompletedTask;
426 }
427
428 public Task PushCapabilitySemanticTokensResultAsync(
429 int requestId,
430 IReadOnlyList<uint> data,
431 string? resultId,
432 CancellationToken cancellationToken = default)
433 {
434 SemanticTokens.Add((requestId, data, resultId));
435 return Task.CompletedTask;
436 }
437 }
438
439 private sealed class FakeLspIntelligence : ICideEditorLspIntelligence
440 {
441 public bool IsActive => true;
442 public bool SupportsSemanticTokens => false;
443 public IReadOnlyList<CideEditorCompletionItem> CompletionItems { get; init; } = [];
444 public CideEditorDefinitionLocation? Definition { get; init; }
445 public int CompletionCalls { get; private set; }
446
447 public Task<IReadOnlyList<CideEditorCompletionItem>> RequestCompletionAsync(
448 string filePath,
449 string text,
450 int line1,
451 int col1,
452 CancellationToken ct)
453 {
454 CompletionCalls++;
455 return Task.FromResult(CompletionItems);
456 }
457
458 public Task<string?> RequestSignatureHelpAsync(string filePath, string text, int line1, int col1, CancellationToken ct) =>
459 Task.FromResult<string?>(null);
460
461 public Task<CideEditorDefinitionLocation?> RequestDefinitionAsync(
462 string filePath,
463 string text,
464 int line1,
465 int col1,
466 CancellationToken ct) =>
467 Task.FromResult(Definition);
468
469 public Task<IReadOnlyList<CideEditorReferenceLocation>> RequestReferencesAsync(
470 string filePath,
471 string text,
472 int line1,
473 int col1,
474 CancellationToken ct) =>
475 Task.FromResult<IReadOnlyList<CideEditorReferenceLocation>>([]);
476
477 public Task<CideEditorSemanticTokensData?> RequestSemanticTokensFullAsync(
478 string filePath,
479 string text,
480 CancellationToken ct) =>
481 Task.FromResult<CideEditorSemanticTokensData?>(null);
482 }
483}
484
View only · write via MCP/CIDE