From f6544221a6ee5321cc6cfa4120084c59136e6b6a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 16:10:11 +0900 Subject: [PATCH 1/6] Support qualified graph member queries (#2819) --- changelog.d/unreleased/2819.added.md | 18 ++ .../Database/DbReader.GraphQueries.cs | 157 +++++++++++++++--- src/CodeIndex/Database/DbReader.References.cs | 102 +++++++++--- src/CodeIndex/Database/DbSymbolReader.cs | 126 ++++++++++++-- tests/CodeIndex.Tests/DbReaderTests.cs | 91 ++++++++++ 5 files changed, 438 insertions(+), 56 deletions(-) create mode 100644 changelog.d/unreleased/2819.added.md 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/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 05c962befa..d749026df4 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -69,19 +69,36 @@ FROM symbol_references r if (excludeSelfReferences) sql += $" AND {selfReferenceSql} = 0"; var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); + 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + sql += $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + sql += $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + sql += $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + sql += $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) sql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -133,6 +150,7 @@ FROM logical_references r callersQueryParam = query; cmd.Parameters.AddWithValue("@query", callersQueryParam); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -208,19 +226,36 @@ 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 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) groupedSql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -252,6 +287,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -298,15 +334,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 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true); + groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' 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 qualifiedLeafFallbackSql = BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false); + groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (exact && _foldReady) groupedSql += allowSqlLeafFallback ? cssScssVariableAlias != null @@ -338,6 +391,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -405,15 +459,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 +547,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 +611,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 +672,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 +716,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 +777,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 @@ -748,15 +856,18 @@ private string ResolveSymbolName(string symbolName, string? lang) var leafName = SqlNameResolver.GetLeafName(normalizedSymbolName); var segmentCount = SqlNameResolver.GetSegmentCount(normalizedSymbolName); var allowLeafFallback = !SqlNameResolver.HasQualifier(normalizedSymbolName); + var qualifiedSymbolClause = SqlNameResolver.HasQualifier(normalizedSymbolName) + ? BuildQualifiedSymbolMatchSql("qualifiedName", _foldReady) + : null; using var cmd = _conn.CreateCommand(); var supportedLangFilter = BuildGraphSupportedLanguagePredicate(cmd, "f", "resolveLang"); var nameCondition = _foldReady ? allowLeafFallback ? "(s.name_folded = @nameFolded OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @segmentCount AND sql_normalize_name_folded(s.name) = @normalizedNameFolded) OR sql_leaf_name_folded(s.name) = @leafNameFolded)))" - : "(s.name_folded = @nameFolded OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name_folded(s.name) = @normalizedNameFolded))" + : $"(s.name_folded = @nameFolded OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name_folded(s.name) = @normalizedNameFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? "(s.name = @name COLLATE NOCASE OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @segmentCount AND sql_normalize_name(s.name) = @normalizedName COLLATE NOCASE) OR sql_leaf_name(s.name) = @leafName COLLATE NOCASE)))" - : "(s.name = @name COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name(s.name) = @normalizedName COLLATE NOCASE))"; + : $"(s.name = @name COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name(s.name) = @normalizedName COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; cmd.CommandText = @"SELECT s.name FROM symbols s JOIN files f ON s.file_id = f.id WHERE " + nameCondition + @" AND " + supportedLangFilter + @" @@ -777,6 +888,12 @@ ELSE 5 cmd.Parameters.AddWithValue("@allowLeafFallback", allowLeafFallback ? 1 : 0); if (_foldReady) cmd.Parameters.AddWithValue("@nameFolded", NameFold.Fold(normalizedSymbolName) ?? normalizedSymbolName); + if (qualifiedSymbolClause != null) + { + cmd.Parameters.AddWithValue("@qualifiedNameLeaf", leafName); + cmd.Parameters.AddWithValue("@qualifiedNameLeafFolded", NameFold.Fold(leafName) ?? leafName); + AddQualifiedSymbolQueryParameters(cmd, "qualifiedName", normalizedSymbolName); + } using var reader = cmd.ExecuteTrackedReader(); return reader.TrackedRead() ? reader.GetString(0) : symbolName; } diff --git a/src/CodeIndex/Database/DbReader.References.cs b/src/CodeIndex/Database/DbReader.References.cs index 20df6bf14e..6f0b87b66e 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -135,6 +135,7 @@ FROM symbol_references r var referencesCssScssVariableAliasScope = referencesCssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; + var allowQualifiedLeafFallback = query != null && HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); const string sqlLeafReferenceScope = " AND f.lang = 'sql'"; if (query != null) { @@ -155,21 +156,37 @@ FROM symbol_references r // することで `FooAuditLog` など無関係な名前を巻き込まないようにする。 // 別名節は C# の attribute 行に限定し、誤一致を避ける。 if (useSqlQualifiedContextMatch && exact && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false); + 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 ({qualifiedContextSql} OR r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{referencesAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' 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 +241,7 @@ FROM symbol_references r queryParam = query; cmd.Parameters.AddWithValue("@query", queryParam); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (referencesSuffixAlias != null) { @@ -511,27 +529,44 @@ FROM symbol_references r var countAliasScope = countSuffixAlias != null ? " AND f.lang = 'csharp' AND r.reference_kind = 'attribute'" : string.Empty; + 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 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 ({qualifiedContextSql} OR r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{countAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' 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 +603,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (countSuffixAlias != null) { @@ -619,27 +655,44 @@ FROM symbol_references r var totalCssScssVariableAliasScope = totalCssScssVariableAlias != null ? " AND f.lang = 'css'" : string.Empty; + 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 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 ({qualifiedContextSql} OR r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{totalAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && exact) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false); + 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch && _foldReady) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: true); + 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + } else if (useSqlQualifiedContextMatch) + { + var qualifiedContextSql = BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: true); + 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}) OR {qualifiedLeafFallbackSql})" + : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' 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 +735,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (totalSuffixAlias != null) { diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 035f418833..6711391f4f 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -267,6 +267,76 @@ public bool AnySearchSymbols(IReadOnlyList? queries, string? kind = null return false; } + private string BuildQualifiedSymbolMatchSql(string parameterStem, bool useFoldedName, string symbolAlias = "s") + { + 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 $@"({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); + return matches.Count == 1; + } + + 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 {foldedNameSql} = @aliasQueryLeafFolded)" + : $"(@allowQualifiedLeafFallback = 1 AND {nameSql} = @aliasQueryLeaf COLLATE NOCASE)"; + + private static void AddQualifiedGraphQueryParameters(SqliteCommand cmd, string query, bool allowLeafFallback) + { + cmd.Parameters.AddWithValue("@aliasQuerySuffix", GetQualifiedQuerySuffix(query)); + cmd.Parameters.AddWithValue("@aliasQueryLeaf", SqlNameResolver.GetLeafName(query)); + cmd.Parameters.AddWithValue("@allowQualifiedLeafFallback", allowLeafFallback ? 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 +357,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 +370,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 +403,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 +458,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 +474,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 +515,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 +618,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 +634,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 +687,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 +912,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 +923,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 +963,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/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index b7ecaaaea8..63dc89a778 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -4710,6 +4710,97 @@ 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_ExactPrefersExactCaseOverFoldSibling() { From c5ff8489dc26fb4e3b5605aad7e651b1fdf1b470 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 16:10:49 +0900 Subject: [PATCH 2/6] Add enclosing symbol metadata to search JSON (#2838) --- changelog.d/unreleased/2838.added.md | 19 ++++++++ src/CodeIndex/Cli/SearchSnippetFormatter.cs | 15 ++++++ src/CodeIndex/Database/DbSearchReader.cs | 48 ++++++++++++++++++- src/CodeIndex/Models/QueryResults.cs | 10 ++++ tests/CodeIndex.Tests/DbReaderTests.cs | 27 +++++++++++ .../SearchSnippetFormatterTests.cs | 10 ++++ 6 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/2838.added.md 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/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index f373ecf505..71772ebf16 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -124,7 +124,12 @@ public List Search(string query, int limit = 20, string? lang = nu SELECT f.path, f.lang, c.start_line, c.end_line, c.content, 0.0 AS rank, {GetSearchVisibilitySql()} AS visibility, - c.id AS chunk_id + c.id AS chunk_id, + {GetSearchEnclosingSymbolColumnSql("s_enclosing.name")} AS enclosing_symbol_name, + {GetSearchEnclosingSymbolColumnSql("s_enclosing.kind")} AS enclosing_symbol_kind, + {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("start_line", "s_enclosing.line", "s_enclosing"))} AS enclosing_symbol_start_line, + {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("end_line", "s_enclosing.line", "s_enclosing"))} AS enclosing_symbol_end_line, + {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("container_name", symbolAlias: "s_enclosing"))} AS enclosing_container_name FROM chunks c JOIN files f ON c.file_id = f.id{SearchSymbolMatchJoinsSql} WHERE instr( @@ -141,7 +146,12 @@ WHERE instr( SELECT f.path, f.lang, c.start_line, c.end_line, c.content, rank, {GetSearchVisibilitySql()} AS visibility, - c.id AS chunk_id + c.id AS chunk_id, + {GetSearchEnclosingSymbolColumnSql("s_enclosing.name")} AS enclosing_symbol_name, + {GetSearchEnclosingSymbolColumnSql("s_enclosing.kind")} AS enclosing_symbol_kind, + {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("start_line", "s_enclosing.line", "s_enclosing"))} AS enclosing_symbol_start_line, + {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("end_line", "s_enclosing.line", "s_enclosing"))} AS enclosing_symbol_end_line, + {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("container_name", symbolAlias: "s_enclosing"))} AS enclosing_container_name FROM fts_chunks JOIN chunks c ON fts_chunks.rowid = c.id JOIN files f ON c.file_id = f.id{SearchSymbolMatchJoinsSql}"; @@ -196,6 +206,11 @@ FROM fts_chunks Score = reader.GetDouble(5), Visibility = GetNullableString(reader, 6), ChunkId = reader.GetInt64(7), + EnclosingSymbolName = GetNullableString(reader, 8), + EnclosingSymbolKind = GetNullableString(reader, 9), + EnclosingSymbolStartLine = GetNullableInt32(reader, 10), + EnclosingSymbolEndLine = GetNullableInt32(reader, 11), + EnclosingContainerName = GetNullableString(reader, 12), NextOffset = nextOffset, }); } @@ -212,6 +227,35 @@ FROM fts_chunks return hasGuardFilters ? PageGuardedSearchResults(results, limit, cursor) : results; } + private string GetSearchEnclosingSymbolColumnSql(string columnSql) + { + var startLineSql = GetSymbolColumnSql("start_line", "s_enclosing.line", "s_enclosing"); + var endLineSql = GetSymbolColumnSql("end_line", "s_enclosing.line", "s_enclosing"); + return $@" + ( + SELECT {columnSql} + FROM symbols s_enclosing + WHERE s_enclosing.file_id = f.id + AND {startLineSql} <= c.end_line + AND {endLineSql} >= c.start_line + ORDER BY CASE s_enclosing.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_enclosing.line DESC, + s_enclosing.id ASC + LIMIT 1 + )"; + } + 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/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 63dc89a778..9cee846e85 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -679,6 +679,33 @@ 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 GetSymbolHotspots_RanksRealCallsAboveManyLowerWeightSubscribeEdges() { 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] From 85f9a84d8b477835f7c2aa77643a9fc4e5adf119 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 16:27:42 +0900 Subject: [PATCH 3/6] Constrain qualified graph fallback (#2819) --- src/CodeIndex/Database/DbSymbolReader.cs | 6 +++- tests/CodeIndex.Tests/DbReaderTests.cs | 36 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 6711391f4f..df9941c681 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -310,7 +310,11 @@ private bool HasSingleQualifiedSymbolDefinition(string query, string? lang, IRea return false; var matches = SearchSymbols(query, 2, kind: null, lang, pathPatterns: null, excludePathPatterns: null, excludeTests, since: null, exact: false); - return matches.Count == 1; + 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 static string BuildQualifiedContextMatchSql(string contextSql, string columnSql, bool folded, bool like) diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 9cee846e85..e016c95ca4 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -4828,6 +4828,42 @@ private void RunEventStreamAsync() 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() { From 46a7430517f923bafce0edbaab67d8350897eaa9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 16:28:07 +0900 Subject: [PATCH 4/6] Anchor search enclosing symbols to match lines (#2838) --- DEVELOPER_GUIDE.md | 4 +- USER_GUIDE.md | 4 +- src/CodeIndex/Database/DbSearchReader.cs | 167 +++++++++++++++++------ tests/CodeIndex.Tests/DbReaderTests.cs | 28 ++++ tests/CodeIndex.Tests/golden/search.json | 7 +- 5 files changed, 162 insertions(+), 48 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a9b9e87e64..bdc2dc85c0 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1019,7 +1019,7 @@ Exact-match flag compatibility is documented in [USER_GUIDE.md](USER_GUIDE.md#fl `search`, `definition`, `references`, `callers`, `callees`, `symbols`, and `files` also share path-aware narrowing via `--path`, repeatable `--exclude-path`, and `--exclude-tests`. The read layer ranks source files ahead of tests and docs, and `search` further boosts exact symbol-name and path matches so AI clients are more likely to land on implementation files first. -`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. +`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`, plus optional `enclosing_symbol_name`, `enclosing_symbol_kind`, `enclosing_symbol_start_line`, `enclosing_symbol_end_line`, and `enclosing_container_name` when the match line falls inside an indexed symbol range. `--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. `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. @@ -2650,7 +2650,7 @@ exact-match flag の互換性は [USER_GUIDE.md](USER_GUIDE.md#フラグ互換 `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files` は `--path`、繰り返し指定できる `--exclude-path`、`--exclude-tests` による絞り込みを共有します。読み取り層は tests や docs より source を優先し、`search` はシンボル名やパスがクエリと正確に一致する候補をさらに上位に出して、AIクライアントが実装ファイルへ早く到達できるようにします。 -`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` は、フルチャンクを `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)、`--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 外に落ちた一致行数を示します。 `excerpt --json` は 1-based の開始/終了位置、token `type`、`modifiers` を持つ軽量 range list の `semantic_tokens` を返すため、IDE や LLM クライアントは生の `content` 文字列を再パースせずに抜粋範囲を描画・後処理できます。 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/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 71772ebf16..a2469fdc75 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -124,12 +124,7 @@ public List Search(string query, int limit = 20, string? lang = nu SELECT f.path, f.lang, c.start_line, c.end_line, c.content, 0.0 AS rank, {GetSearchVisibilitySql()} AS visibility, - c.id AS chunk_id, - {GetSearchEnclosingSymbolColumnSql("s_enclosing.name")} AS enclosing_symbol_name, - {GetSearchEnclosingSymbolColumnSql("s_enclosing.kind")} AS enclosing_symbol_kind, - {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("start_line", "s_enclosing.line", "s_enclosing"))} AS enclosing_symbol_start_line, - {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("end_line", "s_enclosing.line", "s_enclosing"))} AS enclosing_symbol_end_line, - {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("container_name", symbolAlias: "s_enclosing"))} AS enclosing_container_name + c.id AS chunk_id FROM chunks c JOIN files f ON c.file_id = f.id{SearchSymbolMatchJoinsSql} WHERE instr( @@ -146,12 +141,7 @@ WHERE instr( SELECT f.path, f.lang, c.start_line, c.end_line, c.content, rank, {GetSearchVisibilitySql()} AS visibility, - c.id AS chunk_id, - {GetSearchEnclosingSymbolColumnSql("s_enclosing.name")} AS enclosing_symbol_name, - {GetSearchEnclosingSymbolColumnSql("s_enclosing.kind")} AS enclosing_symbol_kind, - {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("start_line", "s_enclosing.line", "s_enclosing"))} AS enclosing_symbol_start_line, - {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("end_line", "s_enclosing.line", "s_enclosing"))} AS enclosing_symbol_end_line, - {GetSearchEnclosingSymbolColumnSql(GetSymbolColumnSql("container_name", symbolAlias: "s_enclosing"))} AS enclosing_container_name + c.id AS chunk_id FROM fts_chunks JOIN chunks c ON fts_chunks.rowid = c.id JOIN files f ON c.file_id = f.id{SearchSymbolMatchJoinsSql}"; @@ -206,11 +196,6 @@ FROM fts_chunks Score = reader.GetDouble(5), Visibility = GetNullableString(reader, 6), ChunkId = reader.GetInt64(7), - EnclosingSymbolName = GetNullableString(reader, 8), - EnclosingSymbolKind = GetNullableString(reader, 9), - EnclosingSymbolStartLine = GetNullableInt32(reader, 10), - EnclosingSymbolEndLine = GetNullableInt32(reader, 11), - EnclosingContainerName = GetNullableString(reader, 12), NextOffset = nextOffset, }); } @@ -224,38 +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 string GetSearchEnclosingSymbolColumnSql(string columnSql) + private void AttachSearchEnclosingSymbols(IReadOnlyList results, string query, bool caseSensitive) { - var startLineSql = GetSymbolColumnSql("start_line", "s_enclosing.line", "s_enclosing"); - var endLineSql = GetSymbolColumnSql("end_line", "s_enclosing.line", "s_enclosing"); - return $@" - ( - SELECT {columnSql} - FROM symbols s_enclosing - WHERE s_enclosing.file_id = f.id - AND {startLineSql} <= c.end_line - AND {endLineSql} >= c.start_line - ORDER BY CASE s_enclosing.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_enclosing.line DESC, - s_enclosing.id ASC - LIMIT 1 - )"; + 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/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index e016c95ca4..87db181fe2 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -706,6 +706,34 @@ public void Run() 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() { 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, From 0bb086fa8c27cb755a972dcaace0bc7b13c953d3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:16:05 +0900 Subject: [PATCH 5/6] Fix qualified graph overmatching (#2819) --- .../Database/DbReader.GraphQueries.cs | 71 +++++++++++------- src/CodeIndex/Database/DbReader.References.cs | 75 ++++++++++++------- src/CodeIndex/Database/DbSymbolReader.cs | 23 ++++-- 3 files changed, 109 insertions(+), 60 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index d749026df4..67cff29495 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -69,6 +69,8 @@ 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); @@ -78,26 +80,30 @@ FROM symbol_references r 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 += $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + 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 @@ -150,7 +156,7 @@ FROM logical_references r callersQueryParam = query; cmd.Parameters.AddWithValue("@query", callersQueryParam); cmd.Parameters.AddWithValue("@aliasQuery", query); - AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -226,6 +232,8 @@ 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); @@ -235,26 +243,30 @@ FROM symbol_references r 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); - groupedSql += $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + 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 @@ -287,7 +299,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); - AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -334,31 +346,37 @@ 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) { 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 ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + 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) { 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + 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 @@ -391,7 +409,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); - AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (cssScssVariableAlias != null) { @@ -856,18 +874,15 @@ private string ResolveSymbolName(string symbolName, string? lang) var leafName = SqlNameResolver.GetLeafName(normalizedSymbolName); var segmentCount = SqlNameResolver.GetSegmentCount(normalizedSymbolName); var allowLeafFallback = !SqlNameResolver.HasQualifier(normalizedSymbolName); - var qualifiedSymbolClause = SqlNameResolver.HasQualifier(normalizedSymbolName) - ? BuildQualifiedSymbolMatchSql("qualifiedName", _foldReady) - : null; using var cmd = _conn.CreateCommand(); var supportedLangFilter = BuildGraphSupportedLanguagePredicate(cmd, "f", "resolveLang"); var nameCondition = _foldReady ? allowLeafFallback ? "(s.name_folded = @nameFolded OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @segmentCount AND sql_normalize_name_folded(s.name) = @normalizedNameFolded) OR sql_leaf_name_folded(s.name) = @leafNameFolded)))" - : $"(s.name_folded = @nameFolded OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name_folded(s.name) = @normalizedNameFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + : "(s.name_folded = @nameFolded OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name_folded(s.name) = @normalizedNameFolded))" : allowLeafFallback ? "(s.name = @name COLLATE NOCASE OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @segmentCount AND sql_normalize_name(s.name) = @normalizedName COLLATE NOCASE) OR sql_leaf_name(s.name) = @leafName COLLATE NOCASE)))" - : $"(s.name = @name COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name(s.name) = @normalizedName COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + : "(s.name = @name COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name(s.name) = @normalizedName COLLATE NOCASE))"; cmd.CommandText = @"SELECT s.name FROM symbols s JOIN files f ON s.file_id = f.id WHERE " + nameCondition + @" AND " + supportedLangFilter + @" @@ -888,12 +903,6 @@ ELSE 5 cmd.Parameters.AddWithValue("@allowLeafFallback", allowLeafFallback ? 1 : 0); if (_foldReady) cmd.Parameters.AddWithValue("@nameFolded", NameFold.Fold(normalizedSymbolName) ?? normalizedSymbolName); - if (qualifiedSymbolClause != null) - { - cmd.Parameters.AddWithValue("@qualifiedNameLeaf", leafName); - cmd.Parameters.AddWithValue("@qualifiedNameLeafFolded", NameFold.Fold(leafName) ?? leafName); - AddQualifiedSymbolQueryParameters(cmd, "qualifiedName", normalizedSymbolName); - } using var reader = cmd.ExecuteTrackedReader(); return reader.TrackedRead() ? reader.GetString(0) : symbolName; } @@ -927,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) : []; @@ -940,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 @@ -982,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 6f0b87b66e..30c08b05ff 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -135,6 +135,9 @@ 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) @@ -158,34 +161,38 @@ FROM symbol_references r 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 ({qualifiedContextSql} OR r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{referencesAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + ? $" 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 @@ -241,7 +248,7 @@ FROM symbol_references r queryParam = query; cmd.Parameters.AddWithValue("@query", queryParam); cmd.Parameters.AddWithValue("@aliasQuery", query); - AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (referencesSuffixAlias != null) { @@ -529,6 +536,9 @@ 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) @@ -538,34 +548,38 @@ FROM symbol_references r 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 ({qualifiedContextSql} OR r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{countAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + ? $" 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 @@ -603,7 +617,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); - AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); + AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback, allowCSharpQualifiedContextMatch); cmd.Parameters.AddWithValue("@aliasQueryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(query)) ?? SqlNameResolver.GetLeafName(query)); if (countSuffixAlias != null) { @@ -655,6 +669,9 @@ 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) @@ -664,34 +681,38 @@ FROM symbol_references r 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 ({qualifiedContextSql} OR r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{totalAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name_folded = @query OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name = @query COLLATE NOCASE OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + ? $" 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 ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}) OR {qualifiedLeafFallbackSql})" - : $" AND ({qualifiedContextSql} OR r.symbol_name LIKE @query ESCAPE '\\' OR {qualifiedLeafFallbackSql})"; + ? $" 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 @@ -735,7 +756,7 @@ FROM symbol_references r : query; cmd.Parameters.AddWithValue("@query", value); cmd.Parameters.AddWithValue("@aliasQuery", query); - AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); + 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/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index df9941c681..fc63b70ab0 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -267,14 +267,15 @@ public bool AnySearchSymbols(IReadOnlyList? queries, string? kind = null return false; } - private string BuildQualifiedSymbolMatchSql(string parameterStem, bool useFoldedName, string symbolAlias = "s") + 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 $@"({nameMatchSql} + 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 '\'))"; @@ -317,6 +318,14 @@ private bool HasSingleQualifiedSymbolDefinition(string query, string? lang, IRea 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 @@ -331,14 +340,18 @@ private static string BuildQualifiedContextMatchSql(string contextSql, string co private static string BuildQualifiedLeafFallbackSql(string nameSql, string foldedNameSql, bool folded) => folded - ? $"(@allowQualifiedLeafFallback = 1 AND {foldedNameSql} = @aliasQueryLeafFolded)" - : $"(@allowQualifiedLeafFallback = 1 AND {nameSql} = @aliasQueryLeaf COLLATE NOCASE)"; + ? $"(@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) + 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) From 8dd2a63a205d155943807f4d250abdfaa8a2064b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 04:18:52 +0900 Subject: [PATCH 6/6] Resolve search JSON docs conflict (#2838) --- DEVELOPER_GUIDE.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index bdc2dc85c0..0f4ed0c3a5 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1019,7 +1019,9 @@ Exact-match flag compatibility is documented in [USER_GUIDE.md](USER_GUIDE.md#fl `search`, `definition`, `references`, `callers`, `callees`, `symbols`, and `files` also share path-aware narrowing via `--path`, repeatable `--exclude-path`, and `--exclude-tests`. The read layer ranks source files ahead of tests and docs, and `search` further boosts exact symbol-name and path matches so AI clients are more likely to land on implementation files first. -`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`, plus optional `enclosing_symbol_name`, `enclosing_symbol_kind`, `enclosing_symbol_start_line`, `enclosing_symbol_end_line`, and `enclosing_container_name` when the match line falls inside an indexed symbol range. `--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. +`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. @@ -2650,7 +2652,9 @@ exact-match flag の互換性は [USER_GUIDE.md](USER_GUIDE.md#フラグ互換 `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files` は `--path`、繰り返し指定できる `--exclude-path`、`--exclude-tests` による絞り込みを共有します。読み取り層は tests や docs より source を優先し、`search` はシンボル名やパスがクエリと正確に一致する候補をさらに上位に出して、AIクライアントが実装ファイルへ早く到達できるようにします。 -`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)、`--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` は、フルチャンクを `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` 文字列を再パースせずに抜粋範囲を描画・後処理できます。