diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index f8dd25b5d..b6890cc18 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1159,6 +1159,11 @@ across live-text eviction and are cleared by `didClose`, so an evicted newer version cannot be replaced by a stale change. Other providers return empty arrays or null when the database cannot answer safely instead of inventing language-server analysis. +Indexed document-symbol candidates, including the fallback when live extraction +is unavailable, are selected by the resolved indexed path's exact binary identity +before ordering or applying the materialization limit and its one-row lookahead. +Case-colliding paths, directory descendants, and glob-like literal characters +cannot introduce another file's declarations into the hierarchy or partial results. Every inbound message must first be an object whose `jsonrpc` member is exactly the string `"2.0"`. Missing, null, non-string, or other-version envelope values return `-32600` (`Invalid Request`) with a valid request ID preserved; validation @@ -5686,7 +5691,11 @@ request token を特定できるよう disk より先に live cache を読む必 live buffer を通常の language extractor と container pipeline で構造的に再抽出できる。このとき path から再判定せず、indexed file の authoritative language を使う。live extraction は document-symbol materialization 上限で停止し、その bounded extractor を利用できない場合は -indexed symbol に fallback する。numeric document-version tombstone は live text の eviction +indexed symbol に fallback する。通常の索引取得と live extraction が利用できない場合の +fallback は、解決済み索引パスのバイナリ完全一致で候補を限定してから並べ替えと +materialization 上限・1 行の先読みを適用する。大小文字だけが異なるパス、配下のパス、 +glob に似たリテラル文字によって別ファイルの宣言が階層や部分結果に混入することはない。 +numeric document-version tombstone は live text の eviction 後も上限付きで保持し、`didClose` で消去するため、evict 済みの新しい version を stale change が 置き換えることはない。それ以外の provider は database が安全に答えられない場合、 language-server analysis を作り上げず、空配列または null を返す。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 9fbdb3697..8d54eeaa4 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -1,5 +1,14 @@ # Testing Guide +LSP document-symbol identity coverage (#5382) shares indexed and unsupported-live- +extractor fallback requests across case-colliding paths, literal path characters, +and descendant/suffix decoys. Keep hierarchical roots, identifier ranges and framed +partial-result parity together in `Run_DocumentSymbol_UsesExactIndexedFileIdentity_Issue5382`. +The existing partial-progress fixture adds more foreign declarations than the +materialization limit: they must neither displace local symbols nor mark the +response truncated. Run all LSP tests on net8/net9, retaining local materialization, +response-byte, chunk-size, cancellation and live-extraction controls. + Named search count coverage (#5376) uses one small indexed fixture in `RunSearch_NamedCountsHonorTokenBoundary_Issue5376` for isolated/repeated tokens, longer identifiers, zero boundary matches, path exclusion, and code-origin filters. @@ -1514,6 +1523,15 @@ Issue #5300 のテストは隣接・入れ子の C# callable、対象行の除 # テストガイド +LSP 文書シンボルのファイル同一性テスト (#5382) は、通常の索引取得とライブ抽出が +利用できない場合のフォールバックで、大小文字衝突、リテラルなパス文字、配下・接尾辞の +別パスを共有します。`Run_DocumentSymbol_UsesExactIndexedFileIdentity_Issue5382` で +階層のルート、識別子の範囲、フレーム化した部分結果の一致を確認してください。 +既存の部分結果・進捗テストでは materialization 上限を超える別ファイルの宣言を追加し、 +要求文書のシンボルが欠落せず、誤った切り詰め通知も出ないことを検証します。 +文書自身の materialization、応答バイト数、チャンク件数、キャンセル、ライブ抽出の +既存テストを維持し、LSP テスト全体を net8/net9 で実行してください。 + 名前付き検索の件数検証 (#5376) は `RunSearch_NamedCountsHonorTokenBoundary_Issue5376` の 小さな共有索引を使い、独立・反復トークン、長い識別子、境界一致ゼロ件、パス除外、 コード origin フィルターを確認します。net8/net9 で通常・名前付きの結果行と `--count`、 diff --git a/changelog.d/unreleased/5382.fixed.md b/changelog.d/unreleased/5382.fixed.md new file mode 100644 index 000000000..aa91f83fa --- /dev/null +++ b/changelog.d/unreleased/5382.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 5382 +affected: + - src/CodeIndex/Lsp/LspServer.SymbolRequests.cs + - src/CodeIndex/Database/DbReader.SymbolSearchList.cs +--- + +## English + +- **LSP document symbols use exact indexed file identity (#5382)** — Both indexed lookup and the unsupported-live-extraction fallback now select the literal, case-sensitive indexed path before candidate limits. Other files cannot corrupt the document's hierarchy or ranges, displace its symbols, or cause false partial-result truncation. + +## 日本語 + +- **LSP 文書シンボルが索引ファイルの完全一致を使用 (#5382)** — 通常の索引取得とライブ抽出が利用できない場合のフォールバックで、候補数の上限より先に、大小文字を区別したリテラルな索引パスを選択します。別ファイルの宣言による階層・範囲の破損、要求文書のシンボル欠落、部分結果の誤った切り詰め通知を防ぎます。 diff --git a/src/CodeIndex/Database/DbReader.SymbolSearchList.cs b/src/CodeIndex/Database/DbReader.SymbolSearchList.cs index ef5f1fb9b..d4a943a37 100644 --- a/src/CodeIndex/Database/DbReader.SymbolSearchList.cs +++ b/src/CodeIndex/Database/DbReader.SymbolSearchList.cs @@ -2,6 +2,15 @@ namespace CodeIndex.Database; public partial class DbReader { + // Resolve one persisted file identity before ordering and limiting candidates. + // The indexed path is literal, independent of filesystem case policy or globs. + internal List GetSymbolsInIndexedFile(string indexedPath, int limit) + => ExecuteSymbolSearchList(new SymbolSearchQueryPlan + { + IndexedFilePath = indexedPath, + Limit = limit, + }); + /// /// Search symbols by one or more name patterns (OR-joined). Empty/null list returns all symbols matching other filters. /// When is true, names are matched case-insensitively for equality instead of substring. @@ -51,6 +60,8 @@ private List ExecuteSymbolSearchList(SymbolSearchQueryPlan plan) var sql = BuildSymbolSearchListSql(plan); cmd.CommandText = sql; + if (plan.IndexedFilePath != null) + SqliteCommandPolicy.Add(cmd, "@indexedFilePath", plan.IndexedFilePath); SymbolSearchQueryBinder.BindFullQueries(this, cmd, plan); SymbolSearchQueryBinder.BindListOrdering(cmd, plan); SymbolSearchQueryBinder.BindFilters(this, cmd, plan, includeLineRange: true); diff --git a/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs b/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs index 091370b49..f1465b830 100644 --- a/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs +++ b/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs @@ -48,6 +48,8 @@ private string BuildSymbolSearchListSql(SymbolSearchQueryPlan plan) canonical.LogicalPartialKey, includeRankSignals); var sql = BuildSymbolSearchSelectSql(columns, ranking, canonical); + if (plan.IndexedFilePath != null) + sql += " AND f.path = @indexedFilePath COLLATE BINARY"; sql += SymbolSearchQueryPredicateBuilder.BuildFull(this, plan); SymbolSearchQueryPredicateBuilder.AppendFilters( this, diff --git a/src/CodeIndex/Database/DbReader.SymbolSearchQueryPlan.cs b/src/CodeIndex/Database/DbReader.SymbolSearchQueryPlan.cs index 64fc91da3..926a56be6 100644 --- a/src/CodeIndex/Database/DbReader.SymbolSearchQueryPlan.cs +++ b/src/CodeIndex/Database/DbReader.SymbolSearchQueryPlan.cs @@ -10,6 +10,7 @@ private sealed record SymbolSearchQueryPlan public string? Lang { get; init; } public IReadOnlyList? PathPatterns { get; init; } public IReadOnlyList? ExcludePathPatterns { get; init; } + public string? IndexedFilePath { get; init; } public bool ExcludeTests { get; init; } public DateTime? Since { get; init; } public bool Exact { get; init; } diff --git a/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs b/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs index cf58cc61a..7905f3c53 100644 --- a/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs +++ b/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs @@ -231,10 +231,9 @@ private IReadOnlyList GetDocumentSymbolCandidates( { if (!_liveDocumentStore.TryGetText(document.ResolvedPath, out var liveText)) { - return _reader.SearchSymbols( - (string?)null, - MaxDocumentSymbolMaterialization + 1, - pathPatterns: [document.IndexedPath]); + return _reader.GetSymbolsInIndexedFile( + document.IndexedPath, + MaxDocumentSymbolMaterialization + 1); } var language = _reader.GetFileByPath(document.IndexedPath)?.Lang; @@ -249,10 +248,9 @@ private IReadOnlyList GetDocumentSymbolCandidates( cancellationToken, out var liveSymbols)) { - return _reader.SearchSymbols( - (string?)null, - MaxDocumentSymbolMaterialization + 1, - pathPatterns: [document.IndexedPath]); + return _reader.GetSymbolsInIndexedFile( + document.IndexedPath, + MaxDocumentSymbolMaterialization + 1); } return liveSymbols diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index f1ffe31b2..a4f7366e2 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -2596,8 +2596,10 @@ public void HandleMessage_WorkspaceSymbol_HonorsClientLimit_Issue3537() } } - [Fact] - public void Run_DocumentSymbol_StreamsBoundedPartialResultsAndWorkDoneProgress_Issue4721() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Run_DocumentSymbol_StreamsBoundedPartialResultsAndWorkDoneProgress_Issue4721(bool fallback) { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_progress"); try @@ -2610,10 +2612,21 @@ public void Run_DocumentSymbol_StreamsBoundedPartialResultsAndWorkDoneProgress_I source.Append("}\n"); File.WriteAllText(sourcePath, source.ToString()); TestProjectHelper.InsertIndexedFile(dbPath, "progress.cs", "csharp", source.ToString()); + var foreignSource = string.Join('\n', Enumerable.Range(0, LspServer.MaxDocumentSymbolMaterialization + 1) + .Select(i => $"class AAA{i:D4} {{ }}")); + TestProjectHelper.InsertIndexedFile(dbPath, "PROGRESS.cs", "csharp", foreignSource); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + if (fallback) + { + using var command = db.Connection.CreateCommand(); + command.CommandText = "UPDATE files SET lang = 'unavailable_issue5382' WHERE path = 'progress.cs' COLLATE BINARY"; + Assert.Equal(1, command.ExecuteNonQuery()); + } using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); InitializeSession(server); + if (fallback) + Assert.Null(server.HandleMessage(CreateDidOpenRequest(sourcePath, source.ToString(), version: 1))); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -2660,7 +2673,7 @@ public void Run_DocumentSymbol_StreamsBoundedPartialResultsAndWorkDoneProgress_I Assert.Equal("begin", workDoneValues[0]["kind"]!.GetValue()); Assert.Contains(workDoneValues, value => value["kind"]!.GetValue() == "report"); Assert.Equal("end", workDoneValues[^1]["kind"]!.GetValue()); - Assert.Contains("Returned 101 symbols", workDoneValues[^1]["message"]!.GetValue(), StringComparison.Ordinal); + Assert.Equal("Returned 101 symbols.", workDoneValues[^1]["message"]!.GetValue()); var response = Assert.Single(messages, entry => entry.Message["id"]?.GetValue() == 4721); Assert.True(response.Message.ContainsKey("result")); @@ -3411,6 +3424,106 @@ public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols() } } + [Theory] + [InlineData("Calls.cs")] + [InlineData("Literal%_[1]'日本語.cs")] + [InlineData("!Literal.cs")] + public void Run_DocumentSymbol_UsesExactIndexedFileIdentity_Issue5382(string indexedPath) + { + // Windows forbids literal '*' and '?' in filenames; retain the portable + // SQL/URI metacharacters there and also exercise globs on POSIX. + if (!OperatingSystem.IsWindows() && indexedPath.StartsWith("Literal", StringComparison.Ordinal)) + indexedPath = indexedPath.Replace("[1]", "[1]*?", StringComparison.Ordinal); + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_identity"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + const string source = "class B\n{\n public void Leaf() {}\n}\nclass Calls\n{\n void Caller(B b)\n {\n b.Leaf();\n }\n}\n"; + const string foreignSource = "class Foreign\n{\n\n\n\n\n\n\n void Leaf() {}\n}\n"; + var sourcePath = TestProjectHelper.WriteTextFile(projectRoot, indexedPath, source); + TestProjectHelper.InsertIndexedFile(dbPath, indexedPath, "csharp", source); + // Only the database contains the colliding/descendant paths, so this also + // exercises exact identity on case-insensitive filesystems. + TestProjectHelper.InsertIndexedFile(dbPath, indexedPath.ToLowerInvariant(), "csharp", foreignSource); + TestProjectHelper.InsertIndexedFile(dbPath, indexedPath + "/foreign.cs", "csharp", foreignSource); + TestProjectHelper.InsertIndexedFile(dbPath, "prefix/" + indexedPath, "csharp", foreignSource); + if (indexedPath.Contains('*')) + TestProjectHelper.InsertIndexedFile(dbPath, indexedPath.Replace("*?", "decoy", StringComparison.Ordinal), "csharp", foreignSource); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + + foreach (var fallback in new[] { false, true }) + { + if (fallback) + { + using var command = db.Connection.CreateCommand(); + command.CommandText = "UPDATE files SET lang = 'unavailable_issue5382' WHERE path = @path COLLATE BINARY"; + command.Parameters.AddWithValue("@path", indexedPath); + Assert.Equal(1, command.ExecuteNonQuery()); + } + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + InitializeSession(server); + if (fallback) + Assert.Null(server.HandleMessage(CreateDidOpenRequest(sourcePath, source, version: 1))); + + var partialRequest = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 53821, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + partialResultToken = "exact-document", + workDoneToken = 5382, + }, + }); + using var input = new MemoryStream(Encoding.UTF8.GetBytes( + Frame(CreateTextDocumentRequest("textDocument/documentSymbol", sourcePath, 53820)) + + Frame(partialRequest))); + using var output = new MemoryStream(); + Assert.Equal(CommandExitCodes.Success, server.Run(input, output)); + var messages = ReadLspMessages(output); + var response = Assert.Single(messages, entry => entry.Message["id"]?.GetValue() == 53820).Message; + var roots = response["result"]!.AsArray(); + Assert.Equal(new[] { "B", "Calls" }, roots.Select(symbol => symbol!["name"]!.GetValue())); + Assert.Equal("Leaf", Assert.Single(roots[0]!["children"]!.AsArray())!["name"]!.GetValue()); + Assert.Equal("Caller", Assert.Single(roots[1]!["children"]!.AsArray())!["name"]!.GetValue()); + var symbols = FlattenDocumentSymbols(roots).ToArray(); + Assert.Equal(new[] { "B", "Leaf", "Calls", "Caller" }, symbols.Select(symbol => symbol!["name"]!.GetValue())); + Assert.Equal(new[] { 0, 2, 4, 6 }, symbols.Select(symbol => symbol!["selectionRange"]!["start"]!["line"]!.GetValue())); + var lines = source.Split('\n'); + foreach (var symbol in symbols) + { + var selection = symbol!["selectionRange"]!; + var line = selection["start"]!["line"]!.GetValue(); + var start = selection["start"]!["character"]!.GetValue(); + var end = selection["end"]!["character"]!.GetValue(); + Assert.Equal(line, selection["end"]!["line"]!.GetValue()); + Assert.Equal(symbol["name"]!.GetValue(), lines[line][start..end]); + Assert.InRange(line, symbol["range"]!["start"]!["line"]!.GetValue(), symbol["range"]!["end"]!["line"]!.GetValue()); + } + + var partialMessages = messages.Where(entry => HasProgressToken(entry.Message, "exact-document")).ToArray(); + var partial = Assert.Single(partialMessages); + Assert.InRange(partial.BodyBytes, 1, LspServer.MaxSymbolProgressChunkBytes); + var items = partial.Message["params"]!["value"]!.AsArray(); + Assert.Equal(symbols.Select(symbol => symbol!["name"]!.GetValue()), items.Select(symbol => symbol!["name"]!.GetValue())); + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(new Uri(sourcePath).AbsoluteUri, items[i]!["location"]!["uri"]!.GetValue()); + Assert.True(JsonNode.DeepEquals(symbols[i]!["selectionRange"], items[i]!["location"]!["range"])); + } + var endProgress = messages.Last(entry => HasProgressToken(entry.Message, 5382)).Message["params"]!["value"]!; + Assert.Equal("Returned 4 symbols.", endProgress["message"]!.GetValue()); + Assert.Null(Assert.Single(messages, entry => entry.Message["id"]?.GetValue() == 53821).Message["result"]); + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_DocumentSymbol_MarkdownRangeEndsOnLastPhysicalLine_Issue4910() {