Skip to content

Commit a3e7e9f

Browse files
committed
Fix search snippet focus for identifiers (#4618)
1 parent 1083d20 commit a3e7e9f

5 files changed

Lines changed: 105 additions & 3 deletions

File tree

DEVELOPER_GUIDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,8 @@ Exact-match flag compatibility is documented in [USER_GUIDE.md](USER_GUIDE.md#fl
13241324

13251325
`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`. Compact CLI rows and MCP search results also echo effective output options with `snippet_lines` / `snippetLines`, `max_line_width` / `maxLineWidth`, `exact`, `raw_fts` / `rawFts`, `literal_highlights_available` / `literalHighlightsAvailable`, and optional `literal_highlight_warning` / `literalHighlightWarning`. `--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`, `length`, plus `visible`, `visible_column`, and `visible_length` for the portion still present in the returned snippet text after line clamping. 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; raw FTS rows set `literal_highlight_warning` / `literalHighlightWarning` to `literal_highlights_unavailable_raw_fts` because FTS syntax can no longer be mapped to one literal phrase. 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. `focus_mode`, `focus_line`, `focus_column`, and `focus_reason` describe the match window selected for the snippet, while `dropped_match_line_count` and optional `next_match` report match lines omitted because they fell outside that selected snippet window.
13261326

1327+
Default `quality` snippet focus treats a single query made only of letters, digits, and underscores as identifier-shaped. When a result mixes matching code with earlier comments or strings, the first `code`-origin match becomes the preferred snippet line; space-delimited phrase queries and explicit `leftmost` / `proximity` focus modes retain their existing selection. The selected line remains auditable through the existing focus and origin metadata, and `next_match` continues from the selected window.
1328+
13271329
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`.
13281330

13291331
`find --json` remains line-delimited for repeated matches and adds bounded match-span/truncation metadata to each row: `length` reports the 1-based `column` span length, `original_line_length` reports the source line length before any line-width clamp, and `snippet_truncation_context.line_count` / `char_counts` / `total_chars` / optional `reason` describe snippet clamping. `reason` is `line_width` when `--max-line-width` elides one or more snippet lines.
@@ -4191,6 +4193,8 @@ literal-safe な `search` query は reader 層で FTS5 sanitization 前に 1000
41914193

41924194
`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` を持つ軽量スニペットへ投影します。compact CLI row と MCP search result は有効な出力オプションも `snippet_lines` / `snippetLines`、`max_line_width` / `maxLineWidth`、`exact`、`raw_fts` / `rawFts`、`literal_highlights_available` / `literalHighlightsAvailable`、任意の `literal_highlight_warning` / `literalHighlightWarning` として返します。`--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` に加えて、行クランプ後に返却 snippet text 内へ残っている部分を示す `visible`、`visible_column`、`visible_length` を記録します。exact substring search では `highlights[].literal_terms` と `highlights[].literal_term_occurrences`(MCP では camelCase)も追加され、広めの診断 token list を残したまま、要求された literal phrase だけを render できます。raw FTS row は FTS 構文を単一の literal phrase へ対応付けられないため、`literal_highlight_warning` / `literalHighlightWarning` に `literal_highlights_unavailable_raw_fts` を設定します。exact ではない記号の多い code phrase 検索では、FTS tokenization が記号を失いやすい場合に exact substring semantics で再検索できるよう、CLI JSON compact result に `exact_substring_hint`、MCP `search` に `recovery_hint` を追加します。`focus_mode`、`focus_line`、`focus_column`、`focus_reason` は snippet に選ばれた match window を説明し、`dropped_match_line_count` と任意の `next_match` は選択された snippet window 外に落ちた一致行を示します。
41934195

4196+
既定の `quality` snippet focus は、文字、数字、underscore だけで構成される単一 query を identifier 形状として扱います。一つの result に一致する code と、それより前の comment / string が混在する場合は、最初の `code` origin match を snippet の優先行にします。空白区切りの phrase query と、明示的な `leftmost` / `proximity` focus mode は従来の選択を維持します。選択行は既存の focus / origin metadata で監査でき、`next_match` は選択された window の先を引き続き示します。
4197+
41944198
マッチ行がインデックス済みシンボル範囲内にある場合、`search --json` と MCP の `search` は任意フィールドの `enclosing_symbol_name`、`enclosing_symbol_kind`、`enclosing_symbol_start_line`、`enclosing_symbol_end_line`、`enclosing_container_name` も返します。
41954199

41964200
`find --json` は繰り返し一致でも line-delimited のまま維持し、各 row に bounded な match span / truncation metadata を追加します。`length` は 1-based の `column` から始まる一致長、`original_line_length` は行幅クランプ前のソース行長、`snippet_truncation_context.line_count` / `char_counts` / `total_chars` / 任意の `reason` は snippet クランプを表します。`--max-line-width` によって snippet 行が省略された場合、`reason` は `line_width` になります。

TESTING_GUIDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding
5353

5454
- `ChunkSplitterTests.cs`, `SymbolExtractorTests.cs`, `ReferenceExtractorTests.cs`, `SearchSnippetFormatterTests.cs`, `DbPathResolverTests.cs`, `ExcerptRecoveryCommandFormatterTests.cs`, `ConsoleUiTests.cs`
5555
Pure or mostly pure behavior tests with in-memory inputs.
56+
Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in one mixed comment/string/code fixture so identifier focus and the phrase control share the same `next_match` boundary.
5657
Recovery-command coverage asserts structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths, and display-only escaping for both POSIX sh and PowerShell, including spaces, quotes, dollar signs, and shell metacharacters.
5758
Console writer synchronization coverage yields between character writes instead of sleeping per character; use enough whole-line iterations to expose interleaving without adding wall-clock delay.
5859
- `SymbolExtractor*Tests.cs` and `ReferenceExtractor*Tests.cs`
@@ -756,6 +757,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests"
756757

757758
- `ChunkSplitterTests.cs``SymbolExtractorTests.cs``ReferenceExtractorTests.cs``SearchSnippetFormatterTests.cs``DbPathResolverTests.cs``ExcerptRecoveryCommandFormatterTests.cs``ConsoleUiTests.cs`
758759
インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。
760+
search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を単一の comment / string / code 混在 fixture にまとめ、identifier focus と phrase の control が同じ `next_match` 境界を共有するようにします。
759761
recovery command の coverage では構造化 argv、現在の `dotnet` / apphost prefix の維持、option と紛らわしい path の再実行、POSIX sh と PowerShell 双方の表示専用 escaping を、空白、quote、dollar sign、shell metacharacter を含めて検証します。
760762
console writer synchronization coverageは文字writeごとのsleepではなくyieldを使い、wall-clock delayを追加せずinterleavingを露出できる十分なwhole-line iterationを維持してください。
761763
- `SymbolExtractor*Tests.cs``ReferenceExtractor*Tests.cs`
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 4618
5+
affected:
6+
- src/CodeIndex/Cli/SearchSnippetFormatter.cs
7+
- tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs
8+
- DEVELOPER_GUIDE.md
9+
- TESTING_GUIDE.md
10+
---
11+
12+
## English
13+
14+
- **Identifier searches now focus executable code ahead of comments and strings (#4618)** — default `quality` snippets prefer the first `code`-origin match for PascalCase, snake_case, and other identifier-shaped queries while preserving phrase-query focus metadata and `next_match` navigation.
15+
16+
## 日本語
17+
18+
- **識別子検索は comment や string より実行 code を先に focus するようになりました (#4618)** — 既定の `quality` snippet は PascalCase、snake_case、その他の identifier 形状 query で最初の `code` origin match を優先しつつ、phrase query の focus metadata と `next_match` navigation を維持します。

src/CodeIndex/Cli/SearchSnippetFormatter.cs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,11 @@ public static CompactSearchResult ToCompactResult(SearchResult result, SearchSni
7878
{
7979
ArgumentNullException.ThrowIfNull(queryContext);
8080

81-
var excerpt = BuildExcerpt(result.Content, queryContext, result.StartLine, maxLines, caseSensitive, maxLineWidth, lang ?? result.Lang, focusMode, exposeLiteralHighlights, preferredMatchLine);
82-
var matchFacets = BuildMatchFacets(result, queryContext, caseSensitive, lang ?? result.Lang, exposeLiteralHighlights);
81+
var effectiveLang = lang ?? result.Lang;
82+
var matchFacets = BuildMatchFacets(result, queryContext, caseSensitive, effectiveLang, exposeLiteralHighlights);
83+
var effectivePreferredMatchLine = preferredMatchLine
84+
?? SelectPreferredCodeMatchLine(queryContext.ForLanguage(effectiveLang).NormalizedQuery, focusMode, matchFacets);
85+
var excerpt = BuildExcerpt(result.Content, queryContext, result.StartLine, maxLines, caseSensitive, maxLineWidth, effectiveLang, focusMode, exposeLiteralHighlights, effectivePreferredMatchLine);
8386
AttachHighlightOrigins(excerpt.Highlights, matchFacets);
8487
return new CompactSearchResult
8588
{
@@ -131,6 +134,41 @@ public static CompactSearchResult ToCompactResult(SearchResult result, SearchSni
131134
};
132135
}
133136

137+
private static int? SelectPreferredCodeMatchLine(
138+
string query,
139+
SearchSnippetFocusMode focusMode,
140+
IReadOnlyList<SearchMatchFacet> matchFacets)
141+
{
142+
if (focusMode != SearchSnippetFocusMode.Quality || !IsIdentifierLikeQuery(query))
143+
return null;
144+
145+
return matchFacets
146+
.Where(facet => string.Equals(facet.Origin, SearchMatchClassifier.Code, StringComparison.Ordinal))
147+
.Select(facet => (int?)facet.Line)
148+
.FirstOrDefault();
149+
}
150+
151+
private static bool IsIdentifierLikeQuery(string query)
152+
{
153+
var value = query.Trim();
154+
if (value.Length == 0)
155+
return false;
156+
157+
if (!char.IsLetter(value[0]) && value[0] != '_')
158+
return false;
159+
160+
for (var index = 1; index < value.Length; index++)
161+
{
162+
var character = value[index];
163+
if (char.IsLetterOrDigit(character) || character == '_')
164+
continue;
165+
166+
return false;
167+
}
168+
169+
return true;
170+
}
171+
134172
private static List<SearchMatchFacet> BuildMatchFacets(SearchResult result, SearchSnippetQueryContext queryContext, bool caseSensitive, string? lang, bool exposeLiteralHighlights)
135173
{
136174
var facets = new List<SearchMatchFacet>();
@@ -375,6 +413,12 @@ public static SearchSnippetExcerpt BuildExcerpt(string content, SearchSnippetQue
375413
});
376414
}
377415

416+
var nextMatchIndex = matchIndexes
417+
.Where(index => index > focusEnd)
418+
.Select(index => (int?)index)
419+
.FirstOrDefault()
420+
?? matchScan.FirstDroppedMatchIndex;
421+
378422
return new SearchSnippetExcerpt
379423
{
380424
StartLine = absoluteStartLine + start,
@@ -392,7 +436,7 @@ public static SearchSnippetExcerpt BuildExcerpt(string content, SearchSnippetQue
392436
FocusLine = matchIndexes.Count > 0 ? absoluteStartLine + focusStart : null,
393437
FocusColumn = focusColumn,
394438
FocusReason = focusReason,
395-
NextMatchLine = matchScan.FirstDroppedMatchIndex.HasValue ? absoluteStartLine + matchScan.FirstDroppedMatchIndex.Value : null,
439+
NextMatchLine = nextMatchIndex.HasValue ? absoluteStartLine + nextMatchIndex.Value : null,
396440
TruncationContext = new SearchTruncationContext
397441
{
398442
LineCount = truncatedLineCount,

tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,40 @@ public void ToCompactResult_ReportsFocusAndNextDroppedMatchMetadata_Issue3556()
510510
Assert.Equal(2, compact.NextMatch.RemainingMatchLineCount);
511511
}
512512

513+
[Theory]
514+
[InlineData("PascalCase", "PascalCase();", 13, "code", 15, 3)]
515+
[InlineData("snake_case", "snake_case();", 13, "code", 15, 3)]
516+
[InlineData("plain phrase", "plain phrase;", 10, "comment", 13, 2)]
517+
public void ToCompactResult_IdentifierQueriesPreferCodeOriginsOverCommentsAndStrings_Issue4618(
518+
string query,
519+
string codeExpression,
520+
int expectedFocusLine,
521+
string expectedFocusOrigin,
522+
int expectedNextMatchLine,
523+
int expectedDroppedMatchLineCount)
524+
{
525+
var result = new SearchResult
526+
{
527+
Path = "src/app.cs",
528+
Lang = "csharp",
529+
StartLine = 10,
530+
EndLine = 15,
531+
Content = $"// {query}\nvar text = \"{query}\";\nvar padding = 0;\n{codeExpression}\nvar gap = 0;\n{codeExpression}",
532+
Score = -1.0,
533+
};
534+
535+
var compact = SearchSnippetFormatter.ToCompactResult(result, query, maxLines: 2);
536+
537+
Assert.Equal(expectedFocusLine, compact.FocusLine);
538+
Assert.Equal("full_query", compact.FocusReason);
539+
var focusHighlight = Assert.Single(compact.Highlights, highlight => highlight.Line == expectedFocusLine);
540+
Assert.Equal([expectedFocusOrigin], focusHighlight.MatchOrigins);
541+
Assert.Equal(expectedDroppedMatchLineCount, compact.DroppedMatchLineCount);
542+
Assert.NotNull(compact.NextMatch);
543+
Assert.Equal(expectedNextMatchLine, compact.NextMatch.Line);
544+
Assert.Equal(expectedDroppedMatchLineCount, compact.NextMatch.RemainingMatchLineCount);
545+
}
546+
513547
[Fact]
514548
public void ToCompactResult_ReportsTruncationContext_WhenMultipleSnippetLinesAreClamped()
515549
{

0 commit comments

Comments
 (0)