From 15588518db5ca2abeadb83cd7fd9cd96767ed5dc Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 07:05:23 +0900 Subject: [PATCH 1/4] Fix LSP symbol kind mappings (#4870) --- DEVELOPER_GUIDE.md | 79 ++++++ TESTING_GUIDE.md | 2 + changelog.d/unreleased/4870.fixed.md | 20 ++ .../Lsp/LspServer.NavigationFeatures.cs | 14 +- src/CodeIndex/Lsp/LspServer.Protocol.cs | 18 +- src/CodeIndex/Lsp/LspServer.SymbolKinds.cs | 162 +++++++++++ tests/CodeIndex.Tests/LspServerTests.cs | 255 ++++++++++++++++++ 7 files changed, 522 insertions(+), 28 deletions(-) create mode 100644 changelog.d/unreleased/4870.fixed.md create mode 100644 src/CodeIndex/Lsp/LspServer.SymbolKinds.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 97fb15b52..e49fb5741 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -485,6 +485,46 @@ 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` whose container kind is `enum` | `EnumMember` | `EnumMember` | +| `function` or `method` whose declaration name and container name identify a constructor | `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 the persisted +catalog stores them as `function`/`method` and `enum`, respectively. A finalizer +is not refined to `Constructor`. 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` @@ -3742,6 +3782,45 @@ 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` | +| container kind が `enum` である `enum` | `EnumMember` | `EnumMember` | +| declaration name と container name が constructor を示す `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 は、永続化 catalog ではそれぞれ `function` / `method` と `enum` として +保存されるため、metadata に基づいて意味を詳細化する。finalizer は `Constructor` に詳細化しない。 +現在の永続化 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 0df1704d6..8e16d783b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -528,6 +528,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 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. 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. Document-symbol hierarchy coverage keeps positional and body record members in one same-line fixture, asserts the complete root-to-member shape and deterministic sibling order, and retains the separate same-range top-level controls so order-independent parent resolution cannot over-nest unrelated symbols. Keep a same-line duplicate-container fixture with distinct positional members so selection-column disambiguation cannot move an earlier member beneath a later same-named container. @@ -1439,6 +1440,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 を検証します。保守的な parameter および未知/plugin fallback は別途固定します。constructor/import の処理と意図的な fallback mapping がずれないよう、3つの protocol surface を同じ fixture に維持してください。 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 してください。 document-symbol hierarchy coverage は positional member と body member を同一行の record fixture にまとめ、root から member までの完全な形状と決定的な sibling 順序を assert します。順序非依存の親解決が無関係な symbol を過剰に nest しないよう、same-range top-level の control は独立したまま維持してください。selection column による曖昧性解消で前の member が行内で後にある同名 container の配下へ移動しないよう、異なる positional member を持つ同一行の duplicate-container fixture も維持してください。 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 c9738fad1..3fd0cefea 100644 --- a/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs +++ b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs @@ -299,7 +299,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, }; @@ -350,18 +350,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, - "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 c3be3320d..70f99b492 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,18 +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, - "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..fde59b1ef --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs @@ -0,0 +1,162 @@ +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); + } + + private static LspProtocolKinds LspKinds(SymbolResult symbol) + { + if (IsConstructorSymbol(symbol)) + return new LspProtocolKinds(SymbolKind: 9, CompletionItemKind: 4); + if (symbol.Kind == "enum" && symbol.ContainerKind == "enum") + 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) || + !string.Equals(symbol.Name, symbol.ContainerName, StringComparison.Ordinal)) + { + return false; + } + + var signature = symbol.Signature; + var searchStart = 0; + while (searchStart < signature.Length) + { + var nameStart = signature.IndexOf(symbol.Name, searchStart, StringComparison.Ordinal); + if (nameStart < 0) + return false; + + var nameEnd = nameStart + symbol.Name.Length; + var hasIdentifierBoundaryBefore = nameStart == 0 || !IsIdentifierCharacter(signature[nameStart - 1]); + var hasIdentifierBoundaryAfter = nameEnd == signature.Length || !IsIdentifierCharacter(signature[nameEnd]); + if (hasIdentifierBoundaryBefore && hasIdentifierBoundaryAfter) + { + var before = nameStart - 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] == '(') + 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 14c627f9e..b8797906b 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1255,6 +1255,261 @@ 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")); + + var expectedRows = mappings + .Select(mapping => (mapping.Kind, mapping.SymbolKind, mapping.CompletionItemKind)) + .ToList(); + expectedRows.Add(("public MapKindB()", 9, 4)); + expectedRows.Add(("static MapKindB()", 9, 4)); + expectedRows.Add(("~MapKindB()", 12, 3)); + expectedRows.Add(("enum member", 22, 20)); + 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.Add(new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = symbolNames[mappings.Length], + Line = mappings.Length + 1, + StartLine = mappings.Length + 1, + StartColumn = 0, + EndLine = mappings.Length + 1, + Signature = "public MapKindB()", + ContainerKind = "class", + ContainerName = "MapKindB", + }); + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = symbolNames[mappings.Length + 1], + Line = mappings.Length + 2, + StartLine = mappings.Length + 2, + StartColumn = 0, + EndLine = mappings.Length + 2, + Signature = "static MapKindB()", + ContainerKind = "class", + ContainerName = "MapKindB", + }); + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = symbolNames[mappings.Length + 2], + Line = mappings.Length + 3, + StartLine = mappings.Length + 3, + StartColumn = 0, + EndLine = mappings.Length + 3, + Signature = "~MapKindB()", + ContainerKind = "class", + ContainerName = "MapKindB", + }); + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "enum", + Name = symbolNames[mappings.Length + 3], + Line = mappings.Length + 4, + StartLine = mappings.Length + 4, + StartColumn = 0, + EndLine = mappings.Length + 4, + Signature = "enum member", + ContainerKind = "enum", + ContainerName = "MappingEnum", + }); + 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() { From e9b26270cfaa527496fbfdb8c54c10824181f04f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 07:29:19 +0900 Subject: [PATCH 2/4] Address LSP kind review findings (#4870) --- DEVELOPER_GUIDE.md | 32 ++-- TESTING_GUIDE.md | 4 +- src/CodeIndex/Lsp/LspServer.SymbolKinds.cs | 117 ++++++++++++- tests/CodeIndex.Tests/LspServerTests.cs | 181 +++++++++++++++------ 4 files changed, 261 insertions(+), 73 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e49fb5741..46f27ac36 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -495,8 +495,8 @@ deliberate mappings and fallbacks are: | `record`, `struct`, `union` | `Struct` | `Struct` | | `annotation`, `implements`, `interface`, `protocol`, `trait` | `Interface` | `Interface` | | `enum` | `Enum` | `Enum` | -| `enum` whose container kind is `enum` | `EnumMember` | `EnumMember` | -| `function` or `method` whose declaration name and container name identify a constructor | `Constructor` | `Constructor` | +| 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` | @@ -519,11 +519,15 @@ deliberate mappings and fallbacks are: | `workdir` | `Module` | `Folder` | | Non-catalog `parameter` and unknown/plugin-defined kinds | `Variable` | `Variable` | -Constructors and enum members are semantic refinements because the persisted -catalog stores them as `function`/`method` and `enum`, respectively. A finalizer -is not refined to `Constructor`. The current persisted catalog has no standalone -`parameter` kind; the conservative fallback keeps legacy or plugin-provided -parameter-like symbols compatible. +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 and finalizers stay functions. Enum-entry detection recognizes the +persisted C#, Java, Kotlin, and Swift shapes 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 @@ -3792,8 +3796,8 @@ fallback は次のとおりである。 | `record`, `struct`, `union` | `Struct` | `Struct` | | `annotation`, `implements`, `interface`, `protocol`, `trait` | `Interface` | `Interface` | | `enum` | `Enum` | `Enum` | -| container kind が `enum` である `enum` | `EnumMember` | `EnumMember` | -| declaration name と container name が constructor を示す `function` または `method` | `Constructor` | `Constructor` | +| 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` | @@ -3816,10 +3820,12 @@ fallback は次のとおりである。 | `workdir` | `Module` | `Folder` | | catalog 外の `parameter` および未知または plugin 定義の kind | `Variable` | `Variable` | -constructor と enum member は、永続化 catalog ではそれぞれ `function` / `method` と `enum` として -保存されるため、metadata に基づいて意味を詳細化する。finalizer は `Constructor` に詳細化しない。 -現在の永続化 catalog には独立した `parameter` kind がないため、legacy または plugin が提供する -parameter 相当の symbol は保守的な fallback で互換性を維持する。 +constructor と enum member は extractor が広い永続化 kind を再利用するため、metadata に基づいて +意味を詳細化する。constructor は明示 subkind / keyword、専用 initializer 名、およびその declaration +形状が有効な言語だけで型名 constructor を認識する。他言語の同名 method と finalizer は function の +ままにする。enum entry は C#、Java、Kotlin、Swift の保存形状を認識し、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 の diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e4591bd7c..201aa319c 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -529,7 +529,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 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. + 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, and Swift extractor output through the shared mapper so keyword constructors, same-name non-constructors, language-specific 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. 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. Document-symbol hierarchy coverage keeps positional and body record members in one same-line fixture, asserts the complete root-to-member shape and deterministic sibling order, and retains the separate same-range top-level controls so order-independent parent resolution cannot over-nest unrelated symbols. Keep a same-line duplicate-container fixture with distinct positional members so selection-column disambiguation cannot move an earlier member beneath a later same-named container. @@ -1442,7 +1442,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 を検証します。保守的な parameter および未知/plugin fallback は別途固定します。constructor/import の処理と意図的な fallback mapping がずれないよう、3つの protocol surface を同じ fixture に維持してください。 + 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 extractor 出力を shared mapper に通し、keyword constructor、同名の非 constructor、言語別 enum entry、nested enum の分類が逆転しないようにします。保守的な parameter および未知/plugin fallback は別途固定します。constructor/import の処理と意図的な fallback mapping がずれないよう、3つの protocol surface を同じ fixture に維持してください。 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 してください。 document-symbol hierarchy coverage は positional member と body member を同一行の record fixture にまとめ、root から member までの完全な形状と決定的な sibling 順序を assert します。順序非依存の親解決が無関係な symbol を過剰に nest しないよう、same-range top-level の control は独立したまま維持してください。selection column による曖昧性解消で前の member が行内で後にある同名 container の配下へ移動しないよう、異なる positional member を持つ同一行の duplicate-container fixture も維持してください。 diff --git a/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs index fde59b1ef..f74d7148f 100644 --- a/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs +++ b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs @@ -97,11 +97,17 @@ internal static (int SymbolKind, int CompletionItemKind) MapLspKindsForTesting(s 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 (symbol.Kind == "enum" && symbol.ContainerKind == "enum") + if (IsEnumMemberSymbol(symbol)) return new LspProtocolKinds(SymbolKind: 22, CompletionItemKind: 20); return LspKinds(symbol.Kind); } @@ -116,20 +122,121 @@ private static bool IsConstructorSymbol(SymbolResult symbol) if (symbol.Kind is not ("function" or "method") || string.IsNullOrEmpty(symbol.Name) || string.IsNullOrWhiteSpace(symbol.Signature) || - !string.Equals(symbol.Name, symbol.ContainerName, StringComparison.Ordinal)) + string.IsNullOrWhiteSpace(symbol.ContainerName)) { return false; } - var signature = symbol.Signature; + if (string.Equals(symbol.SubKind, "constructor", StringComparison.Ordinal) || + SignatureStartsWithKeywordAfterModifiers(symbol.Signature, "constructor")) + { + return true; + } + + var usesContainerName = symbol.Lang is "csharp" or "cpp" or "dart" or "groovy" or "java"; + if (usesContainerName && + string.Equals(symbol.Name, symbol.ContainerName, StringComparison.Ordinal)) + { + return SignatureContainsNamedCall(symbol.Signature, symbol.Name); + } + + 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", + _ => false, + }; + return usesDedicatedName && SignatureContainsNamedCall(symbol.Signature, symbol.Name); + } + + private static bool IsEnumMemberSymbol(SymbolResult symbol) + { + if (symbol.ContainerKind != "enum" || + string.IsNullOrEmpty(symbol.Name) || + string.IsNullOrWhiteSpace(symbol.Signature)) + { + return false; + } + + if (symbol.Kind == "enum") + return !SignatureStartsWithKeywordAfterModifiers(symbol.Signature, "enum"); + if (symbol.Kind is not ("function" or "property")) + return false; + + var signature = symbol.Signature.AsSpan().TrimStart(); + TryConsumeLeadingKeyword(ref signature, "case"); + if (signature.Length > 0 && signature[0] == '@') + signature = signature[1..]; + if (!signature.StartsWith(symbol.Name, StringComparison.Ordinal)) + return false; + + var remainder = signature[symbol.Name.Length..]; + if (remainder.Length > 0 && IsIdentifierCharacter(remainder[0])) + return false; + remainder = remainder.TrimStart(); + return remainder.IsEmpty || remainder[0] is ',' or ';' or '(' or '{' or '='; + } + + private static bool SignatureStartsWithKeywordAfterModifiers(string signature, string keyword) + { + var remaining = signature.AsSpan().TrimStart(); + while (!remaining.IsEmpty) + { + if (TryConsumeLeadingKeyword(ref remaining, keyword)) + return true; + + var wordEnd = 0; + while (wordEnd < remaining.Length && IsIdentifierCharacter(remaining[wordEnd])) + wordEnd++; + if (wordEnd == 0 || !IsDeclarationModifier(remaining[..wordEnd])) + return false; + remaining = remaining[wordEnd..].TrimStart(); + } + + return false; + } + + private static bool TryConsumeLeadingKeyword(ref ReadOnlySpan text, string keyword) + { + if (!text.StartsWith(keyword, StringComparison.Ordinal) || + (text.Length > keyword.Length && IsIdentifierCharacter(text[keyword.Length]))) + { + return false; + } + + text = text[keyword.Length..].TrimStart(); + return true; + } + + private static bool IsDeclarationModifier(ReadOnlySpan word) + => word.SequenceEqual("abstract") || + word.SequenceEqual("actual") || + word.SequenceEqual("class") || + word.SequenceEqual("declare") || + word.SequenceEqual("default") || + word.SequenceEqual("expect") || + word.SequenceEqual("export") || + word.SequenceEqual("external") || + word.SequenceEqual("final") || + word.SequenceEqual("internal") || + word.SequenceEqual("private") || + word.SequenceEqual("protected") || + word.SequenceEqual("public") || + word.SequenceEqual("static"); + + private static bool SignatureContainsNamedCall(string signature, string name) + { var searchStart = 0; while (searchStart < signature.Length) { - var nameStart = signature.IndexOf(symbol.Name, searchStart, StringComparison.Ordinal); + var nameStart = signature.IndexOf(name, searchStart, StringComparison.Ordinal); if (nameStart < 0) return false; - var nameEnd = nameStart + symbol.Name.Length; + var nameEnd = nameStart + name.Length; var hasIdentifierBoundaryBefore = nameStart == 0 || !IsIdentifierCharacter(signature[nameStart - 1]); var hasIdentifierBoundaryAfter = nameEnd == signature.Length || !IsIdentifierCharacter(signature[nameEnd]); if (hasIdentifierBoundaryBefore && hasIdentifierBoundaryAfter) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index b8797906b..13db16a3d 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; @@ -1339,13 +1340,125 @@ public void HandleMessage_SymbolAndCompletionKindsUseSharedMapping_Issue4870() 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, + ContainerKind = symbol.ContainerKind, + ContainerName = symbol.ContainerName, + }; + + var typeScriptSymbols = SymbolExtractor.Extract(1, "typescript", """ + class Widget { + 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"))); + + var kotlinSymbols = SymbolExtractor.Extract(2, "kotlin", """ + class KotlinThing { + constructor(value: Int) + fun KotlinThing() {} + } + enum class KtColor { + RED, + } + """); + 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"))); + + 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 { + A; + enum Inner { + B; + } + } + """); + 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"))); + + var swiftSymbols = SymbolExtractor.Extract(5, "swift", """ + enum SwiftColor { + case red + } + """); + Assert.Equal( + (22, 20), + LspServer.MapLspKindsForTesting(ToResult( + Assert.Single(swiftSymbols, symbol => symbol.Name == "red"), + "swift"))); + + 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", 9, 4), + ("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", "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.Add(("public MapKindB()", 9, 4)); - expectedRows.Add(("static MapKindB()", 9, 4)); - expectedRows.Add(("~MapKindB()", 12, 3)); - expectedRows.Add(("enum member", 22, 20)); + expectedRows.AddRange(semanticMappings.Select(mapping => + (mapping.Detail, mapping.SymbolKind, mapping.CompletionItemKind))); const int queryGroupSize = 40; var symbolNames = expectedRows .Select((_, index) => index < queryGroupSize ? "MapKindA" : "MapKindB") @@ -1391,58 +1504,20 @@ public void HandleMessage_SymbolAndCompletionKindsUseSharedMapping_Issue4870() EndLine = index + 1, Signature = mapping.Kind, }).ToList(); - symbols.Add(new SymbolRecord - { - FileId = fileId, - Kind = "function", - Name = symbolNames[mappings.Length], - Line = mappings.Length + 1, - StartLine = mappings.Length + 1, - StartColumn = 0, - EndLine = mappings.Length + 1, - Signature = "public MapKindB()", - ContainerKind = "class", - ContainerName = "MapKindB", - }); - symbols.Add(new SymbolRecord + symbols.AddRange(semanticMappings.Select((mapping, index) => new SymbolRecord { FileId = fileId, - Kind = "function", - Name = symbolNames[mappings.Length + 1], - Line = mappings.Length + 2, - StartLine = mappings.Length + 2, - StartColumn = 0, - EndLine = mappings.Length + 2, - Signature = "static MapKindB()", - ContainerKind = "class", - ContainerName = "MapKindB", - }); - symbols.Add(new SymbolRecord - { - FileId = fileId, - Kind = "function", - Name = symbolNames[mappings.Length + 2], - Line = mappings.Length + 3, - StartLine = mappings.Length + 3, - StartColumn = 0, - EndLine = mappings.Length + 3, - Signature = "~MapKindB()", - ContainerKind = "class", - ContainerName = "MapKindB", - }); - symbols.Add(new SymbolRecord - { - FileId = fileId, - Kind = "enum", - Name = symbolNames[mappings.Length + 3], - Line = mappings.Length + 4, - StartLine = mappings.Length + 4, + 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 + 4, - Signature = "enum member", - ContainerKind = "enum", - ContainerName = "MappingEnum", - }); + EndLine = mappings.Length + index + 1, + Signature = mapping.Detail, + ContainerKind = mapping.ContainerKind, + ContainerName = mapping.ContainerName, + })); writer.InsertSymbols(symbols); } From 602586832e2d0ef58b4608dd5a88b8df640e9912 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 07:50:10 +0900 Subject: [PATCH 3/4] Cover adversarial LSP kind shapes (#4870) --- DEVELOPER_GUIDE.md | 21 +- TESTING_GUIDE.md | 4 +- src/CodeIndex/Lsp/LspServer.SymbolKinds.cs | 328 ++++++++++++++++++--- tests/CodeIndex.Tests/LspServerTests.cs | 70 +++++ 4 files changed, 367 insertions(+), 56 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 46f27ac36..2683d799e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -523,11 +523,13 @@ 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 and finalizers stay functions. Enum-entry detection recognizes the -persisted C#, Java, Kotlin, and Swift shapes 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. +languages and finalizers stay functions. This includes named Dart constructors, +Java compact record constructors, case-insensitive Pascal constructor keywords, +and Visual Basic `New`. Enum-entry detection recognizes decorated, escaped, +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 @@ -3823,9 +3825,12 @@ fallback は次のとおりである。 constructor と enum member は extractor が広い永続化 kind を再利用するため、metadata に基づいて 意味を詳細化する。constructor は明示 subkind / keyword、専用 initializer 名、およびその declaration 形状が有効な言語だけで型名 constructor を認識する。他言語の同名 method と finalizer は function の -ままにする。enum entry は C#、Java、Kotlin、Swift の保存形状を認識し、enum 内の nested enum は -enum declaration のままにする。現在の永続化 catalog には独立した `parameter` kind がないため、 -legacy または plugin が提供する parameter 相当の symbol は保守的な fallback で互換性を維持する。 +ままにする。これには Dart の named constructor、Java の compact record constructor、大文字小文字を +区別しない Pascal の constructor keyword、Visual Basic の `New` も含む。enum entry は C#、Java、 +Kotlin、PHP、Swift における annotation 付き、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 の diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 201aa319c..792885135 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -529,7 +529,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, and Swift extractor output through the shared mapper so keyword constructors, same-name non-constructors, language-specific 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. + 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, and Pascal extractor output through the shared mapper so keyword, compact, named, and dedicated-name constructors; same-name 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. 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. Document-symbol hierarchy coverage keeps positional and body record members in one same-line fixture, asserts the complete root-to-member shape and deterministic sibling order, and retains the separate same-range top-level controls so order-independent parent resolution cannot over-nest unrelated symbols. Keep a same-line duplicate-container fixture with distinct positional members so selection-column disambiguation cannot move an earlier member beneath a later same-named container. @@ -1442,7 +1442,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 extractor 出力を shared mapper に通し、keyword constructor、同名の非 constructor、言語別 enum entry、nested enum の分類が逆転しないようにします。保守的な parameter および未知/plugin fallback は別途固定します。constructor/import の処理と意図的な fallback mapping がずれないよう、3つの protocol surface を同じ fixture に維持してください。 + 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 extractor 出力を shared mapper に通し、keyword / compact / named / 専用名 constructor、同名の非 constructor、annotation 付き / escape 済み / indirect / comma-grouped enum entry、nested enum の分類が逆転しないようにします。保守的な parameter および未知/plugin fallback は別途固定します。constructor/import の処理と意図的な fallback mapping がずれないよう、3つの protocol surface を同じ fixture に維持してください。 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 してください。 document-symbol hierarchy coverage は positional member と body member を同一行の record fixture にまとめ、root から member までの完全な形状と決定的な sibling 順序を assert します。順序非依存の親解決が無関係な symbol を過剰に nest しないよう、same-range top-level の control は独立したまま維持してください。selection column による曖昧性解消で前の member が行内で後にある同名 container の配下へ移動しないよう、異なる positional member を持つ同一行の duplicate-container fixture も維持してください。 diff --git a/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs index f74d7148f..d98655538 100644 --- a/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs +++ b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs @@ -121,23 +121,32 @@ private static bool IsConstructorSymbol(SymbolResult symbol) { if (symbol.Kind is not ("function" or "method") || string.IsNullOrEmpty(symbol.Name) || - string.IsNullOrWhiteSpace(symbol.Signature) || - string.IsNullOrWhiteSpace(symbol.ContainerName)) + string.IsNullOrWhiteSpace(symbol.Signature)) { return false; } if (string.Equals(symbol.SubKind, "constructor", StringComparison.Ordinal) || - SignatureStartsWithKeywordAfterModifiers(symbol.Signature, "constructor")) + SignatureStartsWithKeywordAfterModifiers( + symbol.Signature, + "constructor", + ignoreCase: symbol.Lang == "pascal")) { return true; } + if (string.IsNullOrWhiteSpace(symbol.ContainerName)) + return false; var usesContainerName = symbol.Lang is "csharp" or "cpp" or "dart" or "groovy" or "java"; - if (usesContainerName && - string.Equals(symbol.Name, symbol.ContainerName, StringComparison.Ordinal)) + var matchesContainerName = string.Equals(symbol.Name, symbol.ContainerName, StringComparison.Ordinal) || + (symbol.Lang == "dart" && + symbol.Name.StartsWith(symbol.ContainerName + ".", StringComparison.Ordinal)); + if (usesContainerName && matchesContainerName) { - return SignatureContainsNamedCall(symbol.Signature, symbol.Name); + return SignatureContainsNamedDeclaration( + symbol.Signature, + symbol.Name, + allowBodyBrace: symbol.Lang == "java"); } var usesDedicatedName = symbol.Lang switch @@ -147,9 +156,15 @@ private static bool IsConstructorSymbol(SymbolResult symbol) "ruby" => symbol.Name == "initialize", "scala" => symbol.Name == "this", "swift" => symbol.Name == "init", + "vb" => symbol.Name.Equals("New", StringComparison.OrdinalIgnoreCase), _ => false, }; - return usesDedicatedName && SignatureContainsNamedCall(symbol.Signature, symbol.Name); + return usesDedicatedName && + SignatureContainsNamedDeclaration( + symbol.Signature, + symbol.Name, + allowBodyBrace: false, + ignoreCase: symbol.Lang == "vb"); } private static bool IsEnumMemberSymbol(SymbolResult symbol) @@ -161,37 +176,36 @@ private static bool IsEnumMemberSymbol(SymbolResult symbol) return false; } - if (symbol.Kind == "enum") - return !SignatureStartsWithKeywordAfterModifiers(symbol.Signature, "enum"); - if (symbol.Kind is not ("function" or "property")) - return false; - - var signature = symbol.Signature.AsSpan().TrimStart(); - TryConsumeLeadingKeyword(ref signature, "case"); - if (signature.Length > 0 && signature[0] == '@') - signature = signature[1..]; - if (!signature.StartsWith(symbol.Name, StringComparison.Ordinal)) + 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); - var remainder = signature[symbol.Name.Length..]; - if (remainder.Length > 0 && IsIdentifierCharacter(remainder[0])) - return false; - remainder = remainder.TrimStart(); - return remainder.IsEmpty || remainder[0] is ',' or ';' or '(' or '{' or '='; + return SignatureContainsEnumMemberDeclarator(symbol.Signature, symbol.Name); } - private static bool SignatureStartsWithKeywordAfterModifiers(string signature, string keyword) + 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 (TryConsumeLeadingKeyword(ref remaining, keyword)) + 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])) + if (wordEnd == 0 || !IsDeclarationModifier(remaining[..wordEnd], comparison)) return false; remaining = remaining[wordEnd..].TrimStart(); } @@ -199,9 +213,12 @@ private static bool SignatureStartsWithKeywordAfterModifiers(string signature, s return false; } - private static bool TryConsumeLeadingKeyword(ref ReadOnlySpan text, string keyword) + private static bool TryConsumeLeadingKeyword( + ref ReadOnlySpan text, + string keyword, + StringComparison comparison = StringComparison.Ordinal) { - if (!text.StartsWith(keyword, StringComparison.Ordinal) || + if (!text.StartsWith(keyword, comparison) || (text.Length > keyword.Length && IsIdentifierCharacter(text[keyword.Length]))) { return false; @@ -211,28 +228,244 @@ private static bool TryConsumeLeadingKeyword(ref ReadOnlySpan text, string return true; } - private static bool IsDeclarationModifier(ReadOnlySpan word) - => word.SequenceEqual("abstract") || - word.SequenceEqual("actual") || - word.SequenceEqual("class") || - word.SequenceEqual("declare") || - word.SequenceEqual("default") || - word.SequenceEqual("expect") || - word.SequenceEqual("export") || - word.SequenceEqual("external") || - word.SequenceEqual("final") || - word.SequenceEqual("internal") || - word.SequenceEqual("private") || - word.SequenceEqual("protected") || - word.SequenceEqual("public") || - word.SequenceEqual("static"); - - private static bool SignatureContainsNamedCall(string signature, string name) + 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) + { + var remaining = signature.AsSpan().TrimStart(); + while (TryConsumeLeadingDecoration(ref remaining)) + { + } + 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); + + 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)) + return true; + segmentStart = index + 1; + break; + case ';' when parenthesisDepth == 0 && bracketDepth == 0 && braceDepth == 0: + return SegmentStartsWithEnumMemberName(remaining[segmentStart..index], name); + } + } + + return false; + } + + private static bool SegmentStartsWithEnumMemberName(ReadOnlySpan segment, string name) + { + segment = segment.TrimStart(); + while (TryConsumeLeadingDecoration(ref segment)) + { + } + + 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) + { + 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; + + 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, StringComparison.Ordinal); + var nameStart = signature.IndexOf(name, searchStart, comparison); if (nameStart < 0) return false; @@ -249,8 +482,11 @@ private static bool SignatureContainsNamedCall(string signature, string name) var after = nameEnd; while (after < signature.Length && char.IsWhiteSpace(signature[after])) after++; - if (after < signature.Length && signature[after] == '(') + if (after < signature.Length && + (signature[after] == '(' || (allowBodyBrace && signature[after] == '{'))) + { return true; + } } } diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 13db16a3d..109674499 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1375,6 +1375,7 @@ fun KotlinThing() {} } enum class KtColor { RED, + `mixed-case`, } """); Assert.Equal( @@ -1392,6 +1393,11 @@ enum class KtColor { 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 { @@ -1412,11 +1418,16 @@ function Vault(uint amount) public {} var javaSymbols = SymbolExtractor.Extract(4, "java", """ enum Outer { + @Deprecated A; enum Inner { B; } } + record Point(int x) { + Point { + } + } """); Assert.Equal( (22, 20), @@ -1428,10 +1439,19 @@ enum Inner { 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"))); var swiftSymbols = SymbolExtractor.Extract(5, "swift", """ enum SwiftColor { case red + indirect case node(Int) + case first, second(Int) } """); Assert.Equal( @@ -1439,6 +1459,52 @@ enum SwiftColor { 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 semanticMappings = new (string Kind, string Detail, string? SubKind, string ContainerKind, string ContainerName, int SymbolKind, int CompletionItemKind)[] { @@ -1451,6 +1517,10 @@ enum SwiftColor { ("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), + ("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), }; From bfa7986512552cc8f0b1a2aa9da19cf8e259a878 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 08:59:55 +0900 Subject: [PATCH 4/4] Fix final LSP review findings (#4870) --- DEVELOPER_GUIDE.md | 22 +++--- TESTING_GUIDE.md | 4 +- src/CodeIndex/Lsp/LspServer.SymbolKinds.cs | 86 +++++++++++++++++----- tests/CodeIndex.Tests/LspServerTests.cs | 54 +++++++++++++- 4 files changed, 136 insertions(+), 30 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2683d799e..4397cf882 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -523,11 +523,13 @@ 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 and finalizers stay functions. This includes named Dart constructors, -Java compact record constructors, case-insensitive Pascal constructor keywords, -and Visual Basic `New`. Enum-entry detection recognizes decorated, escaped, -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 +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. @@ -3825,10 +3827,12 @@ fallback は次のとおりである。 constructor と enum member は extractor が広い永続化 kind を再利用するため、metadata に基づいて 意味を詳細化する。constructor は明示 subkind / keyword、専用 initializer 名、およびその declaration 形状が有効な言語だけで型名 constructor を認識する。他言語の同名 method と finalizer は function の -ままにする。これには Dart の named constructor、Java の compact record constructor、大文字小文字を -区別しない Pascal の constructor keyword、Visual Basic の `New` も含む。enum entry は C#、Java、 -Kotlin、PHP、Swift における annotation 付き、escape 済み、indirect、comma-grouped の保存形状を -認識し、enum 内の nested enum は enum declaration のままにする。現在の永続化 catalog には独立した +ままにする。戻り値型付きの同名 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 で互換性を維持する。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 792885135..9e1001f11 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -529,7 +529,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, and Pascal extractor output through the shared mapper so keyword, compact, named, and dedicated-name constructors; same-name 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. + 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. 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. Document-symbol hierarchy coverage keeps positional and body record members in one same-line fixture, asserts the complete root-to-member shape and deterministic sibling order, and retains the separate same-range top-level controls so order-independent parent resolution cannot over-nest unrelated symbols. Keep a same-line duplicate-container fixture with distinct positional members so selection-column disambiguation cannot move an earlier member beneath a later same-named container. @@ -1442,7 +1442,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 extractor 出力を shared mapper に通し、keyword / compact / named / 専用名 constructor、同名の非 constructor、annotation 付き / escape 済み / indirect / comma-grouped enum entry、nested enum の分類が逆転しないようにします。保守的な parameter および未知/plugin fallback は別途固定します。constructor/import の処理と意図的な fallback mapping がずれないよう、3つの protocol surface を同じ fixture に維持してください。 + 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 に維持してください。 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 してください。 document-symbol hierarchy coverage は positional member と body member を同一行の record fixture にまとめ、root から member までの完全な形状と決定的な sibling 順序を assert します。順序非依存の親解決が無関係な symbol を過剰に nest しないよう、same-range top-level の control は独立したまま維持してください。selection column による曖昧性解消で前の member が行内で後にある同名 container の配下へ移動しないよう、異なる positional member を持つ同一行の duplicate-container fixture も維持してください。 diff --git a/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs index d98655538..9a488bad0 100644 --- a/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs +++ b/src/CodeIndex/Lsp/LspServer.SymbolKinds.cs @@ -126,14 +126,23 @@ private static bool IsConstructorSymbol(SymbolResult symbol) return false; } - if (string.Equals(symbol.SubKind, "constructor", StringComparison.Ordinal) || - SignatureStartsWithKeywordAfterModifiers( - symbol.Signature, - "constructor", - ignoreCase: symbol.Lang == "pascal")) + 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; @@ -141,7 +150,9 @@ private static bool IsConstructorSymbol(SymbolResult symbol) var matchesContainerName = string.Equals(symbol.Name, symbol.ContainerName, StringComparison.Ordinal) || (symbol.Lang == "dart" && symbol.Name.StartsWith(symbol.ContainerName + ".", StringComparison.Ordinal)); - if (usesContainerName && matchesContainerName) + if (usesContainerName && + matchesContainerName && + string.IsNullOrWhiteSpace(symbol.ReturnType)) { return SignatureContainsNamedDeclaration( symbol.Signature, @@ -183,9 +194,9 @@ private static bool IsEnumMemberSymbol(SymbolResult symbol) } if (symbol.Kind is not ("function" or "property")) return symbol.Kind == "enum" && - SignatureContainsEnumMemberDeclarator(symbol.Signature, symbol.Name); + SignatureContainsEnumMemberDeclarator(symbol.Signature, symbol.Name, symbol.Lang); - return SignatureContainsEnumMemberDeclarator(symbol.Signature, symbol.Name); + return SignatureContainsEnumMemberDeclarator(symbol.Signature, symbol.Name, symbol.Lang); } private static bool SignatureStartsWithKeywordAfterModifiers( @@ -255,10 +266,14 @@ private static bool IsDeclarationModifier( word.Equals("sealed", comparison) || word.Equals("static", comparison); - private static bool SignatureContainsEnumMemberDeclarator(string signature, string name) + 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)) + while (TryConsumeLeadingDecoration(ref remaining, verbatimIdentifierName)) { } TryConsumeLeadingKeyword(ref remaining, "indirect"); @@ -273,7 +288,10 @@ private static bool SignatureContainsEnumMemberDeclarator(string signature, stri for (var index = 0; index <= remaining.Length; index++) { if (index == remaining.Length) - return SegmentStartsWithEnumMemberName(remaining[segmentStart..], name); + return SegmentStartsWithEnumMemberName( + remaining[segmentStart..], + name, + verbatimIdentifierName); var character = remaining[index]; if (quote != '\0') @@ -320,22 +338,33 @@ private static bool SignatureContainsEnumMemberDeclarator(string signature, stri braceDepth--; break; case ',' when parenthesisDepth == 0 && bracketDepth == 0 && braceDepth == 0: - if (SegmentStartsWithEnumMemberName(remaining[segmentStart..index], name)) + 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); + return SegmentStartsWithEnumMemberName( + remaining[segmentStart..index], + name, + verbatimIdentifierName); } } return false; } - private static bool SegmentStartsWithEnumMemberName(ReadOnlySpan segment, string name) + private static bool SegmentStartsWithEnumMemberName( + ReadOnlySpan segment, + string name, + string? verbatimIdentifierName) { segment = segment.TrimStart(); - while (TryConsumeLeadingDecoration(ref segment)) + while (TryConsumeLeadingDecoration(ref segment, verbatimIdentifierName)) { } @@ -367,7 +396,9 @@ private static bool SegmentStartsWithEnumMemberName(ReadOnlySpan segment, return segment.IsEmpty || segment[0] is '(' or '{' or '='; } - private static bool TryConsumeLeadingDecoration(ref ReadOnlySpan text) + private static bool TryConsumeLeadingDecoration( + ref ReadOnlySpan text, + string? verbatimIdentifierName = null) { var original = text; if (!text.IsEmpty && text[0] == '[') @@ -381,6 +412,20 @@ private static bool TryConsumeLeadingDecoration(ref ReadOnlySpan text) 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 ':')) @@ -470,11 +515,16 @@ private static bool SignatureContainsNamedDeclaration( return false; var nameEnd = nameStart + name.Length; - var hasIdentifierBoundaryBefore = nameStart == 0 || !IsIdentifierCharacter(signature[nameStart - 1]); + 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 - 1; + var before = nameStart - (hasVerbatimPrefix ? 2 : 1); while (before >= 0 && char.IsWhiteSpace(signature[before])) before--; if (before < 0 || signature[before] != '~') diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 109674499..d2d0d285a 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1347,6 +1347,7 @@ public void HandleMessage_SymbolAndCompletionKindsUseSharedMapping_Issue4870() SubKind = symbol.SubKind, Name = symbol.Name, Signature = symbol.Signature, + ReturnType = symbol.ReturnType, ContainerKind = symbol.ContainerKind, ContainerName = symbol.ContainerName, }; @@ -1354,6 +1355,7 @@ public void HandleMessage_SymbolAndCompletionKindsUseSharedMapping_Issue4870() var typeScriptSymbols = SymbolExtractor.Extract(1, "typescript", """ class Widget { constructor() {} + static constructor() {} Widget() {} } """); @@ -1367,6 +1369,12 @@ class Widget { 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 { @@ -1428,6 +1436,9 @@ record Point(int x) { Point { } } + class JavaThing { + void JavaThing() {} + } """); Assert.Equal( (22, 20), @@ -1446,6 +1457,13 @@ record Point(int x) { 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 { @@ -1506,12 +1524,44 @@ End Class 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", 9, 4), + ("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), @@ -1520,6 +1570,8 @@ End Class ("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),