diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e633e90f0..9c347a6ce 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -508,6 +508,54 @@ inlay hints are emitted only when the indexed return type is not already written before the declaration identifier, so explicit method return, local, and field types remain suppressed. +LSP symbol and completion kinds come from one ordinal internal-kind mapping so +document symbols, workspace symbols, and completion items cannot drift. The +deliberate mappings and fallbacks are: + +| Internal kinds | LSP `SymbolKind` | LSP `CompletionItemKind` | +|---|---|---| +| `base_image`, `class`, `service`, `specialization`, `type` | `Class` | `Class` | +| `record`, `struct`, `union` | `Struct` | `Struct` | +| `annotation`, `implements`, `interface`, `protocol`, `trait` | `Interface` | `Interface` | +| `enum` | `Enum` | `Enum` | +| Enum-entry shapes stored as `enum`, `function`, or `property` under an enum container | `EnumMember` | `EnumMember` | +| `function` or `method` identified by constructor subkind, declaration keyword, dedicated initializer name, or a language-valid type-name declaration | `Constructor` | `Constructor` | +| `method`, `test.method`, `accessor`, `class_hook` | `Method` | `Method` | +| `add`, `async_function`, `async_generator`, `copy`, `delegate`, `function`, `generator`, `hook`, `lambda`, `procedure`, `route`, `run`, `shell`, `subroutine` | `Function` | `Function` | +| `attribute`, `expose`, `property`, `stopsignal` | `Property` | `Property` | +| `field`, `volume` | `Field` | `Field` | +| `event` | `Event` | `Event` | +| `constant` | `Constant` | `Constant` | +| `operator` | `Operator` | `Operator` | +| `associatedtype`, `type_parameter`, `typealias` | `TypeParameter` | `TypeParameter` | +| `array` | `Array` | `Value` | +| `assembly`, `file_module`, `import`, `module`, `program`, `project`, `stage`, `submodule` | `Module` | `Module` | +| `namespace` | `Namespace` | `Module` | +| `package` | `Package` | `Module` | +| `block data`, `layout`, `object`, `protocol_impl`, `rule` | `Object` | `Class` | +| `reference` | `Variable` | `Reference` | +| `build_arg`, `environment`, `user`, `variable` | `Variable` | `Variable` | +| `value` | `Variable` | `Value` | +| `code` | `String` | `Text` | +| `anchor` | `Key` | `Reference` | +| `heading`, `label` | `Key` | `Text` | +| `workdir` | `Module` | `Folder` | +| Non-catalog `parameter` and unknown/plugin-defined kinds | `Variable` | `Variable` | + +Constructors and enum members are semantic refinements because extractors reuse +broader persisted kinds. Constructor detection honors explicit subkind/keyword +metadata, dedicated initializer names, and type-name constructors only in +languages where that declaration shape is valid; same-name methods in other +languages, return-typed same-name methods, JavaScript object/static `constructor` +members, shell functions, and finalizers stay functions. This includes named +Dart constructors, Java compact record constructors, C# verbatim constructor +identifiers, case-insensitive Pascal constructor keywords, and Visual Basic +`New`. Enum-entry detection recognizes decorated, escaped (including C# verbatim +identifiers), indirect, and comma-grouped persisted shapes across C#, Java, +Kotlin, PHP, and Swift while keeping a nested enum as an enum declaration. The current persisted +catalog has no standalone `parameter` kind; the conservative fallback keeps +legacy or plugin-provided parameter-like symbols compatible. + Document/workspace symbol providers advertise work-done support and honor bounded string/integer `partialResultToken` and `workDoneToken` values. Partial results preserve the provider's deterministic order and use `$/progress` @@ -3807,6 +3855,52 @@ identifier を確認して、古い不正確な column にも対応する。sour indexed return type が declaration identifier の前に明示されていない場合だけ返すため、method の 明示 return type、local、field の明示型は表示しない。 +LSP の symbol kind と completion kind は、ordinal 比較する1つの internal-kind mapping から導出する。 +これにより document symbol、workspace symbol、completion item の分類がずれない。意図的な mapping と +fallback は次のとおりである。 + +| internal kind | LSP `SymbolKind` | LSP `CompletionItemKind` | +|---|---|---| +| `base_image`, `class`, `service`, `specialization`, `type` | `Class` | `Class` | +| `record`, `struct`, `union` | `Struct` | `Struct` | +| `annotation`, `implements`, `interface`, `protocol`, `trait` | `Interface` | `Interface` | +| `enum` | `Enum` | `Enum` | +| enum container 配下で `enum`、`function`、`property` として保存される enum-entry 形状 | `EnumMember` | `EnumMember` | +| constructor subkind、declaration keyword、専用 initializer 名、または言語上有効な型名 declaration で識別される `function` / `method` | `Constructor` | `Constructor` | +| `method`, `test.method`, `accessor`, `class_hook` | `Method` | `Method` | +| `add`, `async_function`, `async_generator`, `copy`, `delegate`, `function`, `generator`, `hook`, `lambda`, `procedure`, `route`, `run`, `shell`, `subroutine` | `Function` | `Function` | +| `attribute`, `expose`, `property`, `stopsignal` | `Property` | `Property` | +| `field`, `volume` | `Field` | `Field` | +| `event` | `Event` | `Event` | +| `constant` | `Constant` | `Constant` | +| `operator` | `Operator` | `Operator` | +| `associatedtype`, `type_parameter`, `typealias` | `TypeParameter` | `TypeParameter` | +| `array` | `Array` | `Value` | +| `assembly`, `file_module`, `import`, `module`, `program`, `project`, `stage`, `submodule` | `Module` | `Module` | +| `namespace` | `Namespace` | `Module` | +| `package` | `Package` | `Module` | +| `block data`, `layout`, `object`, `protocol_impl`, `rule` | `Object` | `Class` | +| `reference` | `Variable` | `Reference` | +| `build_arg`, `environment`, `user`, `variable` | `Variable` | `Variable` | +| `value` | `Variable` | `Value` | +| `code` | `String` | `Text` | +| `anchor` | `Key` | `Reference` | +| `heading`, `label` | `Key` | `Text` | +| `workdir` | `Module` | `Folder` | +| catalog 外の `parameter` および未知または plugin 定義の kind | `Variable` | `Variable` | + +constructor と enum member は extractor が広い永続化 kind を再利用するため、metadata に基づいて +意味を詳細化する。constructor は明示 subkind / keyword、専用 initializer 名、およびその declaration +形状が有効な言語だけで型名 constructor を認識する。他言語の同名 method と finalizer は function の +ままにする。戻り値型付きの同名 method、JavaScript object / static の `constructor` member、shell +function も同様である。これには Dart の named constructor、Java の compact record constructor、 +C# の逐語識別子を使う constructor、大文字小文字を区別しない Pascal の constructor keyword、 +Visual Basic の `New` も含む。enum entry は C#、Java、Kotlin、PHP、Swift における annotation 付き、 +C# の逐語識別子を含む escape 済み、indirect、comma-grouped の保存形状を認識し、enum 内の nested +enum は enum declaration のままにする。現在の永続化 catalog には独立した +`parameter` kind がないため、legacy または plugin が提供する parameter 相当の symbol は保守的な +fallback で互換性を維持する。 + document/workspace symbol provider は work-done 対応を advertise し、上限付きの string / integer `partialResultToken` と `workDoneToken` を処理する。partial result は provider の 決定的な順序を維持し、1 notification あたり最大100 item・64 KiB JSON body の diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 98f757a7f..9f9c0a38b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -531,6 +531,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keeps explicit constructors split across partial declarations in one fixture. Preserve the separate `definition` / `goto --kind function` assertions and the grouped `--kind class --group-partials` assertion so callable constructor results cannot merge back into logical type-family results. - `LspServerTests.cs` LSP JSON-RPC behavior and indexed editor semantics. Reference parity coverage must compare LSP locations with the matching CLI candidate bundle, including overload identity and both `includeDeclaration` states. Keep document/workspace identifier ranges and explicit-versus-inferred inlay hints in one source-semantics fixture so persisted-column anchoring, source confirmation, and hint suppression cannot drift independently. + The issue-4870 kind-mapping matrix seeds every registered built-in internal kind plus semantic constructor and enum-member rows, then asserts decoded document-symbol, workspace-symbol, and completion responses. It also feeds real TypeScript, Kotlin, Solidity, Java, Swift, Dart, Visual Basic, Pascal, C#, and Shell extractor output through the shared mapper so keyword, compact, named, dedicated-name, and verbatim-identifier constructors; same-name, return-typed, object/static, and shell non-constructors; decorated, escaped, indirect, and comma-grouped enum entries; and nested enums cannot invert classification. It separately pins the conservative parameter and unknown/plugin fallback. Keep all three protocol surfaces in that one fixture so constructor/import handling and deliberate fallback mappings cannot drift. Coordinate-validation coverage keeps every supported position provider in one negative/malformed/overflow matrix, with missing-document precedence and missing/reversed inlay ranges in the same read-only fixture. Keep UTF-16 and EOF success controls together, but retain invalid ranged-`didChange` as a separate mutable-state fixture so a rejected notification cannot replace accepted live text (#4869). C# semantic-token coverage decodes LSP delta coordinates and pairs the same keyword/modifier, namespace/type, method/property/event/enum-member, parameter/variable/field, generic/record-struct, explicit-interface-member, attribute, using-declaration, accessor-local, numeric member-access, escaped/contextual-identifier, interpolation, comma-separated declaration, and declaration-kind fixture with `QueryCommandRunnerTests.RunExcerpt_JsonCSharpSemanticKindsMatchLspClassifier_Issue4852`. Keep both surfaces on the shared source classifier, retain ordering/non-overlap assertions, preserve the narrow excerpt check whose parameter/type-parameter declarations are outside the returned range, and retain `RunExcerpt_JsonCSharpSemanticTokensUseVisibleRangeBudget_Issue4852` so earlier tokens cannot empty a late excerpt while indexed-source reconstruction remains line/character bounded. `HandleMessage_SemanticTokens_FallsBackToIndexedCSharpSymbolsWhenSourceReadFails_Issue4852` keeps indexed declaration tokens available when bounded source loading fails. Constructor-navigation coverage keeps explicit zero- and one-argument constructors in separate partial files, including a caller beside the zero-argument declaration, plus an implicit partial constructor, a positional record, and an ordinary partial-type reference in one fixture. The adversarial fixtures add static constructors, finalizers, primary-plus-secondary constructors, coexisting generic arities, same-arity overloads, value-type default construction beside explicit constructors, enums, and delegates. Assert both definition and declaration requests so source-site identity, invocation-arity filtering, stable implicit representatives, constructor-only ambiguity, preserved non-class construction, and intentional type-family expansion remain separate. @@ -1446,6 +1447,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は partial declaration の別ファイルに分かれた明示 constructor を 1 つの fixture にまとめます。callable constructor result が logical type-family result に再び混在しないよう、`definition` / `goto --kind function` の検証と、`--kind class --group-partials` による grouped type の検証を分離したまま維持してください。 - `LspServerTests.cs` LSP の JSON-RPC 挙動と indexed editor semantics のテスト。reference parity coverage では、overload identity と `includeDeclaration` の両状態を含め、LSP location と対応する CLI candidate bundle を比較してください。document/workspace の identifier range と explicit/inferred inlay hint は 1 つの source-semantics fixture にまとめ、保存済み column の anchoring、source 上の確認、hint 抑制が別々に drift しないようにします。 + issue-4870 の kind-mapping matrix は、登録済みの全 built-in internal kind に加えて semantic な constructor と enum-member の row を seed し、decode 済みの document-symbol、workspace-symbol、completion response を検証します。また、実際の TypeScript、Kotlin、Solidity、Java、Swift、Dart、Visual Basic、Pascal、C#、Shell extractor 出力を shared mapper に通し、keyword / compact / named / 専用名 / 逐語識別子 constructor、同名 / 戻り値型付き / object・static / shell の非 constructor、annotation 付き / escape 済み / indirect / comma-grouped enum entry、nested enum の分類が逆転しないようにします。保守的な parameter および未知/plugin fallback は別途固定します。constructor/import の処理と意図的な fallback mapping がずれないよう、3つの protocol surface を同じ fixture に維持してください。 coordinate validation coverage は、対応済みの全 position provider を1つの負数 / malformed / overflow matrix にまとめ、missing document より検証を優先することと、inlay range の欠落 / 逆順も同じ read-only fixture で検証します。UTF-16 と EOF の成功 control は一緒に維持し、不正な range 付き `didChange` は mutable state の独立 fixture として残して、拒否された notification が受理済み live text を置き換えられないことを検証してください(#4869)。 C# semantic-token coverage は LSP の delta 座標を decode し、同じ keyword/modifier、namespace/type、method/property/event/enum-member、parameter/variable/field、generic/record struct、explicit interface member、attribute、using declaration、accessor local、numeric member access、escaped/contextual identifier、interpolation、comma-separated declaration、declaration kind の fixture を `QueryCommandRunnerTests.RunExcerpt_JsonCSharpSemanticKindsMatchLspClassifier_Issue4852` と対にします。両 surface が shared source classifier を使い続け、順序と非 overlap の assertion、parameter / type-parameter の declaration が返却範囲外にある narrow excerpt の検証、および indexed-source reconstruction を line / character 上限内に保ちながら手前の token によって file 後半の excerpt が空にならないことを保証する `RunExcerpt_JsonCSharpSemanticTokensUseVisibleRangeBudget_Issue4852` を維持してください。`HandleMessage_SemanticTokens_FallsBackToIndexedCSharpSymbolsWhenSourceReadFails_Issue4852` は bounded source loading が失敗しても indexed declaration token を利用できる状態を維持します。 constructor-navigation coverage は、0 引数と 1 引数の明示 constructor を別々の partial file に置き、0 引数 declaration と同じ file の caller、暗黙 partial constructor、positional record、通常の partial-type reference と同じ fixture にまとめます。adversarial fixture には static constructor、finalizer、primary / secondary constructor の併存、同名 type の generic arity 違い、同一 arity overload、明示 constructor と併存する value type の default construction、enum、delegate も含めます。source-site identity、invocation-arity filtering、暗黙 constructor の安定した代表 declaration、constructor だけの ambiguity、class 以外の construction の維持、意図的な type-family expansion が混在しないよう、definition と declaration の両 request を assert してください。 diff --git a/changelog.d/unreleased/4870.fixed.md b/changelog.d/unreleased/4870.fixed.md new file mode 100644 index 000000000..40bd21172 --- /dev/null +++ b/changelog.d/unreleased/4870.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4870 +affected: + - src/CodeIndex/Lsp/LspServer.SymbolKinds.cs + - src/CodeIndex/Lsp/LspServer.Protocol.cs + - src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **LSP symbols and completion items now share exhaustive protocol-kind mapping (#4870)** — imports are reported as modules, constructors as constructors, and every built-in internal symbol kind plus unknown plugin kinds has a deliberate, tested fallback across document symbols, workspace symbols, and completion. + +## 日本語 + +- **LSP の symbol と completion item が網羅的な protocol-kind mapping を共有するようになりました (#4870)** — import は module、constructor は constructor として返し、全 built-in internal symbol kind と未知の plugin kind に対する意図的な fallback を document symbol、workspace symbol、completion の各経路で検証します。 diff --git a/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs index 8173d5097..a8ae8f178 100644 --- a/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs +++ b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs @@ -267,7 +267,7 @@ private static string BuildCompletionIdentity(SymbolResult symbol) private static JsonObject ToCompletionItem(SymbolResult symbol, int index) => new() { ["label"] = symbol.Name, - ["kind"] = CompletionItemKind(symbol.Kind), + ["kind"] = CompletionItemKind(symbol), ["detail"] = FormatSymbolDetail(symbol), ["sortText"] = index.ToString("D4", CultureInfo.InvariantCulture) + "_" + symbol.Name, }; @@ -318,19 +318,6 @@ private static string FormatSymbolDetail(SymbolResult symbol) : detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; } - private static int CompletionItemKind(string kind) => kind switch - { - "class" => 7, - "function" or "test.method" => 3, - "property" => 10, - "field" => 5, - "enum" => 13, - "interface" => 8, - "namespace" => 9, - "struct" => 22, - _ => 6, - }; - private static void AddDocumentHighlight(JsonArray array, HashSet seenRanges, int startLine, int startColumn, int endLine, int endColumn) { var key = string.Join('\0', startLine, startColumn, endLine, endColumn); diff --git a/src/CodeIndex/Lsp/LspServer.Protocol.cs b/src/CodeIndex/Lsp/LspServer.Protocol.cs index c38390a68..a583d0126 100644 --- a/src/CodeIndex/Lsp/LspServer.Protocol.cs +++ b/src/CodeIndex/Lsp/LspServer.Protocol.cs @@ -23,7 +23,7 @@ private JsonObject ToWorkspaceSymbol( return new JsonObject { ["name"] = symbol.Name, - ["kind"] = SymbolKind(symbol.Kind), + ["kind"] = SymbolKind(symbol), ["location"] = ToLocation(symbol.Path, identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn), ["containerName"] = symbol.ContainerName, }; @@ -41,7 +41,7 @@ private JsonObject ToDocumentSymbol( return new JsonObject { ["name"] = symbol.Name, - ["kind"] = SymbolKind(symbol.Kind), + ["kind"] = SymbolKind(symbol), ["range"] = ToRange(rangeStartLine, 1, rangeEndLine, rangeEndColumn), ["selectionRange"] = ToRange(identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn), ["detail"] = TruncateDocumentSymbolDetail(symbol.Signature), @@ -57,7 +57,7 @@ private JsonObject ToDocumentSymbolInformation( return new JsonObject { ["name"] = symbol.Name, - ["kind"] = SymbolKind(symbol.Kind), + ["kind"] = SymbolKind(symbol), ["location"] = ToLocation( symbol.Path, identifier.Line, @@ -129,19 +129,6 @@ private JsonObject ToDocumentSymbolInformation( ["character"] = Math.Max(column - 1, 0), }; - private static int SymbolKind(string kind) => kind switch - { - "class" => 5, - "function" or "test.method" => 12, - "property" => 7, - "field" => 8, - "enum" => 10, - "interface" => 11, - "namespace" => 3, - "struct" => 23, - _ => 13, - }; - private static string GetDocumentPath(JsonElement root) { var uri = GetTextDocumentUri(root); diff --git a/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs new file mode 100644 index 000000000..9a488bad0 --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs @@ -0,0 +1,555 @@ +using CodeIndex.Database; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer +{ + private readonly record struct LspProtocolKinds(int SymbolKind, int CompletionItemKind); + + private static readonly LspProtocolKinds FallbackLspProtocolKinds = new( + SymbolKind: 13, // Variable + CompletionItemKind: 6); // Variable + + // Keep every persisted symbol kind in one ordinal table so document/workspace symbols and + // completion cannot drift. Kinds without an exact LSP peer use the closest documented shape. + private static readonly IReadOnlyDictionary LspProtocolKindsByInternalKind = + new Dictionary(StringComparer.Ordinal) + { + ["accessor"] = new(6, 2), // Method + ["add"] = new(12, 3), // Function + ["anchor"] = new(20, 18), // Key / Reference + ["annotation"] = new(11, 8), // Interface + ["assembly"] = new(2, 9), // Module + ["array"] = new(18, 12), // Array / Value + ["async_function"] = new(12, 3), // Function + ["async_generator"] = new(12, 3), // Function + ["attribute"] = new(7, 10), // Property + ["associatedtype"] = new(26, 25), // TypeParameter + ["base_image"] = new(5, 7), // Class + ["build_arg"] = new(13, 6), // Variable + ["class"] = new(5, 7), // Class + ["class_hook"] = new(6, 2), // Method + ["code"] = new(15, 1), // String / Text + ["constant"] = new(14, 21), // Constant + ["copy"] = new(12, 3), // Function + ["delegate"] = new(12, 3), // Function + ["enum"] = new(10, 13), // Enum + ["environment"] = new(13, 6), // Variable + ["event"] = new(24, 23), // Event + ["expose"] = new(7, 10), // Property + ["field"] = new(8, 5), // Field + ["file_module"] = new(2, 9), // Module + ["function"] = new(12, 3), // Function + ["generator"] = new(12, 3), // Function + ["heading"] = new(20, 1), // Key / Text + ["hook"] = new(12, 3), // Function + ["implements"] = new(11, 8), // Interface + ["import"] = new(2, 9), // Module + ["interface"] = new(11, 8), // Interface + ["lambda"] = new(12, 3), // Function + ["label"] = new(20, 1), // Key / Text + ["layout"] = new(19, 7), // Object / Class + ["method"] = new(6, 2), // Method + ["module"] = new(2, 9), // Module + ["namespace"] = new(3, 9), // Namespace / Module + ["operator"] = new(25, 24), // Operator + ["object"] = new(19, 7), // Object / Class + ["package"] = new(4, 9), // Package / Module + ["property"] = new(7, 10), // Property + ["procedure"] = new(12, 3), // Function + ["program"] = new(2, 9), // Module + ["project"] = new(2, 9), // Module + ["protocol"] = new(11, 8), // Interface + ["protocol_impl"] = new(19, 7), // Object / Class + ["reference"] = new(13, 18), // Variable / Reference + ["record"] = new(23, 22), // Struct + ["rule"] = new(19, 7), // Object / Class + ["route"] = new(12, 3), // Function + ["run"] = new(12, 3), // Function + ["service"] = new(5, 7), // Class + ["shell"] = new(12, 3), // Function + ["specialization"] = new(5, 7), // Class + ["stage"] = new(2, 9), // Module + ["stopsignal"] = new(7, 10), // Property + ["struct"] = new(23, 22), // Struct + ["submodule"] = new(2, 9), // Module + ["subroutine"] = new(12, 3), // Function + ["test.method"] = new(6, 2), // Method + ["trait"] = new(11, 8), // Interface + ["type"] = new(5, 7), // Class + ["type_parameter"] = new(26, 25), // TypeParameter + ["typealias"] = new(26, 25), // TypeParameter + ["union"] = new(23, 22), // Struct + ["user"] = new(13, 6), // Variable + ["value"] = new(13, 12), // Variable / Value + ["block data"] = new(19, 7), // Object / Class + ["variable"] = new(13, 6), // Variable + ["volume"] = new(8, 5), // Field + ["workdir"] = new(2, 19), // Module / Folder + }; + + internal static IEnumerable MappedInternalKindsForTesting + => LspProtocolKindsByInternalKind.Keys; + + internal static (int SymbolKind, int CompletionItemKind) MapLspKindsForTesting(string kind) + { + var kinds = LspKinds(kind); + return (kinds.SymbolKind, kinds.CompletionItemKind); + } + + internal static (int SymbolKind, int CompletionItemKind) MapLspKindsForTesting(SymbolResult symbol) + { + var kinds = LspKinds(symbol); + return (kinds.SymbolKind, kinds.CompletionItemKind); + } + + private static LspProtocolKinds LspKinds(SymbolResult symbol) + { + if (IsConstructorSymbol(symbol)) + return new LspProtocolKinds(SymbolKind: 9, CompletionItemKind: 4); + if (IsEnumMemberSymbol(symbol)) + return new LspProtocolKinds(SymbolKind: 22, CompletionItemKind: 20); + return LspKinds(symbol.Kind); + } + + private static LspProtocolKinds LspKinds(string kind) + => LspProtocolKindsByInternalKind.TryGetValue(kind, out var kinds) + ? kinds + : FallbackLspProtocolKinds; + + private static bool IsConstructorSymbol(SymbolResult symbol) + { + if (symbol.Kind is not ("function" or "method") || + string.IsNullOrEmpty(symbol.Name) || + string.IsNullOrWhiteSpace(symbol.Signature)) + { + return false; + } + + if (string.Equals(symbol.SubKind, "constructor", StringComparison.Ordinal)) + return true; + + var startsWithConstructorKeyword = SignatureStartsWithKeywordAfterModifiers( + symbol.Signature, + "constructor", + ignoreCase: symbol.Lang == "pascal"); + if (startsWithConstructorKeyword && symbol.Lang == "pascal") + return true; + if (startsWithConstructorKeyword && + symbol.Lang is "javascript" or "typescript" or "kotlin" && + !string.IsNullOrWhiteSpace(symbol.ContainerName) && + string.Equals(symbol.Name, symbol.ContainerName, StringComparison.Ordinal)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(symbol.ContainerName)) + return false; + + var usesContainerName = symbol.Lang is "csharp" or "cpp" or "dart" or "groovy" or "java"; + var matchesContainerName = string.Equals(symbol.Name, symbol.ContainerName, StringComparison.Ordinal) || + (symbol.Lang == "dart" && + symbol.Name.StartsWith(symbol.ContainerName + ".", StringComparison.Ordinal)); + if (usesContainerName && + matchesContainerName && + string.IsNullOrWhiteSpace(symbol.ReturnType)) + { + return SignatureContainsNamedDeclaration( + symbol.Signature, + symbol.Name, + allowBodyBrace: symbol.Lang == "java"); + } + + var usesDedicatedName = symbol.Lang switch + { + "php" => symbol.Name == "__construct", + "python" => symbol.Name == "__init__", + "ruby" => symbol.Name == "initialize", + "scala" => symbol.Name == "this", + "swift" => symbol.Name == "init", + "vb" => symbol.Name.Equals("New", StringComparison.OrdinalIgnoreCase), + _ => false, + }; + return usesDedicatedName && + SignatureContainsNamedDeclaration( + symbol.Signature, + symbol.Name, + allowBodyBrace: false, + ignoreCase: symbol.Lang == "vb"); + } + + private static bool IsEnumMemberSymbol(SymbolResult symbol) + { + if (symbol.ContainerKind != "enum" || + string.IsNullOrEmpty(symbol.Name) || + string.IsNullOrWhiteSpace(symbol.Signature)) + { + return false; + } + + if (symbol.Kind == "enum" && + SignatureStartsWithKeywordAfterModifiers(symbol.Signature, "enum")) + { + return false; + } + if (symbol.Kind is not ("function" or "property")) + return symbol.Kind == "enum" && + SignatureContainsEnumMemberDeclarator(symbol.Signature, symbol.Name, symbol.Lang); + + return SignatureContainsEnumMemberDeclarator(symbol.Signature, symbol.Name, symbol.Lang); + } + + private static bool SignatureStartsWithKeywordAfterModifiers( + string signature, + string keyword, + bool ignoreCase = false) + { + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var remaining = signature.AsSpan().TrimStart(); + while (!remaining.IsEmpty) + { + if (TryConsumeLeadingDecoration(ref remaining)) + continue; + if (TryConsumeLeadingKeyword(ref remaining, keyword, comparison)) + return true; + + var wordEnd = 0; + while (wordEnd < remaining.Length && IsIdentifierCharacter(remaining[wordEnd])) + wordEnd++; + if (wordEnd == 0 || !IsDeclarationModifier(remaining[..wordEnd], comparison)) + return false; + remaining = remaining[wordEnd..].TrimStart(); + } + + return false; + } + + private static bool TryConsumeLeadingKeyword( + ref ReadOnlySpan text, + string keyword, + StringComparison comparison = StringComparison.Ordinal) + { + if (!text.StartsWith(keyword, comparison) || + (text.Length > keyword.Length && IsIdentifierCharacter(text[keyword.Length]))) + { + return false; + } + + text = text[keyword.Length..].TrimStart(); + return true; + } + + private static bool IsDeclarationModifier( + ReadOnlySpan word, + StringComparison comparison) + => word.Equals("abstract", comparison) || + word.Equals("actual", comparison) || + word.Equals("base", comparison) || + word.Equals("class", comparison) || + word.Equals("declare", comparison) || + word.Equals("default", comparison) || + word.Equals("expect", comparison) || + word.Equals("export", comparison) || + word.Equals("external", comparison) || + word.Equals("fileprivate", comparison) || + word.Equals("final", comparison) || + word.Equals("indirect", comparison) || + word.Equals("interface", comparison) || + word.Equals("internal", comparison) || + word.Equals("open", comparison) || + word.Equals("package", comparison) || + word.Equals("partial", comparison) || + word.Equals("private", comparison) || + word.Equals("protected", comparison) || + word.Equals("public", comparison) || + word.Equals("readonly", comparison) || + word.Equals("sealed", comparison) || + word.Equals("static", comparison); + + private static bool SignatureContainsEnumMemberDeclarator( + string signature, + string name, + string? lang) + { + var verbatimIdentifierName = lang == "csharp" ? name : null; + var remaining = signature.AsSpan().TrimStart(); + while (TryConsumeLeadingDecoration(ref remaining, verbatimIdentifierName)) + { + } + TryConsumeLeadingKeyword(ref remaining, "indirect"); + TryConsumeLeadingKeyword(ref remaining, "case"); + + var segmentStart = 0; + var parenthesisDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var quote = '\0'; + var escaped = false; + for (var index = 0; index <= remaining.Length; index++) + { + if (index == remaining.Length) + return SegmentStartsWithEnumMemberName( + remaining[segmentStart..], + name, + verbatimIdentifierName); + + var character = remaining[index]; + if (quote != '\0') + { + if (escaped) + { + escaped = false; + continue; + } + if (character == '\\') + { + escaped = true; + continue; + } + if (character == quote) + quote = '\0'; + continue; + } + + if (character is '"' or '\'') + { + quote = character; + continue; + } + + switch (character) + { + case '(': + parenthesisDepth++; + break; + case ')' when parenthesisDepth > 0: + parenthesisDepth--; + break; + case '[': + bracketDepth++; + break; + case ']' when bracketDepth > 0: + bracketDepth--; + break; + case '{': + braceDepth++; + break; + case '}' when braceDepth > 0: + braceDepth--; + break; + case ',' when parenthesisDepth == 0 && bracketDepth == 0 && braceDepth == 0: + if (SegmentStartsWithEnumMemberName( + remaining[segmentStart..index], + name, + verbatimIdentifierName)) + { + return true; + } + segmentStart = index + 1; + break; + case ';' when parenthesisDepth == 0 && bracketDepth == 0 && braceDepth == 0: + return SegmentStartsWithEnumMemberName( + remaining[segmentStart..index], + name, + verbatimIdentifierName); + } + } + + return false; + } + + private static bool SegmentStartsWithEnumMemberName( + ReadOnlySpan segment, + string name, + string? verbatimIdentifierName) + { + segment = segment.TrimStart(); + while (TryConsumeLeadingDecoration(ref segment, verbatimIdentifierName)) + { + } + + ReadOnlySpan candidate; + if (!segment.IsEmpty && segment[0] == '`') + { + var closingBacktick = segment[1..].IndexOf('`'); + if (closingBacktick < 0) + return false; + candidate = segment.Slice(1, closingBacktick); + segment = segment[(closingBacktick + 2)..]; + } + else + { + if (!segment.IsEmpty && segment[0] == '@') + segment = segment[1..]; + var nameEnd = 0; + while (nameEnd < segment.Length && IsIdentifierCharacter(segment[nameEnd])) + nameEnd++; + if (nameEnd == 0) + return false; + candidate = segment[..nameEnd]; + segment = segment[nameEnd..]; + } + + if (!candidate.Equals(name, StringComparison.Ordinal)) + return false; + segment = segment.TrimStart(); + return segment.IsEmpty || segment[0] is '(' or '{' or '='; + } + + private static bool TryConsumeLeadingDecoration( + ref ReadOnlySpan text, + string? verbatimIdentifierName = null) + { + var original = text; + if (!text.IsEmpty && text[0] == '[') + { + if (!TryConsumeBalanced(ref text, '[', ']')) + return false; + text = text.TrimStart(); + return !text.IsEmpty; + } + + if (text.IsEmpty || text[0] != '@') + return false; + + if (!string.IsNullOrEmpty(verbatimIdentifierName) && + text[1..].StartsWith(verbatimIdentifierName, StringComparison.Ordinal)) + { + var identifierEnd = verbatimIdentifierName.Length + 1; + var hasBoundaryAfter = identifierEnd == text.Length || + !IsIdentifierCharacter(text[identifierEnd]); + var suffix = text[identifierEnd..].TrimStart(); + if (hasBoundaryAfter && + (suffix.IsEmpty || suffix[0] is '(' or '{' or '=' or ',' or ';')) + { + return false; + } + } + + var index = 1; + while (index < text.Length && + (IsIdentifierCharacter(text[index]) || text[index] is '.' or ':')) + { + index++; + } + if (index == 1) + return false; + + var hadWhitespace = index < text.Length && char.IsWhiteSpace(text[index]); + text = text[index..].TrimStart(); + if (!text.IsEmpty && text[0] == '(') + { + if (!TryConsumeBalanced(ref text, '(', ')')) + { + text = original; + return false; + } + text = text.TrimStart(); + return !text.IsEmpty; + } + + if (hadWhitespace && !text.IsEmpty) + return true; + + text = original; + return false; + } + + private static bool TryConsumeBalanced( + ref ReadOnlySpan text, + char opening, + char closing) + { + var depth = 0; + var quote = '\0'; + var escaped = false; + for (var index = 0; index < text.Length; index++) + { + var character = text[index]; + if (quote != '\0') + { + if (escaped) + { + escaped = false; + continue; + } + if (character == '\\') + { + escaped = true; + continue; + } + if (character == quote) + quote = '\0'; + continue; + } + + if (character is '"' or '\'') + { + quote = character; + continue; + } + if (character == opening) + depth++; + else if (character == closing && --depth == 0) + { + text = text[(index + 1)..]; + return true; + } + } + + return false; + } + + private static bool SignatureContainsNamedDeclaration( + string signature, + string name, + bool allowBodyBrace, + bool ignoreCase = false) + { + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var searchStart = 0; + while (searchStart < signature.Length) + { + var nameStart = signature.IndexOf(name, searchStart, comparison); + if (nameStart < 0) + return false; + + var nameEnd = nameStart + name.Length; + var hasVerbatimPrefix = nameStart > 0 && + signature[nameStart - 1] == '@' && + (nameStart == 1 || !IsIdentifierCharacter(signature[nameStart - 2])); + var hasIdentifierBoundaryBefore = nameStart == 0 || + hasVerbatimPrefix || + !IsIdentifierCharacter(signature[nameStart - 1]); + var hasIdentifierBoundaryAfter = nameEnd == signature.Length || !IsIdentifierCharacter(signature[nameEnd]); + if (hasIdentifierBoundaryBefore && hasIdentifierBoundaryAfter) + { + var before = nameStart - (hasVerbatimPrefix ? 2 : 1); + while (before >= 0 && char.IsWhiteSpace(signature[before])) + before--; + if (before < 0 || signature[before] != '~') + { + var after = nameEnd; + while (after < signature.Length && char.IsWhiteSpace(signature[after])) + after++; + if (after < signature.Length && + (signature[after] == '(' || (allowBodyBrace && signature[after] == '{'))) + { + return true; + } + } + } + + searchStart = nameEnd; + } + + return false; + } + + private static bool IsIdentifierCharacter(char character) + => char.IsLetterOrDigit(character) || character is '_' or '@'; + + private static int SymbolKind(SymbolResult symbol) => LspKinds(symbol).SymbolKind; + + private static int CompletionItemKind(SymbolResult symbol) => LspKinds(symbol).CompletionItemKind; +} diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 55c0cd161..9c92f979b 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -8,6 +8,7 @@ using System.Security.Cryptography; using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Indexer; using CodeIndex.Lsp; using CodeIndex.Models; @@ -1483,6 +1484,457 @@ public void HandleMessage_InlayHint_HonorsRangeAndSuppressesExplicitTypes_Issue4 } } + [Fact] + public void HandleMessage_SymbolAndCompletionKindsUseSharedMapping_Issue4870() + { + var mappings = new (string Kind, int SymbolKind, int CompletionItemKind)[] + { + ("accessor", 6, 2), + ("add", 12, 3), + ("anchor", 20, 18), + ("annotation", 11, 8), + ("assembly", 2, 9), + ("array", 18, 12), + ("async_function", 12, 3), + ("async_generator", 12, 3), + ("attribute", 7, 10), + ("associatedtype", 26, 25), + ("base_image", 5, 7), + ("build_arg", 13, 6), + ("class", 5, 7), + ("class_hook", 6, 2), + ("code", 15, 1), + ("constant", 14, 21), + ("copy", 12, 3), + ("delegate", 12, 3), + ("enum", 10, 13), + ("environment", 13, 6), + ("event", 24, 23), + ("expose", 7, 10), + ("field", 8, 5), + ("file_module", 2, 9), + ("function", 12, 3), + ("generator", 12, 3), + ("heading", 20, 1), + ("hook", 12, 3), + ("implements", 11, 8), + ("import", 2, 9), + ("interface", 11, 8), + ("lambda", 12, 3), + ("label", 20, 1), + ("layout", 19, 7), + ("method", 6, 2), + ("module", 2, 9), + ("namespace", 3, 9), + ("operator", 25, 24), + ("object", 19, 7), + ("package", 4, 9), + ("property", 7, 10), + ("procedure", 12, 3), + ("program", 2, 9), + ("project", 2, 9), + ("protocol", 11, 8), + ("protocol_impl", 19, 7), + ("reference", 13, 18), + ("record", 23, 22), + ("rule", 19, 7), + ("route", 12, 3), + ("run", 12, 3), + ("service", 5, 7), + ("shell", 12, 3), + ("specialization", 5, 7), + ("stage", 2, 9), + ("stopsignal", 7, 10), + ("struct", 23, 22), + ("submodule", 2, 9), + ("subroutine", 12, 3), + ("test.method", 6, 2), + ("trait", 11, 8), + ("type", 5, 7), + ("type_parameter", 26, 25), + ("typealias", 26, 25), + ("union", 23, 22), + ("user", 13, 6), + ("value", 13, 12), + ("block data", 19, 7), + ("variable", 13, 6), + ("volume", 8, 5), + ("workdir", 2, 19), + }; + + Assert.Equal( + SymbolKindCatalog.SymbolKinds.Order(StringComparer.Ordinal), + LspServer.MappedInternalKindsForTesting.Order(StringComparer.Ordinal)); + Assert.Equal((13, 6), LspServer.MapLspKindsForTesting("parameter")); + Assert.Equal((13, 6), LspServer.MapLspKindsForTesting("plugin.custom")); + + static SymbolResult ToResult(SymbolRecord symbol, string lang) => new() + { + Lang = lang, + Kind = symbol.Kind, + SubKind = symbol.SubKind, + Name = symbol.Name, + Signature = symbol.Signature, + ReturnType = symbol.ReturnType, + ContainerKind = symbol.ContainerKind, + ContainerName = symbol.ContainerName, + }; + + var typeScriptSymbols = SymbolExtractor.Extract(1, "typescript", """ + class Widget { + constructor() {} + static constructor() {} + Widget() {} + } + """); + Assert.Equal( + (9, 4), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(typeScriptSymbols, symbol => symbol.Signature?.StartsWith("constructor", StringComparison.Ordinal) == true), + "typescript"))); + Assert.Equal( + (12, 3), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(typeScriptSymbols, symbol => symbol.Signature?.StartsWith("Widget", StringComparison.Ordinal) == true), + "typescript"))); + Assert.Equal( + (12, 3), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(typeScriptSymbols, symbol => + symbol.Signature?.StartsWith("static constructor", StringComparison.Ordinal) == true), + "typescript"))); + + var kotlinSymbols = SymbolExtractor.Extract(2, "kotlin", """ + class KotlinThing { + constructor(value: Int) + fun KotlinThing() {} + } + enum class KtColor { + RED, + `mixed-case`, + } + """); + Assert.Equal( + (9, 4), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(kotlinSymbols, symbol => symbol.Signature?.StartsWith("constructor", StringComparison.Ordinal) == true), + "kotlin"))); + Assert.Equal( + (12, 3), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(kotlinSymbols, symbol => symbol.Signature?.StartsWith("fun KotlinThing", StringComparison.Ordinal) == true), + "kotlin"))); + Assert.Equal( + (22, 20), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(kotlinSymbols, symbol => symbol.Name == "RED"), + "kotlin"))); + Assert.Equal( + (22, 20), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(kotlinSymbols, symbol => symbol.Name == "mixed-case"), + "kotlin"))); + + var soliditySymbols = SymbolExtractor.Extract(3, "solidity", """ + contract Vault { + constructor(address owner) {} + function Vault(uint amount) public {} + } + """); + Assert.Equal( + (9, 4), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(soliditySymbols, symbol => symbol.SubKind == "constructor"), + "solidity"))); + Assert.Equal( + (12, 3), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(soliditySymbols, symbol => symbol.SubKind == "function"), + "solidity"))); + + var javaSymbols = SymbolExtractor.Extract(4, "java", """ + enum Outer { + @Deprecated + A; + enum Inner { + B; + } + } + record Point(int x) { + Point { + } + } + class JavaThing { + void JavaThing() {} + } + """); + Assert.Equal( + (22, 20), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(javaSymbols, symbol => symbol.Name == "A"), + "java"))); + Assert.Equal( + (10, 13), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(javaSymbols, symbol => symbol.Name == "Inner"), + "java"))); + Assert.Equal( + (9, 4), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(javaSymbols, symbol => + symbol.Name == "Point" && + symbol.Kind == "function"), + "java"))); + Assert.Equal( + (12, 3), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(javaSymbols, symbol => + symbol.Name == "JavaThing" && + symbol.ReturnType == "void"), + "java"))); + + var swiftSymbols = SymbolExtractor.Extract(5, "swift", """ + enum SwiftColor { + case red + indirect case node(Int) + case first, second(Int) + } + """); + Assert.Equal( + (22, 20), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(swiftSymbols, symbol => symbol.Name == "red"), + "swift"))); + Assert.Equal( + (22, 20), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(swiftSymbols, symbol => symbol.Name == "node"), + "swift"))); + Assert.Equal( + (22, 20), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(swiftSymbols, symbol => symbol.Name == "second"), + "swift"))); + + var dartSymbols = SymbolExtractor.Extract(6, "dart", """ + class Animal { + Animal.named(); + factory Animal.empty() => Animal.named(); + } + """); + Assert.All( + dartSymbols.Where(symbol => symbol.Kind == "function"), + symbol => Assert.Equal( + (9, 4), + LspServer.MapLspKindsForTesting(ToResult(symbol, "dart")))); + + var visualBasicSymbols = SymbolExtractor.Extract(7, "vb", """ + Public Class VisualBasicThing + Public Sub New() + End Sub + End Class + """); + Assert.Equal( + (9, 4), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(visualBasicSymbols, symbol => symbol.Name.Equals("New", StringComparison.OrdinalIgnoreCase)), + "vb"))); + + var pascalSymbols = SymbolExtractor.Extract(8, "pascal", """ + type + PascalThing = class + Constructor Create; + end; + """); + Assert.Equal( + (9, 4), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(pascalSymbols, symbol => symbol.Name == "Create"), + "pascal"))); + + var csharpSymbols = SymbolExtractor.Extract(9, "csharp", """ + class @class { + public @class() {} + } + enum Escaped { + @event = 1, + } + """); + Assert.Equal( + (9, 4), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(csharpSymbols, symbol => + symbol.Name == "class" && + symbol.Kind == "function"), + "csharp"))); + Assert.Equal( + (22, 20), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(csharpSymbols, symbol => symbol.Name == "event"), + "csharp"))); + + var shellSymbols = SymbolExtractor.Extract(10, "shell", """ + constructor() { + echo ordinary + } + """); + Assert.Equal( + (12, 3), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(shellSymbols, symbol => symbol.Name == "constructor"), + "shell"))); + + var semanticMappings = new (string Kind, string Detail, string? SubKind, string ContainerKind, string ContainerName, int SymbolKind, int CompletionItemKind)[] + { + ("function", "public MapKindB()", null, "class", "MapKindB", 9, 4), + ("function", "static MapKindB()", null, "class", "MapKindB", 9, 4), + ("function", "~MapKindB()", null, "class", "MapKindB", 12, 3), + ("function", "constructor()", null, "class", "MapKindB", 12, 3), + ("function", "subkind constructor", "constructor", "class", "MapKindB", 9, 4), + ("enum", "MapKindB,", null, "enum", "MappingEnum", 22, 20), + ("enum", "enum MapKindB {", null, "enum", "MappingEnum", 10, 13), + ("function", "MapKindB", null, "enum", "MappingEnum", 22, 20), + ("property", "case MapKindB", null, "enum", "MappingEnum", 22, 20), + ("property", "indirect case MapKindB(Int)", null, "enum", "MappingEnum", 22, 20), + ("property", "case Other, MapKindB(Int)", null, "enum", "MappingEnum", 22, 20), + ("function", "@Deprecated MapKindB(1)", null, "enum", "MappingEnum", 22, 20), + ("function", "public @MapKindB()", null, "class", "MapKindB", 9, 4), + ("enum", "@MapKindB = 1", null, "enum", "MappingEnum", 22, 20), + ("enum", "[Obsolete] public enum MapKindB {", null, "enum", "MappingEnum", 10, 13), + ("property", "val MapKindB: Int", null, "enum", "MappingEnum", 7, 10), + ("function", "void MapKindB()", null, "enum", "MappingEnum", 12, 3), + }; + var expectedRows = mappings + .Select(mapping => (mapping.Kind, mapping.SymbolKind, mapping.CompletionItemKind)) + .ToList(); + expectedRows.AddRange(semanticMappings.Select(mapping => + (mapping.Detail, mapping.SymbolKind, mapping.CompletionItemKind))); + const int queryGroupSize = 40; + var symbolNames = expectedRows + .Select((_, index) => index < queryGroupSize ? "MapKindA" : "MapKindB") + .ToArray(); + + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_symbol_kind_mapping"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "kinds.cs"); + var source = string.Join('\n', symbolNames) + '\n'; + File.WriteAllText(sourcePath, source); + using (var fixtureDb = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(fixtureDb.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "kinds.cs", + Lang = "csharp", + Size = source.Length, + Lines = expectedRows.Count, + Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Checksum = "issue4870-lsp-kind-mapping", + }); + writer.InsertChunks([ + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = expectedRows.Count, + Content = source, + }, + ]); + var symbols = mappings.Select((mapping, index) => new SymbolRecord + { + FileId = fileId, + Kind = mapping.Kind, + Name = symbolNames[index], + Line = index + 1, + StartLine = index + 1, + StartColumn = 0, + EndLine = index + 1, + Signature = mapping.Kind, + }).ToList(); + symbols.AddRange(semanticMappings.Select((mapping, index) => new SymbolRecord + { + FileId = fileId, + Kind = mapping.Kind, + SubKind = mapping.SubKind, + Name = symbolNames[mappings.Length + index], + Line = mappings.Length + index + 1, + StartLine = mappings.Length + index + 1, + StartColumn = 0, + EndLine = mappings.Length + index + 1, + Signature = mapping.Detail, + ContainerKind = mapping.ContainerKind, + ContainerName = mapping.ContainerName, + })); + writer.InsertSymbols(symbols); + } + + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var documentResponse = HandleInitializedMessage( + server, + CreateTextDocumentRequest("textDocument/documentSymbol", sourcePath, 48700)); + var workspaceResponses = new[] { "MapKindA", "MapKindB" }.Select((query, index) => + HandleInitializedMessage(server, JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 48701 + index, + method = "workspace/symbol", + @params = new { query }, + }))).ToArray(); + var completionResponses = new[] + { + HandleInitializedMessage( + server, + CreatePositionRequest("textDocument/completion", sourcePath, 48703, 0, "MapKindA".Length)), + HandleInitializedMessage( + server, + CreatePositionRequest( + "textDocument/completion", + sourcePath, + 48704, + queryGroupSize, + "MapKindB".Length)), + }; + + Assert.NotNull(documentResponse); + Assert.All(workspaceResponses, response => Assert.NotNull(response)); + Assert.All(completionResponses, response => Assert.NotNull(response)); + var documentKindsByLine = FlattenDocumentSymbols(documentResponse!["result"]!.AsArray()) + .ToDictionary( + symbol => symbol!["selectionRange"]!["start"]!["line"]!.GetValue(), + symbol => symbol!["kind"]!.GetValue()); + var workspaceKindsByLine = workspaceResponses + .SelectMany(response => response!["result"]!.AsArray()) + .ToDictionary( + symbol => symbol!["location"]!["range"]!["start"]!["line"]!.GetValue(), + symbol => symbol!["kind"]!.GetValue()); + var completionKindsByInternalKind = completionResponses + .SelectMany(response => response!["result"]!["items"]!.AsArray()) + .ToDictionary( + item => item!["detail"]!.GetValue(), + item => item!["kind"]!.GetValue(), + StringComparer.Ordinal); + + Assert.Equal(expectedRows.Count, documentKindsByLine.Count); + Assert.Equal(expectedRows.Count, workspaceKindsByLine.Count); + Assert.Equal(expectedRows.Count, completionKindsByInternalKind.Count); + for (var index = 0; index < expectedRows.Count; index++) + { + var expected = expectedRows[index]; + Assert.Equal(expected.SymbolKind, documentKindsByLine[index]); + Assert.Equal(expected.SymbolKind, workspaceKindsByLine[index]); + Assert.Equal(expected.CompletionItemKind, completionKindsByInternalKind[expected.Kind]); + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Completion_ReturnsEmptyListWhenNoIndexedSymbolMatches_Issue4360() {