diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a9b9e87e64..0f4ed0c3a5 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1021,6 +1021,8 @@ Exact-match flag compatibility is documented in [USER_GUIDE.md](USER_GUIDE.md#fl `search --json` and MCP `search` project full chunks into compact match-centered snippets with `chunk_start_line`, `chunk_end_line`, `snippet_start_line`, `snippet_end_line`, `snippet`, `match_lines`, `highlights`, `context_before`, `context_after`, `truncated_line_count`, `dropped_match_line_count`, and `truncation_context`. `--snippet-lines` caps the snippet length up front (default: 8, max: 20), and `--max-line-width` (CLI) / `maxLineWidth` (MCP) clamps each individual snippet line around the first match token via the shared `LineWidthFormatter.ClampLine` contract used by `find` / `references` / `excerpt` / `inspect` (default: 512, max: 4096) so a single match inside a minified / transpiled / generated single-line file no longer returns hundreds of KB per hit. Clamped lines surface `...(+N)...` markers inside the snippet and expose `truncation_context.char_counts`, `truncation_context.total_chars`, `highlights[].truncated`, `highlights[].original_line_length`, and `highlights[].truncated_char_counts` so AI clients can detect clamping and quantify omitted characters. `highlights[].terms` remains a distinct term list for compatibility; `highlights[].term_occurrences` records every matched occurrence with `term`, 1-based `line`, 1-based `column`, and `length`. Exact substring search also adds `highlights[].literal_terms` and `highlights[].literal_term_occurrences` (camelCase in MCP) so clients can render only the requested literal phrase while preserving the broader diagnostic token list. Non-exact punctuation-heavy code-phrase searches add `exact_substring_hint` to CLI JSON compact results and `recovery_hint` to MCP `search` responses so clients can retry with exact substring semantics when FTS tokenization is likely to hide punctuation. `dropped_match_line_count` reports match lines omitted because they fell outside the selected snippet window. +When the match line falls inside an indexed symbol range, `search --json` and MCP `search` also include optional `enclosing_symbol_name`, `enclosing_symbol_kind`, `enclosing_symbol_start_line`, `enclosing_symbol_end_line`, and `enclosing_container_name`. + `excerpt --json` includes `semantic_tokens`, a lightweight range list with 1-based start/end positions, token `type`, and `modifiers`, so IDE and LLM clients can render or post-process excerpt spans without reparsing the raw `content` string. `inspect` and MCP `analyze_symbol` bundle the primary definition, nearby symbols from the same file, references, callers, callees, file metadata, workspace freshness/git metadata, and graph-support metadata into one response. When those bundled graph sections actually depend on SQL-backed reads, the payload also mirrors `sql_graph_contract_ready` / `sql_graph_contract_degraded_reason` (plus the existing camelCase aliases on MCP responses); mixed-language bundles that only return C# / JS / etc. graph rows omit the SQL trust signal entirely. This is intended for symbol-oriented AI workflows that would otherwise need several back-to-back calls. Call graph sections remain language-aware: for unsupported languages, clients can now distinguish "unsupported" from "no hits" via `graphSupported` / `graphSupportReason`, and should prefer `search` instead of assuming graph data will exist. @@ -2652,6 +2654,8 @@ exact-match flag の互換性は [USER_GUIDE.md](USER_GUIDE.md#フラグ互換 `search --json` と MCP の `search` は、フルチャンクを `chunk_start_line`、`chunk_end_line`、`snippet_start_line`、`snippet_end_line`、`snippet`、`match_lines`、`highlights`、`context_before`、`context_after`、`truncated_line_count`、`dropped_match_line_count`、`truncation_context` を持つ軽量スニペットへ投影します。`--snippet-lines` で抜粋長を先に制限でき(デフォルト: 8、最大: 20)、`--max-line-width`(CLI)/ `maxLineWidth`(MCP)は `find` / `references` / `excerpt` / `inspect` と同じ共有 `LineWidthFormatter.ClampLine` 契約(デフォルト: 512、最大: 4096、`0` で切り詰め解除)で各スニペット行を最初のマッチトークン周辺にクランプするため、minified / transpiled / 生成された 1 行ファイル内の 1 ヒットで数百 KB を返さなくなります。クランプされた行はスニペットに `...(+N)...` マーカーが入り、`truncation_context.char_counts`、`truncation_context.total_chars`、`highlights[].truncated`、`highlights[].original_line_length`、`highlights[].truncated_char_counts` で AI クライアントがクランプの有無と省略文字数を検出できます。`highlights[].terms` は互換性のため distinct な term list のまま残し、`highlights[].term_occurrences` は一致ごとの `term`、1-based の `line` / `column`、`length` を記録します。exact substring search では `highlights[].literal_terms` と `highlights[].literal_term_occurrences`(MCP では camelCase)も追加され、広めの診断 token list を残したまま、要求された literal phrase だけを render できます。exact ではない記号の多い code phrase 検索では、FTS tokenization が記号を失いやすい場合に exact substring semantics で再検索できるよう、CLI JSON compact result に `exact_substring_hint`、MCP `search` に `recovery_hint` を追加します。`dropped_match_line_count` は選択された snippet window 外に落ちた一致行数を示します。 +マッチ行がインデックス済みシンボル範囲内にある場合、`search --json` と MCP の `search` は任意フィールドの `enclosing_symbol_name`、`enclosing_symbol_kind`、`enclosing_symbol_start_line`、`enclosing_symbol_end_line`、`enclosing_container_name` も返します。 + `excerpt --json` は 1-based の開始/終了位置、token `type`、`modifiers` を持つ軽量 range list の `semantic_tokens` を返すため、IDE や LLM クライアントは生の `content` 文字列を再パースせずに抜粋範囲を描画・後処理できます。 `inspect` と MCP の `analyze_symbol` は、主定義、同一ファイル内の近傍シンボル、参照、caller、callee、ファイルメタデータ、さらにワークスペース鮮度/git メタデータと graph 対応メタデータを1レスポンスにまとめます。bundle 内の graph 節が実際に SQL ベースの read に依存する場合だけ、`sql_graph_contract_ready` / `sql_graph_contract_degraded_reason`(MCP では既存の camelCase alias も)も返します。mixed-language bundle で C# / JS などの graph row しか返っていない場合は SQL trust signal を出さないため、無関係なクエリが stale SQL state に引きずられません。複数の連続クエリを避けたい AI ワークフロー向けです。call graph 系の節は言語差分を考慮しており、未対応言語では `graphSupported` / `graphSupportReason` によって「未対応」と「ヒットなし」を区別できます。その場合は `search` を優先して使う前提です。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 961f31bfff..fd7d00cf63 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -937,7 +937,7 @@ With `--json`, symbol results also include definition ranges, optional body rang `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, and `find` also share repeatable `--path ` glob-style path filters (multiple values are OR'd together), repeatable `--exclude-path `, and `--exclude-tests`. Use `*` and `?` to match path segments, and plain text still behaves like a substring filter when you do not include wildcards. Search results prefer source files over tests and docs, and `search` boosts files whose symbol names or paths match the query exactly. -`search --json` and MCP `search` return compact match-centered snippets instead of whole chunks. Each result includes `chunk_start_line`, `chunk_end_line`, `snippet_start_line`, `snippet_end_line`, `snippet`, `match_lines`, `highlights`, `context_before`, `context_after`, and `truncated_line_count`. Use `--snippet-lines ` to shrink or widen the excerpt window (default: 8, max: 20), and `--max-line-width ` to clamp each line around the strongest match when a minified / transpiled file would otherwise return a single huge line (default: 512, max: 4096; `0` disables clamping). `--snippet-focus ` controls that long-line focus; `quality` is the default, `leftmost` keeps the legacy earliest-match behavior, and `proximity` favors dense multi-token clusters. Clamped lines are marked with `...(+N)...` in the snippet and expose `highlights[].truncated` / `highlights[].original_line_length` in JSON / MCP output. +`search --json` and MCP `search` return compact match-centered snippets instead of whole chunks. Each result includes `chunk_start_line`, `chunk_end_line`, `snippet_start_line`, `snippet_end_line`, `snippet`, `match_lines`, `highlights`, `context_before`, `context_after`, `truncated_line_count`, `dropped_match_line_count`, and `truncation_context`, plus optional `enclosing_symbol_name`, `enclosing_symbol_kind`, `enclosing_symbol_start_line`, `enclosing_symbol_end_line`, and `enclosing_container_name` when the match line is inside an indexed symbol. Use `--snippet-lines ` to shrink or widen the excerpt window (default: 8, max: 20), and `--max-line-width ` to clamp each line around the strongest match when a minified / transpiled file would otherwise return a single huge line (default: 512, max: 4096; `0` disables clamping). `--snippet-focus ` controls that long-line focus; `quality` is the default, `leftmost` keeps the legacy earliest-match behavior, and `proximity` favors dense multi-token clusters. Clamped lines are marked with `...(+N)...` in the snippet and expose `highlights[].truncated` / `highlights[].original_line_length` in JSON / MCP output. ### Resolve a definition @@ -3071,7 +3071,7 @@ function CreateUser src/Services/UserService.cs: `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files` は共通で繰り返し指定できる `--path ` の glob 形式パスフィルタ(複数値は OR で結合)、繰り返し指定できる `--exclude-path `、`--exclude-tests` に対応しています。`*` と `?` でパスパターンを指定でき、ワイルドカードを含めない場合は従来どおり部分文字列として扱われます。検索結果は tests や docs より source を優先し、`search` はシンボル名やパスがクエリと正確に一致するファイルを上に出します。 -`search --json` と MCP の `search` は、チャンク全文ではなく一致中心の軽量スニペットを返します。各結果には `chunk_start_line`、`chunk_end_line`、`snippet_start_line`、`snippet_end_line`、`snippet`、`match_lines`、`highlights`、`context_before`、`context_after`、`truncated_line_count` が含まれます。抜粋の長さは `--snippet-lines ` で調整でき(デフォルト: 8、最大: 20)、minified / transpiled で 1 行が極端に長いファイルでは `--max-line-width ` を使って各行を最も強い一致周辺へクランプできます(`0` でクランプ解除、デフォルト: 512、最大: 4096)。長い行の焦点は `--snippet-focus ` で制御でき、`quality` がデフォルト、`leftmost` は従来の最左一致、`proximity` は近接した複数トークンを優先します。クランプされた行はスニペット内に `...(+N)...` マーカーが入り、JSON / MCP 出力では `highlights[].truncated` / `highlights[].original_line_length` でも検出できます。 +`search --json` と MCP の `search` は、チャンク全文ではなく一致中心の軽量スニペットを返します。各結果には `chunk_start_line`、`chunk_end_line`、`snippet_start_line`、`snippet_end_line`、`snippet`、`match_lines`、`highlights`、`context_before`、`context_after`、`truncated_line_count`、`dropped_match_line_count`、`truncation_context` が含まれ、マッチ行がインデックス済みシンボル範囲内にある場合は `enclosing_symbol_name`、`enclosing_symbol_kind`、`enclosing_symbol_start_line`、`enclosing_symbol_end_line`、`enclosing_container_name` も含まれます。抜粋の長さは `--snippet-lines ` で調整でき(デフォルト: 8、最大: 20)、minified / transpiled で 1 行が極端に長いファイルでは `--max-line-width ` を使って各行を最も強い一致周辺へクランプできます(`0` でクランプ解除、デフォルト: 512、最大: 4096)。長い行の焦点は `--snippet-focus ` で制御でき、`quality` がデフォルト、`leftmost` は従来の最左一致、`proximity` は近接した複数トークンを優先します。クランプされた行はスニペット内に `...(+N)...` マーカーが入り、JSON / MCP 出力では `highlights[].truncated` / `highlights[].original_line_length` でも検出できます。 ### 定義を引く diff --git a/changelog.d/unreleased/2819.added.md b/changelog.d/unreleased/2819.added.md new file mode 100644 index 0000000000..bdf4afd27b --- /dev/null +++ b/changelog.d/unreleased/2819.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 2819 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Database/DbReader.References.cs + - tests/CodeIndex.Tests/DbReaderTests.cs +--- + +## English + +- **Graph commands now accept qualified member queries (#2819)** — `definition`, `references`, `callers`, and `callees` can resolve inputs such as `Type.Member` or `Namespace.Type.Member` through qualified definition lookup, source-context matching, and unambiguous leaf fallback. + +## 日本語 + +- **graph command が qualified member query を受け付けるようになりました (#2819)** — `definition` / `references` / `callers` / `callees` は `Type.Member` や `Namespace.Type.Member` のような入力を、qualified definition lookup、source context 照合、一意な leaf fallback で解決できます。 diff --git a/changelog.d/unreleased/2838.added.md b/changelog.d/unreleased/2838.added.md new file mode 100644 index 0000000000..298af0cbe3 --- /dev/null +++ b/changelog.d/unreleased/2838.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 2838 +affected: + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/SearchSnippetFormatter.cs + - tests/CodeIndex.Tests/DbReaderTests.cs + - tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs +--- + +## English + +- **Search JSON now includes enclosing symbol metadata (#2838)** — JSON search results report the nearest indexed symbol name, kind, line span, and container when a hit belongs to an indexed symbol. + +## 日本語 + +- **search JSON が enclosing symbol metadata を含むようになりました (#2838)** — JSON search result は hit が indexed symbol に属する場合、最も近い indexed symbol の名前、kind、行範囲、container を返します。 diff --git a/src/CodeIndex/Cli/SearchSnippetFormatter.cs b/src/CodeIndex/Cli/SearchSnippetFormatter.cs index 98985ab738..0fca06bb5f 100644 --- a/src/CodeIndex/Cli/SearchSnippetFormatter.cs +++ b/src/CodeIndex/Cli/SearchSnippetFormatter.cs @@ -53,6 +53,11 @@ public static CompactSearchResult ToCompactResult(SearchResult result, string qu TruncationContext = excerpt.TruncationContext, GuardEvidence = result.GuardEvidence, Score = result.Score, + EnclosingSymbolName = result.EnclosingSymbolName, + EnclosingSymbolKind = result.EnclosingSymbolKind, + EnclosingSymbolStartLine = result.EnclosingSymbolStartLine, + EnclosingSymbolEndLine = result.EnclosingSymbolEndLine, + EnclosingContainerName = result.EnclosingContainerName, }; } @@ -523,6 +528,16 @@ public sealed class CompactSearchResult [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public SearchQueryHint? ExactSubstringHint { get; set; } public double Score { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EnclosingSymbolName { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EnclosingSymbolKind { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? EnclosingSymbolStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? EnclosingSymbolEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EnclosingContainerName { get; set; } } public enum SearchSnippetFocusMode diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 05c962befa..67cff29495 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -69,19 +69,42 @@ FROM symbol_references r if (excludeSelfReferences) sql += $" AND {selfReferenceSql} = 0"; var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); + var allowCSharpQualifiedContextMatch = SqlNameResolver.HasQualifier(query) + && !HasQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); + var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); var useSqlQualifiedContextMatch = SqlNameResolver.HasQualifier(query); var cssScssVariableAlias = ComputeCssScssVariableAlias(query); var cssScssVariableAliasScope = cssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; if (useSqlQualifiedContextMatch && exact && _foldReady) - sql += $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + sql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) - sql += $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + sql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) - sql += $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + sql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) - sql += $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + sql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) sql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -133,6 +156,7 @@ FROM logical_references r callersQueryParam = query; cmd.Parameters.AddWithValue("@query", callersQueryParam); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -208,19 +232,42 @@ FROM symbol_references r else groupedSql += $" AND r.reference_kind IN {CallGraphReferenceKindsSql}"; var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); + var allowCSharpQualifiedContextMatch = SqlNameResolver.HasQualifier(query) + && !HasQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); + var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); var useSqlQualifiedContextMatch = SqlNameResolver.HasQualifier(query); var cssScssVariableAlias = ComputeCssScssVariableAlias(query); var cssScssVariableAliasScope = cssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; if (useSqlQualifiedContextMatch && exact && _foldReady) - groupedSql += $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + groupedSql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) - groupedSql += $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + groupedSql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) - groupedSql += $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + groupedSql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) - groupedSql += $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + groupedSql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) groupedSql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -252,6 +299,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -298,15 +346,38 @@ FROM symbol_references r else groupedSql += $" AND r.reference_kind IN {CallGraphReferenceKindsSql}"; var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); + var allowCSharpQualifiedContextMatch = SqlNameResolver.HasQualifier(query) + && !HasQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); + var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); var useSqlQualifiedContextMatch = SqlNameResolver.HasQualifier(query); if (useSqlQualifiedContextMatch && exact && _foldReady) - groupedSql += $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + groupedSql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) - groupedSql += $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + groupedSql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) - groupedSql += $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + groupedSql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) - groupedSql += $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + groupedSql += $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) groupedSql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -338,6 +409,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -405,15 +477,32 @@ FROM symbol_references r else sql += NonInvocationReferenceKindsExclusion; var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); + var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); var useSqlQualifiedContainerMatch = SqlNameResolver.HasQualifier(query); var cssScssVariableAlias = ComputeCssScssVariableAlias(query); var cssScssVariableAliasScope = cssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; if (exact && useSqlQualifiedContainerMatch && _foldReady) - sql += " AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name_folded(r.container_name) = @aliasQueryNormalizedFolded) OR ((f.lang != 'sql') AND r.container_name_folded = @query))"; + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: true); + sql += $" AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name_folded(r.container_name) = @aliasQueryNormalizedFolded) OR ((f.lang != 'sql') AND r.container_name_folded = @query) OR {qualifiedLeafFallbackSql})"; + } else if (exact && useSqlQualifiedContainerMatch) - sql += " AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name(r.container_name) = @aliasQueryNormalized COLLATE NOCASE) OR ((f.lang != 'sql') AND r.container_name = @query COLLATE NOCASE))"; + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: false); + sql += $" AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name(r.container_name) = @aliasQueryNormalized COLLATE NOCASE) OR ((f.lang != 'sql') AND r.container_name = @query COLLATE NOCASE) OR {qualifiedLeafFallbackSql})"; + } + else if (useSqlQualifiedContainerMatch && _foldReady) + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: true); + sql += $" AND (r.container_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } + else if (useSqlQualifiedContainerMatch) + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: false); + sql += $" AND (r.container_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) sql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -476,6 +565,7 @@ FROM logical_references r cmd.Parameters.AddWithValue("@preferExactCase", exact ? 1 : 0); cmd.Parameters.AddWithValue("@rawQuery", exact ? query : string.Empty); cmd.Parameters.AddWithValue("@rankingQuery", query.Trim()); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); if (referenceKind != null) cmd.Parameters.AddWithValue("@referenceKind", referenceKind); if (lang != null) @@ -539,15 +629,32 @@ FROM symbol_references r else groupedSql += $" AND r.reference_kind IN {CallGraphReferenceKindsSql}"; var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); + var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); var useSqlQualifiedContainerMatch = SqlNameResolver.HasQualifier(query); var cssScssVariableAlias = ComputeCssScssVariableAlias(query); var cssScssVariableAliasScope = cssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; if (exact && useSqlQualifiedContainerMatch && _foldReady) - groupedSql += " AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name_folded(r.container_name) = @aliasQueryNormalizedFolded) OR ((f.lang != 'sql') AND r.container_name_folded = @query))"; + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: true); + groupedSql += $" AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name_folded(r.container_name) = @aliasQueryNormalizedFolded) OR ((f.lang != 'sql') AND r.container_name_folded = @query) OR {qualifiedLeafFallbackSql})"; + } else if (exact && useSqlQualifiedContainerMatch) - groupedSql += " AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name(r.container_name) = @aliasQueryNormalized COLLATE NOCASE) OR ((f.lang != 'sql') AND r.container_name = @query COLLATE NOCASE))"; + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: false); + groupedSql += $" AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name(r.container_name) = @aliasQueryNormalized COLLATE NOCASE) OR ((f.lang != 'sql') AND r.container_name = @query COLLATE NOCASE) OR {qualifiedLeafFallbackSql})"; + } + else if (useSqlQualifiedContainerMatch && _foldReady) + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: true); + groupedSql += $" AND (r.container_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } + else if (useSqlQualifiedContainerMatch) + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: false); + groupedSql += $" AND (r.container_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) groupedSql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -583,6 +690,7 @@ FROM symbol_references r cmd.Parameters.AddWithValue("@aliasQueryNormalized", SqlNameResolver.NormalizeQualifiedName(query)); cmd.Parameters.AddWithValue("@aliasQueryNormalizedFolded", NameFold.Fold(SqlNameResolver.NormalizeQualifiedName(query)) ?? SqlNameResolver.NormalizeQualifiedName(query)); cmd.Parameters.AddWithValue("@aliasQuerySegmentCount", SqlNameResolver.GetSegmentCount(query)); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); if (cssScssVariableAlias != null) { var aliasParam = exact && _foldReady @@ -626,15 +734,32 @@ FROM symbol_references r else groupedSql += $" AND r.reference_kind IN {CallGraphReferenceKindsSql}"; var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); + var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); var useSqlQualifiedContainerMatch = SqlNameResolver.HasQualifier(query); var cssScssVariableAlias = ComputeCssScssVariableAlias(query); var cssScssVariableAliasScope = cssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; if (exact && useSqlQualifiedContainerMatch && _foldReady) - groupedSql += " AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name_folded(r.container_name) = @aliasQueryNormalizedFolded) OR ((f.lang != 'sql') AND r.container_name_folded = @query))"; + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: true); + groupedSql += $" AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name_folded(r.container_name) = @aliasQueryNormalizedFolded) OR ((f.lang != 'sql') AND r.container_name_folded = @query) OR {qualifiedLeafFallbackSql})"; + } else if (exact && useSqlQualifiedContainerMatch) - groupedSql += " AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name(r.container_name) = @aliasQueryNormalized COLLATE NOCASE) OR ((f.lang != 'sql') AND r.container_name = @query COLLATE NOCASE))"; + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: false); + groupedSql += $" AND (((f.lang = 'sql') AND sql_segment_count(r.container_name) = @aliasQuerySegmentCount AND sql_normalize_name(r.container_name) = @aliasQueryNormalized COLLATE NOCASE) OR ((f.lang != 'sql') AND r.container_name = @query COLLATE NOCASE) OR {qualifiedLeafFallbackSql})"; + } + else if (useSqlQualifiedContainerMatch && _foldReady) + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: true); + groupedSql += $" AND (r.container_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } + else if (useSqlQualifiedContainerMatch) + { + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.container_name", "r.container_name_folded", folded: false); + groupedSql += $" AND (r.container_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) groupedSql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -670,6 +795,7 @@ FROM symbol_references r cmd.Parameters.AddWithValue("@aliasQueryNormalized", SqlNameResolver.NormalizeQualifiedName(query)); cmd.Parameters.AddWithValue("@aliasQueryNormalizedFolded", NameFold.Fold(SqlNameResolver.NormalizeQualifiedName(query)) ?? SqlNameResolver.NormalizeQualifiedName(query)); cmd.Parameters.AddWithValue("@aliasQuerySegmentCount", SqlNameResolver.GetSegmentCount(query)); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); if (cssScssVariableAlias != null) { var aliasParam = exact && _foldReady @@ -810,6 +936,9 @@ private List GetCallersExact(string symbolName, int limit, int off // caller 側も leaf `--exact` と同じく FoldReady なら folded equality、legacy DB では // `COLLATE NOCASE` fallback。definition と caller 行の casing 差もここで吸収する。 var allowSqlLeafFallback = !SqlNameResolver.HasQualifier(symbolName); + var allowCSharpQualifiedContextMatch = SqlNameResolver.HasQualifier(symbolName) + && !HasQualifiedSymbolDefinition(symbolName, lang, pathPatterns, excludePathPatterns, excludeTests); + var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(symbolName, lang, pathPatterns, excludePathPatterns, excludeTests); var polymorphicCSharpSymbolNames = lang is null or "csharp" ? GetCSharpPolymorphicDispatchSymbolNames(symbolName) : []; @@ -823,12 +952,12 @@ private List GetCallersExact(string symbolName, int limit, int off ? @" AND (r.symbol_name_folded = @symbolNameFolded OR (f.lang = 'sql' AND r.symbol_name_folded = @symbolNameLeafFolded)" + polymorphicNameCondition + ")" : @" - AND (((f.lang = 'sql') AND sql_context_has_name_folded_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @symbolNameFolded)" + polymorphicNameCondition + ")" + AND (((f.lang = 'sql') AND sql_context_has_name_folded_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @symbolNameFolded) OR " + BuildCSharpQualifiedContextFallbackSql(BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false)) + " OR " + BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true) + polymorphicNameCondition + ")" : allowSqlLeafFallback ? @" AND (r.symbol_name = @symbolName COLLATE NOCASE OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@symbolName) COLLATE NOCASE)" + polymorphicNameCondition + ")" : @" - AND (((f.lang = 'sql') AND sql_context_has_name_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @symbolName COLLATE NOCASE)" + polymorphicNameCondition + ")"; + AND (((f.lang = 'sql') AND sql_context_has_name_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @symbolName COLLATE NOCASE) OR " + BuildCSharpQualifiedContextFallbackSql(BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false)) + " OR " + BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false) + polymorphicNameCondition + ")"; // impact BFS must share the call-graph contract with `callers`/`callees`/`hotspots`, // so event subscriptions (`Click += OnClick`) also participate in the transitive @@ -865,6 +994,9 @@ FROM logical_references r cmd.CommandText = sql; cmd.Parameters.AddWithValue("@symbolName", symbolName); + cmd.Parameters.AddWithValue("@aliasQuery", symbolName); + AddQualifiedGraphQueryParameters(cmd, symbolName, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); + cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(symbolName)) ?? SqlNameResolver.GetLeafName(symbolName)); cmd.Parameters.AddWithValue("@symbolNameLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(symbolName)) ?? SqlNameResolver.GetLeafName(symbolName)); if (_foldReady) cmd.Parameters.AddWithValue("@symbolNameFolded", NameFold.Fold(symbolName) ?? symbolName); diff --git a/src/CodeIndex/Database/DbReader.References.cs b/src/CodeIndex/Database/DbReader.References.cs index 20df6bf14e..30c08b05ff 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -135,6 +135,10 @@ FROM symbol_references r var referencesCssScssVariableAliasScope = referencesCssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; + var allowCSharpQualifiedContextMatch = query != null + && SqlNameResolver.HasQualifier(query) + && !HasQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); + var allowQualifiedLeafFallback = query != null && HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); const string sqlLeafReferenceScope = " AND f.lang = 'sql'"; if (query != null) { @@ -155,21 +159,41 @@ FROM symbol_references r // することで `FooAuditLog` など無関係な名前を巻き込まないようにする。 // 別名節は C# の attribute 行に限定し、誤一致を避ける。 if (useSqlQualifiedContextMatch && exact && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); sql += referencesSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{referencesAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{referencesAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); sql += referencesSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); sql += referencesSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); sql += referencesSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) sql += referencesSuffixAlias != null ? $" AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{referencesAliasScope}){(allowSqlLeafFallback ? $" OR (r.symbol_name_folded = @aliasQueryLeafFolded{sqlLeafReferenceScope})" : string.Empty)})" @@ -224,6 +248,7 @@ FROM symbol_references r queryParam = query; cmd.Parameters.AddWithValue("@query", queryParam); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (referencesSuffixAlias != null) { @@ -511,27 +536,51 @@ FROM symbol_references r var countAliasScope = countSuffixAlias != null ? " AND f.lang = 'csharp' AND r.reference_kind = 'attribute'" : string.Empty; + var allowCSharpQualifiedContextMatch = query != null + && SqlNameResolver.HasQualifier(query) + && !HasQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); + var allowQualifiedLeafFallback = query != null && HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); const string sqlLeafCountScope = " AND f.lang = 'sql'"; if (query != null) { var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); var useSqlQualifiedContextMatch = SqlNameResolver.HasQualifier(query); if (useSqlQualifiedContextMatch && exact && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); innerSql += countSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{countAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{countAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); innerSql += countSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); innerSql += countSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); innerSql += countSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) innerSql += countSuffixAlias != null ? $" AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{countAliasScope}){(allowSqlLeafFallback ? $" OR (r.symbol_name_folded = @aliasQueryLeafFolded{sqlLeafCountScope})" : string.Empty)})" @@ -568,6 +617,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (countSuffixAlias != null) { @@ -619,27 +669,51 @@ FROM symbol_references r var totalCssScssVariableAliasScope = totalCssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; + var allowCSharpQualifiedContextMatch = query != null + && SqlNameResolver.HasQualifier(query) + && !HasQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); + var allowQualifiedLeafFallback = query != null && HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); const string sqlLeafTotalScope = " AND f.lang = 'sql'"; if (query != null) { var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); var useSqlQualifiedContextMatch = SqlNameResolver.HasQualifier(query); if (useSqlQualifiedContextMatch && exact && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); innerSql += totalSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{totalAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_has_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{totalAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name_folded = @query) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); innerSql += totalSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_has_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name = @query COLLATE NOCASE) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); innerSql += totalSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_like_name_folded_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + var csharpQualifiedContextSql = BuildCSharpQualifiedContextFallbackSql(qualifiedContextSql); + var qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); innerSql += totalSuffixAlias != null - ? $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}))))" - : $" AND (((f.lang = 'sql') AND sql_context_like_name_at({contextSql}, @aliasQuery, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\'))"; + ? $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}))) OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})" + : $" AND (((f.lang = 'sql') AND {qualifiedContextSql}) OR ((f.lang != 'sql') AND r.symbol_name LIKE @query ESCAPE '\\') OR {csharpQualifiedContextSql} OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) innerSql += totalSuffixAlias != null ? $" AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{totalAliasScope}){(allowSqlLeafFallback ? $" OR (r.symbol_name_folded = @aliasQueryLeafFolded{sqlLeafTotalScope})" : string.Empty)})" @@ -682,6 +756,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (totalSuffixAlias != null) { diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index f373ecf505..a2469fdc75 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -209,9 +209,134 @@ FROM fts_chunks raw = FilterBySearchGuards(raw, query, normalizedQuery, rawQuery, exact, lang, guardFilters!, guardWindow); var results = deduplicate ? DeduplicateOverlappingResults(raw) : raw; + AttachSearchEnclosingSymbols(results, query, exact); return hasGuardFilters ? PageGuardedSearchResults(results, limit, cursor) : results; } + private void AttachSearchEnclosingSymbols(IReadOnlyList results, string query, bool caseSensitive) + { + foreach (var result in results) + { + var matchLine = GetFirstSearchMatchLine(result, query, caseSensitive); + if (!matchLine.HasValue) + continue; + + var symbol = GetSearchEnclosingSymbol(result.Path, matchLine.Value); + if (symbol == null) + continue; + + result.EnclosingSymbolName = symbol.Name; + result.EnclosingSymbolKind = symbol.Kind; + result.EnclosingSymbolStartLine = symbol.StartLine; + result.EnclosingSymbolEndLine = symbol.EndLine; + result.EnclosingContainerName = symbol.ContainerName; + } + } + + private static int? GetFirstSearchMatchLine(SearchResult result, string query, bool caseSensitive) + { + var lines = result.Content.Replace("\r\n", "\n").Split('\n'); + if (lines.Length == 0) + return null; + + var normalizedQuery = ExactSourceSearchNormalizer.Normalize(query.Trim(), result.Lang); + var normalizedLines = new string[lines.Length]; + for (int i = 0; i < lines.Length; i++) + normalizedLines[i] = ExactSourceSearchNormalizer.Normalize(lines[i], result.Lang); + + var tokens = query + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Select(NormalizeSearchSnippetToken) + .Where(token => token.Length > 0) + .Where(token => token is not "AND" and not "OR" and not "NOT" and not "NEAR") + .Select(token => ExactSourceSearchNormalizer.Normalize(token, result.Lang)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var matchIndexes = FindSearchMatchingLineIndexes(normalizedLines, normalizedQuery, tokens, caseSensitive); + return matchIndexes.Count > 0 ? result.StartLine + matchIndexes[0] : null; + } + + private static List FindSearchMatchingLineIndexes(string[] lines, string query, string[] tokens, bool caseSensitive) + { + var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + var matches = new List(); + + if (!string.IsNullOrWhiteSpace(query)) + { + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].Contains(query, comparison)) + matches.Add(i); + } + } + + if (matches.Count > 0 || tokens.Length == 0) + return matches; + + for (int i = 0; i < lines.Length; i++) + { + if (tokens.Any(token => lines[i].Contains(token, comparison))) + matches.Add(i); + } + + return matches; + } + + private static string NormalizeSearchSnippetToken(string token) + => token + .Trim('"', '\'', '(', ')') + .TrimEnd('*'); + + private SearchEnclosingSymbol? GetSearchEnclosingSymbol(string path, int matchLine) + { + using var cmd = _conn.CreateCommand(); + var startLineSql = GetSymbolColumnSql("start_line", "s.line", "s"); + var endLineSql = GetSymbolColumnSql("end_line", "s.line", "s"); + var containerNameSql = GetSymbolColumnSql("container_name", symbolAlias: "s"); + cmd.CommandText = $@" + SELECT s.name, + s.kind, + {startLineSql} AS start_line, + {endLineSql} AS end_line, + {containerNameSql} AS container_name + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE f.path = @path + AND {startLineSql} <= @matchLine + AND {endLineSql} >= @matchLine + ORDER BY CASE s.kind + WHEN 'function' THEN 0 + WHEN 'test.method' THEN 0 + WHEN 'property' THEN 1 + WHEN 'class' THEN 2 + WHEN 'interface' THEN 2 + WHEN 'struct' THEN 2 + WHEN 'enum' THEN 2 + ELSE 3 + END, + ({endLineSql} - {startLineSql}) ASC, + {startLineSql} DESC, + s.line DESC, + s.id ASC + LIMIT 1"; + cmd.Parameters.AddWithValue("@path", path); + cmd.Parameters.AddWithValue("@matchLine", matchLine); + + using var reader = cmd.ExecuteTrackedReader(); + if (!reader.TrackedRead()) + return null; + + return new SearchEnclosingSymbol( + reader.GetString(0), + reader.GetString(1), + reader.GetInt32(2), + reader.GetInt32(3), + GetNullableString(reader, 4)); + } + + private sealed record SearchEnclosingSymbol(string Name, string Kind, int StartLine, int EndLine, string? ContainerName); + public QueryCountResult CountSearchResults(string query, string? lang = null, bool rawQuery = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, IReadOnlyList? guardFilters = null, int guardWindow = DefaultSearchGuardWindow) { if (string.IsNullOrWhiteSpace(query)) diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 035f418833..fc63b70ab0 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -267,6 +267,93 @@ public bool AnySearchSymbols(IReadOnlyList? queries, string? kind = null return false; } + private string BuildQualifiedSymbolMatchSql(string parameterStem, bool useFoldedName, string symbolAlias = "s", string fileAlias = "f") + { + var containerNameSql = GetSymbolColumnSql("container_name", "''", symbolAlias); + var containerQualifiedNameSql = GetSymbolColumnSql("container_qualified_name", containerNameSql, symbolAlias); + var nameMatchSql = useFoldedName + ? $"{symbolAlias}.name_folded = @{parameterStem}LeafFolded" + : $"{symbolAlias}.name = @{parameterStem}Leaf COLLATE NOCASE"; + return $@"({fileAlias}.lang = 'csharp' + AND {nameMatchSql} + AND ({containerNameSql} = @{parameterStem}Container COLLATE NOCASE + OR {containerQualifiedNameSql} = @{parameterStem}Container COLLATE NOCASE + OR {containerQualifiedNameSql} COLLATE NOCASE LIKE @{parameterStem}ContainerSuffixLike ESCAPE '\'))"; + } + + private static string GetQualifiedQueryContainer(string query) + { + var normalized = SqlNameResolver.NormalizeQualifiedName(query); + var lastDot = normalized.LastIndexOf('.'); + return lastDot > 0 ? normalized[..lastDot] : string.Empty; + } + + private static string GetQualifiedQuerySuffix(string query) + { + var normalized = SqlNameResolver.NormalizeQualifiedName(query); + var lastDot = normalized.LastIndexOf('.'); + if (lastDot <= 0) + return normalized; + var previousDot = normalized.LastIndexOf('.', lastDot - 1); + return previousDot >= 0 ? normalized[(previousDot + 1)..] : normalized; + } + + private static void AddQualifiedSymbolQueryParameters(SqliteCommand cmd, string parameterStem, string query) + { + var container = GetQualifiedQueryContainer(query); + cmd.Parameters.AddWithValue($"@{parameterStem}Container", container); + cmd.Parameters.AddWithValue($"@{parameterStem}ContainerSuffixLike", $"%.{EscapeLikeQuery(container)}"); + } + + private bool HasSingleQualifiedSymbolDefinition(string query, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) + { + if (!SqlNameResolver.HasQualifier(query)) + return false; + + var matches = SearchSymbols(query, 2, kind: null, lang, pathPatterns: null, excludePathPatterns: null, excludeTests, since: null, exact: false); + if (matches.Count != 1) + return false; + + var leafMatches = SearchSymbols(SqlNameResolver.GetLeafName(query), 2, kind: null, lang, pathPatterns: null, excludePathPatterns: null, excludeTests, since: null, exact: true); + return leafMatches.Count == 1; + } + + private bool HasQualifiedSymbolDefinition(string query, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) + { + if (!SqlNameResolver.HasQualifier(query)) + return false; + + return SearchSymbols(query, 1, kind: null, lang, pathPatterns: null, excludePathPatterns: null, excludeTests, since: null, exact: false).Count > 0; + } + + private static string BuildQualifiedContextMatchSql(string contextSql, string columnSql, bool folded, bool like) + { + var functionName = (folded, like) switch + { + (true, true) => "sql_context_like_name_folded_at", + (true, false) => "sql_context_has_name_folded_at", + (false, true) => "sql_context_like_name_at", + _ => "sql_context_has_name_at", + }; + return $"({functionName}({contextSql}, @aliasQuery, {columnSql}) = 1 OR {functionName}({contextSql}, @aliasQuerySuffix, {columnSql}) = 1)"; + } + + private static string BuildQualifiedLeafFallbackSql(string nameSql, string foldedNameSql, bool folded) + => folded + ? $"(@allowQualifiedLeafFallback = 1 AND f.lang = 'csharp' AND {foldedNameSql} = @aliasQueryLeafFolded)" + : $"(@allowQualifiedLeafFallback = 1 AND f.lang = 'csharp' AND {nameSql} = @aliasQueryLeaf COLLATE NOCASE)"; + + private static string BuildCSharpQualifiedContextFallbackSql(string qualifiedContextSql) + => $"(@allowCSharpQualifiedContextMatch = 1 AND f.lang = 'csharp' AND {qualifiedContextSql})"; + + private static void AddQualifiedGraphQueryParameters(SqliteCommand cmd, string query, bool allowLeafFallback, bool allowCSharpContextMatch = false) + { + cmd.Parameters.AddWithValue("@aliasQuerySuffix", GetQualifiedQuerySuffix(query)); + cmd.Parameters.AddWithValue("@aliasQueryLeaf", SqlNameResolver.GetLeafName(query)); + cmd.Parameters.AddWithValue("@allowQualifiedLeafFallback", allowLeafFallback ? 1 : 0); + cmd.Parameters.AddWithValue("@allowCSharpQualifiedContextMatch", allowCSharpContextMatch ? 1 : 0); + } + public int CountSearchSymbols(IReadOnlyList? queries, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { if (HasVisibilityFilters(visibilityFilters, excludeVisibilityFilters)) @@ -287,6 +374,9 @@ FROM symbols s if (validQueries != null && validQueries.Count == 1) { var allowLeafFallback = !SqlNameResolver.HasQualifier(validQueries[0]); + var qualifiedSymbolClause = SqlNameResolver.HasQualifier(validQueries[0]) + ? BuildQualifiedSymbolMatchSql("query0", _foldReady) + : null; var rustQualifiedExact = ShouldPreserveRustQualifiedExactQuery(validQueries[0], lang, exact); var rustQualifiedParts = rustQualifiedExact ? NormalizeRustQualifiedExactQueryParts(validQueries[0]) : default; innerSql += exact @@ -297,11 +387,11 @@ FROM symbols s : _foldReady ? allowLeafFallback ? " AND (s.name_folded = @query0 OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query0LeafFolded)))" - : " AND (s.name_folded = @query0 OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded))" + : $" AND (s.name_folded = @query0 OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? " AND (s.name = @query0 COLLATE NOCASE OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query0Leaf COLLATE NOCASE)))" - : " AND (s.name = @query0 COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE))" - : " AND (s.name LIKE @query0 ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query0NormalizedLike ESCAPE '\\'))"; + : $" AND (s.name = @query0 COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + : $" AND (s.name LIKE @query0 ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query0NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; } if (kind != null) innerSql += " AND s.kind = @kind"; @@ -330,6 +420,8 @@ FROM symbols s cmd.Parameters.AddWithValue("@query0LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(value)) ?? SqlNameResolver.GetLeafName(value)); cmd.Parameters.AddWithValue("@query0SegmentCount", SqlNameResolver.GetSegmentCount(value)); cmd.Parameters.AddWithValue("@query0NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(value))}%"); + if (SqlNameResolver.HasQualifier(value)) + AddQualifiedSymbolQueryParameters(cmd, "query0", value); if (rustQualifiedParts.QualifiedPath != null) { cmd.Parameters.AddWithValue("@query0RustContainer", rustQualifiedParts.ContainerPath ?? string.Empty); @@ -383,6 +475,9 @@ FROM symbols s var rustQualifiedExact = ShouldPreserveRustQualifiedExactQuery(queryValue, lang, exact); var rustQualifiedParts = rustQualifiedExact ? NormalizeRustQualifiedExactQueryParts(queryValue) : default; var allowLeafFallback = !SqlNameResolver.HasQualifier(queryValue); + var qualifiedSymbolClause = SqlNameResolver.HasQualifier(queryValue) + ? BuildQualifiedSymbolMatchSql($"query{idx}", _foldReady) + : null; var swiftBacktickAlias = ComputeSwiftBacktickAlias(queryValue, lang); var swiftBacktickClause = swiftBacktickAlias != null ? _foldReady @@ -396,12 +491,18 @@ FROM symbols s return _foldReady ? allowLeafFallback ? $"(s.name_folded = @query{idx}{swiftBacktickClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" - : $"(s.name_folded = @query{idx}{swiftBacktickClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded))" + : $"(s.name_folded = @query{idx}{swiftBacktickClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" - : $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE))"; + : $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; })) - : string.Join(" OR ", effectiveQueries.Select((_, idx) => $"(s.name LIKE @query{idx} ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query{idx}NormalizedLike ESCAPE '\\'))")); + : string.Join(" OR ", effectiveQueries.Select((queryValue, idx) => + { + var qualifiedSymbolClause = SqlNameResolver.HasQualifier(queryValue) + ? BuildQualifiedSymbolMatchSql($"query{idx}", _foldReady) + : null; + return $"(s.name LIKE @query{idx} ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query{idx}NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + })); sql += $" AND ({orClauses})"; } if (kind != null) @@ -431,6 +532,8 @@ FROM symbols s cmd.Parameters.AddWithValue($"@query{i}LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(value)) ?? SqlNameResolver.GetLeafName(value)); cmd.Parameters.AddWithValue($"@query{i}SegmentCount", SqlNameResolver.GetSegmentCount(value)); cmd.Parameters.AddWithValue($"@query{i}NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(value))}%"); + if (SqlNameResolver.HasQualifier(value)) + AddQualifiedSymbolQueryParameters(cmd, $"query{i}", value); var swiftBacktickAlias = ComputeSwiftBacktickAlias(value, lang); if (swiftBacktickAlias != null) { @@ -532,6 +635,9 @@ FROM symbols s var rustQualifiedExact = ShouldPreserveRustQualifiedExactQuery(queryValue, lang, exact); var rustQualifiedParts = rustQualifiedExact ? NormalizeRustQualifiedExactQueryParts(queryValue) : default; var allowLeafFallback = !SqlNameResolver.HasQualifier(queryValue); + var qualifiedSymbolClause = SqlNameResolver.HasQualifier(queryValue) + ? BuildQualifiedSymbolMatchSql($"query{idx}", _foldReady) + : null; var swiftBacktickAlias = ComputeSwiftBacktickAlias(queryValue, lang); var swiftBacktickClause = swiftBacktickAlias != null ? _foldReady @@ -545,12 +651,18 @@ FROM symbols s return _foldReady ? allowLeafFallback ? $"(s.name_folded = @query{idx}{swiftBacktickClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" - : $"(s.name_folded = @query{idx}{swiftBacktickClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded))" + : $"(s.name_folded = @query{idx}{swiftBacktickClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" - : $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE))"; + : $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; })) - : string.Join(" OR ", effectiveQueries.Select((_, idx) => $"(s.name LIKE @query{idx} ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query{idx}NormalizedLike ESCAPE '\\'))")); + : string.Join(" OR ", effectiveQueries.Select((queryValue, idx) => + { + var qualifiedSymbolClause = SqlNameResolver.HasQualifier(queryValue) + ? BuildQualifiedSymbolMatchSql($"query{idx}", _foldReady) + : null; + return $"(s.name LIKE @query{idx} ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query{idx}NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + })); sql += $" AND ({orClauses})"; } if (kind != null) @@ -592,6 +704,8 @@ FROM symbols s cmd.Parameters.AddWithValue($"@query{idx}LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(effectiveQueries[idx])) ?? SqlNameResolver.GetLeafName(effectiveQueries[idx])); cmd.Parameters.AddWithValue($"@query{idx}SegmentCount", SqlNameResolver.GetSegmentCount(effectiveQueries[idx])); cmd.Parameters.AddWithValue($"@query{idx}NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(effectiveQueries[idx]))}%"); + if (SqlNameResolver.HasQualifier(effectiveQueries[idx])) + AddQualifiedSymbolQueryParameters(cmd, $"query{idx}", effectiveQueries[idx]); if (rustQualifiedParts.QualifiedPath != null) { cmd.Parameters.AddWithValue($"@query{idx}RustContainer", rustQualifiedParts.ContainerPath ?? string.Empty); @@ -815,6 +929,9 @@ FROM symbols s var rustQualifiedExact = ShouldPreserveRustQualifiedExactQuery(normalizedQuery, lang, exact); var rustQualifiedParts = rustQualifiedExact ? NormalizeRustQualifiedExactQueryParts(normalizedQuery) : default; var allowLeafFallback = !SqlNameResolver.HasQualifier(normalizedQuery); + var qualifiedSymbolClause = SqlNameResolver.HasQualifier(normalizedQuery) + ? BuildQualifiedSymbolMatchSql("query", _foldReady) + : null; sql += exact ? rustQualifiedParts.QualifiedPath != null ? _foldReady @@ -823,11 +940,11 @@ FROM symbols s : _foldReady ? allowLeafFallback ? " AND (s.name_folded = @query OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded) OR sql_leaf_name_folded(s.name) = @queryLeafFolded)))" - : " AND (s.name_folded = @query OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded))" + : $" AND (s.name_folded = @query OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? " AND (s.name = @query COLLATE NOCASE OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @queryLeaf COLLATE NOCASE)))" - : " AND (s.name = @query COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE))" - : " AND (s.name LIKE @query ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @queryNormalizedLike ESCAPE '\\'))"; + : $" AND (s.name = @query COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + : $" AND (s.name LIKE @query ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @queryNormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; } if (kind != null) sql += " AND s.kind = @kind"; @@ -863,6 +980,8 @@ FROM chunks c cmd.Parameters.AddWithValue("@queryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(normalizedQuery)) ?? SqlNameResolver.GetLeafName(normalizedQuery)); cmd.Parameters.AddWithValue("@querySegmentCount", SqlNameResolver.GetSegmentCount(normalizedQuery)); cmd.Parameters.AddWithValue("@queryNormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(normalizedQuery))}%"); + if (SqlNameResolver.HasQualifier(normalizedQuery)) + AddQualifiedSymbolQueryParameters(cmd, "query", normalizedQuery); if (rustQualifiedParts.QualifiedPath != null) { cmd.Parameters.AddWithValue("@queryRustContainer", rustQualifiedParts.ContainerPath ?? string.Empty); diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 0d6ecfae36..0242e4e24a 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -20,6 +20,16 @@ public class SearchResult public double Score { get; set; } public string? Visibility { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EnclosingSymbolName { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EnclosingSymbolKind { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? EnclosingSymbolStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? EnclosingSymbolEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EnclosingContainerName { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? GuardEvidence { get; set; } [JsonIgnore] public long ChunkId { get; set; } diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index b7ecaaaea8..87db181fe2 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -679,6 +679,61 @@ public void Search_FindsMatchingChunks() Assert.Equal(1, results[0].StartLine); } + [Fact] + public void Search_ReturnsEnclosingSymbolMetadata_Issue2838() + { + const string token = "issue2838_unique_needle"; + InsertIndexedFile("src/issue2838/SearchContainer.cs", "csharp", + $$""" + namespace Issue2838; + + public sealed class SearchContainer + { + public void Run() + { + var message = "{{token}}"; + } + } + """); + + var result = Assert.Single(_reader.Search(token, lang: "csharp") + .Where(result => result.Path == "src/issue2838/SearchContainer.cs")); + + Assert.Equal("Run", result.EnclosingSymbolName); + Assert.Equal("function", result.EnclosingSymbolKind); + Assert.Equal("SearchContainer", result.EnclosingContainerName); + Assert.True(result.EnclosingSymbolStartLine > 0); + Assert.True(result.EnclosingSymbolEndLine >= result.EnclosingSymbolStartLine); + } + + [Fact] + public void Search_ReturnsEnclosingSymbolForActualMatchLine_Issue2838() + { + const string token = "issue2838_multifunction_needle"; + InsertIndexedFile("src/issue2838/MultiFunctionSearch.cs", "csharp", + $$""" + namespace Issue2838; + + public sealed class MultiFunctionSearch + { + public void Tiny() { } + + public void Larger() + { + var message = "{{token}}"; + Console.WriteLine(message); + } + } + """); + + var result = Assert.Single(_reader.Search(token, lang: "csharp") + .Where(result => result.Path == "src/issue2838/MultiFunctionSearch.cs")); + + Assert.Equal("Larger", result.EnclosingSymbolName); + Assert.Equal("function", result.EnclosingSymbolKind); + Assert.Equal("MultiFunctionSearch", result.EnclosingContainerName); + } + [Fact] public void GetSymbolHotspots_RanksRealCallsAboveManyLowerWeightSubscribeEdges() { @@ -4710,6 +4765,133 @@ public void GraphReaders_ExactMatchesNameEquality() Assert.Single(_reader.GetCallers("AUTHENTICATE", exact: true)); } + [Fact] + public void GraphReaders_QualifiedMemberQueriesUseContextAndDefinitionFallback_Issue2819() + { + InsertIndexedFile("src/issue2819/HttpMcpTransport.cs", "csharp", + """ + namespace Issue2819; + + public sealed class HttpMcpTransport + { + public void HandleContext() + { + RunEventStreamAsync(); + System.Guid.NewGuid(); + } + + private void RunEventStreamAsync() + { + Guid.NewGuid(); + } + } + """); + + var definition = Assert.Single(_reader.GetDefinitions( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + Assert.Equal("RunEventStreamAsync", definition.Name); + Assert.Equal("HttpMcpTransport", definition.ContainerName); + + var reference = Assert.Single(_reader.SearchReferences( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + Assert.Equal("RunEventStreamAsync", reference.SymbolName); + Assert.Equal("HandleContext", reference.ContainerName); + Assert.Equal(1, _reader.CountSearchReferences( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + Assert.Equal(new QueryCountResult(1, 1, IncludesSql: false), _reader.CountSearchReferencesTotal( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + + var caller = Assert.Single(_reader.GetCallers( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + Assert.Equal("HandleContext", caller.CallerName); + Assert.Equal(1, _reader.CountCallers( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + Assert.Equal(new QueryCountResult(1, 1, IncludesSql: false), _reader.CountCallersTotal( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + + var callees = _reader.GetCallees( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"]); + Assert.Contains(callees, result => result.CallerName == "RunEventStreamAsync" && result.CalleeName == "NewGuid"); + Assert.Equal(callees.Count, _reader.CountCallees( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + Assert.Equal(new QueryCountResult(callees.Count, 1, IncludesSql: false), _reader.CountCalleesTotal( + "HttpMcpTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"])); + + var frameworkCallers = _reader.GetCallers( + "System.Guid.NewGuid", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819"]); + Assert.Contains(frameworkCallers, result => result.CallerName == "HandleContext"); + Assert.Contains(frameworkCallers, result => result.CallerName == "RunEventStreamAsync"); + } + + [Fact] + public void GraphReaders_QualifiedMemberLeafFallbackRequiresUniqueLeaf_Issue2819() + { + InsertIndexedFile("src/issue2819/AmbiguousLeaf.cs", "csharp", + """ + namespace Issue2819; + + public sealed class TargetTransport + { + public void RunEventStreamAsync() { } + } + + public sealed class OtherTransport + { + public void RunEventStreamAsync() { } + + public void HandleOther() + { + RunEventStreamAsync(); + } + } + """); + + Assert.Empty(_reader.SearchReferences( + "TargetTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819/AmbiguousLeaf"])); + + Assert.Empty(_reader.GetCallers( + "TargetTransport.RunEventStreamAsync", + lang: "csharp", + exact: true, + pathPatterns: ["issue2819/AmbiguousLeaf"])); + } + [Fact] public void GraphReaders_ExactPrefersExactCaseOverFoldSibling() { diff --git a/tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs b/tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs index 6e8c7f80fa..8e122e5f19 100644 --- a/tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs +++ b/tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs @@ -97,6 +97,11 @@ public void ToCompactResult_UsesSnippetInsteadOfFullChunkContent() EndLine = 24, Content = "line 20\nline 21\nRun(App)\nline 23\nline 24", Score = -1.5, + EnclosingSymbolName = "Run", + EnclosingSymbolKind = "function", + EnclosingSymbolStartLine = 18, + EnclosingSymbolEndLine = 25, + EnclosingContainerName = "AppService", }; var compact = SearchSnippetFormatter.ToCompactResult(result, "App", maxLines: 3); @@ -110,6 +115,11 @@ public void ToCompactResult_UsesSnippetInsteadOfFullChunkContent() Assert.Equal([22], compact.MatchLines); Assert.Single(compact.Highlights); Assert.Equal(-1.5, compact.Score); + Assert.Equal("Run", compact.EnclosingSymbolName); + Assert.Equal("function", compact.EnclosingSymbolKind); + Assert.Equal(18, compact.EnclosingSymbolStartLine); + Assert.Equal(25, compact.EnclosingSymbolEndLine); + Assert.Equal("AppService", compact.EnclosingContainerName); } [Fact] diff --git a/tests/CodeIndex.Tests/golden/search.json b/tests/CodeIndex.Tests/golden/search.json index f4f34dc11b..2d9d6ede3d 100644 --- a/tests/CodeIndex.Tests/golden/search.json +++ b/tests/CodeIndex.Tests/golden/search.json @@ -41,7 +41,12 @@ "char_counts": [], "total_chars": 0 }, - "score": "\u003CSCORE\u003E" + "score": "\u003CSCORE\u003E", + "enclosing_symbol_name": "Add", + "enclosing_symbol_kind": "function", + "enclosing_symbol_start_line": 5, + "enclosing_symbol_end_line": 5, + "enclosing_container_name": "Lib" } { "done": true,