diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 07a9a587b4..a9860322a6 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -5,6 +5,13 @@ compact metadata. Keep this static path ahead of database access and runtime diagnostic enrichment. See [MCP status explanations](docs/mcp-status-explanations.md#english) for argument compatibility, output schemas, byte scopes and measured retry errors. +## LSP call hierarchy (#5351) + +`LspServer.CallHierarchy.cs` adapts persisted call identities to standard LSP items; +`DbReader.CallHierarchy.cs` retains individual sites rather than grouped display rows. +Preserve generation checks, cancellation, checksum verification and explicit failure +when evidence or budgets cannot support the result. See [the contract](docs/lsp-call-hierarchy.md#english). + ## C# scoped-update expansion (#5347) See [C# update scope diagnostics](docs/csharp-update-expansion.md#english) for the @@ -4580,6 +4587,13 @@ MCP の `status.explainField` は、CLI の serializer 由来フィールド説 引数の併用条件、出力スキーマ、バイト数の計測範囲、実測値付き再試行エラーについては [MCP status の説明](docs/mcp-status-explanations.md#日本語)を参照してください。 +### LSPコール階層 (#5351) + +`LspServer.CallHierarchy.cs` は永続化済みの呼び出し識別情報を標準LSP項目へ変換し、 +`DbReader.CallHierarchy.cs` は表示用の集約行ではなく個々の呼び出し箇所を返します。 +世代確認、キャンセル、チェックサム検証を維持し、証拠や上限の制約で結果を保証できない +場合は明示的に失敗させてください。[契約](docs/lsp-call-hierarchy.md#日本語)を参照してください。 + 名前付きクエリの行選択(#5325)は、各クエリの固定 10,000 候補をフィルター・重複排除した後、 `ApplySearchOutputSelection` を独立に利用します。重複排除前の候補上限到達は `SearchWithCandidateEvidence` で保持してください。first-per-file、sample、クエリ上限、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 6f204d1e2d..6bc7b0cc4c 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -18,6 +18,15 @@ Keep tools/list schema validation and aggregate explain controls alongside these Run `--filter 'FullyQualifiedName~Issue5352|FullyQualifiedName~Issue5093|FullyQualifiedName~RunStatus_Explain|FullyQualifiedName~ToolsCall_Status'` on net8/net9 and verify initialized stdio MCP calls. +## LSP call hierarchy coverage (#5351) + +`LspCallHierarchyTests` runs framed, initialized LSP sessions against real indexed +temporary projects on net8.0/net9.0. Keep exact overload/same-name identities, +recursive and repeated sites, UTF-16/file-URI ranges, existing navigation controls, +unsaved/stale/incomplete/ambiguous evidence, exact file identity, overlapping large +callable ranges, high-degree/response-size bounds and cancellation with session +reuse together. See [the protocol contract](docs/lsp-call-hierarchy.md#english). + MCP search parity coverage (#5349) lives in `McpServerIssue5349Tests` and `HttpMcpTransportTests`. Run `--filter FullyQualifiedName~Issue5349` on net8/net9 with existing MCP schema/dispatch and CLI find/search-classification tests. @@ -1452,6 +1461,15 @@ Check the following: net8/net9 で `--filter 'FullyQualifiedName~Issue5352|FullyQualifiedName~Issue5093|FullyQualifiedName~RunStatus_Explain|FullyQualifiedName~ToolsCall_Status'` を実行し、初期化済み stdio MCP の呼び出しも確認してください。 +### LSPコール階層の検証 (#5351) + +`LspCallHierarchyTests` は実際に索引を作成した一時プロジェクトを使い、初期化済みの +LSPセッションにフレーム形式で要求を送り、net8.0/net9.0で検証します。同名・ +オーバーロードの正確な識別、再帰・複数の呼び出し箇所、UTF-16とファイルURIの範囲、 +既存ナビゲーション、未保存・古い・未完成・曖昧な証拠、正確なファイル識別、重なり合う +大きな関数範囲、高次数・応答サイズの上限、キャンセル後のセッション継続を併せて +維持してください。[プロトコル契約](docs/lsp-call-hierarchy.md#日本語)を参照してください。 + 名前付き検索の行選択(#5325)は、重複チャンクを含む小さな共通 fixture で単一・複数・ 共有ファイル・空クエリ、selector、全体/クエリ上限、rich / compact / 投影 JSON、text、 決定的な再実行、UTF-8 上限の境界、下限表記、非対応形式を検証します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 9cf2cdb1af..2feae1e0ef 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -3636,6 +3636,9 @@ MCP stdio is line protocol: send one compact UTF-8 JSON-RPC object per LF-delimi ### LSP Server (for LSP-native editors) +Indexed call hierarchy is available through the standard prepare/incoming/outgoing +methods. See [supported languages, error behavior and limits](docs/lsp-call-hierarchy.md#english). + `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`, @@ -7716,6 +7719,8 @@ request は JSON-RPC `-32002`(`Server not initialized`)を返します。重 `textDocument/inlayHint` は end position を含まない requested LSP range を尊重し、 indexed return type が symbol name の直前にすでに明記されている場合は type label を 省略するため、field / property / method の明示型を hint として重複表示しません。 +標準の準備・呼び出し元・呼び出し先メソッドによるコール階層に対応しています。 +[対象言語・エラー動作・上限](docs/lsp-call-hierarchy.md#日本語)を参照してください。 対応する provider は `initialize` が返す `capabilities` で確認してください。 未実装の optional LSP method は advertise しません。現在の対応状況では `textDocument/typeDefinition`、`textDocument/implementation`、`textDocument/codeLens`、 diff --git a/changelog.d/unreleased/5351.added.md b/changelog.d/unreleased/5351.added.md new file mode 100644 index 0000000000..856e15e3b4 --- /dev/null +++ b/changelog.d/unreleased/5351.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 5351 +affected: + - src/CodeIndex/Lsp/LspServer.CallHierarchy.cs + - src/CodeIndex/Database/DbReader.CallHierarchy.cs + - docs/lsp-call-hierarchy.md +--- + +## English + +- **Added indexed LSP call hierarchy (#5351)** — Editors can prepare and expand incoming/outgoing calls using generation-bound symbol identities, UTF-16 ranges and repeated call sites. Unavailable, ambiguous, stale or over-budget evidence produces explicit LSP errors; successful results retain indexed-graph limitations. + +## 日本語 + +- **索引に基づくLSPコール階層を追加しました (#5351)** — 世代に結び付いたシンボル識別情報、UTF-16範囲、複数の呼び出し箇所を使い、エディターから呼び出し元・先を展開できます。利用不能・曖昧・古い証拠や上限超過には明示的なLSPエラーを返し、成功時も索引グラフの制限を通知します。 diff --git a/docs/lsp-call-hierarchy.md b/docs/lsp-call-hierarchy.md new file mode 100644 index 0000000000..6b311dd678 --- /dev/null +++ b/docs/lsp-call-hierarchy.md @@ -0,0 +1,88 @@ +# Indexed LSP call hierarchy + +## English + +`cdidx lsp --db .cdidx/codeindex.db` advertises `callHierarchyProvider` and supports +`textDocument/prepareCallHierarchy`, `callHierarchy/incomingCalls`, and +`callHierarchy/outgoingCalls`. Prepare on a callable declaration or uniquely +resolved indexed reference, then send the returned item unchanged to expand it. +Recursive calls remain traversable. Repeated call sites are grouped by exact +endpoint identity and retained in `fromRanges`. + +The initial language scope is C#, Java, JavaScript, TypeScript, Python, Go, Rust, +C, C++, Kotlin, Ruby, PHP and Swift. Only indexed `function`, `test.method` and +`lambda` symbols with usable ranges and current reference identities qualify. +Edges use the shared CLI/MCP persisted `call` references; this does not add language +binding, implicit constructor calls, subscriptions or member reads. Ambiguous +overloads and same-name symbols are never combined by name. Preparation uses the +existing LSP position and invocation-selection rules without a bare-name fallback. + +This is heuristic indexed evidence. External calls, dynamic dispatch and unindexed +code can be absent even when the stored graph is complete. Each successful request +sends a standard `window/logMessage` reminder, including for an empty result; +item `detail` also labels the evidence as indexed. An empty expansion means no +eligible indexed calls were found, not that the callable has no possible callers +or callees. Non-callable declarations and positions without a token return `null` +from preparation. + +Incomplete/unavailable graphs, unresolved or ambiguous contributing references, +unusable source ranges and exceeded budgets return LSP `RequestFailed` (`-32803`). +The server returns no successful partial hierarchy. Changed files, unsaved open +documents and stale items return `ContentModified` (`-32801`). Error `data.reason` +contains a bounded machine reason. Save documents, refresh the index, and prepare +again. If the live-document cache has discarded any text, save and reconnect too. +The opaque `data` string (at most 160 characters) binds the item to the server +session, indexed generation and exact symbol ID; never reuse it across sessions +or indexing runs. File URIs and all positions follow the existing LSP path policy +and zero-based UTF-16 coordinates. + +Each request allows at most 1,000 raw call sites, 100 distinct neighbors, 512 KiB +of response JSON, 4 MiB per source file and a 16 MiB source set read twice, with a five-second +query deadline and SQLite cancellation. `$/cancelRequest` returns `-32800` and +leaves the session usable. Call hierarchy does not stream partial results or +advertise work-done progress. Use CLI/MCP graph diagnostics when a hierarchy +exceeds these bounds. All retained open buffers must match disk and the index; +participating source files are checksum checked before and after range assembly. +This does not perform a whole-workspace filesystem freshness scan per request. + +## 日本語 + +`cdidx lsp --db .cdidx/codeindex.db` は `callHierarchyProvider` を通知し、 +`textDocument/prepareCallHierarchy`、`callHierarchy/incomingCalls`、 +`callHierarchy/outgoingCalls` に対応します。呼び出し可能な宣言、または索引内で +一意に解決できる参照位置で準備し、返された項目を変更せずに渡して展開します。 +再帰呼び出しも辿れます。同じ相手への複数の呼び出し箇所は、正確なシンボルIDで +まとめ、すべての位置を `fromRanges` に保持します。 + +初期の対象言語は C#、Java、JavaScript、TypeScript、Python、Go、Rust、C、C++、 +Kotlin、Ruby、PHP、Swift です。有効な範囲と最新の参照識別情報を持つ、索引内の +`function`、`test.method`、`lambda` シンボルが対象です。CLI/MCP と共通の永続化済み +`call` 参照を使い、言語の束縛解析、暗黙のコンストラクター呼び出し、購読、 +メンバー読み取りは追加しません。曖昧なオーバーロードや同名シンボルを名前だけで +統合しません。準備処理は既存LSPの位置・引数に基づく選択規則を利用し、単純な +名前検索へのフォールバックは行いません。 + +結果は索引に保存されたヒューリスティックな証拠です。保存済みグラフが完全でも、 +外部呼び出し、動的ディスパッチ、索引対象外のコードは含まれないことがあります。 +成功した要求では空の結果も含めて標準の `window/logMessage` でこの制限を通知し、 +項目の `detail` にも索引由来であることを表示します。空の展開結果は該当する索引内の +呼び出しが見つからなかったことを意味し、呼び出し元・先が存在しないことの証明には +なりません。呼び出せない宣言やトークンのない位置での準備は `null` を返します。 + +グラフが未完成・利用不能、関連する参照が未解決・曖昧、ソース範囲が利用不能、 +または上限超過の場合は LSP `RequestFailed`(`-32803`)を返します。部分的な階層を +成功として返しません。ファイル変更、開いている文書の未保存編集、古い項目には +`ContentModified`(`-32801`)を返します。エラーの `data.reason` に上限付きの +機械判読用理由を含めます。保存・索引更新の後、準備からやり直してください。 +文書キャッシュがテキストを破棄した場合は、保存後に再接続も必要です。 +最大160文字の不透明な `data` はセッション・索引世代・正確なシンボルIDに結び付きます。 +再接続や索引更新をまたいで再利用しないでください。ファイルURIは既存のLSPパス方針、 +位置は0始まりのUTF-16座標に従います。 + +要求ごとの上限は生の呼び出し箇所1,000件、相手シンボル100件、応答JSON 512 KiB、 +ソース1ファイル4 MiB、2回読み取るソース集合の合計16 MiBです。問い合わせの期限は5秒で、 +SQLiteの処理にもキャンセルを適用します。`$/cancelRequest` は `-32800` を返し、 +セッションは継続利用できます。コール階層の部分結果配信・進捗通知は提供しません。 +上限に達した場合はCLI/MCPのグラフ診断を利用してください。保持中の全バッファは +ディスクと索引に一致する必要があり、結果に関わるソースのチェックサムを範囲の構築前後に +検証します。要求ごとのワークスペース全体のファイル鮮度走査は行いません。 diff --git a/src/CodeIndex/Database/DbReader.CallHierarchy.cs b/src/CodeIndex/Database/DbReader.CallHierarchy.cs new file mode 100644 index 0000000000..f106ff056c --- /dev/null +++ b/src/CodeIndex/Database/DbReader.CallHierarchy.cs @@ -0,0 +1,90 @@ +using System.Globalization; + +namespace CodeIndex.Database; + +internal sealed record IndexedCallSite( + long? SourceId, long? TargetId, string Path, int Line, int Column, int Length, + string Name, string? ResolutionState); + +public partial class DbReader +{ + // Position and endpoint lookups return metadata only. Reconstructing overlapping + // definition excerpts would bypass the LSP request's bounded source-file cache. + internal List GetCallHierarchyDeclarations(string path, int line, int limit) => + GetSymbolsAtLine(path, line, limit, kind: null, lang: null); + + internal SymbolResult? GetCallHierarchySymbol(long symbolId) + { + using var command = _conn.CreateCommand(); + command.CommandText = $""" + SELECT f.path, f.lang, s.kind, s.name, s.line, + {GetSymbolColumnSql("start_line", "s.line")}, + {GetSymbolColumnSql("end_line", "s.line")}, + {GetSymbolColumnSql("body_start_line")}, + {GetSymbolColumnSql("body_end_line")}, + {GetSymbolColumnSql("signature")}, + {GetSymbolColumnSql("container_kind")}, + {GetSymbolColumnSql("container_name")}, + {GetSymbolColumnSql("visibility")}, + {GetSymbolColumnSql("return_type")}, s.id, + {GetSymbolColumnSql("container_qualified_name")}, + {GetSymbolColumnSql("sub_kind")}, + {GetSymbolColumnSql("start_column")} + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE s.id = @symbol + LIMIT 1 + """; + SqliteCommandPolicy.Add(command, "@symbol", symbolId); + using var rows = command.ExecuteTrackedReader(); + return rows.TrackedRead() ? ReadSymbolResult(rows) : null; + } + + // LSP needs individual sites and both endpoints, rather than the grouped CLI rows. + // Use the same persisted reference identities and call kind; never bind by name. + internal List GetCallHierarchySites(long symbolId, bool incoming, int limit) + { + using var command = _conn.CreateCommand(); + var identityFilter = incoming + ? """ + (r.target_symbol_id = @symbol OR EXISTS ( + SELECT 1 FROM symbol_reference_candidates c + WHERE c.reference_id = r.id AND c.symbol_id = @symbol)) + """ + : "r.source_symbol_id = @symbol"; + command.CommandText = $""" + SELECT r.source_symbol_id, r.target_symbol_id, f.path, r.line, + r.column_number, r.span_length, r.symbol_name, r.resolution_state + FROM symbol_references r + JOIN files f ON f.id = r.file_id + WHERE {identityFilter} AND r.reference_kind = 'call' + ORDER BY r.id + LIMIT @limit + """; + SqliteCommandPolicy.Add(command, "@symbol", symbolId); + SqliteCommandPolicy.Add(command, "@limit", limit); + var sites = new List(); + using var rows = command.ExecuteTrackedReader(); + while (rows.TrackedRead()) + { + Cancellation.ThrowIfCancellationRequested(); + sites.Add(new IndexedCallSite( + rows.IsDBNull(0) ? null : rows.GetInt64(0), + rows.IsDBNull(1) ? null : rows.GetInt64(1), + rows.GetString(2), rows.GetInt32(3), rows.GetInt32(4), + rows.IsDBNull(5) ? 0 : rows.GetInt32(5), rows.GetString(6), + rows.IsDBNull(7) ? null : rows.GetString(7))); + } + return sites; + } + + internal bool CallHierarchyIdentityAvailable => + !_indexNewerThanReader && HasCurrentReferenceIdentityContractForRead() + && _referenceColumns.Contains("source_symbol_id") + && _referenceColumns.Contains("span_length") + && HasTable("symbol_reference_candidates"); + + internal string GetCallHierarchyGeneration() => string.Create( + CultureInfo.InvariantCulture, + $"{GetSymbolSelectorGenerationIdentity()}\n{ExecuteScalar("PRAGMA data_version")}\n{ExecuteScalar("SELECT total_changes()")}"); +} diff --git a/src/CodeIndex/Lsp/LspLiveDocumentStore.cs b/src/CodeIndex/Lsp/LspLiveDocumentStore.cs index ba29e9d25f..9b6036cea2 100644 --- a/src/CodeIndex/Lsp/LspLiveDocumentStore.cs +++ b/src/CodeIndex/Lsp/LspLiveDocumentStore.cs @@ -34,6 +34,12 @@ internal LspLiveDocumentStore(StringComparer comparer, StringComparison keyCompa internal long EvictedBytes => _evictedBytes; + internal IEnumerable> Documents => _documents; + + // Once text has been discarded, indexed graph navigation cannot prove that all + // open buffers agree with the index. Reconnect after saving to recover. + internal bool HasDiscardedText { get; private set; } + internal int VersionTombstoneCount => _documentVersions.Keys.Count(key => !_documents.ContainsKey(key)); @@ -49,6 +55,7 @@ internal bool SetText(string key, string text, int? version = null) var textBytes = Encoding.UTF8.GetByteCount(text); if (textBytes > _maxDocumentBytes || textBytes > _maxLiveBytes) { + HasDiscardedText = true; RememberVersion(key, version); Remove(key, preserveVersion: true); TrimVersionTombstones(); @@ -78,6 +85,7 @@ internal void Remove(string key, bool recordEviction = false, bool preserveVersi _documentBytes = Math.Max(0, _documentBytes - bytes); if (recordEviction) { + HasDiscardedText = true; _evictionCount++; _evictedBytes += bytes; } diff --git a/src/CodeIndex/Lsp/LspServer.CallHierarchy.cs b/src/CodeIndex/Lsp/LspServer.CallHierarchy.cs new file mode 100644 index 0000000000..455c8a36d2 --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.CallHierarchy.cs @@ -0,0 +1,348 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer +{ + internal const int MaxCallHierarchySites = 1000; + internal const int MaxCallHierarchyItems = 100; + internal const int MaxCallHierarchyResponseBytes = 512 * 1024; + private const int MaxCallHierarchyDataChars = 160; + private readonly string _callHierarchySession = Guid.NewGuid().ToString("N"); + internal Action? BeforeCallHierarchyForTesting { get; set; } + internal Action? BeforeCallHierarchyValidationForTesting { get; set; } + private const string CallHierarchyNotice = + "CodeIndex call hierarchy shows resolved indexed call evidence, not compiler-complete calls. " + + "External, dynamic and unindexed calls may be absent, including in empty results."; + + private sealed class CallHierarchyException(string reason, bool changed = false) : Exception(reason) + { + internal bool Changed { get; } = changed; + } + + private sealed class CallHierarchyRead(string generation) + { + internal string Generation { get; } = generation; + internal Dictionary Lines)> Files { get; } = new(StringComparer.Ordinal); + internal long SourceBytes { get; set; } + } + + private JsonObject HandleCallHierarchy( + JsonNode? id, JsonElement root, string method, Action? outbound, + CancellationToken requestCancellation) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(requestCancellation, _reader.Cancellation); + deadline.CancelAfter(TimeSpan.FromSeconds(5)); + using var cancellationScope = _reader.BeginCancellationScope(deadline.Token); + try + { + deadline.Token.ThrowIfCancellationRequested(); + BeforeCallHierarchyForTesting?.Invoke(deadline.Token); + var result = _reader.RunWithCancellationInterrupt(() => + { + var read = new CallHierarchyRead(_reader.GetCallHierarchyGeneration()); + var readiness = _reader.GetPersistedIndexGenerationReadiness(); + if (!readiness.GraphDataCurrent || !readiness.ReferenceGraphComplete + || !readiness.IndexComplete || !_reader.CallHierarchyIdentityAvailable) + throw new CallHierarchyException("graph_unavailable_or_incomplete"); + if (_liveDocumentStore.HasDiscardedText) + throw new CallHierarchyException("live_document_evicted_reconnect_required", changed: true); + + // A dirty caller in another open document can change even a zero incoming result. + foreach (var document in _liveDocumentStore.Documents) + { + deadline.Token.ThrowIfCancellationRequested(); + if (!TryResolveDocumentPath(document.Key, out var fullPath, out var relativePath, out var workspaceRoot)) + continue; + var indexedPath = ResolveIndexedPath(document.Key, fullPath, relativePath, workspaceRoot); + if (indexedPath == null) + throw new CallHierarchyException("open_document_not_indexed", changed: true); + ReadCallHierarchyFile(indexedPath, read); + } + + JsonNode? items = method == "textDocument/prepareCallHierarchy" + ? PrepareCallHierarchy(root, read) + : ExpandCallHierarchy(root, method == "callHierarchy/incomingCalls", read); + BeforeCallHierarchyValidationForTesting?.Invoke(); + deadline.Token.ThrowIfCancellationRequested(); + // Recheck the bounded source set after assembling ranges to catch concurrent saves. + foreach (var file in read.Files.Values) + { + if (!FileIndexer.TryComputeChecksum(file.FullPath, MaxPositionDocumentBytes, out var checksum, deadline.Token) + || checksum != file.Checksum) + throw new CallHierarchyException("document_changed", changed: true); + } + if (read.Generation != _reader.GetCallHierarchyGeneration() + || (_ownedQueryDb != null && !_ownedQueryDb.IsQueryOnlySnapshotCurrent())) + throw new CallHierarchyException("index_generation_changed", changed: true); + var response = Result(id?.DeepClone(), items); + if (Encoding.UTF8.GetByteCount(response.ToJsonString(_jsonOptions)) > MaxCallHierarchyResponseBytes) + throw new CallHierarchyException("response_budget_exceeded"); + return response; + }); + outbound?.Invoke(new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "window/logMessage", + ["params"] = new JsonObject { ["type"] = 3, ["message"] = CallHierarchyNotice }, + }); + return result; + } + catch (CallHierarchyException exception) + { + var response = Error(id, exception.Changed ? -32801 : -32803, + "Indexed call hierarchy unavailable: " + exception.Message + + ". Save documents, refresh the index and prepare the hierarchy again; " + + "reconnect if an open buffer was evicted. Use CLI/MCP for bounded graph diagnostics."); + response["error"]!["data"] = new JsonObject { ["reason"] = exception.Message }; + return response; + } + catch (OperationCanceledException) + { + return requestCancellation.IsCancellationRequested + ? Error(id, JsonRpcRequestCancelledCode, JsonRpcRequestCancelledMessage) + : Error(id, -32803, "Indexed call hierarchy query deadline exceeded."); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException) + { + return Error(id, -32803, "Indexed call hierarchy source unavailable. Save and refresh the index."); + } + } + + private JsonNode? PrepareCallHierarchy(JsonElement root, CallHierarchyRead read) + { + if (!TryResolveIndexedDocument(root, out var document)) + return null; + if (!IsCallHierarchyLanguage(_reader.GetResourceFileMetadata(document.IndexedPath)?.Lang)) + return null; + ReadCallHierarchyFile(document.IndexedPath, read); + if (!TryExtractPositionToken(root, out var context, out var reason)) + { + if (reason is FailureNoTokenAtPosition or FailureFileNotIndexed or FailureOutsideProject) + return null; + throw new CallHierarchyException("position_unavailable"); + } + var lines = ReadCallHierarchyFile(context.IndexedPath, read); + var definitions = _reader.GetCallHierarchyDeclarations(context.IndexedPath, + context.Line + 1, MaxReferencePositionCandidates + 1); + if (definitions.Count > MaxReferencePositionCandidates) + throw new CallHierarchyException("symbol_candidate_budget_exceeded"); + var lineCache = new Dictionary { [context.Line] = lines[context.Line] }; + var selected = definitions.Where(definition => + { + if (definition.Line != context.Line + 1) + return false; + var identifier = GetSymbolIdentifierPosition(definition, context.ResolvedPath, lineCache); + return context.StartCharacter < identifier.EndColumn - 1 + && context.EndCharacter > identifier.StartColumn - 1; + }).ToList(); + if (selected.Count == 0) + { + var resolution = _reader.GetReferencePositionResolution(context.IndexedPath, context.Token, + context.Line + 1, context.StartCharacter + 1, MaxReferencePositionCandidates); + if (!resolution.IdentityAvailable || resolution.CandidatesTruncated) + throw new CallHierarchyException("reference_identity_unavailable"); + selected = resolution.Candidates.Where(candidate => candidate.Authoritative) + .Select(candidate => candidate.Definition).ToList(); + if (selected.Count != 1) + { + selected = resolution.Candidates.Select(candidate => candidate.Definition).ToList(); + if (TryGetCSharpInvocationArgumentCount(context, out var argumentCount)) + { + var matchingArity = selected.Where(candidate => + TryGetCSharpDefinitionParameterCount(candidate, out var count) && count == argumentCount).ToList(); + if (matchingArity.Count > 0) + selected = matchingArity; + } + } + if (selected.Count == 0) + throw new CallHierarchyException("unresolved_or_ambiguous_position"); + } + if (selected.Count != 1) + throw new CallHierarchyException("ambiguous_position"); + if (!IsCallHierarchyCallable(selected[0])) + return null; + return new JsonArray(CreateCallHierarchyItem(selected[0], read)); + } + + private static bool IsCallHierarchyCallable(SymbolResult symbol) => + symbol.SymbolId.HasValue + && symbol.Kind is "function" or "test.method" or "lambda" + && IsCallHierarchyLanguage(symbol.Lang); + + private static bool IsCallHierarchyLanguage(string? language) => + language is "csharp" or "java" or "javascript" or "typescript" or "python" + or "go" or "rust" or "c" or "cpp" or "kotlin" or "ruby" or "php" or "swift"; + + private JsonArray ExpandCallHierarchy(JsonElement root, bool incoming, CallHierarchyRead read) + { + if (!TryGet(root, out var data, "params", "item", "data") || data.ValueKind != JsonValueKind.String) + throw new ArgumentException("Call hierarchy item data is required."); + var token = data.GetString()!; + if (token.Length > MaxCallHierarchyDataChars) + throw new ArgumentException("Call hierarchy item data is too long."); + var prefix = CallHierarchyDataPrefix(read); + if (!token.StartsWith(prefix, StringComparison.Ordinal)) + throw new CallHierarchyException("stale_item", changed: true); + if (!SymbolSelector.TryParse(token[prefix.Length..], out var selector) + || selector.GenerationFingerprint == null || !_reader.IsCurrentSymbolSelector(selector) + || _reader.GetCallHierarchySymbol(selector.SymbolId) is not { } definition) + throw new CallHierarchyException("stale_item", changed: true); + if (!IsCallHierarchyCallable(definition)) + throw new ArgumentException("Call hierarchy item is not callable."); + var canonical = CreateCallHierarchyItem(definition, read); + if (!TryGet(root, out var uri, "params", "item", "uri") || uri.ValueKind != JsonValueKind.String + || uri.GetString() != canonical["uri"]!.GetValue()) + throw new ArgumentException("Call hierarchy item URI does not match its identity."); + + var sites = _reader.GetCallHierarchySites(definition.SymbolId!.Value, incoming, MaxCallHierarchySites + 1); + if (sites.Count > MaxCallHierarchySites) + throw new CallHierarchyException("call_site_budget_exceeded"); + var groups = new Dictionary(); + var definitionsById = new Dictionary { [definition.SymbolId.Value] = definition }; + var seen = new HashSet<(long Source, long Target, int Line, int Column, int Length)>(); + foreach (var site in sites) + { + _reader.Cancellation.ThrowIfCancellationRequested(); + if (site.SourceId is not long sourceId || site.TargetId is not long targetId + || site.ResolutionState != "resolved" + || (incoming && targetId != definition.SymbolId)) + throw new CallHierarchyException("unresolved_or_ambiguous_calls"); + var source = GetDefinition(sourceId); + var target = GetDefinition(targetId); + if (!IsCallHierarchyCallable(source) || !IsCallHierarchyCallable(target)) + throw new CallHierarchyException("call_endpoint_unavailable"); + var otherId = incoming ? sourceId : targetId; + if (!groups.TryGetValue(otherId, out var group)) + { + if (groups.Count >= MaxCallHierarchyItems) + throw new CallHierarchyException("call_item_budget_exceeded"); + group = (CreateCallHierarchyItem(incoming ? source : target, read), new JsonArray()); + groups.Add(otherId, group); + } + var sourceItem = incoming ? group.Item : canonical; + var lines = ReadCallHierarchyFile(site.Path, read); + if (site.Path != source.Path || site.Line <= 0 || site.Line > lines.Count + || lines[site.Line - 1] is not { } line || site.Column <= 0) + throw new CallHierarchyException("call_site_range_unavailable"); + var start = site.Column - 1; + var span = ExtractTokenAtUtf16Position(line, start); + var (tokenStart, tokenEnd) = FindTokenRangeAtUtf16Position(line, start); + if (span == null || tokenStart != start || tokenEnd <= start + || !string.Equals(span.TrimStart('@'), site.Name.TrimStart('@'), StringComparison.Ordinal)) + throw new CallHierarchyException("call_site_range_unavailable"); + var range = ToRange(site.Line, site.Column, site.Line, tokenEnd + 1); + var sourceRange = sourceItem["range"]!; + if (ComparePosition(site.Line - 1, start, + sourceRange["start"]!["line"]!.GetValue(), sourceRange["start"]!["character"]!.GetValue()) < 0 + || ComparePosition(site.Line - 1, tokenEnd, + sourceRange["end"]!["line"]!.GetValue(), sourceRange["end"]!["character"]!.GetValue()) > 0) + throw new CallHierarchyException("call_site_outside_callable"); + if (seen.Add((sourceId, targetId, site.Line, site.Column, tokenEnd - start))) + group.Ranges.Add(range); + } + var result = new JsonArray(); + foreach (var group in groups.OrderBy(pair => pair.Key).Select(pair => pair.Value)) + result.Add(new JsonObject { [incoming ? "from" : "to"] = group.Item, ["fromRanges"] = group.Ranges }); + return result; + + SymbolResult GetDefinition(long symbolId) + { + if (definitionsById.TryGetValue(symbolId, out var cached)) + return cached; + var found = _reader.GetCallHierarchySymbol(symbolId) + ?? throw new CallHierarchyException("call_endpoint_unavailable"); + definitionsById.Add(symbolId, found); + return found; + } + } + + private string CallHierarchyDataPrefix(CallHierarchyRead read) => + _callHierarchySession + ":" + SymbolSelector.BuildGenerationFingerprint(read.Generation) + ":"; + + private JsonObject CreateCallHierarchyItem(SymbolResult symbol, CallHierarchyRead read) + { + var lines = ReadCallHierarchyFile(symbol.Path, read); + var fullPath = read.Files[symbol.Path].FullPath; + var identifierLineNumber = symbol.Line > 0 ? symbol.Line : Math.Max(1, symbol.StartLine); + var cache = new Dictionary + { + [identifierLineNumber - 1] = identifierLineNumber <= lines.Count ? lines[identifierLineNumber - 1] : null, + }; + var identifier = GetSymbolIdentifierPosition(symbol, fullPath, cache); + var startLine = symbol.StartLine; + var endLine = symbol.EndLine; + if (startLine <= 0 || endLine < startLine || endLine > lines.Count + || identifier.Line < startLine || identifier.Line > endLine + || lines[identifier.Line - 1] is not { } identifierLine + || identifier.StartColumn < 1 || identifier.StartColumn > identifierLine.Length + || lines[endLine - 1] is not { } lastLine) + throw new CallHierarchyException("symbol_range_unavailable"); + var (tokenStart, tokenEnd) = FindTokenRangeAtUtf16Position(identifierLine, identifier.StartColumn - 1); + var actualName = ExtractTokenAtUtf16Position(identifierLine, identifier.StartColumn - 1); + if (tokenStart != identifier.StartColumn - 1 || tokenEnd <= tokenStart + || !string.Equals(actualName?.TrimStart('@'), symbol.Name.TrimStart('@'), StringComparison.Ordinal)) + throw new CallHierarchyException("symbol_range_unavailable"); + return new JsonObject + { + ["name"] = symbol.Name, + ["kind"] = SymbolKind(symbol), + ["uri"] = PathToUri(fullPath), + ["range"] = ToRange(startLine, 1, endLine, lastLine.Length + 1), + ["selectionRange"] = ToRange(identifier.Line, tokenStart + 1, identifier.Line, tokenEnd + 1), + ["detail"] = "Indexed call evidence · " + TruncateDocumentSymbolDetail(symbol.Signature), + ["data"] = CallHierarchyDataPrefix(read) + _reader.BuildSymbolCandidateSelector(symbol).Selector, + }; + } + + private IReadOnlyList ReadCallHierarchyFile(string indexedPath, CallHierarchyRead read) + { + if (read.Files.TryGetValue(indexedPath, out var cached)) + return cached.Lines; + _reader.Cancellation.ThrowIfCancellationRequested(); + var workspaceRoot = _projectRoot ?? (_workspaceFolders.Count == 1 ? _workspaceFolders[0] : null); + if ((!Path.IsPathRooted(indexedPath) && workspaceRoot == null) + || !TryResolveIndexedFilePath(indexedPath, workspaceRoot, out var fullPath) + || !TryResolveDocumentPath(fullPath, out _, out _, out _)) + throw new CallHierarchyException("source_path_unavailable"); + var metadata = _reader.GetResourceFileMetadata(indexedPath); + if (metadata?.Checksum == null) + throw new CallHierarchyException("source_checksum_unavailable"); + var remainingBytes = Math.Min(MaxPositionDocumentBytes, MaxLiveDocumentBytes - read.SourceBytes); + if (remainingBytes <= 0) + throw new CallHierarchyException("source_budget_exceeded"); + LoadedFileContent loaded; + try + { + loaded = new FileContentLoader(remainingBytes).Load(fullPath, indexedPath, indexedPath, _reader.Cancellation); + } + catch (FileIndexer.FileTooLargeSkippedException) + { + throw new CallHierarchyException("source_budget_exceeded"); + } + catch (FileIndexer.BinaryFileSkippedException) + { + throw new CallHierarchyException("source_content_unavailable"); + } + read.SourceBytes += loaded.RawBytes.LongLength; + if (!string.Equals(loaded.Checksum, metadata.Checksum, StringComparison.OrdinalIgnoreCase)) + throw new CallHierarchyException("document_not_indexed", changed: true); + using var content = new MemoryStream(loaded.RawBytes, writable: false); + using var textReader = new StreamReader(content, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + var text = textReader.ReadToEnd(); + var normalizedText = text.ReplaceLineEndings("\n"); + if (loaded.Content != normalizedText && loaded.Content != "\uFEFF" + normalizedText) + throw new CallHierarchyException("source_content_unavailable"); + if (_liveDocumentStore.TryGetText(fullPath, out var live) + && !string.Equals(live.ReplaceLineEndings("\n"), normalizedText, StringComparison.Ordinal)) + throw new CallHierarchyException("unsaved_document", changed: true); + var lines = SplitPositionLines(text); + read.Files.Add(indexedPath, (fullPath, FileIndexer.ComputeChecksum(loaded.RawBytes), lines)); + return lines; + } +} diff --git a/src/CodeIndex/Lsp/LspServer.PositionValidation.cs b/src/CodeIndex/Lsp/LspServer.PositionValidation.cs index 1918f0a311..e0be7dfd52 100644 --- a/src/CodeIndex/Lsp/LspServer.PositionValidation.cs +++ b/src/CodeIndex/Lsp/LspServer.PositionValidation.cs @@ -17,6 +17,7 @@ private static void ValidateCoordinateParameters(string method, JsonElement root case "textDocument/hover": case "textDocument/completion": case "textDocument/documentHighlight": + case "textDocument/prepareCallHierarchy": _ = ReadRequiredLspPosition(root, "params", "position"); break; case "textDocument/inlayHint": diff --git a/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs b/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs index b327037216..cf58cc61a7 100644 --- a/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs +++ b/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs @@ -24,6 +24,7 @@ internal sealed partial class LspServer : IDisposable ["definitionProvider"] = true, ["declarationProvider"] = true, ["referencesProvider"] = true, + ["callHierarchyProvider"] = true, ["documentSymbolProvider"] = new JsonObject { ["workDoneProgress"] = true, diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 1483c09724..7301a1b272 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -486,6 +486,8 @@ await messages.Writer "textDocument/definition" => Result(id, Definition(root, "textDocument/definition")), "textDocument/declaration" => Result(id, Definition(root, "textDocument/declaration")), "textDocument/references" => Result(id, References(root, "textDocument/references")), + "textDocument/prepareCallHierarchy" or "callHierarchy/incomingCalls" or "callHierarchy/outgoingCalls" + => HandleCallHierarchy(id, root, method, outbound, requestCancellation), "textDocument/hover" => Result(id, Hover(root, "textDocument/hover")), "textDocument/completion" => Result(id, Completion(root, "textDocument/completion")), "textDocument/documentHighlight" => Result(id, DocumentHighlight(root, "textDocument/documentHighlight")), diff --git a/tests/CodeIndex.Tests/LspCallHierarchyTests.cs b/tests/CodeIndex.Tests/LspCallHierarchyTests.cs new file mode 100644 index 0000000000..34b303f694 --- /dev/null +++ b/tests/CodeIndex.Tests/LspCallHierarchyTests.cs @@ -0,0 +1,416 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Lsp; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public sealed class LspCallHierarchyTests +{ + private const string Source = """ + class Alpha + { + public void Leaf() { } + public void Leaf(int x) { } + public void Caller() + { + Leaf(); Leaf(); + Leaf(1); + } + public void Recursive() { Recursive(); } + public void Missing() { Unknown(); } + } + class Beta + { + public void Leaf() { } + public void Caller() { Leaf(); } + } + """; + + [Fact] + public void FramedHierarchy_PreservesIdentityRepeatedSitesAndNavigation_Issue5351() + { + using var fixture = new Fixture(Source); + Assert.True(fixture.Capabilities["callHierarchyProvider"]!.GetValue()); + var leaf = fixture.Prepare(2, "Leaf"); + var overloaded = fixture.Prepare(3, "Leaf"); + var caller = fixture.Prepare(4, "Caller"); + var other = fixture.Prepare(14, "Leaf"); + Assert.NotEqual(leaf["data"]!.GetValue(), overloaded["data"]!.GetValue()); + Assert.NotEqual(leaf["data"]!.GetValue(), other["data"]!.GetValue()); + Assert.Equal(leaf["data"]!.GetValue(), fixture.Prepare(6, "Leaf")["data"]!.GetValue()); + + var incoming = fixture.Expand(leaf, incoming: true); + var edge = Assert.Single(incoming); + Assert.Equal("Caller", edge!["from"]!["name"]!.GetValue()); + Assert.Equal(4, edge["from"]!["selectionRange"]!["start"]!["line"]!.GetValue()); + var ranges = edge["fromRanges"]!.AsArray(); + Assert.Equal(2, ranges.Count); + AssertRange(ranges[0]!, 6, 8, 12); + AssertRange(ranges[1]!, 6, 16, 20); + var outgoing = fixture.Expand(caller, incoming: false); + Assert.Equal(2, outgoing.Count); + Assert.Equal(new[] { 2, 3 }, outgoing.Select(node => node!["to"]!["selectionRange"]!["start"]!["line"]!.GetValue()).Order().ToArray()); + Assert.Equal(3, outgoing.Sum(node => node!["fromRanges"]!.AsArray().Count)); + Assert.Single(fixture.Expand(other, incoming: true)); + Assert.Empty(fixture.Expand(leaf, incoming: false)); + var recursive = fixture.Prepare(9, "Recursive"); + Assert.Single(fixture.Expand(recursive, incoming: true)); + Assert.Single(fixture.Expand(recursive, incoming: false)); + + Assert.Null(fixture.Position("textDocument/prepareCallHierarchy", 0, 6)["result"]); + Assert.Null(fixture.Position("textDocument/prepareCallHierarchy", 1, 0)["result"]); + Assert.NotEmpty(fixture.Position("textDocument/definition", 6, 8)["result"]!.AsArray()); + Assert.NotEmpty(fixture.Position("textDocument/references", 2, 16)["result"]!.AsArray()); + Assert.NotEmpty(fixture.Request("textDocument/documentSymbol", new { textDocument = new { uri = fixture.Uri } })["result"]!.AsArray()); + + var wrongUri = leaf.DeepClone(); + wrongUri["uri"] = "file:///outside.cs"; + Assert.Equal(-32602, fixture.Request("callHierarchy/incomingCalls", new { item = wrongUri })["error"]!["code"]!.GetValue()); + Assert.Equal(-32602, fixture.Request("callHierarchy/outgoingCalls", new { item = new { data = new string('x', 161) } })["error"]!["code"]!.GetValue()); + Assert.Equal(-32602, fixture.Position("textDocument/prepareCallHierarchy", -1, 0)["error"]!["code"]!.GetValue()); + Assert.All(fixture.Notices, notice => Assert.Contains("not compiler-complete", notice, StringComparison.Ordinal)); + Assert.NotEmpty(fixture.Notices); + } + + [Theory] + [InlineData("cs", "class Café\n{\n void 終了() { }\n void 開始() { var s = \"😀\"; 終了(); }\n}\n", 2, "終了", 3)] + [InlineData("py", "def 終了():\n pass\ndef 開始():\n 終了()\n", 0, "終了", 3)] + public void FramedHierarchy_UsesUtf16AndFileUris_Issue5351(string extension, string source, int declarationLine, string name, int callLine) + { + using var fixture = new Fixture(source, "日本 語 #." + extension); + var item = fixture.Prepare(declarationLine, name); + Assert.Equal(fixture.Uri, item["uri"]!.GetValue()); + var declarationColumn = source.Split('\n')[declarationLine].IndexOf(name, StringComparison.Ordinal); + AssertRange(item["selectionRange"]!, declarationLine, declarationColumn, declarationColumn + name.Length); + var edge = Assert.Single(fixture.Expand(item, incoming: true)); + var column = source.Split('\n')[callLine].IndexOf(name, StringComparison.Ordinal); + AssertRange(Assert.Single(edge!["fromRanges"]!.AsArray())!, callLine, column, column + name.Length); + } + + [Fact] + public void FramedHierarchy_RejectsUnresolvedIncompleteStaleAndUnsavedEvidence_Issue5351() + { + using var fixture = new Fixture(Source); + var leaf = fixture.Prepare(2, "Leaf"); + var missing = fixture.Prepare(10, "Missing"); + AssertError(fixture.Request("callHierarchy/outgoingCalls", new { item = missing }), -32803, "unresolved_or_ambiguous_calls"); + fixture.Notify("textDocument/didOpen", new { textDocument = new { uri = fixture.Uri, version = 1, text = Source } }); + Assert.Single(fixture.Expand(leaf, incoming: true)); + fixture.Notify("textDocument/didChange", new { textDocument = new { uri = fixture.Uri, version = 2 }, contentChanges = new[] { new { text = Source + "\n// unsaved" } } }); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32801, "unsaved_document"); + fixture.Notify("textDocument/didChange", new { textDocument = new { uri = fixture.Uri, version = 1 }, contentChanges = new[] { new { text = Source } } }); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32801, "unsaved_document"); + fixture.Notify("textDocument/didClose", new { textDocument = new { uri = fixture.Uri } }); + Assert.Single(fixture.Expand(leaf, incoming: true)); + File.AppendAllText(fixture.SourcePath, "\n// saved after indexing"); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32801, "document_not_indexed"); + fixture.Reindex(); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32801, "stale_item"); + leaf = fixture.Prepare(2, "Leaf"); + Assert.Single(fixture.Expand(leaf, incoming: true)); + using (var writerDb = new DbContext(DbOpenIntent.WriteIndex, fixture.DbPath)) + new DbWriter(writerDb).SetMeta(DbContext.IndexCompletenessMetaKey, "incomplete"); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32803, "graph_unavailable_or_incomplete"); + } + + [Fact] + public void FramedHierarchy_RejectsAmbiguityAndForeignItems_Issue5351() + { + const string ambiguous = "class A\n{\n void Leaf(int x) { }\n void Leaf(string x) { }\n void Caller() { Leaf(value); }\n}\n"; + using var fixture = new Fixture(ambiguous); + var first = fixture.Prepare(2, "Leaf"); + var second = fixture.Prepare(3, "Leaf"); + Assert.NotEqual(first["data"]!.GetValue(), second["data"]!.GetValue()); + AssertError(fixture.Position("textDocument/prepareCallHierarchy", 4, 17), -32803, "ambiguous_position"); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = first }), -32803, "unresolved_or_ambiguous_calls"); + using var other = new Fixture(ambiguous); + AssertError(other.Request("callHierarchy/incomingCalls", new { item = first }), -32801, "stale_item"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FramedHierarchy_RejectsHighDegreeWithoutPartialSuccess_Issue5351(bool incoming) + { + var source = new StringBuilder("class Calls\n{\n void Leaf() { }\n"); + for (var i = 0; i <= LspServer.MaxCallHierarchyItems; i++) + source.AppendLine($" void Node{i}() {{ Leaf(); }}"); + source.AppendLine(" void Root() {"); + for (var i = 0; i <= LspServer.MaxCallHierarchyItems; i++) + source.AppendLine($" Node{i}();"); + source.AppendLine(" }\n}"); + using var fixture = new Fixture(source.ToString()); + var item = incoming ? fixture.Prepare(2, "Leaf") : fixture.Prepare(LspServer.MaxCallHierarchyItems + 4, "Root"); + AssertError(fixture.Request(incoming ? "callHierarchy/incomingCalls" : "callHierarchy/outgoingCalls", new { item }), -32803, "call_item_budget_exceeded"); + } + + [Fact] + public void FramedHierarchy_CancelsAndKeepsSessionUsable_Issue5351() + { + using var fixture = new Fixture(Source); + var leaf = fixture.Prepare(2, "Leaf"); + fixture.Server.BeforeCallHierarchyForTesting = token => + { + fixture.Notify("$/cancelRequest", new { id = 5351 }); + token.ThrowIfCancellationRequested(); + }; + var cancelled = fixture.Request("callHierarchy/incomingCalls", new { item = leaf }); + Assert.Equal(-32800, cancelled["error"]!["code"]!.GetValue()); + fixture.Server.BeforeCallHierarchyForTesting = null; + Assert.Single(fixture.Expand(leaf, incoming: true)); + fixture.Notify("textDocument/didOpen", new { textDocument = new { uri = fixture.Uri, version = 1, text = new string('x', LspServer.MaxPositionDocumentBytes + 1) } }); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32801, "live_document_evicted_reconnect_required"); + } + + [Fact] + public void FramedHierarchy_RejectsOversizedResponseAndKeepsSessionUsable_Issue5351() + { + var source = new StringBuilder("class Calls\n{\n void Leaf() {}\n"); + for (var i = 0; i < LspServer.MaxCallHierarchyItems; i++) + source.AppendLine($" void {new string('節', 2048)}{i}() {{ Leaf(); }}"); + source.AppendLine("}"); + using var fixture = new Fixture(source.ToString()); + var leaf = fixture.Prepare(2, "Leaf"); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32803, "response_budget_exceeded"); + Assert.Empty(fixture.Expand(leaf, incoming: false)); + Assert.Equal(leaf["data"]!.GetValue(), fixture.Prepare(2, "Leaf")["data"]!.GetValue()); + } + + [Fact] + public void FramedHierarchy_PreparesWithinExactFileIdentity_Issue5351() + { + 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}"; + const string foreign = "class Foreign\n{\n\n\n\n\n\n\n void Leaf() {}\n}"; + using var fixture = new Fixture(source, additionalFiles: new Dictionary { ["Foreign.cs"] = foreign }); + // Model case-colliding indexed files even on a case-insensitive test filesystem. + // The foreign file must never be selected or read for this document's position. + using (var db = new DbContext(DbOpenIntent.WriteIndex, fixture.DbPath)) + using (var command = db.Connection.CreateCommand()) + { + command.CommandText = "UPDATE files SET path = 'calls.cs' WHERE path = 'Foreign.cs'"; + command.ExecuteNonQuery(); + } + var declared = fixture.Prepare(2, "Leaf"); + var atCall = fixture.Prepare(8, "Leaf"); + Assert.Equal(fixture.Uri, atCall["uri"]!.GetValue()); + Assert.Equal(declared["data"]!.GetValue(), atCall["data"]!.GetValue()); + } + + [Fact] + public void FramedHierarchy_BoundsMetadataForOverlappingCallables_Issue5351() + { + const int nestedCount = 40; + var source = new StringBuilder("class Calls\n{\n void Leaf() {}\n void Root() {\n Leaf();\n"); + for (var i = 0; i < nestedCount; i++) + source.AppendLine($" void Node{i}() {{\n Leaf();"); + for (var i = 0; i < 2000; i++) + source.AppendLine("// " + new string('x', 1000)); + source.AppendLine(new string('}', nestedCount + 2)); + using var fixture = new Fixture(source.ToString()); + using (var db = new DbContext(DbOpenIntent.QueryOnly, fixture.DbPath)) + { + var reader = new DbReader(db); + var node = Assert.Single(reader.GetCallHierarchyDeclarations("Calls.cs", 6, 256).Where(symbol => symbol.Name == "Node0")); + Assert.NotNull(reader.GetCallHierarchySymbol(node.SymbolId!.Value)); + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) + Assert.NotNull(reader.GetCallHierarchySymbol(node.SymbolId.Value)); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated < 4 * 1024 * 1024, $"Metadata queries allocated {allocated} bytes for overlapping 2 MiB ranges."); + } + var leaf = fixture.Prepare(2, "Leaf"); + Assert.Equal(nestedCount + 1, fixture.Expand(leaf, incoming: true).Count); + } + + [Theory] + [InlineData("java", "class Calls {\n void leaf() {}\n void caller() { leaf(); }\n}", 1)] + [InlineData("js", "function leaf() {}\nfunction caller() { leaf(); }", 0)] + [InlineData("ts", "function leaf() {}\nfunction caller() { leaf(); }", 0)] + [InlineData("go", "package main\nfunc leaf() {}\nfunc caller() { leaf() }", 1)] + [InlineData("rs", "fn leaf() {}\nfn caller() { leaf(); }", 0)] + [InlineData("c", "void leaf() {}\nvoid caller() { leaf(); }", 0)] + [InlineData("cpp", "void leaf() {}\nvoid caller() { leaf(); }", 0)] + [InlineData("kt", "fun leaf() {}\nfun caller() { leaf() }", 0)] + [InlineData("rb", "def leaf()\nend\ndef caller()\n leaf()\nend", 0)] + [InlineData("php", "()); + Assert.Single(fixture.Expand(edge["from"]!, incoming: false)); + } + + [Theory] + [InlineData(1000)] + [InlineData(1001)] + public void FramedHierarchy_BoundsRepeatedCallSites_Issue5351(int sites) + { + var source = "class Calls\n{\n void Leaf() {}\n void Caller() {\n" + + string.Concat(Enumerable.Repeat(" Leaf();\n", sites)) + " }\n}"; + using var fixture = new Fixture(source); + var leaf = fixture.Prepare(2, "Leaf"); + var response = fixture.Request("callHierarchy/incomingCalls", new { item = leaf }); + if (sites > LspServer.MaxCallHierarchySites) + AssertError(response, -32803, "call_site_budget_exceeded"); + else + Assert.Equal(sites, Assert.Single(response["result"]!.AsArray())!["fromRanges"]!.AsArray().Count); + } + + [Fact] + public void FramedHierarchy_RejectsConcurrentGenerationAndBadRanges_Issue5351() + { + using var fixture = new Fixture(Source); + var leaf = fixture.Prepare(2, "Leaf"); + fixture.Server.BeforeCallHierarchyValidationForTesting = () => + { + using var writerDb = new DbContext(DbOpenIntent.WriteIndex, fixture.DbPath); + new DbWriter(writerDb).SetMeta("hierarchy_test_generation", "changed"); + }; + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32801, "index_generation_changed"); + fixture.Server.BeforeCallHierarchyValidationForTesting = null; + using (var writerDb = new DbContext(DbOpenIntent.WriteIndex, fixture.DbPath)) + using (var command = writerDb.Connection.CreateCommand()) + { + command.CommandText = "UPDATE symbol_references SET column_number = 1 WHERE reference_kind = 'call'"; + command.ExecuteNonQuery(); + } + leaf = fixture.Prepare(2, "Leaf"); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32803, "call_site_range_unavailable"); + fixture.Notify("textDocument/didOpen", new { textDocument = new { uri = LspServer.PathToUri(Path.Combine(Path.GetDirectoryName(fixture.SourcePath)!, "New.cs")), version = 1, text = "class New {}" } }); + AssertError(fixture.Request("callHierarchy/incomingCalls", new { item = leaf }), -32801, "open_document_not_indexed"); + } + + [Fact] + public void FramedHierarchy_UnsupportedLanguageReturnsNull_Issue5351() + { + using var fixture = new Fixture("# Heading\n", "Notes.md"); + var response = fixture.Position("textDocument/prepareCallHierarchy", 0, 2); + Assert.Null(response["error"]); + Assert.Null(response["result"]); + } + + [Theory] + [InlineData("utf8")] + [InlineData("utf8-bom")] + [InlineData("utf16-le")] + [InlineData("utf16-be")] + public void FramedHierarchy_UsesIndexerEncodingAndNewlinePolicy_Issue5351(string encodingName) + { + var encoding = encodingName switch + { + "utf8-bom" => new UTF8Encoding(true), + "utf16-le" => Encoding.Unicode, + "utf16-be" => Encoding.BigEndianUnicode, + _ => new UTF8Encoding(false), + }; + using var fixture = new Fixture(Source.ReplaceLineEndings("\r\n"), encoding: encoding); + var leaf = fixture.Prepare(2, "Leaf"); + var edge = Assert.Single(fixture.Expand(leaf, incoming: true)); + Assert.Equal(2, edge!["fromRanges"]!.AsArray().Count); + fixture.Notify("textDocument/didOpen", new { textDocument = new { uri = fixture.Uri, version = 1, text = Source } }); + Assert.Single(fixture.Expand(leaf, incoming: true)); + } + + private static void AssertError(JsonObject response, int code, string reason) + { + Assert.Equal(code, response["error"]?["code"]?.GetValue()); + Assert.Equal(reason, response["error"]?["data"]?["reason"]?.GetValue()); + Assert.False(response.ContainsKey("result")); + } + + private static void AssertRange(JsonNode range, int line, int start, int end) + { + Assert.Equal(line, range["start"]!["line"]!.GetValue()); + Assert.Equal(line, range["end"]!["line"]!.GetValue()); + Assert.Equal(start, range["start"]!["character"]!.GetValue()); + Assert.Equal(end, range["end"]!["character"]!.GetValue()); + } + + private sealed class Fixture : IDisposable + { + private readonly string _root = TestProjectHelper.CreateTempProject("cdidx_lsp_hierarchy"); + internal string SourcePath { get; } + internal string DbPath { get; } + internal string Uri => LspServer.PathToUri(SourcePath); + internal LspServer Server { get; } + internal JsonNode Capabilities { get; } + internal List Notices { get; } = []; + + internal Fixture(string source, string fileName = "Calls.cs", Encoding? encoding = null, + IReadOnlyDictionary? additionalFiles = null) + { + SourcePath = Path.Combine(_root, fileName); + DbPath = Path.Combine(_root, ".cdidx", "codeindex.db"); + File.WriteAllText(SourcePath, source, encoding ?? new UTF8Encoding(false)); + if (additionalFiles != null) + { + foreach (var file in additionalFiles) + File.WriteAllText(Path.Combine(_root, file.Key), file.Value); + } + Reindex(); + var db = new DbContext(DbOpenIntent.QueryOnly, DbPath); + Server = new LspServer(db, DbPath, "test", ProgramRunner.CreateDefaultJsonOptions(), _root); + Capabilities = Request("initialize", new { })["result"]!["capabilities"]!; + } + + internal void Reindex() + { + var (exitCode, stdout, stderr) = QueryCommandTestSupport.CaptureConsole(() => + ProgramRunner.Run(["index", _root, "--db", DbPath, "--json"], ProgramRunner.CreateDefaultJsonOptions(), "test")); + Assert.True(exitCode == 0, stdout + stderr); + } + + internal JsonNode Prepare(int line, string name) + { + var column = File.ReadAllText(SourcePath).Split('\n')[line].IndexOf(name, StringComparison.Ordinal); + var response = Position("textDocument/prepareCallHierarchy", line, column); + Assert.True(response["error"] == null, response.ToJsonString()); + return Assert.Single(response["result"]!.AsArray())!; + } + + internal JsonArray Expand(JsonNode item, bool incoming) + { + var response = Request(incoming ? "callHierarchy/incomingCalls" : "callHierarchy/outgoingCalls", new { item }); + Assert.True(response["error"] == null, response.ToJsonString()); + return response["result"]!.AsArray(); + } + + internal JsonObject Position(string method, int line, int character) => + Request(method, new { textDocument = new { uri = Uri }, position = new { line, character } }); + + internal void Notify(string method, object parameters) => + Assert.Null(Server.HandleMessage(JsonSerializer.Serialize(new { jsonrpc = "2.0", method, @params = parameters }))); + + internal JsonObject Request(string method, object parameters) + { + var payload = JsonSerializer.Serialize(new { jsonrpc = "2.0", id = 5351, method, @params = parameters }); + using var input = new MemoryStream(Encoding.UTF8.GetBytes($"Content-Length: {Encoding.UTF8.GetByteCount(payload)}\r\n\r\n{payload}")); + using var output = new MemoryStream(); + Assert.Equal(0, Server.Run(input, output)); + output.Position = 0; + JsonObject? response = null; + while (LspServer.TryReadMessage(output, out var message)) + { + var node = JsonNode.Parse(message)!.AsObject(); + if (node.ContainsKey("id")) + response = node; + else if (node["method"]?.GetValue() == "window/logMessage") + Notices.Add(node["params"]!["message"]!.GetValue()); + } + return response ?? throw new InvalidDataException("Missing framed LSP response."); + } + + public void Dispose() + { + Server.Dispose(); + TestProjectHelper.DeleteDirectory(_root); + } + } +}