Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 を返す。
Expand Down
18 changes: 18 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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`、
Expand Down
16 changes: 16 additions & 0 deletions changelog.d/unreleased/5382.fixed.md
Original file line number Diff line number Diff line change
@@ -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)** — 通常の索引取得とライブ抽出が利用できない場合のフォールバックで、候補数の上限より先に、大小文字を区別したリテラルな索引パスを選択します。別ファイルの宣言による階層・範囲の破損、要求文書のシンボル欠落、部分結果の誤った切り詰め通知を防ぎます。
11 changes: 11 additions & 0 deletions src/CodeIndex/Database/DbReader.SymbolSearchList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SymbolResult> GetSymbolsInIndexedFile(string indexedPath, int limit)
=> ExecuteSymbolSearchList(new SymbolSearchQueryPlan
{
IndexedFilePath = indexedPath,
Limit = limit,
});

/// <summary>
/// Search symbols by one or more name patterns (OR-joined). Empty/null list returns all symbols matching other filters.
/// When <paramref name="exact"/> is true, names are matched case-insensitively for equality instead of substring.
Expand Down Expand Up @@ -51,6 +60,8 @@ private List<SymbolResult> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/CodeIndex/Database/DbReader.SymbolSearchQueryPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ private sealed record SymbolSearchQueryPlan
public string? Lang { get; init; }
public IReadOnlyList<string>? PathPatterns { get; init; }
public IReadOnlyList<string>? ExcludePathPatterns { get; init; }
public string? IndexedFilePath { get; init; }
public bool ExcludeTests { get; init; }
public DateTime? Since { get; init; }
public bool Exact { get; init; }
Expand Down
14 changes: 6 additions & 8 deletions src/CodeIndex/Lsp/LspServer.SymbolRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,9 @@ private IReadOnlyList<SymbolResult> 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;
Expand All @@ -249,10 +248,9 @@ private IReadOnlyList<SymbolResult> GetDocumentSymbolCandidates(
cancellationToken,
out var liveSymbols))
{
return _reader.SearchSymbols(
(string?)null,
MaxDocumentSymbolMaterialization + 1,
pathPatterns: [document.IndexedPath]);
return _reader.GetSymbolsInIndexedFile(
document.IndexedPath,
MaxDocumentSymbolMaterialization + 1);
}

return liveSymbols
Expand Down
119 changes: 116 additions & 3 deletions tests/CodeIndex.Tests/LspServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -2660,7 +2673,7 @@ public void Run_DocumentSymbol_StreamsBoundedPartialResultsAndWorkDoneProgress_I
Assert.Equal("begin", workDoneValues[0]["kind"]!.GetValue<string>());
Assert.Contains(workDoneValues, value => value["kind"]!.GetValue<string>() == "report");
Assert.Equal("end", workDoneValues[^1]["kind"]!.GetValue<string>());
Assert.Contains("Returned 101 symbols", workDoneValues[^1]["message"]!.GetValue<string>(), StringComparison.Ordinal);
Assert.Equal("Returned 101 symbols.", workDoneValues[^1]["message"]!.GetValue<string>());

var response = Assert.Single(messages, entry => entry.Message["id"]?.GetValue<int>() == 4721);
Assert.True(response.Message.ContainsKey("result"));
Expand Down Expand Up @@ -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<int>() == 53820).Message;
var roots = response["result"]!.AsArray();
Assert.Equal(new[] { "B", "Calls" }, roots.Select(symbol => symbol!["name"]!.GetValue<string>()));
Assert.Equal("Leaf", Assert.Single(roots[0]!["children"]!.AsArray())!["name"]!.GetValue<string>());
Assert.Equal("Caller", Assert.Single(roots[1]!["children"]!.AsArray())!["name"]!.GetValue<string>());
var symbols = FlattenDocumentSymbols(roots).ToArray();
Assert.Equal(new[] { "B", "Leaf", "Calls", "Caller" }, symbols.Select(symbol => symbol!["name"]!.GetValue<string>()));
Assert.Equal(new[] { 0, 2, 4, 6 }, symbols.Select(symbol => symbol!["selectionRange"]!["start"]!["line"]!.GetValue<int>()));
var lines = source.Split('\n');
foreach (var symbol in symbols)
{
var selection = symbol!["selectionRange"]!;
var line = selection["start"]!["line"]!.GetValue<int>();
var start = selection["start"]!["character"]!.GetValue<int>();
var end = selection["end"]!["character"]!.GetValue<int>();
Assert.Equal(line, selection["end"]!["line"]!.GetValue<int>());
Assert.Equal(symbol["name"]!.GetValue<string>(), lines[line][start..end]);
Assert.InRange(line, symbol["range"]!["start"]!["line"]!.GetValue<int>(), symbol["range"]!["end"]!["line"]!.GetValue<int>());
}

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<string>()), items.Select(symbol => symbol!["name"]!.GetValue<string>()));
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(new Uri(sourcePath).AbsoluteUri, items[i]!["location"]!["uri"]!.GetValue<string>());
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<string>());
Assert.Null(Assert.Single(messages, entry => entry.Message["id"]?.GetValue<int>() == 53821).Message["result"]);
}
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Fact]
public void HandleMessage_DocumentSymbol_MarkdownRangeEndsOnLastPhysicalLine_Issue4910()
{
Expand Down
Loading