From e66e0a136b06b3c2b53cac0a8d7cabe9bdabd052 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 10:15:00 +0900 Subject: [PATCH 1/3] Fix constructor navigation identity filtering (#4850) --- TESTING_GUIDE.md | 6 + USER_GUIDE.md | 20 ++ changelog.d/unreleased/4850.fixed.md | 21 ++ src/CodeIndex/Database/DbReader.References.cs | 13 +- src/CodeIndex/Database/DbWriter.References.cs | 281 ++++++++++++++++-- .../Lsp/LspServer.PositionResolution.cs | 59 +++- tests/CodeIndex.Tests/LspServerTests.cs | 141 +++++++++ .../QueryCommandRunnerSymbolTests.cs | 75 +++++ 8 files changed, 575 insertions(+), 41 deletions(-) create mode 100644 changelog.d/unreleased/4850.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index aa9f267af..ad8fb810a 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -517,8 +517,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, an implicit partial constructor, a positional record, and an ordinary partial-type reference in one fixture. Assert both definition and declaration requests so source-site identity, arity filtering, stable implicit representatives, 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. @@ -1411,8 +1414,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 に置き、暗黙 partial constructor、positional record、通常の partial-type reference と同じ fixture にまとめます。source-site identity、arity filtering、暗黙 constructor の安定した代表 declaration、意図的な 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 7b2b19700..4f725f0f5 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2702,6 +2702,16 @@ 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. +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. `textDocument/inlayHint` honors the requested LSP range (including its exclusive end position) and omits type labels when the indexed return type is already written immediately before the symbol name, so explicit field, property, and @@ -5813,6 +5823,16 @@ 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 を意図的に返す場合があります。 +CLI の `definition` と `goto` は名前ベースなので、明示 constructor には +`--kind function`、logical type family には type kind と `--group-partials` を +組み合わせてください。 `textDocument/inlayHint` は end position を含まない requested LSP range を尊重し、 indexed return type が symbol name の直前にすでに明記されている場合は type label を 省略するため、field / property / method の明示型を hint として重複表示しません。 diff --git a/changelog.d/unreleased/4850.fixed.md b/changelog.d/unreleased/4850.fixed.md new file mode 100644 index 000000000..13dc30e88 --- /dev/null +++ b/changelog.d/unreleased/4850.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 4850 +affected: + - src/CodeIndex/Database/DbReader.References.cs + - src/CodeIndex/Database/DbWriter.References.cs + - src/CodeIndex/Lsp/LspServer.PositionResolution.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, and invocation arity instead of mixing same-named type declarations into the candidate set. Explicit overloads navigate only to matching constructors, while implicit partial constructors and positional records use one deterministic type representative. Ordinary type references may still expose every declaration in the logical partial family. + +## 日本語 + +- **constructor navigation と partial-type family を分離しました (#4850)** — #4566 の follow-up として、C# の `instantiate` reference は同名の type declaration を candidate set に混在させず、正確な source site、callable constructor identity、invocation arity で解決するようになりました。明示 overload は一致する constructor だけへ移動し、暗黙 partial constructor と positional record は決定的な代表 type を使います。通常の type reference は引き続き logical partial family の全 declaration を公開できます。 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 86151ae26..ac672fce6 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -123,11 +123,150 @@ 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)} + ) + """; + + private static string BuildCSharpConstructorIdentitySql(string symbolAlias, string fileAlias) + => $""" + ( + COALESCE( + {BuildCSharpProjectPrefixSql(symbolAlias)}, + CASE + WHEN EXISTS ( + SELECT 1 + FROM symbols AS constructor_partial_type + JOIN files AS constructor_partial_file + ON constructor_partial_file.id = constructor_partial_type.file_id + AND constructor_partial_file.lang = 'csharp' + WHERE constructor_partial_type.name_folded = {symbolAlias}.name_folded + AND constructor_partial_type.name = {symbolAlias}.name COLLATE BINARY + AND constructor_partial_type.kind IN ('class', 'struct', 'record') + AND INSTR( + ' ' || LOWER( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + COALESCE(constructor_partial_type.signature, ''), + '(', + ' '), + ')', + ' '), + ':', + ' '), + CHAR(9), + ' ')) || ' ', + ' partial ') > 0 + AND {BuildCSharpUnprefixedTypeIdentitySql("constructor_partial_type")} + = COALESCE( + NULLIF({symbolAlias}.container_qualified_name, ''), + NULLIF({symbolAlias}.container_name, ''), + {symbolAlias}.name) COLLATE BINARY + ) THEN '' + ELSE {fileAlias}.path || char(31) + END + ) || + COALESCE( + NULLIF({symbolAlias}.container_qualified_name, ''), + NULLIF({symbolAlias}.container_name, ''), + {symbolAlias}.name) + ) + """; + + 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 THEN 1 + WHEN r.reference_kind = 'instantiate' + AND s.kind IN ('class', 'struct', 'record') + 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') + 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 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 {BuildCSharpConstructorIdentitySql("explicit_constructor", "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( @@ -448,11 +587,22 @@ THEN r.symbol_name_folded || 'attribute' END 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', @@ -631,7 +781,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( @@ -639,20 +789,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' @@ -663,8 +831,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( @@ -773,15 +941,70 @@ 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 + FROM ( + SELECT s.name_folded, + s.name, + 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') + GROUP BY s.name_folded, s.name + 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 {BuildCSharpConstructorIdentitySql("candidate", "candidate_file")} + = unique_type.type_identity COLLATE BINARY + ) + OR ( + candidate.kind IN ('class', 'struct', 'record') + 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') + 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 + ) + 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_type.name_folded + AND explicit_constructor.name = unique_type.name COLLATE BINARY + AND explicit_constructor.kind = 'function' + AND explicit_constructor.container_name = + explicit_constructor.name COLLATE BINARY + AND {BuildCSharpConstructorIdentitySql("explicit_constructor", "constructor_file")} + = unique_type.type_identity 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 = 'instantiate' diff --git a/src/CodeIndex/Lsp/LspServer.PositionResolution.cs b/src/CodeIndex/Lsp/LspServer.PositionResolution.cs index 51879da12..f6bae39f4 100644 --- a/src/CodeIndex/Lsp/LspServer.PositionResolution.cs +++ b/src/CodeIndex/Lsp/LspServer.PositionResolution.cs @@ -25,16 +25,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 +70,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,14 +84,17 @@ 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)) { @@ -95,12 +104,40 @@ private IReadOnlyList ResolveLspReferences(PositionTokenContext .Take(2) .ToList(); if (selected.Count == 1) - return _reader.GetDefinitionForSymbol(selected[0].Definition); + { + var arityDefinition = _reader.GetDefinitionForSymbol(selected[0].Definition); + return arityDefinition == null ? [] : [arityDefinition]; + } + } + + 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.Count == 1 - ? _reader.GetDefinitionForSymbol(resolution.Candidates[0].Definition) - : null; + 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) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 945a156ed..aa3db6154 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -2425,6 +2425,147 @@ 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() { } + } + """; + 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); + } + MarkGraphReady(dbPath); + + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + 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); + + 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_Declaration_ReturnsDefinitionLocation_Issues3537And4420() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index 092203462..44b2945b2 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() { From b25d05d59e12b1348411ec3335b62032b3931dc6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 10:58:17 +0900 Subject: [PATCH 2/3] Address constructor navigation review findings (#4850) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 8 + changelog.d/unreleased/4850.fixed.md | 9 +- .../Database/DbContext.ConnectionFunctions.cs | 4 + .../Database/DbContext.SchemaMetadata.cs | 14 +- src/CodeIndex/Database/DbWriter.References.cs | 223 ++++++++++++------ .../Indexer/CSharpTypeReferenceArity.cs | 165 +++++++++++++ .../Lsp/LspServer.PositionResolution.cs | 42 +++- ...ConstructorParameterCountIssue4850Tests.cs | 36 +++ .../IndexCommandRunnerUpdateTests.cs | 81 +++++++ tests/CodeIndex.Tests/LspServerTests.cs | 117 +++++++++ 11 files changed, 614 insertions(+), 89 deletions(-) create mode 100644 tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ad8fb810a..3485c8043 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -521,7 +521,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. - Constructor-navigation coverage keeps explicit zero- and one-argument constructors in separate partial files, an implicit partial constructor, a positional record, and an ordinary partial-type reference in one fixture. Assert both definition and declaration requests so source-site identity, arity filtering, stable implicit representatives, and intentional type-family expansion remain separate. + Constructor-navigation coverage keeps explicit zero- and one-argument constructors in separate partial files, an implicit partial constructor, a positional record, and an ordinary partial-type reference in one fixture. The adversarial fixture adds static constructors, finalizers, primary-plus-secondary constructors, coexisting generic arities, and same-arity overloads. Assert both definition and declaration requests so source-site identity, arity filtering, stable implicit representatives, constructor-only ambiguity, 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. @@ -1418,7 +1418,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 しないようにします。 - constructor-navigation coverage は、0 引数と 1 引数の明示 constructor を別々の partial file に置き、暗黙 partial constructor、positional record、通常の partial-type reference と同じ fixture にまとめます。source-site identity、arity filtering、暗黙 constructor の安定した代表 declaration、意図的な type-family expansion が混在しないよう、definition と declaration の両 request を assert してください。 + constructor-navigation coverage は、0 引数と 1 引数の明示 constructor を別々の partial file に置き、暗黙 partial constructor、positional record、通常の partial-type reference と同じ fixture にまとめます。adversarial fixture には static constructor、finalizer、primary / secondary constructor の併存、同名 type の generic arity 違い、同一 arity overload も含めます。source-site identity、arity filtering、暗黙 constructor の安定した代表 declaration、constructor だけの ambiguity、意図的な 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 4f725f0f5..73c92b453 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2709,6 +2709,10 @@ symbols. When no explicit constructor is indexed, including implicit constructor 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. 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. @@ -5830,6 +5834,10 @@ C# の constructor navigation は source position を考慮します。`new Type 明示 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 の集合として返します。 CLI の `definition` と `goto` は名前ベースなので、明示 constructor には `--kind function`、logical type family には type kind と `--group-partials` を 組み合わせてください。 diff --git a/changelog.d/unreleased/4850.fixed.md b/changelog.d/unreleased/4850.fixed.md index 13dc30e88..0c17008c1 100644 --- a/changelog.d/unreleased/4850.fixed.md +++ b/changelog.d/unreleased/4850.fixed.md @@ -3,9 +3,14 @@ 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 @@ -14,8 +19,8 @@ affected: ## 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, and invocation arity instead of mixing same-named type declarations into the candidate set. Explicit overloads navigate only to matching constructors, while implicit partial constructors and positional records use one deterministic type representative. Ordinary type references may still expose every declaration in the logical partial family. +- **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. 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、invocation arity で解決するようになりました。明示 overload は一致する constructor だけへ移動し、暗黙 partial constructor と positional record は決定的な代表 type を使います。通常の type reference は引き続き logical partial family の全 declaration を公開できます。 +- **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 を使います。同一 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 1e4221bdf..894954958 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -92,6 +92,10 @@ 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_base_identifiers_json", (string? signature) => diff --git a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs index 2c776ba92..6032c5e71 100644 --- a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs +++ b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs @@ -62,13 +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 4 (#4845) invalidates dependency-lock candidates written before resolution - // became file-local. Version 3 (#4825) previously invalidated C# type-reference - // candidates written before type-like kind and compatible generic-arity constraints. - // バージョン 4 (#4845) では、解決を file 内に限定する前に書かれた dependency-lock - // candidate を無効化する。バージョン 3 (#4825) では、それ以前に C# 型参照の - // type-like kind / 互換 generic arity 制約より前に書かれた candidate を無効化した。 - public const int ReferenceIdentityContractVersion = 4; + // Version 5 (#4850) invalidates C# instantiate candidates written before constructor + // callables and logical partial-type families had separate identities. Version 4 (#4845) + // previously made dependency-lock resolution file-local. + // バージョン 5 (#4850) では、constructor callable と logical partial type family の + // identity 分離前に書かれた C# instantiate candidate を無効化する。バージョン 4 + // (#4845) では dependency-lock 解決を file-local にした。 + public const int ReferenceIdentityContractVersion = 5; 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/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index ac672fce6..dbe6fd5f0 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -173,57 +173,92 @@ WHEN INSTR( ELSE {fileAlias}.path || char(31) END ) || - {BuildCSharpUnprefixedTypeIdentitySql(symbolAlias)} + {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)}, - CASE - WHEN EXISTS ( - SELECT 1 - FROM symbols AS constructor_partial_type - JOIN files AS constructor_partial_file - ON constructor_partial_file.id = constructor_partial_type.file_id - AND constructor_partial_file.lang = 'csharp' - WHERE constructor_partial_type.name_folded = {symbolAlias}.name_folded - AND constructor_partial_type.name = {symbolAlias}.name COLLATE BINARY - AND constructor_partial_type.kind IN ('class', 'struct', 'record') - AND INSTR( - ' ' || LOWER( - REPLACE( - REPLACE( - REPLACE( - REPLACE( - COALESCE(constructor_partial_type.signature, ''), - '(', - ' '), - ')', - ' '), - ':', - ' '), - CHAR(9), - ' ')) || ' ', - ' partial ') > 0 - AND {BuildCSharpUnprefixedTypeIdentitySql("constructor_partial_type")} - = COALESCE( - NULLIF({symbolAlias}.container_qualified_name, ''), - NULLIF({symbolAlias}.container_name, ''), - {symbolAlias}.name) COLLATE BINARY - ) THEN '' - ELSE {fileAlias}.path || char(31) - END - ) || + {fileAlias}.path || char(31)) || COALESCE( NULLIF({symbolAlias}.container_qualified_name, ''), NULLIF({symbolAlias}.container_name, ''), - {symbolAlias}.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 CSharpTypeReferenceCandidatePredicateSql => $""" ( source_file.lang <> 'csharp' @@ -233,9 +268,23 @@ OR CASE 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 THEN 1 + AND s.container_name = s.name COLLATE BINARY + AND csharp_constructor_parameter_count( + s.signature, + s.name, + s.kind) IS NOT NULL + AND ( + {CSharpReferenceTypeAritySql} IS NULL + OR {BuildCSharpConstructorTypeAritySql("s")} + = {CSharpReferenceTypeAritySql} + ) THEN 1 WHEN r.reference_kind = 'instantiate' AND s.kind IN ('class', 'struct', 'record') + 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 @@ -252,19 +301,29 @@ AND representative.kind IN ('class', 'struct', 'record') representative.id LIMIT 1 ) - 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 {BuildCSharpConstructorIdentitySql("explicit_constructor", "constructor_file")} - = {BuildCSharpTypeIdentitySql("s", "target_file")} COLLATE BINARY + AND ( + csharp_constructor_parameter_count( + s.signature, + s.name, + s.kind) IS NOT NULL + OR 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 + ) ) THEN 1 WHEN r.reference_kind = 'instantiate' THEN 0 WHEN s.kind NOT IN ('class', 'struct', 'record', 'interface', 'enum', 'delegate') THEN 0 @@ -943,17 +1002,27 @@ FROM symbol_references AS r JOIN ( SELECT candidate.id AS symbol_id, unique_type.name_folded, - unique_type.name + unique_type.name, + unique_type.type_arity 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') - GROUP BY s.name_folded, s.name + 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 @@ -963,8 +1032,12 @@ JOIN files AS candidate_file ON candidate_file.id = candidate.file_id AND candidate_file.lang = 'csharp' WHERE ( - candidate.kind = 'function' + 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 ) @@ -988,19 +1061,29 @@ AND representative.kind IN ('class', 'struct', 'record') representative.id LIMIT 1 ) - 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_type.name_folded - AND explicit_constructor.name = unique_type.name COLLATE BINARY - AND explicit_constructor.kind = 'function' - AND explicit_constructor.container_name = - explicit_constructor.name COLLATE BINARY - AND {BuildCSharpConstructorIdentitySql("explicit_constructor", "constructor_file")} - = unique_type.type_identity COLLATE BINARY + AND ( + csharp_constructor_parameter_count( + candidate.signature, + candidate.name, + candidate.kind) IS NOT NULL + OR 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_type.name_folded + AND explicit_constructor.name = unique_type.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_type.type_identity COLLATE BINARY + ) ) ) ) AS unique_target ON unique_target.name_folded = r.symbol_name_folded @@ -1008,6 +1091,10 @@ explicit_constructor.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 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..ff0bf36d1 100644 --- a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs +++ b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs @@ -37,6 +37,69 @@ 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; + } + private static int FindClosestIdentifierOccurrence(string text, string identifier, long? columnNumber) { var expectedIndex = columnNumber is > 0 and <= int.MaxValue @@ -97,6 +160,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 +325,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 f6bae39f4..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; @@ -101,12 +102,15 @@ private List ResolveReferenceTargetsAtPosition(PositionTokenCo selected = resolution.Candidates .Where(candidate => TryGetCSharpDefinitionParameterCount(candidate.Definition, out var parameterCount) && parameterCount == argumentCount) - .Take(2) .ToList(); - if (selected.Count == 1) + if (selected.Count > 0) { - var arityDefinition = _reader.GetDefinitionForSymbol(selected[0].Definition); - return arityDefinition == null ? [] : [arityDefinition]; + return selected + .Select(candidate => _reader.GetDefinitionForSymbol(candidate.Definition)) + .OfType() + .OrderBy(definition => definition.Path, StringComparer.Ordinal) + .ThenBy(definition => definition.StartLine) + .ToList(); } } @@ -163,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..56a5bb8f3 --- /dev/null +++ b/tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs @@ -0,0 +1,36 @@ +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")); + } +} diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs index e069b4a18..b3f278ff1 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs @@ -268,6 +268,87 @@ WHERE reference.resolution_state IN ('resolved', 'resolved_group') } } + [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 = '4' + 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_RefreshesMutualRecursionOncePerBatchIncludingDeleteOnly() { diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index aa3db6154..86646393b 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -2566,6 +2566,123 @@ public class Caller } } + [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); + } + MarkGraphReady(dbPath); + + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var reader = new DbReader(db); + foreach (var (line, token, expectedKinds, expectedLines) in new[] + { + (4, "Lifecycle", new[] { "class" }, new[] { 2 }), + (5, "Primary", new[] { "class", "function" }, new[] { 10 }), + (6, "Primary", new[] { "class", "function" }, 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); + 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_Declaration_ReturnsDefinitionLocation_Issues3537And4420() { From 1c83372d60189633fd18ad6fccbd87bf850f1fe3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 15:06:07 +0900 Subject: [PATCH 3/3] Address final constructor navigation edge cases (#4850) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 9 +- changelog.d/unreleased/4850.fixed.md | 4 +- .../Database/DbContext.ConnectionFunctions.cs | 8 + src/CodeIndex/Database/DbWriter.References.cs | 248 ++++++++++++++---- .../Indexer/CSharpTypeReferenceArity.cs | 44 ++++ ...ConstructorParameterCountIssue4850Tests.cs | 15 ++ tests/CodeIndex.Tests/LspServerTests.cs | 161 +++++++++++- 8 files changed, 430 insertions(+), 63 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index bcce5cf3a..d9a55d59c 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -522,7 +522,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. - Constructor-navigation coverage keeps explicit zero- and one-argument constructors in separate partial files, an implicit partial constructor, a positional record, and an ordinary partial-type reference in one fixture. The adversarial fixture adds static constructors, finalizers, primary-plus-secondary constructors, coexisting generic arities, and same-arity overloads. Assert both definition and declaration requests so source-site identity, arity filtering, stable implicit representatives, constructor-only ambiguity, and intentional type-family expansion remain separate. + 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. @@ -1420,7 +1420,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 しないようにします。 - constructor-navigation coverage は、0 引数と 1 引数の明示 constructor を別々の partial file に置き、暗黙 partial constructor、positional record、通常の partial-type reference と同じ fixture にまとめます。adversarial fixture には static constructor、finalizer、primary / secondary constructor の併存、同名 type の generic arity 違い、同一 arity overload も含めます。source-site identity、arity filtering、暗黙 constructor の安定した代表 declaration、constructor だけの ambiguity、意図的な type-family expansion が混在しないよう、definition と declaration の両 request を assert してください。 + 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 b0b430e83..52c1d5351 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2720,7 +2720,10 @@ 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. +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. @@ -5860,7 +5863,9 @@ C# の constructor navigation は source position を考慮します。`new Type static constructor と finalizer は `new` の target として扱いません。primary constructor declaration は secondary constructor と併存しても候補に残り、generic type arity で constructor identity を限定します。同一 arity の overload も同名 type declaration へ -fallback せず、constructor location の集合として返します。 +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` を 組み合わせてください。 diff --git a/changelog.d/unreleased/4850.fixed.md b/changelog.d/unreleased/4850.fixed.md index 0c17008c1..9c22da2f0 100644 --- a/changelog.d/unreleased/4850.fixed.md +++ b/changelog.d/unreleased/4850.fixed.md @@ -19,8 +19,8 @@ affected: ## 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. 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 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 を使います。同一 arity overload は constructor location のまま返し、通常の type reference は引き続き logical partial family の全 declaration を公開できます。既存 index では対象 candidate contract を自動再構築します。 +- **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 b2026c134..76af876a3 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -101,6 +101,14 @@ internal static void RegisterConnectionFunctions(SqliteConnection connection) "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/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index 9ac3b6016..c26de8588 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -259,6 +259,17 @@ FROM reference_lines AS reference_line 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' @@ -273,13 +284,20 @@ 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') + AND s.kind IN ('class', 'struct', 'record', 'enum', 'delegate') AND ( {CSharpReferenceTypeAritySql} IS NULL OR csharp_definition_type_arity(s.signature, s.name, s.kind) @@ -293,7 +311,12 @@ JOIN files AS representative_file 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') + 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, @@ -302,27 +325,81 @@ AND representative.kind IN ('class', 'struct', 'record') LIMIT 1 ) AND ( - csharp_constructor_parameter_count( - s.signature, - s.name, - s.kind) IS NOT NULL - OR 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 + ( + 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 @@ -1040,7 +1117,16 @@ FROM symbol_references AS r SELECT candidate.id AS symbol_id, unique_type.name_folded, unique_type.name, - unique_type.type_arity + 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, @@ -1053,7 +1139,7 @@ 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') + AND s.kind IN ('class', 'struct', 'record', 'enum', 'delegate') GROUP BY s.name_folded, s.name, csharp_definition_type_arity( @@ -1079,7 +1165,7 @@ AND csharp_constructor_parameter_count( = unique_type.type_identity COLLATE BINARY ) OR ( - candidate.kind IN ('class', 'struct', 'record') + candidate.kind IN ('class', 'struct', 'record', 'enum', 'delegate') AND {BuildCSharpTypeIdentitySql("candidate", "candidate_file")} = unique_type.type_identity COLLATE BINARY AND candidate.id = ( @@ -1090,7 +1176,12 @@ JOIN files AS representative_file 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') + 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, @@ -1098,30 +1189,6 @@ AND representative.kind IN ('class', 'struct', 'record') representative.id LIMIT 1 ) - AND ( - csharp_constructor_parameter_count( - candidate.signature, - candidate.name, - candidate.kind) IS NOT NULL - OR 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_type.name_folded - AND explicit_constructor.name = unique_type.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_type.type_identity COLLATE BINARY - ) - ) ) ) AS unique_target ON unique_target.name_folded = r.symbol_name_folded AND unique_target.name = r.symbol_name COLLATE BINARY @@ -1132,6 +1199,85 @@ AND r.target_qualifier IS NULL {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 ff0bf36d1..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)) @@ -100,6 +138,12 @@ internal static bool IsMemberReceiver(string? context, string? symbolName, long? 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 diff --git a/tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs b/tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs index 56a5bb8f3..22f27d3da 100644 --- a/tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs +++ b/tests/CodeIndex.Tests/CSharpConstructorParameterCountIssue4850Tests.cs @@ -33,4 +33,19 @@ public void GetConstructorParameterCount_RejectsNonInstanceConstructorTargets_Is "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/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index a4e122243..b9ca14c5b 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -2912,6 +2912,11 @@ public partial class Widget { public Widget() { } } + + public class LocalCaller + { + public object CreateValue() => new Widget(1); + } """; var explicitBSource = """ namespace Demo; @@ -2966,9 +2971,9 @@ public class Caller File.WriteAllText(path, source); TestProjectHelper.InsertIndexedFile(dbPath, relativePath, "csharp", source); } - MarkGraphReady(dbPath); - 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", @@ -2987,6 +2992,37 @@ public class Caller 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), @@ -3089,15 +3125,15 @@ public class Caller File.WriteAllText(path, source); TestProjectHelper.InsertIndexedFile(dbPath, relativePath, "csharp", source); } - MarkGraphReady(dbPath); - 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[] { "class", "function" }, new[] { 10 }), - (6, "Primary", new[] { "class", "function" }, new[] { 8 }), + (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 }), }) @@ -3154,6 +3190,119 @@ public class Caller } } + [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() {