| 1 | #nullable enable |
| 2 | |
| 3 | using CascadeIDE.Features.Editor.Application.Monaco; |
| 4 | using CascadeIDE.Services; |
| 5 | using Microsoft.CodeAnalysis; |
| 6 | using Xunit; |
| 7 | using TestContext = Xunit.TestContext; |
| 8 | |
| 9 | namespace CascadeIDE.Tests.MonacoForward; |
| 10 | |
| 11 | [Trait("Category", "MonacoForward")] |
| 12 | public 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 | |