diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1a9218e46..d9a55d59c 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -518,8 +518,11 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keeps C# LINQ range-member, parenthesized group-by, awaited keyword-local, nullable suffix, ternary, and keyword-local boundaries beside a Visual Basic query-syntax call in one multi-language workspace with one CLI index. Keep unique per-file sentinels and language-specific queries so zero-reference negatives and positive context, kind, container, and line contracts remain independently diagnostic. - `QueryCommandRunnerTests.CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825` indexes one real C# fixture containing user-defined generic classes and delegates, block-comment generic trivia, repeated same-line generic names under indentation, unrelated same-name properties, ordinal-case type/property collisions, `Action` / `Stream` references, and uppercase property receivers split across partial-class and inherited-base files. Keep CLI `references`, `inspect`, and `deps` assertions together with the MCP `references`, `analyze_symbol`, and `deps` wrappers so unresolved diagnostics, generic-arity selection, case-sensitive receiver handling, false dependency-edge suppression, and preservation of real member dependencies cannot drift across surfaces. +- `QueryCommandRunnerTests.ConstructorCommands_KeepCallableAndPartialTypeResultsSeparate_Issue4850` + 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. + 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. Symbol-progress coverage crosses the production chunk item limit with one sentinel, verifies the complete deterministic sequence across partial-result frames, and keeps live work-done delivery, truncation, typed request-ID cancellation, full-queue cancellation, queue-pressure notification preservation, backpressured `Server busy` response retention, emitted-count cancellation reporting, output-failure propagation, and invalid-token variants in the same protocol fixture. Assert both the item and UTF-8 JSON body budgets for every emitted chunk. The server-busy backpressure cancellation fixture must stage the cancel frame until both the active symbol request and blocked response write have been observed. Block the request on its cancellation token instead of matching fixed delays so suite load cannot convert the expected cancellation into a successful response. @@ -1413,8 +1416,11 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は C# LINQ の range-member、parenthesized group-by、awaited keyword-local、nullable suffix、ternary、keyword-local boundary と Visual Basic query syntax 内の call を、CLI index 1回の multi-language workspace で共有します。file ごとの固有 sentinel と言語別 query を使い、zero-reference negative と positive の context、kind、container、line 契約の診断性を独立に保ってください。 - `QueryCommandRunnerTests.CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825` は user-defined generic class / delegate、generic 間の block comment trivia、indent 下で同一行に反復する同名 generic、無関係な同名 property、大文字小文字だけ異なる type / property collision、`Action` / `Stream` reference、partial class の別ファイルおよび継承 base file に分かれた大文字始まりの property receiver を含む実 C# fixture を1回 index します。CLI の `references`、`inspect`、`deps` と MCP wrapper の `references`、`analyze_symbol`、`deps` を同じ fixture で検証し、unresolved 診断、generic arity による選択、case-sensitive receiver 判定、偽 dependency edge の抑制、実在する member dependency の維持が surface 間でずれないよう維持してください。 +- `QueryCommandRunnerTests.ConstructorCommands_KeepCallableAndPartialTypeResultsSeparate_Issue4850` + は 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 しないようにします。 + 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 も維持してください。 symbol-progress coverage は production の chunk item limit を sentinel 1 件だけで超え、partial-result frame 全体の決定的な完全 sequence を検証します。live work-done delivery、truncation、型付き request ID の cancellation、full-queue cancellation、queue pressure 下の notification 保持、backpressure された `Server busy` response の保持、cancellation 時の送信済み件数、output-failure propagation、invalid-token variant は同じ protocol fixture にまとめ、送信した全 chunk の item 上限と UTF-8 JSON body budget の両方を assert してください。 server-busy backpressure cancellation fixture では、active symbol request と block された response write の両方を観測するまで cancel frame を stage してください。固定 delay の一致に依存せず cancellation token 上で request を block することで、suite load により期待する cancellation が成功 response に変わる競合を防ぎます。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 70a3d6027..52c1d5351 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2710,6 +2710,23 @@ but do not speak MCP. It also advertises full `textDocument` sync and conservative `hover`, `completion`, `documentHighlight`, `semanticTokens/full`, `codeLens`, and `inlayHint` providers backed by indexed symbols and references where available. +C# constructor navigation is source-position-aware. For `new Type(...)`, +`textDocument/definition` and `textDocument/declaration` use the exact indexed +reference site and invocation arity to select only matching explicit constructor +symbols. When no explicit constructor is indexed, including implicit constructors +on partial classes and positional records, they return one stable representative +type declaration. This is separate from ordinary type-reference navigation, which +may intentionally return every declaration in one logical partial-type family. +Static constructors and finalizers are never treated as `new` targets. Primary +constructor declarations remain eligible beside secondary constructors, generic +type arity scopes constructor identity, and same-arity overloads remain constructor +locations instead of falling back to same-named type declarations. Default +construction of value types remains attached to the type declaration even when +other explicit constructors exist, and enum and delegate construction remain +navigable. +CLI `definition` and `goto` are name-based; use `--kind function` for explicit +constructors, or a type kind together with `--group-partials` for the logical type +family. Clients must follow the standard LSP lifecycle: send one `initialize` request first, optionally send the `initialized` notification after its response, then send ordinary requests, finish with one `shutdown` request, and finally send the @@ -5836,6 +5853,22 @@ MCP stdio は line protocol です。LF 区切りの各行に compact な UTF-8 indexed symbols / references で答えられる範囲に限定した `hover`、`completion`、 `documentHighlight`、`semanticTokens/full`、`codeLens`、`inlayHint` provider を advertise します。 +C# の constructor navigation は source position を考慮します。`new Type(...)` に +対する `textDocument/definition` と `textDocument/declaration` は、index 済みの +正確な reference site と invocation arity を使い、一致する明示 constructor symbol +だけを選択します。partial class の暗黙 constructor や positional record のように +明示 constructor が index されていない場合は、安定した代表 type declaration を +1 件返します。この経路は通常の type-reference navigation と分離されており、後者は +1 つの logical partial-type family に属する全 declaration を意図的に返す場合があります。 +static constructor と finalizer は `new` の target として扱いません。primary constructor +declaration は secondary constructor と併存しても候補に残り、generic type arity で +constructor identity を限定します。同一 arity の overload も同名 type declaration へ +fallback せず、constructor location の集合として返します。他の明示 constructor が +存在する場合も value type の default construction は type declaration に結び付けたままにし、 +enum と delegate の construction も navigation 可能な状態を保ちます。 +CLI の `definition` と `goto` は名前ベースなので、明示 constructor には +`--kind function`、logical type family には type kind と `--group-partials` を +組み合わせてください。 client は標準の LSP lifecycle に従う必要があります。最初に `initialize` request を 1 回だけ 送り、その response 後に必要なら `initialized` notification を送り、通常 request を処理した後、 `shutdown` request、最後に `exit` notification の順で終了してください。初期化完了前に受信した diff --git a/changelog.d/unreleased/4850.fixed.md b/changelog.d/unreleased/4850.fixed.md new file mode 100644 index 000000000..9c22da2f0 --- /dev/null +++ b/changelog.d/unreleased/4850.fixed.md @@ -0,0 +1,26 @@ +--- +category: fixed +issues: + - 4850 +affected: + - src/CodeIndex/Database/DbContext.ConnectionFunctions.cs + - src/CodeIndex/Database/DbContext.SchemaMetadata.cs + - src/CodeIndex/Database/DbReader.References.cs + - src/CodeIndex/Database/DbWriter.References.cs + - src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs + - src/CodeIndex/Lsp/LspServer.PositionResolution.cs + - tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Constructor navigation now stays separate from partial-type families (#4850)** — As a follow-up to #4566, C# `instantiate` references now resolve by exact source site, callable constructor identity, generic type arity, and invocation arity instead of mixing same-named type declarations into the candidate set. Explicit and primary overloads navigate only to matching constructor targets, static constructors and finalizers are excluded, and implicit partial constructors use one deterministic type representative. Value-type default construction remains attached to its type declaration even beside other explicit constructors, and enum and delegate construction remain navigable. Same-arity overloads remain constructor locations, while ordinary type references may still expose every declaration in the logical partial family. Existing indexes rebuild the affected candidate contract automatically. + +## 日本語 + +- **constructor navigation と partial-type family を分離しました (#4850)** — #4566 の follow-up として、C# の `instantiate` reference は同名の type declaration を candidate set に混在させず、正確な source site、callable constructor identity、generic type arity、invocation arity で解決するようになりました。明示 / primary overload は一致する constructor target だけへ移動し、static constructor と finalizer を除外し、暗黙 partial constructor は決定的な代表 type を使います。他の明示 constructor と併存する場合も value type の default construction は type declaration に結び付き、enum と delegate の construction も navigation 可能です。同一 arity overload は constructor location のまま返し、通常の type reference は引き続き logical partial family の全 declaration を公開できます。既存 index では対象 candidate contract を自動再構築します。 diff --git a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs index b36d84bc9..76af876a3 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -97,6 +97,18 @@ internal static void RegisterConnectionFunctions(SqliteConnection connection) "csharp_definition_type_arity", (string? signature, string? identifier, string? symbolKind) => CSharpTypeReferenceArity.GetDefinitionArity(signature, identifier, symbolKind)); + connection.CreateFunction( + "csharp_constructor_parameter_count", + (string? signature, string? identifier, string? symbolKind) => + CSharpTypeReferenceArity.GetConstructorParameterCount(signature, identifier, symbolKind)); + connection.CreateFunction( + "csharp_invocation_argument_count", + (string? context, string? identifier, long? columnNumber) => + CSharpTypeReferenceArity.GetInvocationArgumentCount(context, identifier, columnNumber)); + connection.CreateFunction( + "csharp_definition_is_value_type", + (string? signature, string? symbolKind) => + CSharpTypeReferenceArity.IsValueTypeDeclaration(signature, symbolKind)); connection.CreateFunction( "csharp_base_identifiers_json", (string? signature) => diff --git a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs index 63c46039b..ce3f081c0 100644 --- a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs +++ b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs @@ -62,15 +62,13 @@ public static string BuildIncompleteHotspotFamilyMarkerFingerprint(string? finge public const string SqlGraphContractVersionMetaKey = "sql_graph_contract_version"; public const int HdlGraphContractVersion = 1; public const string HdlGraphContractVersionMetaKey = "hdl_graph_contract_version"; - // Version 5 (#4846) invalidates name-only Markdown candidates written before - // fragment resolution became document/path-scoped. Version 4 (#4845) previously - // invalidated dependency-lock candidates written before resolution became file-local, - // and version 3 (#4825) constrained C# type-reference candidates by kind and arity. - // バージョン 5 (#4846) では、Markdown fragment の解決を document/path 内に限定する - // 前に書かれた name-only candidate を無効化する。バージョン 4 (#4845) では、それ以前の - // dependency-lock candidate を file 内に限定し、バージョン 3 (#4825) では C# 型参照を - // kind と arity で制約した。 - public const int ReferenceIdentityContractVersion = 5; + // Version 6 (#4850) invalidates C# instantiate candidates written before constructor + // callables and logical partial-type families had separate identities. Version 5 + // (#4846) previously made Markdown fragment resolution document/path-scoped. + // バージョン 6 (#4850) では、constructor callable と logical partial type family の + // identity 分離前に書かれた C# instantiate candidate を無効化する。バージョン 5 + // (#4846) では Markdown fragment 解決を document/path 内に限定した。 + public const int ReferenceIdentityContractVersion = 6; public const string ReferenceIdentityContractVersionMetaKey = "reference_identity_contract_version"; public static string GetDynamicReferenceGraphContractVersionMetaKey(string lang) => $"dynamic_reference_graph_contract_version_{lang}"; diff --git a/src/CodeIndex/Database/DbReader.References.cs b/src/CodeIndex/Database/DbReader.References.cs index 2b30b5886..d668d7ef3 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -177,6 +177,15 @@ internal ReferencePositionResolution GetReferencePositionResolution( var startLineSql = GetSymbolColumnSql("start_line", "s.line"); var endLineSql = GetSymbolColumnSql("end_line", "s.line"); var signatureSql = GetSymbolColumnSql("signature"); + var logicalPartialKeySql = LogicalPartialSymbolGrouper.BuildSqlKeyExpression( + "target_file.lang", + "s.kind", + "s.name", + "s.id", + signatureSql, + GetSymbolColumnSql("container_name"), + GetSymbolColumnSql("container_qualified_name"), + GetSymbolColumnSql("family_key")); cmd.CommandText = $@" SELECT target_file.path, target_file.lang, @@ -196,6 +205,7 @@ internal ReferencePositionResolution GetReferencePositionResolution( {GetSymbolColumnSql("visibility")} AS visibility, {GetSymbolColumnSql("return_type")} AS return_type, s.id AS symbol_id, + {logicalPartialKeySql} AS logical_partial_key, MAX(CASE WHEN r.target_symbol_id = s.id THEN 1 ELSE 0 END) AS authoritative, MIN(candidate.scope_rank) AS scope_rank FROM symbol_references AS r @@ -243,8 +253,9 @@ GROUP BY s.id Visibility = GetNullableString(reader, 15), ReturnType = GetNullableString(reader, 16), SymbolId = reader.GetInt64(17), + LogicalPartialKey = GetNullableString(reader, 18), }, - reader.GetInt32(18) != 0)); + reader.GetInt32(19) != 0)); } var truncated = candidates.Count > maxCandidates; diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index 2cef55151..c26de8588 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -123,11 +123,286 @@ PRIMARY KEY(derived_qualified_name, base_qualified_name) ) WITHOUT ROWID """; - private const string CSharpTypeReferenceCandidatePredicateSql = """ + private static string BuildCSharpProjectPrefixSql(string symbolAlias) + => $""" + CASE + WHEN INSTR(COALESCE({symbolAlias}.family_key, ''), '|') > 0 + THEN SUBSTR( + {symbolAlias}.family_key, + 1, + INSTR({symbolAlias}.family_key, '|')) + ELSE NULL + END + """; + + private static string BuildCSharpUnprefixedTypeIdentitySql(string symbolAlias) + => $""" + CASE + WHEN COALESCE({symbolAlias}.container_qualified_name, '') = '' + THEN {symbolAlias}.name + WHEN {symbolAlias}.container_qualified_name = {symbolAlias}.name COLLATE BINARY + OR substr( + {symbolAlias}.container_qualified_name, + -length({symbolAlias}.name) - 1 + ) = ('.' || {symbolAlias}.name) COLLATE BINARY + THEN {symbolAlias}.container_qualified_name + ELSE {symbolAlias}.container_qualified_name || '.' || {symbolAlias}.name + END + """; + + private static string BuildCSharpTypeIdentitySql(string symbolAlias, string fileAlias) + => $""" + ( + COALESCE( + {BuildCSharpProjectPrefixSql(symbolAlias)}, + CASE + WHEN INSTR( + ' ' || LOWER( + REPLACE( + REPLACE( + REPLACE( + REPLACE(COALESCE({symbolAlias}.signature, ''), '(', ' '), + ')', + ' '), + ':', + ' '), + CHAR(9), + ' ')) || ' ', + ' partial ') > 0 + THEN '' + ELSE {fileAlias}.path || char(31) + END + ) || + {BuildCSharpUnprefixedTypeIdentitySql(symbolAlias)} || + char(31) || + COALESCE( + csharp_definition_type_arity( + {symbolAlias}.signature, + {symbolAlias}.name, + {symbolAlias}.kind), + -1) + ) + """; + + private static string BuildCSharpConstructorIdentitySql(string symbolAlias, string fileAlias) + => $""" + COALESCE( + ( + SELECT {BuildCSharpTypeIdentitySql("constructor_type", "constructor_type_file")} + FROM symbols AS constructor_type + JOIN files AS constructor_type_file + ON constructor_type_file.id = constructor_type.file_id + AND constructor_type_file.lang = 'csharp' + WHERE constructor_type.file_id = {symbolAlias}.file_id + AND constructor_type.kind IN ('class', 'struct', 'record') + AND COALESCE({symbolAlias}.start_line, {symbolAlias}.line) + BETWEEN COALESCE(constructor_type.start_line, constructor_type.line) + AND COALESCE(constructor_type.end_line, constructor_type.line) + AND {BuildCSharpUnprefixedTypeIdentitySql("constructor_type")} + = COALESCE( + NULLIF({symbolAlias}.container_qualified_name, ''), + NULLIF({symbolAlias}.container_name, ''), + {symbolAlias}.name) COLLATE BINARY + ORDER BY + COALESCE(constructor_type.end_line, constructor_type.line) + - COALESCE(constructor_type.start_line, constructor_type.line), + COALESCE(constructor_type.start_line, constructor_type.line) DESC, + constructor_type.id + LIMIT 1 + ), + COALESCE( + {BuildCSharpProjectPrefixSql(symbolAlias)}, + {fileAlias}.path || char(31)) || + COALESCE( + NULLIF({symbolAlias}.container_qualified_name, ''), + NULLIF({symbolAlias}.container_name, ''), + {symbolAlias}.name) || + char(31) || '-1' + ) + """; + + private static string BuildCSharpConstructorTypeAritySql(string symbolAlias) + => $""" + ( + SELECT csharp_definition_type_arity( + constructor_type.signature, + constructor_type.name, + constructor_type.kind) + FROM symbols AS constructor_type + WHERE constructor_type.file_id = {symbolAlias}.file_id + AND constructor_type.kind IN ('class', 'struct', 'record') + AND COALESCE({symbolAlias}.start_line, {symbolAlias}.line) + BETWEEN COALESCE(constructor_type.start_line, constructor_type.line) + AND COALESCE(constructor_type.end_line, constructor_type.line) + AND {BuildCSharpUnprefixedTypeIdentitySql("constructor_type")} + = COALESCE( + NULLIF({symbolAlias}.container_qualified_name, ''), + NULLIF({symbolAlias}.container_name, ''), + {symbolAlias}.name) COLLATE BINARY + ORDER BY + COALESCE(constructor_type.end_line, constructor_type.line) + - COALESCE(constructor_type.start_line, constructor_type.line), + COALESCE(constructor_type.start_line, constructor_type.line) DESC, + constructor_type.id + LIMIT 1 + ) + """; + + private static string CSharpReferenceTypeAritySql => """ + csharp_reference_type_arity( + COALESCE( + r.context, + (SELECT reference_line.context + FROM reference_lines AS reference_line + WHERE reference_line.id = r.reference_line_id)), + r.symbol_name, + r.column_number) + """; + + private static string CSharpReferenceArgumentCountSql => """ + csharp_invocation_argument_count( + COALESCE( + r.context, + (SELECT reference_line.context + FROM reference_lines AS reference_line + WHERE reference_line.id = r.reference_line_id)), + r.symbol_name, + r.column_number) + """; + + private static string CSharpTypeReferenceCandidatePredicateSql => $""" ( source_file.lang <> 'csharp' - OR r.reference_kind <> 'type_reference' + OR r.reference_kind NOT IN ('instantiate', 'type_reference') OR CASE + WHEN r.reference_kind = 'instantiate' + AND s.name <> r.symbol_name COLLATE BINARY THEN 0 + WHEN r.reference_kind = 'instantiate' + AND s.kind = 'function' + AND s.container_name = s.name COLLATE BINARY + AND csharp_constructor_parameter_count( + s.signature, + s.name, + s.kind) IS NOT NULL + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR csharp_constructor_parameter_count( + s.signature, + s.name, + s.kind) = {CSharpReferenceArgumentCountSql} + ) + AND ( + {CSharpReferenceTypeAritySql} IS NULL + OR {BuildCSharpConstructorTypeAritySql("s")} + = {CSharpReferenceTypeAritySql} + ) THEN 1 + WHEN r.reference_kind = 'instantiate' + AND s.kind IN ('class', 'struct', 'record', 'enum', 'delegate') + AND ( + {CSharpReferenceTypeAritySql} IS NULL + OR csharp_definition_type_arity(s.signature, s.name, s.kind) + = {CSharpReferenceTypeAritySql} + ) + AND s.id = ( + SELECT representative.id + FROM symbols AS representative + JOIN files AS representative_file + ON representative_file.id = representative.file_id + AND representative_file.lang = 'csharp' + WHERE representative.name_folded = s.name_folded + AND representative.name = s.name COLLATE BINARY + AND representative.kind IN ( + 'class', + 'struct', + 'record', + 'enum', + 'delegate') + AND {BuildCSharpTypeIdentitySql("representative", "representative_file")} + = {BuildCSharpTypeIdentitySql("s", "target_file")} COLLATE BINARY + ORDER BY representative_file.path, + COALESCE(representative.start_line, representative.line), + representative.id + LIMIT 1 + ) + AND ( + ( + csharp_constructor_parameter_count( + s.signature, + s.name, + s.kind) IS NOT NULL + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR csharp_constructor_parameter_count( + s.signature, + s.name, + s.kind) = {CSharpReferenceArgumentCountSql} + ) + ) + OR s.kind = 'delegate' + OR ( + s.kind = 'enum' + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR {CSharpReferenceArgumentCountSql} = 0 + ) + ) + OR ( + s.kind IN ('class', 'record') + AND csharp_definition_is_value_type(s.signature, s.kind) = 0 + AND csharp_constructor_parameter_count( + s.signature, + s.name, + s.kind) IS NULL + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR {CSharpReferenceArgumentCountSql} = 0 + ) + AND NOT EXISTS ( + SELECT 1 + FROM symbols AS explicit_constructor + JOIN files AS constructor_file + ON constructor_file.id = explicit_constructor.file_id + AND constructor_file.lang = 'csharp' + WHERE explicit_constructor.name_folded = s.name_folded + AND explicit_constructor.name = s.name COLLATE BINARY + AND explicit_constructor.kind = 'function' + AND explicit_constructor.container_name = + explicit_constructor.name COLLATE BINARY + AND csharp_constructor_parameter_count( + explicit_constructor.signature, + explicit_constructor.name, + explicit_constructor.kind) IS NOT NULL + AND {BuildCSharpConstructorIdentitySql("explicit_constructor", "constructor_file")} + = {BuildCSharpTypeIdentitySql("s", "target_file")} COLLATE BINARY + ) + ) + OR ( + csharp_definition_is_value_type(s.signature, s.kind) = 1 + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR {CSharpReferenceArgumentCountSql} = 0 + ) + AND NOT EXISTS ( + SELECT 1 + FROM symbols AS explicit_zero_constructor + JOIN files AS zero_constructor_file + ON zero_constructor_file.id = explicit_zero_constructor.file_id + AND zero_constructor_file.lang = 'csharp' + WHERE explicit_zero_constructor.name_folded = s.name_folded + AND explicit_zero_constructor.name = s.name COLLATE BINARY + AND explicit_zero_constructor.kind = 'function' + AND explicit_zero_constructor.container_name = + explicit_zero_constructor.name COLLATE BINARY + AND csharp_constructor_parameter_count( + explicit_zero_constructor.signature, + explicit_zero_constructor.name, + explicit_zero_constructor.kind) = 0 + AND {BuildCSharpConstructorIdentitySql("explicit_zero_constructor", "zero_constructor_file")} + = {BuildCSharpTypeIdentitySql("s", "target_file")} COLLATE BINARY + ) + ) + ) THEN 1 + WHEN r.reference_kind = 'instantiate' THEN 0 WHEN s.kind NOT IN ('class', 'struct', 'record', 'interface', 'enum', 'delegate') THEN 0 WHEN s.name <> r.symbol_name COLLATE BINARY THEN 0 WHEN csharp_reference_type_arity( @@ -481,11 +756,22 @@ AND source_file.lang <> 'markdown' AND r.target_qualifier IS NOT NULL AND r.target_qualifier NOT LIKE char(31) || 'receiver:%' AND ( - s.container_name = r.target_qualifier COLLATE NOCASE - OR s.container_qualified_name = r.target_qualifier COLLATE NOCASE - OR s.container_qualified_name LIKE '%.' || r.target_qualifier COLLATE NOCASE - OR ( - source_file.lang IN ( + s.container_name = r.target_qualifier COLLATE NOCASE + OR s.container_qualified_name = r.target_qualifier COLLATE NOCASE + OR s.container_qualified_name LIKE '%.' || r.target_qualifier COLLATE NOCASE + OR ( + source_file.lang = 'csharp' + AND r.reference_kind = 'instantiate' + AND s.kind = 'function' + AND ( + s.container_qualified_name = + (r.target_qualifier || '.' || r.symbol_name) COLLATE NOCASE + OR s.container_qualified_name LIKE + ('%.' || r.target_qualifier || '.' || r.symbol_name) COLLATE NOCASE + ) + ) + OR ( + source_file.lang IN ( 'ada', 'ambiguous_m', 'cython', @@ -668,7 +954,7 @@ INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) FROM symbol_references AS r JOIN files AS source_file ON source_file.id = r.file_id JOIN ( - SELECT MIN(type_symbol.id) AS symbol_id, + SELECT type_symbol.id AS symbol_id, type_symbol.name_folded, type_symbol.name, csharp_definition_type_arity( @@ -676,20 +962,38 @@ SELECT MIN(type_symbol.id) AS symbol_id, type_symbol.name, type_symbol.kind) AS type_arity FROM symbols AS type_symbol - JOIN files AS target_file ON target_file.id = type_symbol.file_id + JOIN files AS target_file + ON target_file.id = type_symbol.file_id WHERE target_file.lang = 'csharp' AND type_symbol.name_folded IS NOT NULL AND type_symbol.kind IN ('class', 'struct', 'record', 'interface', 'enum', 'delegate') - GROUP BY type_symbol.name_folded, type_symbol.name, type_arity - HAVING type_arity IS NOT NULL - AND COUNT(DISTINCT target_file.path || char(31) || - COALESCE( - type_symbol.container_qualified_name, - type_symbol.container_name, - '') || char(31) || - COALESCE(type_symbol.name, '')) = 1 - ) AS unique_target ON unique_target.name_folded = r.symbol_name_folded - AND unique_target.name = r.symbol_name COLLATE BINARY + AND csharp_definition_type_arity( + type_symbol.signature, + type_symbol.name, + type_symbol.kind) IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM symbols AS other_type + JOIN files AS other_type_file + ON other_type_file.id = other_type.file_id + AND other_type_file.lang = 'csharp' + WHERE other_type.name_folded = type_symbol.name_folded + AND other_type.name = type_symbol.name COLLATE BINARY + AND other_type.kind IN ('class', 'struct', 'record', 'interface', 'enum', 'delegate') + AND csharp_definition_type_arity( + other_type.signature, + other_type.name, + other_type.kind) + = csharp_definition_type_arity( + type_symbol.signature, + type_symbol.name, + type_symbol.kind) + AND {BuildCSharpTypeIdentitySql("other_type", "other_type_file")} + <> {BuildCSharpTypeIdentitySql("type_symbol", "target_file")} COLLATE BINARY + ) + ) AS unique_target + ON unique_target.name_folded = r.symbol_name_folded + AND unique_target.name = r.symbol_name COLLATE BINARY WHERE source_file.lang = 'csharp' AND r.target_qualifier IS NULL AND r.reference_kind = 'type_reference' @@ -700,8 +1004,8 @@ AND r.target_qualifier IS NULL (SELECT reference_line.context FROM reference_lines AS reference_line WHERE reference_line.id = r.reference_line_id)), - r.symbol_name, - r.column_number) IS NULL + r.symbol_name, + r.column_number) IS NULL OR unique_target.type_arity = csharp_reference_type_arity( COALESCE( @@ -810,18 +1114,170 @@ INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) FROM symbol_references AS r JOIN files AS source_file ON source_file.id = r.file_id JOIN ( - SELECT MIN(s.id) AS symbol_id, s.name_folded - FROM symbols AS s - JOIN files AS target_file ON target_file.id = s.file_id - WHERE target_file.lang = 'csharp' - AND s.name_folded IS NOT NULL - AND s.kind IN ('class', 'struct', 'record') - GROUP BY s.name_folded - HAVING COUNT(*) = 1 + SELECT candidate.id AS symbol_id, + unique_type.name_folded, + unique_type.name, + unique_type.type_arity, + unique_type.type_identity, + candidate.kind AS candidate_kind, + csharp_constructor_parameter_count( + candidate.signature, + candidate.name, + candidate.kind) AS constructor_parameter_count, + csharp_definition_is_value_type( + candidate.signature, + candidate.kind) AS is_value_type + FROM ( + SELECT s.name_folded, + s.name, + csharp_definition_type_arity( + s.signature, + s.name, + s.kind) AS type_arity, + MIN({BuildCSharpTypeIdentitySql("s", "target_file")}) AS type_identity + FROM symbols AS s + JOIN files AS target_file ON target_file.id = s.file_id + WHERE target_file.lang = 'csharp' + AND s.name_folded IS NOT NULL + AND s.kind IN ('class', 'struct', 'record', 'enum', 'delegate') + GROUP BY s.name_folded, + s.name, + csharp_definition_type_arity( + s.signature, + s.name, + s.kind) + HAVING COUNT(DISTINCT {BuildCSharpTypeIdentitySql("s", "target_file")}) = 1 + ) AS unique_type + JOIN symbols AS candidate + ON candidate.name_folded = unique_type.name_folded + AND candidate.name = unique_type.name COLLATE BINARY + JOIN files AS candidate_file + ON candidate_file.id = candidate.file_id + AND candidate_file.lang = 'csharp' + WHERE ( + candidate.kind = 'function' + AND candidate.container_name = candidate.name COLLATE BINARY + AND csharp_constructor_parameter_count( + candidate.signature, + candidate.name, + candidate.kind) IS NOT NULL + AND {BuildCSharpConstructorIdentitySql("candidate", "candidate_file")} + = unique_type.type_identity COLLATE BINARY + ) + OR ( + candidate.kind IN ('class', 'struct', 'record', 'enum', 'delegate') + AND {BuildCSharpTypeIdentitySql("candidate", "candidate_file")} + = unique_type.type_identity COLLATE BINARY + AND candidate.id = ( + SELECT representative.id + FROM symbols AS representative + JOIN files AS representative_file + ON representative_file.id = representative.file_id + AND representative_file.lang = 'csharp' + WHERE representative.name_folded = unique_type.name_folded + AND representative.name = unique_type.name COLLATE BINARY + AND representative.kind IN ( + 'class', + 'struct', + 'record', + 'enum', + 'delegate') + AND {BuildCSharpTypeIdentitySql("representative", "representative_file")} + = unique_type.type_identity COLLATE BINARY + ORDER BY representative_file.path, + COALESCE(representative.start_line, representative.line), + representative.id + LIMIT 1 + ) + ) ) AS unique_target ON unique_target.name_folded = r.symbol_name_folded + AND unique_target.name = r.symbol_name COLLATE BINARY WHERE source_file.lang = 'csharp' AND r.target_qualifier IS NULL AND r.reference_kind = 'instantiate' + AND ( + {CSharpReferenceTypeAritySql} IS NULL + OR unique_target.type_arity = {CSharpReferenceTypeAritySql} + ) + AND ( + ( + unique_target.candidate_kind = 'function' + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR unique_target.constructor_parameter_count + = {CSharpReferenceArgumentCountSql} + ) + ) + OR ( + unique_target.candidate_kind IN ('class', 'struct', 'record') + AND unique_target.constructor_parameter_count IS NOT NULL + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR unique_target.constructor_parameter_count + = {CSharpReferenceArgumentCountSql} + ) + ) + OR unique_target.candidate_kind = 'delegate' + OR ( + unique_target.candidate_kind = 'enum' + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR {CSharpReferenceArgumentCountSql} = 0 + ) + ) + OR ( + unique_target.candidate_kind IN ('class', 'record') + AND unique_target.is_value_type = 0 + AND unique_target.constructor_parameter_count IS NULL + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR {CSharpReferenceArgumentCountSql} = 0 + ) + AND NOT EXISTS ( + SELECT 1 + FROM symbols AS explicit_constructor + JOIN files AS constructor_file + ON constructor_file.id = explicit_constructor.file_id + AND constructor_file.lang = 'csharp' + WHERE explicit_constructor.name_folded = unique_target.name_folded + AND explicit_constructor.name = unique_target.name COLLATE BINARY + AND explicit_constructor.kind = 'function' + AND explicit_constructor.container_name = + explicit_constructor.name COLLATE BINARY + AND csharp_constructor_parameter_count( + explicit_constructor.signature, + explicit_constructor.name, + explicit_constructor.kind) IS NOT NULL + AND {BuildCSharpConstructorIdentitySql("explicit_constructor", "constructor_file")} + = unique_target.type_identity COLLATE BINARY + ) + ) + OR ( + unique_target.is_value_type = 1 + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR {CSharpReferenceArgumentCountSql} = 0 + ) + AND NOT EXISTS ( + SELECT 1 + FROM symbols AS explicit_zero_constructor + JOIN files AS zero_constructor_file + ON zero_constructor_file.id = explicit_zero_constructor.file_id + AND zero_constructor_file.lang = 'csharp' + WHERE explicit_zero_constructor.name_folded = unique_target.name_folded + AND explicit_zero_constructor.name = unique_target.name COLLATE BINARY + AND explicit_zero_constructor.kind = 'function' + AND explicit_zero_constructor.container_name = + explicit_zero_constructor.name COLLATE BINARY + AND csharp_constructor_parameter_count( + explicit_zero_constructor.signature, + explicit_zero_constructor.name, + explicit_zero_constructor.kind) = 0 + AND {BuildCSharpConstructorIdentitySql("explicit_zero_constructor", "zero_constructor_file")} + = unique_target.type_identity COLLATE BINARY + ) + ) + ) AND NOT EXISTS ( SELECT 1 FROM symbol_reference_candidates AS existing WHERE existing.reference_id = r.id diff --git a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs index d400a9bf7..f0a5ce54c 100644 --- a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs +++ b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs @@ -25,6 +25,44 @@ internal static bool IsMemberReceiver(string? context, string? symbolName, long? return cursor < context.Length && context[cursor] == '.'; } + internal static int? GetInvocationArgumentCount( + string? context, + string? symbolName, + long? columnNumber) + { + if (string.IsNullOrWhiteSpace(context) || string.IsNullOrWhiteSpace(symbolName)) + return null; + + var occurrence = FindClosestIdentifierOccurrence(context, symbolName, columnNumber); + if (occurrence < 0) + return null; + + var cursor = occurrence + symbolName.Length; + if (!SkipCSharpTrivia(context, ref cursor)) + return null; + if (cursor < context.Length && context[cursor] == '<') + { + if (!TryCountTopLevelTypeArguments( + context, + cursor, + out _, + out var closeAngleIndex)) + { + return null; + } + + cursor = closeAngleIndex + 1; + if (!SkipCSharpTrivia(context, ref cursor)) + return null; + } + + return cursor < context.Length + && context[cursor] == '(' + && TryCountTopLevelParameters(context, cursor, out var count) + ? count + : null; + } + internal static int? GetDefinitionArity(string? signature, string? symbolName, string? symbolKind) { if (string.IsNullOrWhiteSpace(symbolName)) @@ -37,6 +75,75 @@ internal static bool IsMemberReceiver(string? context, string? symbolName, long? return occurrence < 0 ? null : ReadArityAfterIdentifier(signature, occurrence, symbolName.Length); } + internal static int? GetConstructorParameterCount( + string? signature, + string? symbolName, + string? symbolKind) + { + if (string.IsNullOrWhiteSpace(signature) || string.IsNullOrWhiteSpace(symbolName)) + return null; + + var typeDeclaration = symbolKind is "class" or "struct" or "record"; + var constructorFunction = string.Equals(symbolKind, "function", StringComparison.Ordinal); + if (!typeDeclaration && !constructorFunction) + return null; + + var searchStart = typeDeclaration ? FindDeclarationKeywordEnd(signature, symbolKind) : 0; + for (var searchAt = Math.Clamp(searchStart, 0, signature.Length); + searchAt <= signature.Length - symbolName.Length;) + { + var occurrence = signature.IndexOf(symbolName, searchAt, StringComparison.Ordinal); + if (occurrence < 0) + return null; + searchAt = occurrence + Math.Max(1, symbolName.Length); + if (!IsIdentifierOccurrence(signature, occurrence, symbolName.Length)) + continue; + + if (constructorFunction) + { + var previous = occurrence - 1; + while (previous >= 0 && char.IsWhiteSpace(signature[previous])) + previous--; + if (previous >= 0 && signature[previous] == '~') + return null; + if (ContainsIdentifier(signature, "static", occurrence)) + return null; + } + + var cursor = occurrence + symbolName.Length; + if (!SkipCSharpTrivia(signature, ref cursor)) + return null; + if (typeDeclaration && cursor < signature.Length && signature[cursor] == '<') + { + if (!TryCountTopLevelTypeArguments( + signature, + cursor, + out _, + out var closeAngleIndex)) + { + return null; + } + + cursor = closeAngleIndex + 1; + if (!SkipCSharpTrivia(signature, ref cursor)) + return null; + } + + if (cursor < signature.Length && signature[cursor] == '(') + return TryCountTopLevelParameters(signature, cursor, out var count) ? count : null; + if (typeDeclaration) + return null; + } + + return null; + } + + internal static bool IsValueTypeDeclaration(string? signature, string? symbolKind) + => string.Equals(symbolKind, "struct", StringComparison.Ordinal) + || (string.Equals(symbolKind, "record", StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(signature) + && ContainsIdentifier(signature, "struct", signature.Length)); + private static int FindClosestIdentifierOccurrence(string text, string identifier, long? columnNumber) { var expectedIndex = columnNumber is > 0 and <= int.MaxValue @@ -97,6 +204,21 @@ private static int FindClosestIdentifierOccurrence(string text, string identifie return -1; } + private static bool ContainsIdentifier(string text, string identifier, int endExclusive) + { + for (var searchAt = 0; searchAt <= endExclusive - identifier.Length;) + { + var occurrence = text.IndexOf(identifier, searchAt, StringComparison.Ordinal); + if (occurrence < 0 || occurrence >= endExclusive) + return false; + if (IsIdentifierOccurrence(text, occurrence, identifier.Length)) + return true; + searchAt = occurrence + identifier.Length; + } + + return false; + } + private static int FindDefinitionIdentifierOccurrence( string signature, string symbolName, @@ -247,6 +369,93 @@ private static bool TryCountTopLevelTypeArguments( return false; } + private static bool TryCountTopLevelParameters(string text, int openParenthesis, out int count) + { + count = 0; + var parenthesisDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var angleDepth = 0; + var hasItemContent = false; + for (var i = openParenthesis + 1; i < text.Length; i++) + { + var c = text[i]; + if (c is '"' or '\'') + { + i = SkipQuotedLiteral(text, i, c); + if (i >= text.Length) + return false; + hasItemContent = true; + continue; + } + if (c == '/' && i + 1 < text.Length) + { + if (text[i + 1] == '/') + return false; + if (text[i + 1] == '*') + { + i = SkipBlockComment(text, i); + if (i >= text.Length) + return false; + continue; + } + } + + switch (c) + { + case '(': + parenthesisDepth++; + hasItemContent = true; + break; + case ')' when parenthesisDepth > 0: + parenthesisDepth--; + hasItemContent = true; + break; + case ')' when bracketDepth == 0 && braceDepth == 0 && angleDepth == 0: + count = hasItemContent ? count + 1 : 0; + return true; + case '[': + bracketDepth++; + hasItemContent = true; + break; + case ']' when bracketDepth > 0: + bracketDepth--; + hasItemContent = true; + break; + case '{': + braceDepth++; + hasItemContent = true; + break; + case '}' when braceDepth > 0: + braceDepth--; + hasItemContent = true; + break; + case '<': + angleDepth++; + hasItemContent = true; + break; + case '>' when angleDepth > 0: + angleDepth--; + hasItemContent = true; + break; + case ',' when parenthesisDepth == 0 + && bracketDepth == 0 + && braceDepth == 0 + && angleDepth == 0: + if (!hasItemContent) + return false; + count++; + hasItemContent = false; + break; + default: + hasItemContent |= !char.IsWhiteSpace(c); + break; + } + } + + return false; + } + private static int SkipQuotedLiteral(string text, int quoteIndex, char quote) { var verbatim = quote == '"' && quoteIndex > 0 && text[quoteIndex - 1] == '@'; diff --git a/src/CodeIndex/Lsp/LspServer.PositionResolution.cs b/src/CodeIndex/Lsp/LspServer.PositionResolution.cs index 51879da12..742c8bf03 100644 --- a/src/CodeIndex/Lsp/LspServer.PositionResolution.cs +++ b/src/CodeIndex/Lsp/LspServer.PositionResolution.cs @@ -8,6 +8,7 @@ using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Diagnostics; +using CodeIndex.Indexer; using CodeIndex.Mcp; using CodeIndex.Models; using CodeIndex.Security; @@ -25,16 +26,16 @@ private List ResolveLspDefinitions(PositionTokenContext contex if (positionDefinitions.Count > 0) return positionDefinitions; - var localReferenceTarget = ResolveReferenceTargetAtPosition(context); - return localReferenceTarget == null ? localDefinitions : [localReferenceTarget]; + var localReferenceTargets = ResolveReferenceTargetsAtPosition(context); + return localReferenceTargets.Count == 0 ? localDefinitions : localReferenceTargets; } var workspaceDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true); if (workspaceDefinitions.Count > 1) { - var referenceTarget = ResolveReferenceTargetAtPosition(context); - if (referenceTarget != null) - return [referenceTarget]; + var referenceTargets = ResolveReferenceTargetsAtPosition(context); + if (referenceTargets.Count > 0) + return referenceTargets; } return workspaceDefinitions; } @@ -70,6 +71,12 @@ private IReadOnlyList ResolveLspReferences(PositionTokenContext } private DefinitionResult? ResolveReferenceTargetAtPosition(PositionTokenContext context) + { + var targets = ResolveReferenceTargetsAtPosition(context); + return targets.Count == 1 ? targets[0] : null; + } + + private List ResolveReferenceTargetsAtPosition(PositionTokenContext context) { var resolution = _reader.GetReferencePositionResolution( context.IndexedPath, @@ -78,29 +85,63 @@ private IReadOnlyList ResolveLspReferences(PositionTokenContext context.StartCharacter + 1, MaxReferencePositionCandidates); if (!resolution.IdentityAvailable || resolution.CandidatesTruncated) - return null; + return []; var selected = resolution.Candidates .Where(candidate => candidate.Authoritative) .Take(2) .ToList(); if (selected.Count == 1) - return _reader.GetDefinitionForSymbol(selected[0].Definition); + { + var authoritativeDefinition = _reader.GetDefinitionForSymbol(selected[0].Definition); + return authoritativeDefinition == null ? [] : [authoritativeDefinition]; + } if (TryGetCSharpInvocationArgumentCount(context, out var argumentCount)) { selected = resolution.Candidates .Where(candidate => TryGetCSharpDefinitionParameterCount(candidate.Definition, out var parameterCount) && parameterCount == argumentCount) - .Take(2) .ToList(); - if (selected.Count == 1) - return _reader.GetDefinitionForSymbol(selected[0].Definition); + if (selected.Count > 0) + { + return selected + .Select(candidate => _reader.GetDefinitionForSymbol(candidate.Definition)) + .OfType() + .OrderBy(definition => definition.Path, StringComparer.Ordinal) + .ThenBy(definition => definition.StartLine) + .ToList(); + } } - return resolution.Candidates.Count == 1 - ? _reader.GetDefinitionForSymbol(resolution.Candidates[0].Definition) - : null; + if (resolution.Candidates.Count == 1) + { + var onlyDefinition = _reader.GetDefinitionForSymbol(resolution.Candidates[0].Definition); + return onlyDefinition == null ? [] : [onlyDefinition]; + } + + var typeFamilyKeys = resolution.Candidates + .Select(candidate => + LogicalPartialSymbolGrouper.TryBuildKey(candidate.Definition, out var key) + ? key + : null) + .Where(static key => key != null) + .Distinct(StringComparer.Ordinal) + .Take(2) + .ToList(); + if (typeFamilyKeys.Count != 1 || + resolution.Candidates.Any(candidate => + !LogicalPartialSymbolGrouper.TryBuildKey(candidate.Definition, out _))) + { + return []; + } + + return resolution.Candidates + .Select(candidate => _reader.GetDefinitionForSymbol(candidate.Definition)) + .OfType() + .OrderBy(definition => definition.Path, StringComparer.Ordinal) + .ThenBy(definition => definition.StartLine) + .ToList(); } private bool TryGetCSharpInvocationArgumentCount(PositionTokenContext context, out int argumentCount) @@ -126,12 +167,30 @@ private static bool TryGetCSharpDefinitionParameterCount(SymbolResult definition return false; } - var nameStart = FindIdentifierOccurrence(definition.Signature, definition.Name, 0); - if (nameStart < 0) - return false; - var openParenthesis = definition.Signature.IndexOf('(', nameStart + definition.Name.Length); - return openParenthesis >= 0 && - TryCountCommaSeparatedItems(definition.Signature, openParenthesis, allowAngleBrackets: true, out parameterCount); + var count = CSharpTypeReferenceArity.GetConstructorParameterCount( + definition.Signature, + definition.Name, + definition.Kind); + if (!count.HasValue) + { + if (!string.Equals(definition.Kind, "function", StringComparison.Ordinal)) + return false; + var nameStart = FindIdentifierOccurrence(definition.Signature, definition.Name, 0); + if (nameStart < 0) + return false; + var openParenthesis = definition.Signature.IndexOf( + '(', + nameStart + definition.Name.Length); + return openParenthesis >= 0 && + TryCountCommaSeparatedItems( + definition.Signature, + openParenthesis, + allowAngleBrackets: true, + out parameterCount); + } + + parameterCount = count.Value; + return true; } private static bool TryCountCommaSeparatedItems( diff --git a/tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs b/tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs new file mode 100644 index 000000000..22f27d3da --- /dev/null +++ b/tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs @@ -0,0 +1,51 @@ +using CodeIndex.Indexer; + +namespace CodeIndex.Tests; + +public sealed class CSharpConstructorParameterCountIssue4850Tests +{ + [Theory] + [InlineData("public Widget()", "Widget", "function", 0)] + [InlineData("public Widget(Dictionary value, string text = \"x,y\")", "Widget", "function", 2)] + [InlineData("public class Widget(int value)", "Widget", "class", 1)] + [InlineData("public record Packet(T Value, string Name)", "Packet", "record", 2)] + public void GetConstructorParameterCount_ReturnsInstanceAndPrimaryArities_Issue4850( + string signature, + string name, + string kind, + int expected) + { + Assert.Equal( + expected, + CSharpTypeReferenceArity.GetConstructorParameterCount(signature, name, kind)); + } + + [Theory] + [InlineData("static Widget()")] + [InlineData("~Widget()")] + [InlineData("public class Widget")] + public void GetConstructorParameterCount_RejectsNonInstanceConstructorTargets_Issue4850( + string signature) + { + Assert.Null( + CSharpTypeReferenceArity.GetConstructorParameterCount( + signature, + "Widget", + signature.Contains("class", StringComparison.Ordinal) ? "class" : "function")); + } + + [Theory] + [InlineData("new Widget()", "Widget", 5, 0)] + [InlineData("new Widget(1, Factory.Create(\"x,y\"))", "Widget", 5, 2)] + [InlineData("first = new Widget(1); second = new Widget(2, 3);", "Widget", 37, 2)] + public void GetInvocationArgumentCount_UsesReferencedOccurrence_Issue4850( + string context, + string name, + long column, + int expected) + { + Assert.Equal( + expected, + CSharpTypeReferenceArity.GetInvocationArgumentCount(context, name, column)); + } +} diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs index c947c08ef..164d45109 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs @@ -286,7 +286,7 @@ public void Run_UpdateMode_NoOpRepairsVersion4MarkdownCandidates_Issue4846() var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); Assert.Equal(CommandExitCodes.Success, initialExitCode); - Assert.Equal(5, DbContext.ReferenceIdentityContractVersion); + Assert.Equal(6, DbContext.ReferenceIdentityContractVersion); var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); using (var connection = new SqliteConnection($"Data Source={dbPath}")) @@ -351,7 +351,7 @@ UPDATE codeindex_meta using var markerCommand = verification.CreateCommand(); markerCommand.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key"; markerCommand.Parameters.AddWithValue("@key", DbContext.ReferenceIdentityContractVersionMetaKey); - Assert.Equal("5", Convert.ToString(markerCommand.ExecuteScalar(), CultureInfo.InvariantCulture)); + Assert.Equal("6", Convert.ToString(markerCommand.ExecuteScalar(), CultureInfo.InvariantCulture)); using var resolutionCommand = verification.CreateCommand(); resolutionCommand.CommandText = """ @@ -392,6 +392,87 @@ FROM symbol_reference_candidates AS candidate } } + [Fact] + public void Run_UpdateMode_NoOpRepairsPriorConstructorIdentityContract_Issue4850() + { + var projectRoot = CreateTempProject(); + try + { + File.WriteAllText( + Path.Combine(projectRoot, "Caller.cs"), + "public class Caller { public object Create() => new Target(); }\n"); + File.WriteAllText( + Path.Combine(projectRoot, "Target.cs"), + "public class Target { public Target() { } }\n"); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + DELETE FROM symbol_reference_candidates + WHERE reference_id IN ( + SELECT id + FROM symbol_references + WHERE reference_kind = 'instantiate' + AND symbol_name = 'Target' + ); + INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) + SELECT reference.id, symbol.id, 5 + FROM symbol_references AS reference + JOIN symbols AS symbol ON symbol.name = reference.symbol_name + WHERE reference.reference_kind = 'instantiate' + AND reference.symbol_name = 'Target'; + UPDATE codeindex_meta + SET value = '5' + WHERE key = @key; + """; + command.Parameters.AddWithValue("@key", DbContext.ReferenceIdentityContractVersionMetaKey); + command.ExecuteNonQuery(); + } + + var (updateExitCode, updateJson) = RunAndCaptureJson( + [projectRoot, "--files", "Caller.cs", "--json"]); + + Assert.Equal(CommandExitCodes.Success, updateExitCode); + Assert.Equal(0, updateJson.GetProperty("summary").GetProperty("updated").GetInt32()); + Assert.Equal(1, updateJson.GetProperty("summary").GetProperty("skipped").GetInt32()); + + using var verification = new SqliteConnection($"Data Source={dbPath}"); + verification.Open(); + using var markerCommand = verification.CreateCommand(); + markerCommand.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key"; + markerCommand.Parameters.AddWithValue("@key", DbContext.ReferenceIdentityContractVersionMetaKey); + Assert.Equal( + DbContext.ReferenceIdentityContractVersion.ToString(CultureInfo.InvariantCulture), + Convert.ToString(markerCommand.ExecuteScalar(), CultureInfo.InvariantCulture)); + + using var candidateCommand = verification.CreateCommand(); + candidateCommand.CommandText = """ + SELECT symbol.kind + FROM symbol_reference_candidates AS candidate + JOIN symbol_references AS reference ON reference.id = candidate.reference_id + JOIN symbols AS symbol ON symbol.id = candidate.symbol_id + WHERE reference.reference_kind = 'instantiate' + AND reference.symbol_name = 'Target' + """; + using var candidateReader = candidateCommand.ExecuteReader(); + var candidateKinds = new List(); + while (candidateReader.Read()) + candidateKinds.Add(candidateReader.GetString(0)); + Assert.Equal(["function"], candidateKinds); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_UpdateMode_TargetOnlyMarkdownAnchorChangesRefreshExactReferences_Issue4846() { diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 305e2fd32..b9ca14c5b 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -2892,6 +2892,417 @@ public Widget() { } } } + [Fact] + public void HandleMessage_ConstructorNavigation_SeparatesCallableAndPartialTypeIdentities_Issue4850() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_constructor_identity"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var explicitAPath = Path.Combine(projectRoot, "explicit-a.cs"); + var explicitBPath = Path.Combine(projectRoot, "explicit-b.cs"); + var implicitAPath = Path.Combine(projectRoot, "implicit-a.cs"); + var implicitBPath = Path.Combine(projectRoot, "implicit-b.cs"); + var recordPath = Path.Combine(projectRoot, "packet.cs"); + var callerPath = Path.Combine(projectRoot, "caller.cs"); + var explicitASource = """ + namespace Demo; + + public partial class Widget + { + public Widget() { } + } + + public class LocalCaller + { + public object CreateValue() => new Widget(1); + } + """; + var explicitBSource = """ + namespace Demo; + + public partial class Widget + { + public Widget(int value) { } + } + """; + var implicitASource = """ + namespace Demo; + + public partial class ImplicitWidget + { + } + """; + var implicitBSource = """ + namespace Demo; + + public partial class ImplicitWidget + { + } + """; + var recordSource = """ + namespace Demo; + + public sealed record Packet(int Id, string Name); + """; + var callerSource = """ + namespace Demo; + + public class Caller + { + private Widget? _widget; + public object CreateDefault() => new Widget(); + public object CreateValue() => new Widget(1); + public object CreateImplicit() => new ImplicitWidget(); + public object CreatePacket() => new Packet(1, "x"); + } + """; + + foreach (var (path, relativePath, source) in new[] + { + (explicitAPath, "explicit-a.cs", explicitASource), + (explicitBPath, "explicit-b.cs", explicitBSource), + (implicitAPath, "implicit-a.cs", implicitASource), + (implicitBPath, "implicit-b.cs", implicitBSource), + (recordPath, "packet.cs", recordSource), + (callerPath, "caller.cs", callerSource), + }) + { + File.WriteAllText(path, source); + TestProjectHelper.InsertIndexedFile(dbPath, relativePath, "csharp", source); + } + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + db.InitializeSchema(); + new DbWriter(db.Connection).MarkGraphReady(); + using var reader = new DbReader(db); + var packetResolution = reader.GetReferencePositionResolution( + "caller.cs", + "Packet", + 9, + CharacterOf(callerSource, 8, "new Packet") + "new ".Length + 1, + 64); + var packetCandidate = Assert.Single(packetResolution.Candidates); + Assert.Equal("packet.cs", packetCandidate.Definition.Path); + Assert.Equal("class", packetCandidate.Definition.Kind); + using var server = new LspServer( + reader, + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + Assert.NotNull(server.HandleMessage( + CreateInitializeRequestWithWorkspaceFolder(projectRoot, 48500))); + + var localValueCharacter = + CharacterOf(explicitASource, 9, "new Widget") + "new ".Length; + var localValueResolution = reader.GetReferencePositionResolution( + "explicit-a.cs", + "Widget", + 10, + localValueCharacter + 1, + 64); + var localValueCandidate = Assert.Single(localValueResolution.Candidates); + Assert.Equal("explicit-b.cs", localValueCandidate.Definition.Path); + Assert.Equal("function", localValueCandidate.Definition.Kind); + + foreach (var method in new[] { "textDocument/definition", "textDocument/declaration" }) + { + var localValueResponse = server.HandleMessage(CreatePositionRequest( + method, + explicitAPath, + 48511, + 9, + localValueCharacter)); + + Assert.NotNull(localValueResponse); + var localValueLocation = Assert.Single(localValueResponse!["result"]!.AsArray()); + Assert.Equal( + new Uri(explicitBPath).AbsoluteUri, + localValueLocation!["uri"]!.GetValue()); + Assert.Equal( + 4, + localValueLocation["range"]!["start"]!["line"]!.GetValue()); + } + + foreach (var (line, character, expectedPath, expectedLine) in new[] + { + (5, CharacterOf(callerSource, 5, "Widget"), explicitAPath, 4), + (6, CharacterOf(callerSource, 6, "Widget"), explicitBPath, 4), + (7, CharacterOf(callerSource, 7, "ImplicitWidget"), implicitAPath, 2), + (8, CharacterOf(callerSource, 8, "new Packet") + "new ".Length, recordPath, 2), + }) + { + foreach (var method in new[] { "textDocument/definition", "textDocument/declaration" }) + { + var response = server.HandleMessage(CreatePositionRequest( + method, + callerPath, + 48500 + line, + line, + character)); + + Assert.NotNull(response); + var location = Assert.Single(response!["result"]!.AsArray()); + Assert.Equal(new Uri(expectedPath).AbsoluteUri, location!["uri"]!.GetValue()); + Assert.Equal(expectedLine, location["range"]!["start"]!["line"]!.GetValue()); + } + } + + var typeResponse = server.HandleMessage(CreateDefinitionRequest( + callerPath, + 48510, + 4, + CharacterOf(callerSource, 4, "Widget"))); + + Assert.NotNull(typeResponse); + var typeLocations = typeResponse!["result"]!.AsArray(); + Assert.Equal(2, typeLocations.Count); + Assert.Equal( + new[] { explicitAPath, explicitBPath }, + typeLocations + .Select(location => new Uri(location!["uri"]!.GetValue()).LocalPath) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray()); + Assert.All(typeLocations, location => + Assert.Equal(2, location!["range"]!["start"]!["line"]!.GetValue())); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_ConstructorNavigation_HandlesAdversarialConstructorForms_Issue4850() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_constructor_adversarial"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var typesPath = Path.Combine(projectRoot, "types.cs"); + var callerPath = Path.Combine(projectRoot, "caller.cs"); + var typesSource = """ + namespace Demo; + + public class Lifecycle + { + static Lifecycle() { } + ~Lifecycle() { } + } + + public class Primary(int value) + { + public Primary() : this(0) { } + } + + public class Box { } + public class Box { } + + public class Choice + { + public Choice(int value) { } + public Choice(string value) { } + } + """; + var callerSource = """ + namespace Demo; + + public class Caller + { + public object CreateLifecycle() => new Lifecycle(); + public object CreatePrimaryDefault() => new Primary(); + public object CreatePrimaryValue() => new Primary(1); + public object CreateGeneric() => new Box(); + public object CreateChoice() => new Choice(1); + } + """; + + foreach (var (path, relativePath, source) in new[] + { + (typesPath, "types.cs", typesSource), + (callerPath, "caller.cs", callerSource), + }) + { + File.WriteAllText(path, source); + TestProjectHelper.InsertIndexedFile(dbPath, relativePath, "csharp", source); + } + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + db.InitializeSchema(); + new DbWriter(db.Connection).MarkGraphReady(); + using var reader = new DbReader(db); + foreach (var (line, token, expectedKinds, expectedLines) in new[] + { + (4, "Lifecycle", new[] { "class" }, new[] { 2 }), + (5, "Primary", new[] { "function" }, new[] { 10 }), + (6, "Primary", new[] { "class" }, new[] { 8 }), + (7, "Box", new[] { "class" }, new[] { 14 }), + (8, "Choice", new[] { "function", "function" }, new[] { 18, 19 }), + }) + { + var character = + CharacterOf(callerSource, line, $"new {token}") + "new ".Length; + var resolution = reader.GetReferencePositionResolution( + "caller.cs", + token, + line + 1, + character + 1, + 64); + var actualKinds = resolution.Candidates + .Select(candidate => candidate.Definition.Kind) + .OrderBy(kind => kind, StringComparer.Ordinal) + .ToArray(); + Assert.True( + expectedKinds.OrderBy(kind => kind, StringComparer.Ordinal).SequenceEqual(actualKinds), + $"Unexpected candidates for {token} on line {line}: {string.Join(", ", actualKinds)}"); + + using var server = new LspServer( + reader, + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + Assert.NotNull(server.HandleMessage( + CreateInitializeRequestWithWorkspaceFolder(projectRoot, 48520 + line))); + foreach (var method in new[] { "textDocument/definition", "textDocument/declaration" }) + { + var response = server.HandleMessage(CreatePositionRequest( + method, + callerPath, + 48520 + line, + line, + character)); + + Assert.NotNull(response); + var locations = response!["result"]!.AsArray(); + Assert.Equal(expectedLines.Length, locations.Count); + Assert.All(locations, location => + Assert.Equal(new Uri(typesPath).AbsoluteUri, location!["uri"]!.GetValue())); + Assert.Equal( + expectedLines, + locations + .Select(location => location!["range"]!["start"]!["line"]!.GetValue()) + .OrderBy(value => value) + .ToArray()); + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_ConstructorNavigation_PreservesValueTypeAndDelegateConstruction_Issue4850() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_constructor_value_types"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var typesPath = Path.Combine(projectRoot, "types.cs"); + var callerPath = Path.Combine(projectRoot, "caller.cs"); + var typesSource = """ + namespace Demo; + + public struct Sample + { + public Sample(int value) { } + } + + public readonly record struct Pair + { + public Pair(int value) { } + } + + public readonly record struct InlinePair(int Value); + + public enum Shade { Red } + public delegate void Callback(); + """; + var callerSource = """ + namespace Demo; + + public class Caller + { + private void Run() { } + public object CreateSampleDefault() => new Sample(); + public object CreateSampleValue() => new Sample(1); + public object CreatePairDefault() => new Pair(); + public object CreatePairValue() => new Pair(1); + public object CreateInlinePairDefault() => new InlinePair(); + public object CreateInlinePairValue() => new InlinePair(1); + public Shade CreateShade() => new Shade(); + public Callback CreateCallback() => new Callback(Run); + } + """; + + foreach (var (path, relativePath, source) in new[] + { + (typesPath, "types.cs", typesSource), + (callerPath, "caller.cs", callerSource), + }) + { + File.WriteAllText(path, source); + TestProjectHelper.InsertIndexedFile(dbPath, relativePath, "csharp", source); + } + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + db.InitializeSchema(); + new DbWriter(db.Connection).MarkGraphReady(); + using var reader = new DbReader(db); + using var server = new LspServer( + reader, + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + Assert.NotNull(server.HandleMessage( + CreateInitializeRequestWithWorkspaceFolder(projectRoot, 48540))); + + foreach (var (line, token, expectedKind, expectedLine) in new[] + { + (5, "Sample", "struct", 2), + (6, "Sample", "function", 4), + (7, "Pair", "struct", 7), + (8, "Pair", "function", 9), + (9, "InlinePair", "struct", 12), + (10, "InlinePair", "struct", 12), + (11, "Shade", "enum", 14), + (12, "Callback", "delegate", 15), + }) + { + var character = + CharacterOf(callerSource, line, $"new {token}") + "new ".Length; + var resolution = reader.GetReferencePositionResolution( + "caller.cs", + token, + line + 1, + character + 1, + 64); + var candidate = Assert.Single(resolution.Candidates); + Assert.Equal(expectedKind, candidate.Definition.Kind); + Assert.Equal("types.cs", candidate.Definition.Path); + + foreach (var method in new[] { "textDocument/definition", "textDocument/declaration" }) + { + var response = server.HandleMessage(CreatePositionRequest( + method, + callerPath, + 48540 + line, + line, + character)); + + Assert.NotNull(response); + var location = Assert.Single(response!["result"]!.AsArray()); + Assert.Equal(new Uri(typesPath).AbsoluteUri, location!["uri"]!.GetValue()); + Assert.Equal( + expectedLine, + location["range"]!["start"]!["line"]!.GetValue()); + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Declaration_ReturnsDefinitionLocation_Issues3537And4420() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index c18f04700..ffad6fc9f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -250,6 +250,81 @@ public partial class McpServer } } + [Fact] + public void ConstructorCommands_KeepCallableAndPartialTypeResultsSeparate_Issue4850() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_constructor_commands_issue4850"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/A.Widget.cs", + "csharp", + """ + namespace Demo; + + public partial class Widget + { + public Widget() { } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/B.Widget.cs", + "csharp", + """ + namespace Demo; + + public partial class Widget + { + public Widget(int value) { } + } + """); + + var (definitionExit, definitionStdout, definitionStderr) = CaptureConsole(() => + QueryCommandRunner.RunDefinition( + ["Widget", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp", "--kind", "function"], + _jsonOptions)); + var constructorDefinitions = ParseJsonLines(definitionStdout); + + Assert.Equal(CommandExitCodes.Success, definitionExit); + Assert.Equal(string.Empty, definitionStderr); + Assert.Equal(2, constructorDefinitions.Count); + Assert.All(constructorDefinitions, row => + Assert.Equal("function", row.RootElement.GetProperty("kind").GetString())); + + var (gotoExit, gotoStdout, gotoStderr) = CaptureConsole(() => + QueryCommandRunner.RunGoto( + ["Widget", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp", "--kind", "function", "--all"], + _jsonOptions)); + using var gotoDocument = ParseJsonOutput(gotoStdout); + var constructorLocations = gotoDocument.RootElement.EnumerateArray().ToList(); + + Assert.Equal(CommandExitCodes.Success, gotoExit); + Assert.Equal(string.Empty, gotoStderr); + Assert.Equal(2, constructorLocations.Count); + Assert.All(constructorLocations, location => + Assert.Equal(4, location.GetProperty("range").GetProperty("start").GetProperty("line").GetInt32())); + + var (typeExit, typeStdout, typeStderr) = CaptureConsole(() => + QueryCommandRunner.RunDefinition( + ["Widget", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp", "--kind", "class", "--group-partials"], + _jsonOptions)); + var typeDefinitions = ParseJsonLines(typeStdout); + var partialType = Assert.Single(typeDefinitions); + + Assert.Equal(CommandExitCodes.Success, typeExit); + Assert.Equal(string.Empty, typeStderr); + Assert.Equal("class", partialType.RootElement.GetProperty("kind").GetString()); + Assert.Equal(2, partialType.RootElement.GetProperty("definition_sites").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunGoto_AllIgnoresDefaultLimitAndHonorsExplicitLimit_Issue4837() {