From db00ff767fdd6be05aea7446960507b9c7e9dd9e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 11 Jun 2026 23:31:28 +0900 Subject: [PATCH 1/3] Fix LSP frame cancellation (#3427) --- USER_GUIDE.md | 5 +++ changelog.d/unreleased/3427.fixed.md | 18 ++++++++ src/CodeIndex/Cli/ProgramRunner.cs | 10 +++-- src/CodeIndex/Lsp/LspServer.cs | 60 +++++++++++++++++-------- tests/CodeIndex.Tests/LspServerTests.cs | 32 +++++++++++++ 5 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 changelog.d/unreleased/3427.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4c49a86911..a47e470d46 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1985,6 +1985,9 @@ matching the MCP resource URI limit and keeping error responses bounded. LSP frame parsing also rejects more than 64 header lines, more than 65536 aggregate header bytes, any one header line above 8192 bytes, duplicate `Content-Length` headers, or a body above 8388608 bytes before reading the message body. +The stdio loop observes the CLI cancellation token while reading headers and +message bodies, so Ctrl-C / host cancellation can interrupt pending frame reads +instead of waiting for another complete request. Unknown-method diagnostics echo at most 240 method-name characters with `...` when the method name is longer. Request IDs must be bounded JSON-RPC scalar values: strings are capped at 256 @@ -4277,6 +4280,8 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて LSP frame parsing は、message body を読む前に 64 行を超える header、合計 65536 bytes を 超える header、8192 bytes を超える単一 header 行、重複した `Content-Length` header、 8388608 bytes を超える body を拒否します。 +stdio loop は header / message body 読み取り中も CLI cancellation token を監視するため、 +Ctrl-C や host cancellation が次の完全な request を待たずに pending frame read を中断できます。 method-not-found diagnostic で echo する method name は最大 240 文字に制限され、 長い場合は `...` を付けて切り詰めます。 request ID は bounded な JSON-RPC scalar value に限定され、string は 256 文字まで、 diff --git a/changelog.d/unreleased/3427.fixed.md b/changelog.d/unreleased/3427.fixed.md new file mode 100644 index 0000000000..18d94eb67b --- /dev/null +++ b/changelog.d/unreleased/3427.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3427 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP frame reads now observe cancellation and reuse bounded header buffers (#3427)** — `cdidx lsp` now threads the CLI cancellation token through the stdio request loop, message body reads, and header parsing while avoiding per-header `List` growth. + +## 日本語 + +- **LSP frame read が cancellation を監視し、bounded header buffer を再利用するようになりました (#3427)** — `cdidx lsp` は CLI cancellation token を stdio request loop、message body read、header parsing に渡し、header ごとの `List` 増加を避けるようになりました。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 387ee6f517..6195917035 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -352,7 +352,7 @@ private static int RunDispatchedCommand( if (args[0] is "lsp" or "--lsp") { - var lspExitCode = RunLsp(args[1..], context.AppVersion, context.JsonOptions); + var lspExitCode = RunLsp(args[1..], context.AppVersion, context.JsonOptions, context.CancellationToken); GlobalToolLog.Info($"command_complete exit_code={lspExitCode} command=lsp"); EmitCommandMetric("lsp", args, context.StartTimestamp, context.Stopwatch, lspExitCode); return lspExitCode; @@ -2290,7 +2290,11 @@ internal static void EmitCommandMetric(string tool, string[] args, DateTimeOffse private const string DefaultMcpHttpListen = "127.0.0.1:38080"; internal const string McpHttpTokenEnvVar = "CDIDX_MCP_HTTP_TOKEN"; - private static int RunLsp(string[] cmdArgs, string appVersion, JsonSerializerOptions jsonOptions) + private static int RunLsp( + string[] cmdArgs, + string appVersion, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken = default) { var options = QueryCommandRunner.ParseArgs(cmdArgs, jsonDefault: true); if (options.ParseError != null) @@ -2350,7 +2354,7 @@ private static int RunLsp(string[] cmdArgs, string appVersion, JsonSerializerOpt } using var server = new LspServer(new DbReader(db), appVersion, jsonOptions, indexedProjectRoot); - return server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput()); + return server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput(), cancellationToken); } catch (OperationCanceledException) { diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index a8fd0155fe..a01e77c24f 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -61,10 +61,13 @@ public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOpti _pathStringComparison = PathCasing.ComparisonFor(_projectRoot ?? Environment.CurrentDirectory); } - public int Run(Stream input, Stream output) + public int Run(Stream input, Stream output) => Run(input, output, CancellationToken.None); + + public int Run(Stream input, Stream output, CancellationToken cancellationToken) { - while (TryReadMessage(input, out var payload)) + while (TryReadMessage(input, out var payload, cancellationToken)) { + cancellationToken.ThrowIfCancellationRequested(); var response = HandleMessage(payload); if (response != null) WriteMessage(output, response.ToJsonString(_jsonOptions)); @@ -731,7 +734,10 @@ internal static string UriToPath(string uri) }, }; - internal static bool TryReadMessage(Stream input, out string payload) + internal static bool TryReadMessage(Stream input, out string payload) => + TryReadMessage(input, out payload, CancellationToken.None); + + internal static bool TryReadMessage(Stream input, out string payload, CancellationToken cancellationToken) { payload = string.Empty; var contentLength = -1; @@ -740,7 +746,8 @@ internal static bool TryReadMessage(Stream input, out string payload) var headerBytes = 0; while (true) { - var line = ReadAsciiLine(input); + cancellationToken.ThrowIfCancellationRequested(); + var line = ReadAsciiLine(input, cancellationToken); if (line == null) return false; if (line.Length == 0) @@ -779,7 +786,7 @@ internal static bool TryReadMessage(Stream input, out string payload) var offset = 0; while (offset < contentLength) { - var read = input.Read(buffer, offset, contentLength - offset); + var read = Read(input, buffer, offset, contentLength - offset, cancellationToken); if (read == 0) return false; offset += read; @@ -802,24 +809,41 @@ internal static void WriteMessage(Stream output, string payload) output.Flush(); } - private static string? ReadAsciiLine(Stream input) + private static string? ReadAsciiLine(Stream input, CancellationToken cancellationToken) { - var bytes = new List(); - while (true) + var buffer = ArrayPool.Shared.Rent(MaxLspHeaderLineBytes + 1); + var length = 0; + try { - var value = input.ReadByte(); - if (value < 0) - return bytes.Count == 0 ? null : Encoding.ASCII.GetString(bytes.ToArray()); - if (value == '\n') - break; - if (value != '\r') + while (true) { - if (bytes.Count >= MaxLspHeaderLineBytes) - return null; - bytes.Add((byte)value); + var read = Read(input, buffer, length, 1, cancellationToken); + if (read == 0) + return length == 0 ? null : Encoding.ASCII.GetString(buffer, 0, length); + + var value = buffer[length]; + if (value == '\n') + break; + if (value != '\r') + { + if (length >= MaxLspHeaderLineBytes) + return null; + length++; + } } + + return Encoding.ASCII.GetString(buffer, 0, length); } - return Encoding.ASCII.GetString(bytes.ToArray()); + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static int Read(Stream input, byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return input.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask().GetAwaiter().GetResult(); } public void Dispose() diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index a202813a12..e1225dd464 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -97,6 +97,38 @@ public void TryReadMessage_RejectsDuplicateContentLength_Issue3229(string firstL Assert.Equal(string.Empty, actual); } + [Fact] + public void TryReadMessage_CanceledBeforeRead_ThrowsOperationCanceled_Issue3427() + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("Content-Length: 2\r\n\r\n{}")); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws(() => LspServer.TryReadMessage(stream, out _, cts.Token)); + } + + [Fact] + public void Run_CanceledBeforeRead_ThrowsOperationCanceled_Issue3427() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_canceled"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + using var input = new MemoryStream(Encoding.UTF8.GetBytes("Content-Length: 2\r\n\r\n{}")); + using var output = new MemoryStream(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws(() => server.Run(input, output, cts.Token)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Initialize_AdvertisesCoreCapabilities() { From 269040e0ac50d055f42bff81c08c511ba1a22878 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 11 Jun 2026 23:36:01 +0900 Subject: [PATCH 2/3] Add LSP lookup diagnostics (#3428) --- USER_GUIDE.md | 7 ++ changelog.d/unreleased/3428.added.md | 17 ++++ src/CodeIndex/Lsp/LspServer.cs | 129 ++++++++++++++++++++---- tests/CodeIndex.Tests/LspServerTests.cs | 53 ++++++++++ 4 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 changelog.d/unreleased/3428.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a47e470d46..3b34b74bb7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1999,6 +1999,10 @@ each `detail` string to 512 characters with `...`, and stops adding symbols before the result array exceeds 524288 JSON bytes. Position-based `definition` and `references` lookups read at most 16384 characters from the target source line before returning an empty result. +When a position lookup returns an empty result because the request cannot be +resolved safely, the `CodeIndex` `ActivitySource` emits an `lsp.lookup_failed` +event with a safe `lsp.lookup.failure_reason` code such as `outside_project`, +`file_not_indexed`, `position_file_too_large`, or `no_token_at_position`. When exact indexed path resolution misses, LSP document path fallback inspects at most 32 basename candidates before treating the document as unresolved. @@ -4292,6 +4296,9 @@ invalid request として拒否します。 `...` 付きの 512 文字に切り詰め、result array が 524288 JSON bytes を超える前に symbol 追加を止めます。 position-based な `definition` / `references` lookup は、対象 source line を最大 16384 文字まで読み、 超過時は空の result を返します。 +position lookup が安全に解決できず空の result を返す場合、`CodeIndex` `ActivitySource` は +`outside_project`、`file_not_indexed`、`position_file_too_large`、`no_token_at_position` +などの安全な `lsp.lookup.failure_reason` code を持つ `lsp.lookup_failed` event を出します。 exact indexed path resolution が失敗した場合、LSP document path fallback は最大 32 件の basename candidate だけを確認し、見つからなければ unresolved document として扱います。 diff --git a/changelog.d/unreleased/3428.added.md b/changelog.d/unreleased/3428.added.md new file mode 100644 index 0000000000..f0c67f6f6c --- /dev/null +++ b/changelog.d/unreleased/3428.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 3428 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP position lookup misses now emit structured trace diagnostics (#3428)** — empty `definition` / `references` results caused by safe preflight failures now add an `lsp.lookup_failed` `ActivitySource` event with a bounded `lsp.lookup.failure_reason` code such as `outside_project`, `file_not_indexed`, `position_file_too_large`, or `no_token_at_position`. + +## 日本語 + +- **LSP position lookup miss が構造化 trace diagnostic を出すようになりました (#3428)** — safe preflight failure による空の `definition` / `references` result は、`outside_project`、`file_not_indexed`、`position_file_too_large`、`no_token_at_position` などの bounded な `lsp.lookup.failure_reason` code を持つ `lsp.lookup_failed` `ActivitySource` event を追加します。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index a01e77c24f..593c0d075c 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Diagnostics; using System.Globalization; using System.Text; using System.Text.Json; @@ -32,6 +33,20 @@ internal sealed class LspServer : IDisposable private const int JsonRpcInternalErrorCode = -32603; private const string JsonRpcInvalidParamsMessage = "Invalid params"; private const string JsonRpcInternalErrorMessage = "Internal error"; + private const string LspLookupFailureEventName = "lsp.lookup_failed"; + private const string LspLookupFailureReasonTag = "lsp.lookup.failure_reason"; + private const string LspMethodTag = "lsp.method"; + private const string FailureInvalidPosition = "invalid_position"; + private const string FailureOutsideProject = "outside_project"; + private const string FailureDocumentPathUnresolved = "document_path_unresolved"; + private const string FailureFileNotIndexed = "file_not_indexed"; + private const string FailureIndexedFileUnresolved = "indexed_file_unresolved"; + private const string FailurePathCasingMismatch = "path_casing_mismatch"; + private const string FailurePositionFileTooLarge = "position_file_too_large"; + private const string FailurePositionLineTooLong = "position_line_too_long"; + private const string FailurePositionLineMissing = "position_line_missing"; + private const string FailurePositionFileUnreadable = "position_file_unreadable"; + private const string FailureNoTokenAtPosition = "no_token_at_position"; private static readonly JsonReaderOptions LspJsonReaderOptions = new() { MaxDepth = MaxJsonDepth, @@ -109,6 +124,7 @@ public int Run(Stream input, Stream output, CancellationToken cancellationToken) if (method == null) return hasId ? Error(id, -32600, "Invalid Request") : null; + using var activity = StartLspRequestActivity(method); return method switch { "initialize" => Result(id, BuildInitializeResult()), @@ -255,6 +271,15 @@ private JsonObject HandleShutdown(JsonNode? id) return null; } + private static Activity? StartLspRequestActivity(string method) + { + var activity = CodeIndexTelemetry.ActivitySource.StartActivity("lsp.request", ActivityKind.Server); + activity?.SetTag("rpc.system", "jsonrpc"); + activity?.SetTag("rpc.service", "lsp"); + activity?.SetTag("rpc.method", method); + return activity; + } + private JsonObject BuildInitializeResult() => new() { ["capabilities"] = new JsonObject @@ -311,11 +336,13 @@ private JsonArray DocumentSymbol(JsonElement root) private JsonArray Definition(JsonElement root) { - var context = ExtractPositionToken(root); - if (context == null) + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure("textDocument/definition", failureReason); return []; + } - var definitions = ResolveLspDefinitions(context.Value); + var definitions = ResolveLspDefinitions(context); var array = new JsonArray(); foreach (var definition in definitions) array.Add(ToLocation(definition.Path, definition.StartLine, 1, definition.EndLine, 1)); @@ -324,17 +351,33 @@ private JsonArray Definition(JsonElement root) private JsonArray References(JsonElement root) { - var context = ExtractPositionToken(root); - if (context == null) + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure("textDocument/references", failureReason); return []; + } - var analysis = ResolveLspReferences(context.Value); + var analysis = ResolveLspReferences(context); var array = new JsonArray(); foreach (var reference in analysis.References) - array.Add(ToLocation(reference.Path, reference.Line, Math.Max(reference.Column, 1), reference.Line, Math.Max(reference.Column, 1) + Math.Max(context.Value.Token.Length, 1))); + array.Add(ToLocation(reference.Path, reference.Line, Math.Max(reference.Column, 1), reference.Line, Math.Max(reference.Column, 1) + Math.Max(context.Token.Length, 1))); return array; } + private static void RecordLookupFailure(string method, string? failureReason) + { + if (string.IsNullOrEmpty(failureReason)) + return; + + Activity.Current?.AddEvent(new ActivityEvent( + LspLookupFailureEventName, + tags: new ActivityTagsCollection + { + [LspMethodTag] = method, + [LspLookupFailureReasonTag] = failureReason, + })); + } + private List ResolveLspDefinitions(PositionTokenContext context) { var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); @@ -370,39 +413,67 @@ private static bool HasSingleLspDefinitionTarget(IReadOnlyList private static string BuildLspDefinitionTargetKey(DefinitionResult definition) => string.Join('\0', definition.Path, definition.Kind, definition.ContainerKind, definition.ContainerName, definition.Name); - private PositionTokenContext? ExtractPositionToken(JsonElement root) + private bool TryExtractPositionToken(JsonElement root, out PositionTokenContext context, out string? failureReason) { + context = default; + failureReason = null; var path = GetDocumentPath(root); var line = GetInt32(root, "params", "position", "line"); var character = GetInt32(root, "params", "position", "character"); if (line < 0 || character < 0) - return null; + { + failureReason = FailureInvalidPosition; + return false; + } - if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath)) - return null; + if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath, out failureReason)) + return false; var indexedPath = ResolveIndexedPath(path, resolvedPath, projectRelativePath); - if (indexedPath == null || !TryResolveIndexedFilePath(indexedPath, out var indexedFullPath)) - return null; + if (indexedPath == null) + { + failureReason = FailureFileNotIndexed; + return false; + } + + if (!TryResolveIndexedFilePath(indexedPath, out var indexedFullPath)) + { + failureReason = FailureIndexedFileUnresolved; + return false; + } if (!string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) - return null; + { + failureReason = FailurePathCasingMismatch; + return false; + } - if (!TryReadPositionLine(indexedFullPath, line, out var sourceLine)) - return null; + if (!TryReadPositionLine(indexedFullPath, line, out var sourceLine, out failureReason)) + return false; var token = ExtractTokenAtUtf16Position(sourceLine, character); - return string.IsNullOrWhiteSpace(token) ? null : new PositionTokenContext(token, indexedPath); + if (string.IsNullOrWhiteSpace(token)) + { + failureReason = FailureNoTokenAtPosition; + return false; + } + + context = new PositionTokenContext(token, indexedPath); + return true; } - private static bool TryReadPositionLine(string path, int targetLine, out string sourceLine) + private static bool TryReadPositionLine(string path, int targetLine, out string sourceLine, out string? failureReason) { sourceLine = string.Empty; + failureReason = null; try { using var stream = File.OpenRead(path); if (stream.Length > MaxPositionDocumentBytes) + { + failureReason = FailurePositionFileTooLarge; return false; + } using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); var currentLine = 0; @@ -419,6 +490,7 @@ private static bool TryReadPositionLine(string path, int targetLine, out string return true; } + failureReason = FailurePositionLineMissing; return false; } @@ -444,7 +516,10 @@ private static bool TryReadPositionLine(string path, int targetLine, out string if (currentLineLength > MaxPositionLineChars) { if (currentLine == targetLine) + { + failureReason = FailurePositionLineTooLong; return false; + } continue; } @@ -453,6 +528,7 @@ private static bool TryReadPositionLine(string path, int targetLine, out string } catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) { + failureReason = FailurePositionFileUnreadable; return false; } } @@ -536,10 +612,18 @@ private bool MatchesDocumentPath(string indexedPath, string documentPath, string return matches.Count == 1 ? matches[0].Path : null; } - private bool TryResolveDocumentPath(string documentPath, out string resolvedPath, out string? projectRelativePath) + private bool TryResolveDocumentPath(string documentPath, out string resolvedPath, out string? projectRelativePath) => + TryResolveDocumentPath(documentPath, out resolvedPath, out projectRelativePath, out _); + + private bool TryResolveDocumentPath( + string documentPath, + out string resolvedPath, + out string? projectRelativePath, + out string? failureReason) { resolvedPath = string.Empty; projectRelativePath = null; + failureReason = null; try { resolvedPath = Path.IsPathRooted(documentPath) @@ -548,13 +632,18 @@ private bool TryResolveDocumentPath(string documentPath, out string resolvedPath } catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) { + failureReason = FailureDocumentPathUnresolved; return false; } if (_projectRoot == null) return true; - return TryGetProjectRelativePath(resolvedPath, out projectRelativePath); + if (TryGetProjectRelativePath(resolvedPath, out projectRelativePath)) + return true; + + failureReason = FailureOutsideProject; + return false; } private bool TryResolveIndexedFilePath(string indexedPath, out string resolvedPath) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index e1225dd464..84a1726adc 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Globalization; using System.Text; using System.Text.Json; @@ -965,6 +966,46 @@ public void HandleMessage_Definition_ReturnsEmptyForUnindexedDocument() } } + [Fact] + public void HandleMessage_Definition_UnindexedDocument_EmitsLookupFailureTrace_Issue3428() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_unindexed_trace"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var indexedPath = Path.Combine(projectRoot, "indexed.cs"); + var indexedSource = "class Indexed { void Needle() { } }\n"; + File.WriteAllText(indexedPath, indexedSource); + TestProjectHelper.InsertIndexedFile(dbPath, "indexed.cs", "csharp", indexedSource); + var unindexedPath = Path.Combine(projectRoot, "unindexed.cs"); + var unindexedSource = "class Unindexed { void Call() { Needle(); } }\n"; + File.WriteAllText(unindexedPath, unindexedSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + unindexedPath, + 3428, + 0, + unindexedSource.IndexOf("Needle();", StringComparison.Ordinal)); + var activities = new List(); + using var listener = CaptureCodeIndexActivities(activities); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + var requestActivity = Assert.Single(activities.Where(activity => activity.OperationName == "lsp.request")); + var failureEvent = Assert.Single(requestActivity.Events.Where(activityEvent => activityEvent.Name == "lsp.lookup_failed")); + var tags = failureEvent.Tags.ToDictionary(tag => tag.Key, tag => tag.Value?.ToString(), StringComparer.Ordinal); + Assert.Equal("textDocument/definition", tags["lsp.method"]); + Assert.Equal("file_not_indexed", tags["lsp.lookup.failure_reason"]); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Definition_ReturnsEmptyForOutsideProjectDocument() { @@ -1259,6 +1300,18 @@ private static string BuildNestedLspRequest(int nestedObjectCount) return builder.ToString(); } + private static ActivityListener CaptureCodeIndexActivities(List activities) + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == CodeIndexTelemetry.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activities.Add, + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + private static void MarkGraphReady(string dbPath) { using var db = new DbContext(dbPath); From 27b043455a4df0e4a09e99aed4a9cefe48a25ef3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 06:27:25 +0900 Subject: [PATCH 3/3] Preserve LSP navigation semantics (#3537) --- USER_GUIDE.md | 40 +- changelog.d/unreleased/3537.changed.md | 17 + src/CodeIndex/Lsp/LspServer.cs | 402 ++++++++++++++++--- tests/CodeIndex.Tests/LspServerTests.cs | 511 +++++++++++++++++++++++- 4 files changed, 902 insertions(+), 68 deletions(-) create mode 100644 changelog.d/unreleased/3537.changed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 3b34b74bb7..8e27fcd38d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1977,8 +1977,10 @@ cdidx includes a built-in **MCP (Model Context Protocol) server**. MCP is a stan `cdidx lsp --db .cdidx/codeindex.db` starts a read-only Language Server Protocol server over stdio. It reuses the existing CodeIndex database and exposes `initialize`, `workspace/symbol`, `textDocument/documentSymbol`, -`textDocument/definition`, and `textDocument/references` for editors that can -launch an arbitrary LSP command but do not speak MCP. +`textDocument/definition`, `textDocument/declaration`, +`textDocument/typeDefinition`, `textDocument/implementation`, and +`textDocument/references` for editors that can launch an arbitrary LSP command +but do not speak MCP. Incoming `textDocument.uri` values must be strings, must be absolute `file:` URIs, and are rejected before URI parsing when they exceed 4096 characters, matching the MCP resource URI limit and keeping error responses bounded. LSP @@ -1994,11 +1996,23 @@ values: strings are capped at 256 characters, integer IDs must fit in `Int64`, and non-scalar IDs are rejected as invalid requests before response IDs are cloned. `workspace/symbol` query strings are capped at 1000 characters before symbol search runs. -`textDocument/documentSymbol` returns at most 1000 indexed symbols, truncates -each `detail` string to 512 characters with `...`, and stops adding symbols -before the result array exceeds 524288 JSON bytes. +`workspace/symbol` accepts optional numeric `limit` / `maxResults` parameters +and clamps them to 1000 results. `textDocument/documentSymbol` returns +hierarchical `DocumentSymbol` children when container metadata is available, +returns at most 1000 indexed symbols, truncates each `detail` string to 512 +characters with `...`, and trims the tree before the result array exceeds +524288 JSON bytes. Position-based `definition` and `references` lookups read at most 16384 characters from the target source line before returning an empty result. +`textDocument/references` honors `context.includeDeclaration`; when true, the +definition locations are prepended to the reference result without duplicating +identical locations. `declaration`, `typeDefinition`, and `implementation` +requests reuse the same indexed definition lookup and return the same location +shape as `definition`. +Tracked `workspaceFolders` are used when resolving position-based requests for +indexed absolute paths, including folders added or removed through +`workspace/didChangeWorkspaceFolders`; relative indexed paths remain anchored to +the database project root. When a position lookup returns an empty result because the request cannot be resolved safely, the `CodeIndex` `ActivitySource` emits an `lsp.lookup_failed` event with a safe `lsp.lookup.failure_reason` code such as `outside_project`, @@ -4277,7 +4291,9 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて サーバーを stdio で起動します。既存の CodeIndex database を再利用し、 任意の LSP command を起動できるが MCP には対応していない editor 向けに `initialize`、`workspace/symbol`、`textDocument/documentSymbol`、 -`textDocument/definition`、`textDocument/references` を公開します。 +`textDocument/definition`、`textDocument/declaration`、 +`textDocument/typeDefinition`、`textDocument/implementation`、 +`textDocument/references` を公開します。 受信した `textDocument.uri` は string かつ absolute `file:` URI である必要があり、 4096 文字を超える場合は URI parse の前に拒否されます。これは MCP resource URI の上限と 揃えており、エラー応答が過大にならないようにします。 @@ -4292,10 +4308,18 @@ request ID は bounded な JSON-RPC scalar value に限定され、string は 25 integer ID は `Int64` に収まるものだけを受理し、non-scalar ID は response ID を複製する前に invalid request として拒否します。 `workspace/symbol` の query string は symbol search を実行する前に 1000 文字で上限をかけます。 -`textDocument/documentSymbol` は最大 1000 件の indexed symbol を返し、各 `detail` string を -`...` 付きの 512 文字に切り詰め、result array が 524288 JSON bytes を超える前に symbol 追加を止めます。 +`workspace/symbol` は任意の numeric `limit` / `maxResults` parameter を受け取り、1000 件までに +clamp します。`textDocument/documentSymbol` は container metadata がある場合に階層化された +`DocumentSymbol` children を返し、最大 1000 件の indexed symbol を返し、各 `detail` string を +`...` 付きの 512 文字に切り詰め、result tree が 524288 JSON bytes を超える前に trim します。 position-based な `definition` / `references` lookup は、対象 source line を最大 16384 文字まで読み、 超過時は空の result を返します。 +`textDocument/references` は `context.includeDeclaration` を尊重し、true の場合は definition location を +重複なしで reference result の先頭に追加します。`declaration`、`typeDefinition`、`implementation` +request は同じ indexed definition lookup を再利用し、`definition` と同じ location shape を返します。 +追跡中の `workspaceFolders` は indexed absolute path に対する position-based request の解決に使われ、 +`workspace/didChangeWorkspaceFolders` で追加・削除された folder も反映されます。relative indexed path は +database project root に紐づいたままです。 position lookup が安全に解決できず空の result を返す場合、`CodeIndex` `ActivitySource` は `outside_project`、`file_not_indexed`、`position_file_too_large`、`no_token_at_position` などの安全な `lsp.lookup.failure_reason` code を持つ `lsp.lookup_failed` event を出します。 diff --git a/changelog.d/unreleased/3537.changed.md b/changelog.d/unreleased/3537.changed.md new file mode 100644 index 0000000000..0d42223dec --- /dev/null +++ b/changelog.d/unreleased/3537.changed.md @@ -0,0 +1,17 @@ +--- +category: changed +issues: + - 3537 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP navigation now preserves more client semantics (#3537)** — `references` honors `context.includeDeclaration`, ambiguous workspace definitions return all matching locations instead of an empty result, declaration/type-definition/implementation requests reuse indexed definition lookup, tracked `workspaceFolders` feed position-based absolute-path resolution while relative paths stay anchored to the DB root, `workspace/symbol` accepts a bounded client limit, and `documentSymbol` returns hierarchical children when container metadata is available. + +## 日本語 + +- **LSP navigation がより多くの client semantics を保持するようになりました (#3537)** — `references` は `context.includeDeclaration` を尊重し、曖昧な workspace definition は空 result ではなく一致 location をすべて返し、declaration / type-definition / implementation request は indexed definition lookup を再利用し、追跡中の `workspaceFolders` は position-based な absolute path 解決に反映される一方で relative path は DB root に固定され、`workspace/symbol` は bounded な client limit を受け取り、`documentSymbol` は container metadata がある場合に階層化された children を返します。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 593c0d075c..00eed434ad 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -14,6 +14,8 @@ namespace CodeIndex.Lsp; internal sealed class LspServer : IDisposable { private const int DefaultLimit = 50; + internal const int MaxWorkspaceSymbols = 1000; + private const int MaxWorkspaceFolders = 32; internal const int MaxLspFrameBytes = 8 * 1024 * 1024; internal const int MaxLspHeaderLineBytes = 8 * 1024; internal const int MaxLspHeaderCount = 64; @@ -64,8 +66,10 @@ internal sealed class LspServer : IDisposable private bool _shutdownRequested; private bool _exitRequested; private bool _exitRequestedBeforeShutdown; + private readonly List _workspaceFolders = []; - private readonly record struct PositionTokenContext(string Token, string IndexedPath); + private readonly record struct PositionTokenContext(string Token, string IndexedPath, string? WorkspaceRoot); + private readonly record struct DocumentSymbolNode(SymbolResult Symbol, JsonObject Item); public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOptions, string? projectRoot = null) { @@ -74,6 +78,8 @@ public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOpti _jsonOptions = jsonOptions; _projectRoot = string.IsNullOrWhiteSpace(projectRoot) ? null : projectRoot; _pathStringComparison = PathCasing.ComparisonFor(_projectRoot ?? Environment.CurrentDirectory); + if (_projectRoot != null) + _workspaceFolders.Add(Path.GetFullPath(_projectRoot)); } public int Run(Stream input, Stream output) => Run(input, output, CancellationToken.None); @@ -127,14 +133,18 @@ public int Run(Stream input, Stream output, CancellationToken cancellationToken) using var activity = StartLspRequestActivity(method); return method switch { - "initialize" => Result(id, BuildInitializeResult()), + "initialize" => HandleInitialize(id, root), "initialized" => null, "shutdown" => HandleShutdown(id), "exit" => HandleExit(), + "workspace/didChangeWorkspaceFolders" => HandleDidChangeWorkspaceFolders(root), "workspace/symbol" => Result(id, WorkspaceSymbol(root)), "textDocument/documentSymbol" => Result(id, DocumentSymbol(root)), - "textDocument/definition" => Result(id, Definition(root)), - "textDocument/references" => Result(id, References(root)), + "textDocument/definition" => Result(id, Definition(root, "textDocument/definition")), + "textDocument/declaration" => Result(id, Definition(root, "textDocument/declaration")), + "textDocument/typeDefinition" => Result(id, Definition(root, "textDocument/typeDefinition")), + "textDocument/implementation" => Result(id, Definition(root, "textDocument/implementation")), + "textDocument/references" => Result(id, References(root, "textDocument/references")), _ => hasId ? Error(id, -32601, $"Method not found: {SanitizeUnknownMethod(method)}") : null, }; } @@ -271,6 +281,41 @@ private JsonObject HandleShutdown(JsonNode? id) return null; } + private JsonObject HandleInitialize(JsonNode? id, JsonElement root) + { + CaptureInitializeWorkspaceFolders(root); + return Result(id, BuildInitializeResult()); + } + + private JsonObject? HandleDidChangeWorkspaceFolders(JsonElement root) + { + if (TryGet(root, out var removed, "params", "event", "removed") && removed.ValueKind == JsonValueKind.Array) + { + foreach (var folder in removed.EnumerateArray()) + { + if (TryGetWorkspaceFolderPath(folder, out var path)) + _workspaceFolders.RemoveAll(existing => string.Equals(existing, path, _pathStringComparison)); + } + } + + if (TryGet(root, out var added, "params", "event", "added") && added.ValueKind == JsonValueKind.Array) + { + foreach (var folder in added.EnumerateArray()) + { + if (_workspaceFolders.Count >= MaxWorkspaceFolders) + break; + if (TryGetWorkspaceFolderPath(folder, out var path) + && !_workspaceFolders.Any(existing => string.Equals(existing, path, _pathStringComparison))) + { + _workspaceFolders.Add(path); + } + } + } + + Activity.Current?.SetTag("lsp.workspace_folder_count", _workspaceFolders.Count); + return null; + } + private static Activity? StartLspRequestActivity(string method) { var activity = CodeIndexTelemetry.ActivitySource.StartActivity("lsp.request", ActivityKind.Server); @@ -285,10 +330,21 @@ private JsonObject HandleShutdown(JsonNode? id) ["capabilities"] = new JsonObject { ["definitionProvider"] = true, + ["declarationProvider"] = true, + ["typeDefinitionProvider"] = true, + ["implementationProvider"] = true, ["referencesProvider"] = true, ["documentSymbolProvider"] = true, ["workspaceSymbolProvider"] = true, ["textDocumentSync"] = 0, + ["workspace"] = new JsonObject + { + ["workspaceFolders"] = new JsonObject + { + ["supported"] = true, + ["changeNotifications"] = true, + }, + }, }, ["serverInfo"] = new JsonObject { @@ -303,10 +359,13 @@ private JsonArray WorkspaceSymbol(JsonElement root) if (query != null && query.Length > QueryLimits.MaxQueryLength) throw new ArgumentException(QueryLimits.FormatQueryTooLongError()); - var symbols = _reader.SearchSymbols(query, DefaultLimit); + var limit = GetLimit(root, DefaultLimit, MaxWorkspaceSymbols, "params", "limit") + ?? GetLimit(root, DefaultLimit, MaxWorkspaceSymbols, "params", "maxResults") + ?? DefaultLimit; + IReadOnlyList symbols = limit == 0 ? [] : _reader.SearchSymbols(query, limit); var array = new JsonArray(); foreach (var symbol in symbols) - array.Add(ToWorkspaceSymbol(symbol)); + array.Add((JsonNode)ToWorkspaceSymbol(symbol)); return array; } @@ -317,53 +376,104 @@ private JsonArray DocumentSymbol(JsonElement root) if (indexedPath == null) return []; - var symbols = _reader.SearchSymbols((string?)null, MaxDocumentSymbols, pathPatterns: [indexedPath]); - var array = new JsonArray(); - var responseBytes = 2; - foreach (var symbol in symbols.OrderBy(s => s.StartLine).ThenBy(s => s.Name, StringComparer.Ordinal)) + var symbols = _reader.SearchSymbols((string?)null, MaxDocumentSymbols, pathPatterns: [indexedPath]) + .OrderBy(s => s.StartLine) + .ThenByDescending(s => s.EndLine) + .ThenBy(s => s.ContainerName == null ? 0 : 1) + .ThenBy(s => s.Name, StringComparer.Ordinal) + .ToList(); + return BuildDocumentSymbolTree(symbols); + } + + private JsonArray BuildDocumentSymbolTree(IReadOnlyList symbols) + { + var roots = new JsonArray(); + var nodes = new List(symbols.Count); + foreach (var symbol in symbols) { var item = ToDocumentSymbol(symbol); - var itemBytes = Encoding.UTF8.GetByteCount(item.ToJsonString(_jsonOptions)); - var separatorBytes = array.Count == 0 ? 0 : 1; - if (responseBytes + separatorBytes + itemBytes > MaxDocumentSymbolResponseBytes) - break; - - responseBytes += separatorBytes + itemBytes; - array.Add(item); + var node = new DocumentSymbolNode(symbol, item); + var parent = FindDocumentSymbolParent(nodes, symbol); + if (parent == null) + roots.Add((JsonNode)item); + else + AddDocumentSymbolChild(parent.Value.Item, item); + nodes.Add(node); } - return array; + + TrimDocumentSymbolsToBudget(roots); + return roots; } - private JsonArray Definition(JsonElement root) + private JsonArray Definition(JsonElement root, string method) { if (!TryExtractPositionToken(root, out var context, out var failureReason)) { - RecordLookupFailure("textDocument/definition", failureReason); + RecordLookupFailure(method, failureReason); return []; } var definitions = ResolveLspDefinitions(context); var array = new JsonArray(); foreach (var definition in definitions) - array.Add(ToLocation(definition.Path, definition.StartLine, 1, definition.EndLine, 1)); + array.Add((JsonNode)ToLocation(definition.Path, definition.StartLine, 1, definition.EndLine, 1, GetLocationWorkspaceRoot(definition.Path, context))); return array; } - private JsonArray References(JsonElement root) + private JsonArray References(JsonElement root, string method) { if (!TryExtractPositionToken(root, out var context, out var failureReason)) { - RecordLookupFailure("textDocument/references", failureReason); + RecordLookupFailure(method, failureReason); return []; } + var includeDeclaration = GetBool(root, "params", "context", "includeDeclaration") == true; var analysis = ResolveLspReferences(context); var array = new JsonArray(); + var seenLocations = new HashSet(StringComparer.Ordinal); + if (includeDeclaration) + { + foreach (var definition in ResolveLspDefinitions(context)) + AddLocation(array, seenLocations, definition.Path, definition.StartLine, 1, definition.EndLine, 1, context); + } + foreach (var reference in analysis.References) - array.Add(ToLocation(reference.Path, reference.Line, Math.Max(reference.Column, 1), reference.Line, Math.Max(reference.Column, 1) + Math.Max(context.Token.Length, 1))); + AddLocation( + array, + seenLocations, + reference.Path, + reference.Line, + Math.Max(reference.Column, 1), + reference.Line, + Math.Max(reference.Column, 1) + Math.Max(context.Token.Length, 1), + context); return array; } + private void AddLocation( + JsonArray array, + HashSet seenLocations, + string path, + int startLine, + int startColumn, + int endLine, + int endColumn, + PositionTokenContext context) + { + var workspaceRoot = GetLocationWorkspaceRoot(path, context); + var key = string.Join('\0', PathToUri(path, workspaceRoot ?? _projectRoot), startLine, startColumn, endLine, endColumn); + if (seenLocations.Add(key)) + array.Add((JsonNode)ToLocation(path, startLine, startColumn, endLine, endColumn, workspaceRoot)); + } + + private string? GetLocationWorkspaceRoot(string path, PositionTokenContext context) + { + if (Path.IsPathRooted(path)) + return null; + return _projectRoot ?? context.WorkspaceRoot; + } + private static void RecordLookupFailure(string method, string? failureReason) { if (string.IsNullOrEmpty(failureReason)) @@ -378,6 +488,91 @@ private static void RecordLookupFailure(string method, string? failureReason) })); } + private DocumentSymbolNode? FindDocumentSymbolParent(IReadOnlyList nodes, SymbolResult symbol) + { + for (var i = nodes.Count - 1; i >= 0; i--) + { + var candidate = nodes[i].Symbol; + if (!ContainsDocumentSymbol(candidate, symbol)) + continue; + if (symbol.ContainerName != null + && !string.Equals(candidate.Name, symbol.ContainerName, StringComparison.Ordinal)) + { + continue; + } + if (symbol.ContainerKind != null + && !string.Equals(candidate.Kind, symbol.ContainerKind, StringComparison.Ordinal)) + { + continue; + } + + return nodes[i]; + } + + if (symbol.ContainerName != null) + return null; + + for (var i = nodes.Count - 1; i >= 0; i--) + { + var candidate = nodes[i].Symbol; + if (ContainsDocumentSymbol(candidate, symbol)) + return nodes[i]; + } + + return null; + } + + private static bool ContainsDocumentSymbol(SymbolResult candidate, SymbolResult symbol) => + candidate.StartLine <= symbol.StartLine + && candidate.EndLine >= symbol.EndLine + && (candidate.StartLine < symbol.StartLine + || candidate.EndLine > symbol.EndLine + || (symbol.ContainerName != null + && symbol.ContainerKind != null + && string.Equals(candidate.Name, symbol.ContainerName, StringComparison.Ordinal) + && string.Equals(candidate.Kind, symbol.ContainerKind, StringComparison.Ordinal))); + + private static void AddDocumentSymbolChild(JsonObject parent, JsonObject child) + { + if (parent["children"] is not JsonArray children) + { + children = []; + parent["children"] = children; + } + + children.Add((JsonNode)child); + } + + private void TrimDocumentSymbolsToBudget(JsonArray roots) + { + while (roots.Count > 0 + && Encoding.UTF8.GetByteCount(roots.ToJsonString(_jsonOptions)) > MaxDocumentSymbolResponseBytes + && RemoveLastDocumentSymbol(roots)) + { + } + } + + private static bool RemoveLastDocumentSymbol(JsonArray symbols) + { + if (symbols.Count == 0) + return false; + + if (symbols[symbols.Count - 1] is JsonObject last + && last["children"] is JsonArray children + && children.Count > 0) + { + if (RemoveLastDocumentSymbol(children)) + { + if (children.Count == 0) + last.Remove("children"); + return true; + } + } + + symbols.RemoveAt(symbols.Count - 1); + return true; + } + private List ResolveLspDefinitions(PositionTokenContext context) { var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); @@ -385,7 +580,7 @@ private List ResolveLspDefinitions(PositionTokenContext contex return localDefinitions; var workspaceDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true); - return HasSingleLspDefinitionTarget(workspaceDefinitions) ? workspaceDefinitions : []; + return workspaceDefinitions; } private SymbolAnalysisResult ResolveLspReferences(PositionTokenContext context) @@ -426,17 +621,18 @@ private bool TryExtractPositionToken(JsonElement root, out PositionTokenContext return false; } - if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath, out failureReason)) + if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath, out var workspaceRoot, out failureReason)) return false; - var indexedPath = ResolveIndexedPath(path, resolvedPath, projectRelativePath); + var indexedPath = ResolveIndexedPath(path, resolvedPath, projectRelativePath, workspaceRoot); if (indexedPath == null) { failureReason = FailureFileNotIndexed; return false; } - if (!TryResolveIndexedFilePath(indexedPath, out var indexedFullPath)) + var indexedPathRoot = _projectRoot == null ? workspaceRoot : null; + if (!TryResolveIndexedFilePath(indexedPath, indexedPathRoot, out var indexedFullPath)) { failureReason = FailureIndexedFileUnresolved; return false; @@ -458,7 +654,7 @@ private bool TryExtractPositionToken(JsonElement root, out PositionTokenContext return false; } - context = new PositionTokenContext(token, indexedPath); + context = new PositionTokenContext(token, indexedPath, workspaceRoot); return true; } @@ -556,22 +752,20 @@ private static bool TryReadPositionLine(string path, int targetLine, out string private static bool IsTokenChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '@'; - private bool MatchesDocumentPath(string indexedPath, string documentPath, string? projectRelativePath) + private bool MatchesDocumentPath(string indexedPath, string documentPath, string? projectRelativePath, string resolvedPath, string? workspaceRoot) { - var normalizedIndexed = indexedPath.Replace('\\', '/'); - if (_projectRoot != null) - { - if (Path.IsPathRooted(indexedPath) - && TryResolveIndexedFilePath(indexedPath, out var indexedFullPath) - && TryGetProjectRelativePath(indexedFullPath, out var indexedRelativePath) - && indexedRelativePath != null) - { - normalizedIndexed = indexedRelativePath.Replace('\\', '/'); - } + if (TryResolveIndexedFilePath(indexedPath, null, out var indexedFullPath) + && string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) + return true; + + if (Path.IsPathRooted(indexedPath)) + return false; - return projectRelativePath != null + var normalizedIndexed = indexedPath.Replace('\\', '/'); + if (projectRelativePath != null) + return _projectRoot == null + && workspaceRoot != null && string.Equals(normalizedIndexed, projectRelativePath.Replace('\\', '/'), _pathStringComparison); - } if (string.Equals(indexedPath, documentPath, StringComparison.Ordinal)) return true; @@ -582,19 +776,19 @@ private bool MatchesDocumentPath(string indexedPath, string documentPath, string private string? ResolveIndexedPath(string documentPath) { - if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath)) + if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath, out var workspaceRoot)) return null; - return ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath); + return ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath, workspaceRoot); } - private string? ResolveIndexedPath(string documentPath, string resolvedPath, string? projectRelativePath) + private string? ResolveIndexedPath(string documentPath, string resolvedPath, string? projectRelativePath, string? workspaceRoot) { if (projectRelativePath != null) { var exactPath = projectRelativePath.Replace('\\', '/'); var exactFile = _reader.GetFileByPath(exactPath); - if (exactFile != null) + if (exactFile != null && MatchesDocumentPath(exactFile.Path, documentPath, projectRelativePath, resolvedPath, workspaceRoot)) return exactFile.Path; } @@ -606,23 +800,32 @@ private bool MatchesDocumentPath(string indexedPath, string documentPath, string var files = _reader.ListFiles(fileName, MaxDocumentPathFallbackCandidates); var matches = files - .Where(file => MatchesDocumentPath(file.Path, documentPath, projectRelativePath)) + .Where(file => MatchesDocumentPath(file.Path, documentPath, projectRelativePath, resolvedPath, workspaceRoot)) .Take(2) .ToList(); return matches.Count == 1 ? matches[0].Path : null; } private bool TryResolveDocumentPath(string documentPath, out string resolvedPath, out string? projectRelativePath) => - TryResolveDocumentPath(documentPath, out resolvedPath, out projectRelativePath, out _); + TryResolveDocumentPath(documentPath, out resolvedPath, out projectRelativePath, out _, out _); private bool TryResolveDocumentPath( string documentPath, out string resolvedPath, out string? projectRelativePath, + out string? workspaceRoot) => + TryResolveDocumentPath(documentPath, out resolvedPath, out projectRelativePath, out workspaceRoot, out _); + + private bool TryResolveDocumentPath( + string documentPath, + out string resolvedPath, + out string? projectRelativePath, + out string? workspaceRoot, out string? failureReason) { resolvedPath = string.Empty; projectRelativePath = null; + workspaceRoot = null; failureReason = null; try { @@ -636,10 +839,10 @@ private bool TryResolveDocumentPath( return false; } - if (_projectRoot == null) + if (_workspaceFolders.Count == 0) return true; - if (TryGetProjectRelativePath(resolvedPath, out projectRelativePath)) + if (TryGetWorkspaceRelativePath(resolvedPath, out projectRelativePath, out workspaceRoot)) return true; failureReason = FailureOutsideProject; @@ -647,13 +850,16 @@ private bool TryResolveDocumentPath( } private bool TryResolveIndexedFilePath(string indexedPath, out string resolvedPath) + => TryResolveIndexedFilePath(indexedPath, null, out resolvedPath); + + private bool TryResolveIndexedFilePath(string indexedPath, string? workspaceRoot, out string resolvedPath) { resolvedPath = string.Empty; try { resolvedPath = Path.IsPathRooted(indexedPath) ? Path.GetFullPath(indexedPath) - : Path.GetFullPath(indexedPath, _projectRoot ?? Environment.CurrentDirectory); + : Path.GetFullPath(indexedPath, workspaceRoot ?? _projectRoot ?? Environment.CurrentDirectory); return true; } catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) @@ -668,9 +874,32 @@ private bool TryGetProjectRelativePath(string resolvedPath, out string? relative if (_projectRoot == null) return false; + return TryGetRelativePath(Path.GetFullPath(_projectRoot), resolvedPath, out relativePath); + } + + private bool TryGetWorkspaceRelativePath(string resolvedPath, out string? relativePath, out string? workspaceRoot) + { + relativePath = null; + workspaceRoot = null; + foreach (var candidateRoot in _workspaceFolders) + { + if (!TryGetRelativePath(candidateRoot, resolvedPath, out var candidateRelativePath)) + continue; + + relativePath = candidateRelativePath; + workspaceRoot = candidateRoot; + return true; + } + + return false; + } + + private static bool TryGetRelativePath(string root, string resolvedPath, out string? relativePath) + { + relativePath = null; try { - var relative = Path.GetRelativePath(Path.GetFullPath(_projectRoot), resolvedPath); + var relative = Path.GetRelativePath(Path.GetFullPath(root), resolvedPath); if (relative == "." || relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) @@ -713,9 +942,9 @@ private bool TryGetProjectRelativePath(string resolvedPath, out string? relative return detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; } - private JsonObject ToLocation(string path, int startLine, int startColumn, int endLine, int endColumn) => new() + private JsonObject ToLocation(string path, int startLine, int startColumn, int endLine, int endColumn, string? workspaceRoot = null) => new() { - ["uri"] = PathToUri(path, _projectRoot), + ["uri"] = PathToUri(path, workspaceRoot ?? _projectRoot), ["range"] = ToRange(startLine, startColumn, endLine, endColumn), }; @@ -772,6 +1001,27 @@ private static string GetTextDocumentUri(JsonElement root) return value.GetString(); } + private static bool? GetBool(JsonElement root, params string[] path) + { + if (!TryGet(root, out var value, path)) + return null; + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + }; + } + + private static int? GetLimit(JsonElement root, int defaultLimit, int maxLimit, params string[] path) + { + if (!TryGet(root, out var value, path)) + return null; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var limit)) + return defaultLimit; + return Math.Clamp(limit, 0, maxLimit); + } + private static int GetInt32(JsonElement root, params string[] path) { if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var result)) @@ -805,6 +1055,50 @@ internal static string UriToPath(string uri) return parsed.LocalPath; } + private void CaptureInitializeWorkspaceFolders(JsonElement root) + { + if (!TryGet(root, out var folders, "params", "workspaceFolders") || folders.ValueKind != JsonValueKind.Array) + return; + + foreach (var folder in folders.EnumerateArray()) + { + if (_workspaceFolders.Count >= MaxWorkspaceFolders) + break; + if (TryGetWorkspaceFolderPath(folder, out var path) + && !_workspaceFolders.Any(existing => string.Equals(existing, path, _pathStringComparison))) + { + _workspaceFolders.Add(path); + } + } + + Activity.Current?.SetTag("lsp.workspace_folder_count", _workspaceFolders.Count); + } + + private static bool TryGetWorkspaceFolderPath(JsonElement folder, out string path) + { + path = string.Empty; + if (folder.ValueKind != JsonValueKind.Object + || !folder.TryGetProperty("uri", out var uriElement) + || uriElement.ValueKind != JsonValueKind.String) + { + return false; + } + + var uri = uriElement.GetString(); + if (string.IsNullOrWhiteSpace(uri) || uri.Length > MaxTextDocumentUriChars) + return false; + + try + { + path = Path.GetFullPath(UriToPath(uri)); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + private static JsonObject Result(JsonNode? id, JsonNode? result) => new() { ["jsonrpc"] = "2.0", diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 84a1726adc..e964257d16 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Lsp; @@ -144,7 +145,12 @@ public void HandleMessage_Initialize_AdvertisesCoreCapabilities() Assert.NotNull(response); Assert.True(response!["result"]!["capabilities"]!["definitionProvider"]!.GetValue()); + Assert.True(response["result"]!["capabilities"]!["declarationProvider"]!.GetValue()); + Assert.True(response["result"]!["capabilities"]!["typeDefinitionProvider"]!.GetValue()); + Assert.True(response["result"]!["capabilities"]!["implementationProvider"]!.GetValue()); Assert.True(response["result"]!["capabilities"]!["documentSymbolProvider"]!.GetValue()); + Assert.True(response["result"]!["capabilities"]!["workspace"]!["workspaceFolders"]!["supported"]!.GetValue()); + Assert.True(response["result"]!["capabilities"]!["workspace"]!["workspaceFolders"]!["changeNotifications"]!.GetValue()); Assert.Equal("cdidx", response["result"]!["serverInfo"]!["name"]!.GetValue()); } finally @@ -427,6 +433,47 @@ public void HandleMessage_WorkspaceSymbol_RejectsOversizedQuery_Issue3128() } } + [Fact] + public void HandleMessage_WorkspaceSymbol_HonorsClientLimit_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_symbol_limit"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < 5; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"file{i}.cs", + "csharp", + $"class Needle{i} {{ }}\n"); + } + + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3537, + method = "workspace/symbol", + @params = new + { + query = "Needle", + limit = 2, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Equal(2, response!["result"]!.AsArray().Count); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() { @@ -545,8 +592,124 @@ public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols() Assert.NotNull(response); var symbols = response!["result"]!.AsArray(); - Assert.Contains(symbols, symbol => symbol?["name"]?.GetValue() == "App"); - Assert.Contains(symbols, symbol => symbol?["name"]?.GetValue() == "Needle"); + var app = Assert.Single(symbols.Where(symbol => symbol?["name"]?.GetValue() == "App")); + var children = app!["children"]!.AsArray(); + Assert.Contains(children, symbol => symbol?["name"]?.GetValue() == "Needle"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_DocumentSymbol_DoesNotNestSameRangeTopLevelSymbols_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_same_range"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = "class Alpha { } class Beta { }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 35374, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var symbols = response!["result"]!.AsArray(); + var alpha = Assert.Single(symbols.Where(symbol => symbol?["name"]?.GetValue() == "Alpha")); + var beta = Assert.Single(symbols.Where(symbol => symbol?["name"]?.GetValue() == "Beta")); + Assert.Null(alpha!["children"]); + Assert.Null(beta!["children"]); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_DocumentSymbol_NestsSameRangeChildAfterContainer_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_same_range_child"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = "class Z { void A() { } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 35375, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var symbols = response!["result"]!.AsArray(); + var z = Assert.Single(symbols.Where(symbol => symbol?["name"]?.GetValue() == "Z")); + var children = z!["children"]!.AsArray(); + Assert.Contains(children, symbol => symbol?["name"]?.GetValue() == "A"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_DocumentSymbol_NestsSameStartLongerContainerBeforeChild_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_same_start_container"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = "namespace N { class C {\n}\n}\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 35376, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var symbols = response!["result"]!.AsArray(); + var n = Assert.Single(symbols.Where(symbol => symbol?["name"]?.GetValue() == "N")); + var children = n!["children"]!.AsArray(); + Assert.Contains(children, symbol => symbol?["name"]?.GetValue() == "C"); } finally { @@ -598,6 +761,43 @@ public void HandleMessage_DocumentSymbol_ResolvesDuplicateBasenamesByRelativePat } } + [Fact] + public void HandleMessage_DocumentSymbol_DoesNotSuffixMatchProjectRootedUnindexedFile_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_unindexed_same_name"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var indexedPath = Path.Combine(projectRoot, "app.cs"); + var unindexedPath = Path.Combine(projectRoot, "dir", "app.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(unindexedPath)!); + File.WriteAllText(indexedPath, "class IndexedApp { }\n"); + File.WriteAllText(unindexedPath, "class UnindexedApp { }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", File.ReadAllText(indexedPath)); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 35377, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(unindexedPath).AbsoluteUri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_DocumentSymbol_RejectsOversizedTextDocumentUri_Issue3129() { @@ -671,13 +871,14 @@ public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue31 Assert.NotEmpty(symbols); Assert.True(symbols.Count < LspServer.MaxDocumentSymbols); Assert.True(Encoding.UTF8.GetByteCount(symbols.ToJsonString()) <= LspServer.MaxDocumentSymbolResponseBytes); - Assert.Contains(symbols, symbol => + var allSymbols = FlattenDocumentSymbols(symbols).ToArray(); + Assert.Contains(allSymbols, symbol => { var detail = symbol?["detail"]?.GetValue(); return detail is { Length: <= LspServer.MaxDocumentSymbolDetailChars } && detail.EndsWith("...", StringComparison.Ordinal); }); - Assert.All(symbols, symbol => + Assert.All(allSymbols, symbol => { var detail = symbol?["detail"]?.GetValue(); if (detail != null) @@ -799,6 +1000,187 @@ public void HandleMessage_Definition_ReturnsLocationForTokenAtPosition() } } + [Theory] + [InlineData("textDocument/declaration")] + [InlineData("textDocument/typeDefinition")] + [InlineData("textDocument/implementation")] + public void HandleMessage_DefinitionAliasMethods_ReturnLocations_Issue3537(string method) + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_alias"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = "class App { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreatePositionRequest( + method, + sourcePath, + 3537, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var locations = response!["result"]!.AsArray(); + Assert.NotEmpty(locations); + Assert.Equal(new Uri(sourcePath).AbsoluteUri, locations[0]!["uri"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_Definition_UsesTrackedWorkspaceFolders_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_root_primary"); + var secondaryRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_root_secondary"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(secondaryRoot, "app.cs"); + var source = "class App { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, sourcePath, "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + sourcePath, + 35370, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var beforeInitialize = server.HandleMessage(request); + Assert.NotNull(beforeInitialize); + Assert.Empty(beforeInitialize!["result"]!.AsArray()); + + var initialize = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 35371, + method = "initialize", + @params = new + { + workspaceFolders = new[] + { + new { uri = new Uri(secondaryRoot).AbsoluteUri, name = "secondary" }, + }, + }, + }); + Assert.NotNull(server.HandleMessage(initialize)); + + var afterInitialize = server.HandleMessage(request); + Assert.NotNull(afterInitialize); + var locations = afterInitialize!["result"]!.AsArray(); + var location = Assert.Single(locations); + Assert.Equal(new Uri(sourcePath).AbsoluteUri, location!["uri"]!.GetValue()); + + var removeFolder = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + method = "workspace/didChangeWorkspaceFolders", + @params = new + { + @event = new + { + added = Array.Empty(), + removed = new[] + { + new { uri = new Uri(secondaryRoot).AbsoluteUri, name = "secondary" }, + }, + }, + }, + }); + Assert.Null(server.HandleMessage(removeFolder)); + + var afterRemove = server.HandleMessage(request); + Assert.NotNull(afterRemove); + Assert.Empty(afterRemove!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(secondaryRoot); + } + } + + [Fact] + public void HandleMessage_Definition_DoesNotMapRelativeIndexPathToAddedWorkspaceFolder_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_relative_primary"); + var secondaryRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_relative_secondary"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var secondaryPath = Path.Combine(secondaryRoot, "app.cs"); + var primarySource = "class Primary { void Needle() { } }\n"; + var secondarySource = "class Secondary { void Call() { Needle(); } }\n"; + File.WriteAllText(secondaryPath, secondarySource); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", primarySource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + Assert.NotNull(server.HandleMessage(CreateInitializeRequestWithWorkspaceFolder(secondaryRoot, 35372))); + + var response = server.HandleMessage(CreateDefinitionRequest( + secondaryPath, + 35373, + 0, + secondarySource.IndexOf("Needle();", StringComparison.Ordinal))); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(secondaryRoot); + } + } + + [Fact] + public void HandleMessage_Definition_KeepsRelativeResultUriAtProjectRoot_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_relative_result_primary"); + var secondaryRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_relative_result_secondary"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var targetPath = Path.Combine(projectRoot, "app.cs"); + var callerPath = Path.Combine(secondaryRoot, "caller.cs"); + var targetSource = "class App { void Needle() { } }\n"; + var callerSource = "class Caller { void Call() { Needle(); } }\n"; + File.WriteAllText(targetPath, targetSource); + File.WriteAllText(callerPath, callerSource); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", targetSource); + TestProjectHelper.InsertIndexedFile(dbPath, callerPath, "csharp", callerSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + Assert.NotNull(server.HandleMessage(CreateInitializeRequestWithWorkspaceFolder(secondaryRoot, 35376))); + + var response = server.HandleMessage(CreateDefinitionRequest( + callerPath, + 35377, + 0, + callerSource.IndexOf("Needle();", StringComparison.Ordinal))); + + Assert.NotNull(response); + var locations = response!["result"]!.AsArray(); + Assert.Contains(locations, location => location?["uri"]?.GetValue() == new Uri(targetPath).AbsoluteUri); + Assert.DoesNotContain(locations, location => location?["uri"]?.GetValue() == new Uri(Path.Combine(secondaryRoot, "app.cs")).AbsoluteUri); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(secondaryRoot); + } + } + [Fact] public void HandleMessage_Definition_PrefersCurrentIndexedDocumentForCommonToken() { @@ -843,6 +1225,45 @@ void Run() { } } } + [Fact] + public void HandleMessage_Definition_ReturnsMultipleWorkspaceCandidates_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_multiple_candidates"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var alphaPath = Path.Combine(projectRoot, "alpha.cs"); + var betaPath = Path.Combine(projectRoot, "beta.cs"); + var callerPath = Path.Combine(projectRoot, "caller.cs"); + var alphaSource = "class Alpha { void Shared() { } }\n"; + var betaSource = "class Beta { void Shared() { } }\n"; + var callerSource = "class Caller { void Call() { Shared(); } }\n"; + File.WriteAllText(alphaPath, alphaSource); + File.WriteAllText(betaPath, betaSource); + File.WriteAllText(callerPath, callerSource); + TestProjectHelper.InsertIndexedFile(dbPath, "alpha.cs", "csharp", alphaSource); + TestProjectHelper.InsertIndexedFile(dbPath, "beta.cs", "csharp", betaSource); + TestProjectHelper.InsertIndexedFile(dbPath, "caller.cs", "csharp", callerSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest(callerPath, 3537, 0, callerSource.IndexOf("Shared();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var uris = response!["result"]! + .AsArray() + .Select(location => location!["uri"]!.GetValue()) + .ToArray(); + Assert.Contains(new Uri(alphaPath).AbsoluteUri, uris); + Assert.Contains(new Uri(betaPath).AbsoluteUri, uris); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_References_PrefersCurrentIndexedDocumentForCommonToken() { @@ -890,6 +1311,52 @@ class Beta } } + [Fact] + public void HandleMessage_References_HonorsIncludeDeclaration_Issue3537() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_references_include_declaration"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = """ + class App + { + void Needle() { } + void Call() { Needle(); } + } + """; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + MarkGraphReady(dbPath); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var character = CharacterOf(source, 3, "Needle();"); + var withoutDeclaration = CreateReferencesRequest(sourcePath, 3537, 3, character, includeDeclaration: false); + var withDeclaration = CreateReferencesRequest(sourcePath, 3538, 3, character, includeDeclaration: true); + + var withoutResponse = server.HandleMessage(withoutDeclaration); + var withResponse = server.HandleMessage(withDeclaration); + + Assert.NotNull(withoutResponse); + Assert.NotNull(withResponse); + var withoutLines = withoutResponse!["result"]! + .AsArray() + .Select(location => location!["range"]!["start"]!["line"]!.GetValue()) + .ToArray(); + var withLines = withResponse!["result"]! + .AsArray() + .Select(location => location!["range"]!["start"]!["line"]!.GetValue()) + .ToArray(); + Assert.DoesNotContain(2, withoutLines); + Assert.Contains(2, withLines); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_References_PrefersCurrentIndexedDocumentWhenCommonTokenHasNoDefinitions() { @@ -1252,11 +1719,29 @@ public void HandleMessage_Definition_BasenameFallbackHonorsCandidateCap_Issue313 } private static string CreateDefinitionRequest(string sourcePath, int id, int line, int character) => + CreatePositionRequest("textDocument/definition", sourcePath, id, line, character); + + private static string CreateInitializeRequestWithWorkspaceFolder(string workspaceRoot, int id) => + JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id, + method = "initialize", + @params = new + { + workspaceFolders = new[] + { + new { uri = new Uri(workspaceRoot).AbsoluteUri, name = "workspace" }, + }, + }, + }); + + private static string CreatePositionRequest(string method, string sourcePath, int id, int line, int character) => JsonSerializer.Serialize(new { jsonrpc = "2.0", id, - method = "textDocument/definition", + method, @params = new { textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, @@ -1267,7 +1752,7 @@ private static string CreateDefinitionRequest(string sourcePath, int id, int lin private static string Frame(string payload) => $"Content-Length: {Encoding.UTF8.GetByteCount(payload)}\r\n\r\n{payload}"; - private static string CreateReferencesRequest(string sourcePath, int id, int line, int character) => + private static string CreateReferencesRequest(string sourcePath, int id, int line, int character, bool includeDeclaration = false) => JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -1277,6 +1762,7 @@ private static string CreateReferencesRequest(string sourcePath, int id, int lin { textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, position = new { line, character }, + context = new { includeDeclaration }, }, }); @@ -1286,6 +1772,19 @@ private static int CharacterOf(string source, int line, string value) return lines[line].IndexOf(value, StringComparison.Ordinal); } + private static IEnumerable FlattenDocumentSymbols(JsonArray symbols) + { + foreach (var symbol in symbols) + { + yield return symbol; + if (symbol?["children"] is JsonArray children) + { + foreach (var child in FlattenDocumentSymbols(children)) + yield return child; + } + } + } + private static string BuildNestedLspRequest(int nestedObjectCount) { var builder = new StringBuilder("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":""");