From db99b7044d9c4e5ae7e68bc27e70d788ee7211f4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:38:30 +0900 Subject: [PATCH 1/6] Fix MCP context clamping for issues #1413 #1414 --- changelog.d/unreleased/1413-1414.fixed.md | 18 +++++++++++++++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 8 +++---- src/CodeIndex/Mcp/McpToolHandlers.cs | 23 ++++++++++++++----- tests/CodeIndex.Tests/McpServerTests.cs | 28 ++++++++++++++++++----- 4 files changed, 61 insertions(+), 16 deletions(-) create mode 100644 changelog.d/unreleased/1413-1414.fixed.md diff --git a/changelog.d/unreleased/1413-1414.fixed.md b/changelog.d/unreleased/1413-1414.fixed.md new file mode 100644 index 0000000000..275a6d5e7e --- /dev/null +++ b/changelog.d/unreleased/1413-1414.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 1413 + - 1414 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP excerpt and find_in_file now clamp oversized context windows (#1413, #1414)** - `before` and `after` values above the MCP context cap are clamped to 1000 lines and reported with `contextTruncated`, preventing accidental multi-million-line responses. + +## 日本語 + +- **MCP の excerpt / find_in_file が過大な前後文脈をクランプするようになりました (#1413, #1414)** - MCP の `before` / `after` が上限を超えた場合は 1000 行に丸め、`contextTruncated` で通知することで、誤って数百万行のレスポンスを生成しないようにしました。 diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 698ea0473b..47276bc3ee 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -200,8 +200,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Indexed file path" }, ["startLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Start line (1-based)" }, ["endLine"] = new JsonObject { ["type"] = "integer", ["description"] = "End line (default: startLine)" }, - ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Extra context lines before the range", ["default"] = 0, ["minimum"] = 0 }, - ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Extra context lines after the range", ["default"] = 0, ["minimum"] = 0 }, + ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Extra context lines before the range (clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, + ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Extra context lines after the range (clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, ["focusLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional line inside the excerpt whose focused column should stay visible when clamping; requires focusColumn", ["minimum"] = 1 }, ["focusColumn"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based column to keep centered when clamping long single-line content; must be within the focused line length", ["minimum"] = 1 }, ["focusLength"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional focused span width when clamping (default: 1); requires focusColumn", ["default"] = 1, ["minimum"] = 1 }, @@ -225,8 +225,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines before the match (default: 0)", ["default"] = 0, ["minimum"] = 0 }, - ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines after the match (default: 0)", ["default"] = 0, ["minimum"] = 0 }, + ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines before the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, + ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines after the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive literal substring match. Default is case-insensitive literal substring matching.", ["default"] = false } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index a7f42aa0c0..1845fc9976 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1710,14 +1710,15 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, "endLine must be greater than or equal to startLine"); var beforeValue = args?["before"]?.GetValue(); - if (beforeValue.HasValue && (beforeValue.Value < 0 || beforeValue.Value > MaxContextLines)) + if (beforeValue.HasValue && beforeValue.Value < 0) return CreateToolErrorResponse(id, $"before must be in [0, {MaxContextLines}]"); - var before = beforeValue ?? 0; + var before = ClampContextLines(beforeValue ?? 0); var afterValue = args?["after"]?.GetValue(); - if (afterValue.HasValue && (afterValue.Value < 0 || afterValue.Value > MaxContextLines)) + if (afterValue.HasValue && afterValue.Value < 0) return CreateToolErrorResponse(id, $"after must be in [0, {MaxContextLines}]"); - var after = afterValue ?? 0; + var after = ClampContextLines(afterValue ?? 0); + var contextTruncated = beforeValue > MaxContextLines || afterValue > MaxContextLines; var focusLine = args?["focusLine"]?.GetValue(); var focusColumn = args?["focusColumn"]?.GetValue(); @@ -1782,6 +1783,9 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) } var payload = JsonSerializer.SerializeToNode(excerpt, _jsonOptions)!.AsObject(); + payload["before"] = before; + payload["after"] = after; + payload["contextTruncated"] = contextTruncated; payload["maxLineWidth"] = maxLineWidth; if (focusLine.HasValue) payload["focusLine"] = focusLine.Value; @@ -1812,12 +1816,13 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) var beforeValue = args?["before"]?.GetValue(); if (beforeValue.HasValue && beforeValue.Value < 0) return CreateToolErrorResponse(id, "before must be greater than or equal to 0"); - var before = beforeValue ?? 0; + var before = ClampContextLines(beforeValue ?? 0); var afterValue = args?["after"]?.GetValue(); if (afterValue.HasValue && afterValue.Value < 0) return CreateToolErrorResponse(id, "after must be greater than or equal to 0"); - var after = afterValue ?? 0; + var after = ClampContextLines(afterValue ?? 0); + var contextTruncated = beforeValue > MaxContextLines || afterValue > MaxContextLines; if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var exact = args?["exact"]?.GetValue() ?? false; @@ -1832,6 +1837,7 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) ["excludeTests"] = excludeTests, ["before"] = before, ["after"] = after, + ["contextTruncated"] = contextTruncated, ["maxLineWidth"] = maxLineWidth, ["exact"] = exact, ["count"] = results.Count, @@ -1849,6 +1855,11 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) }); } + private static int ClampContextLines(int value) + { + return Math.Min(value, MaxContextLines); + } + private JsonNode ExecuteBatchQuery(JsonNode? id, JsonNode? args) { var queries = args?["queries"]?.AsArray(); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 1cfd82820d..c317e8cfd3 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5440,27 +5440,29 @@ public void ToolsCall_Excerpt_NegativeAfterReturnsError() } [Fact] - public void ToolsCall_Excerpt_BeforeAboveCapReturnsError() + public void ToolsCall_Excerpt_BeforeAboveCapClampsContext() { InsertIndexedFile("dist/data-before-overflow.txt", "text", "line one\nline two"); var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"excerpt","arguments":{"path":"dist/data-before-overflow.txt","startLine":1,"before":2147483647}}}""")!; var response = _server.HandleMessage(request)!; - Assert.True(response["result"]!["isError"]!.GetValue()); - Assert.Equal("before must be in [0, 1000]", response["result"]!["content"]![0]!["text"]!.GetValue()); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1000, structured["before"]!.GetValue()); + Assert.True(structured["contextTruncated"]!.GetValue()); } [Fact] - public void ToolsCall_Excerpt_AfterAboveCapReturnsError() + public void ToolsCall_Excerpt_AfterAboveCapClampsContext() { InsertIndexedFile("dist/data-after-overflow.txt", "text", "line one\nline two"); var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"excerpt","arguments":{"path":"dist/data-after-overflow.txt","startLine":1,"after":2147483647}}}""")!; var response = _server.HandleMessage(request)!; - Assert.True(response["result"]!["isError"]!.GetValue()); - Assert.Equal("after must be in [0, 1000]", response["result"]!["content"]![0]!["text"]!.GetValue()); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1000, structured["after"]!.GetValue()); + Assert.True(structured["contextTruncated"]!.GetValue()); } [Fact] @@ -5588,6 +5590,20 @@ public void ToolsCall_FindInFile_NegativeAfterReturnsError() Assert.Equal("after must be greater than or equal to 0", response["result"]!["content"]![0]!["text"]!.GetValue()); } + [Fact] + public void ToolsCall_FindInFile_BeforeAfterAboveCapClampContext() + { + InsertIndexedFile("dist/search-context-overflow.txt", "text", "target"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_in_file","arguments":{"query":"target","path":"dist/search-context-overflow.txt","before":2147483647,"after":2147483647}}}""")!; + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + + Assert.Equal(1000, structured["before"]!.GetValue()); + Assert.Equal(1000, structured["after"]!.GetValue()); + Assert.True(structured["contextTruncated"]!.GetValue()); + } + [Fact] public void ToolsCall_AnalyzeSymbol_ClampsBundledReferenceContext() { From 0f665aadced720673e4804042367b3e4c0dba8fd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:46:31 +0900 Subject: [PATCH 2/6] Add find snippet controls for issue #1593 --- USER_GUIDE.md | 4 +-- changelog.d/unreleased/1593.fixed.md | 20 ++++++++++++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 4 +-- src/CodeIndex/Cli/QueryCommandRunner.cs | 31 ++++++++++++++++--- src/CodeIndex/Mcp/McpToolDefinitions.cs | 1 + src/CodeIndex/Mcp/McpToolHandlers.cs | 15 ++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 18 +++++++++++ .../QueryCommandRunnerTests.cs | 28 +++++++++++++++++ 9 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 changelog.d/unreleased/1593.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6a60f225fc..05bb551a2a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -240,7 +240,7 @@ sections below show examples and option details for the most common workflows. | Index | `backfill-fold` | Upgrade Unicode folded-name metadata in an existing DB | `backfill_fold` | | Index | `hooks` | Install, remove, or inspect the optional git pre-commit hook | -- | | Search | `search` | Full-text search across indexed chunks | `search` | -| Search | `find` | Literal substring search inside one known indexed file | `find_in_file` | +| Search | `find` | Literal substring search inside one known indexed file; use `--snippet-lines` / `snippetLines` to widen match context | `find_in_file` | | Search | `excerpt` | Reconstruct a focused line range from indexed chunks | `excerpt` | | Navigation | `definition` | Resolve symbol definitions and optional bodies | `definition` | | Navigation | `symbols` | Search extracted symbols by name, kind, language, and path | `symbols` | @@ -2239,7 +2239,7 @@ cdidx index . --quiet | Index | `backfill-fold` | 既存 DB の Unicode folded-name metadata を更新 | `backfill_fold` | | Index | `hooks` | 任意の git pre-commit hook を install / remove / inspect | -- | | Search | `search` | indexed chunk の全文検索 | `search` | -| Search | `find` | 既知の indexed file 内で literal substring 検索 | `find_in_file` | +| Search | `find` | 既知の indexed file 内で literal substring 検索。`--snippet-lines` / `snippetLines` で一致前後の文脈行数を調整 | `find_in_file` | | Search | `excerpt` | indexed chunk から指定行範囲を復元 | `excerpt` | | Navigation | `definition` | symbol definition と任意の body を解決 | `definition` | | Navigation | `symbols` | name / kind / language / path で symbol を検索 | `symbols` | diff --git a/changelog.d/unreleased/1593.fixed.md b/changelog.d/unreleased/1593.fixed.md new file mode 100644 index 0000000000..093fb25c2c --- /dev/null +++ b/changelog.d/unreleased/1593.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 1593 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - USER_GUIDE.md +--- + +## English + +- **find_in_file now supports snippet line controls (#1593)** - CLI `find --snippet-lines N` and MCP `find_in_file` `snippetLines` can widen or narrow match context without a separate `excerpt` call. + +## 日本語 + +- **find_in_file が snippet 行数指定に対応しました (#1593)** - CLI の `find --snippet-lines N` と MCP `find_in_file` の `snippetLines` で、別途 `excerpt` を呼ばずに一致箇所の前後文脈を広げたり狭めたりできます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 8501977961..e10990b6e2 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -241,7 +241,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--prefix", Description = "Trailing-asterisk prefix shorthand", Commands = Set("search") }, new() { Name = "--name", ValuePlaceholder = "", Description = "Exact symbol name", Commands = Set("symbols") }, new() { Name = "--max-line-width", ValuePlaceholder = "", Description = "Clamp long single-line payloads (0 disables clamping)", Commands = Set(MaxLineWidthCommands) }, - new() { Name = "--snippet-lines", ValuePlaceholder = "", Description = "Snippet length", Commands = Set("search", "references", "callers", "callees", "impact") }, + new() { Name = "--snippet-lines", ValuePlaceholder = "", Description = "Snippet length", Commands = Set("search", "find", "references", "callers", "callees", "impact") }, new() { Name = "--snippet-focus", ValuePlaceholder = "", Description = "Search snippet long-line focus mode", Commands = Set("search") }, new() { Name = "--fts", Description = "Raw FTS5 syntax", Commands = Set("search") }, new() { Name = "--no-dedup", Description = "Show duplicate chunks", Commands = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index d9b935293e..b68ec26395 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -76,7 +76,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("callees", "cdidx callees |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]"), ("files", "cdidx files [query|--query |-- ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), - ("find", "cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--max-line-width ] [--exact] [--count]"), + ("find", "cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--max-line-width ] [--exact] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--min-entrypoint-confidence <0.0..1.0>]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), @@ -814,7 +814,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" --exclude-path Exclude glob-style path patterns (* and ?) (repeatable)"); Console.WriteLine(" --exclude-tests Exclude likely test files"); Console.WriteLine(" --include-generated Include generated files in query results"); - Console.WriteLine(" --snippet-lines Search snippet length (1-20, default: 8)"); + Console.WriteLine(" --snippet-lines search/find snippet length (1-20, default: search 8; find 1)"); Console.WriteLine(" --snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)"); WriteHelpLine($" --max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: {LineWidthFormatter.DefaultMaxLineWidth})"); Console.WriteLine(" --focus-line excerpt: line whose focused column should stay visible (requires --focus-column)"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 7270bf0b96..5c9f8a3d67 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -201,7 +201,7 @@ private sealed record StatusReadinessField( private const string OutputFormatSarif = "sarif"; private static readonly HashSet InlineValueOptions = new(ValueTakingOptions.Concat(["--json"]), StringComparer.Ordinal); - private const string FindUsage = "Usage: cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--max-line-width ] [--exact] [--count]\n cdidx find --query --path [...]\n cdidx find [options] -- "; + private const string FindUsage = "Usage: cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--max-line-width ] [--exact] [--count]\n cdidx find --query --path [...]\n cdidx find [options] -- "; public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) { @@ -1956,7 +1956,8 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.Success; } - var results = reader.FindInFiles(options.Query, options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.ContextBefore, options.ContextAfter, options.Exact, options.MaxLineWidth); + var (contextBefore, contextAfter, snippetLines) = ResolveFindContext(options, preparedFindArgs); + var results = reader.FindInFiles(options.Query, options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, contextBefore, contextAfter, options.Exact, options.MaxLineWidth); if (results.Count == 0) { var candidateFileCount = reader.CountFindCandidateFiles(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); @@ -1969,8 +1970,10 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) payload["query"] = options.Query; payload["path"] = JsonSerializer.SerializeToNode(options.PathPatterns, CliJsonSerializerContextFactory.Create(jsonOptions).ListString); payload["exclude_tests"] = options.ExcludeTests; - payload["before"] = options.ContextBefore; - payload["after"] = options.ContextAfter; + payload["before"] = contextBefore; + payload["after"] = contextAfter; + if (snippetLines.HasValue) + payload["snippet_lines"] = snippetLines.Value; payload["exact"] = options.Exact; payload["file_count"] = candidateFileCount; }); @@ -2086,6 +2089,13 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) && NumericFlagUpperBounds.TryGetValue(arg, out var contextMax) && contextCeil > contextMax) return BuildNonNegativeIntegerUpperBoundError(arg, value, contextMax); + if (arg == "--snippet-lines" && (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var snippetLines) || snippetLines <= 0)) + return BuildPositiveIntegerError(arg, value, arg); + if (arg == "--snippet-lines" + && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var snippetLinesCeil) + && NumericFlagUpperBounds.TryGetValue(arg, out var snippetLinesMax) + && snippetLinesCeil > snippetLinesMax) + return BuildPositiveIntegerUpperBoundError(arg, value, snippetLinesMax); if (arg == "--query") { queryCount++; @@ -2127,6 +2137,19 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) return null; } + private static (int Before, int After, int? SnippetLines) ResolveFindContext(QueryCommandOptions options, string[] preparedFindArgs) + { + if (!HasOption(preparedFindArgs, "--snippet-lines")) + return (options.ContextBefore, options.ContextAfter, null); + + var explicitBefore = HasOption(preparedFindArgs, "--before"); + var explicitAfter = HasOption(preparedFindArgs, "--after"); + var surroundingLines = Math.Max(0, options.SnippetLines - 1); + var before = explicitBefore ? options.ContextBefore : surroundingLines / 2; + var after = explicitAfter ? options.ContextAfter : surroundingLines - before; + return (before, after, options.SnippetLines); + } + private static string[] PrepareFindArgs(string[] args, out string? error) { var normalized = new List(args.Length); diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 47276bc3ee..6cbaa0c6f8 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -227,6 +227,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines before the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines after the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, + ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Total snippet lines around each match when before/after are not set (1-20)", ["default"] = 1, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive literal substring match. Default is case-insensitive literal substring matching.", ["default"] = false } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 1845fc9976..1a2e98439f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -321,7 +321,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "project", "solution" }, "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" }, "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since" }, - "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "maxLineWidth", "exact" }, + "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "maxLineWidth", "exact" }, "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth" }, "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "project", "solution" }, "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" }, @@ -1823,6 +1823,17 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, "after must be greater than or equal to 0"); var after = ClampContextLines(afterValue ?? 0); var contextTruncated = beforeValue > MaxContextLines || afterValue > MaxContextLines; + var snippetLinesValue = args?["snippetLines"]?.GetValue(); + if (snippetLinesValue.HasValue && (snippetLinesValue.Value <= 0 || snippetLinesValue.Value > SearchSnippetFormatter.MaxSnippetLines)) + return CreateToolErrorResponse(id, $"snippetLines must be in [1, {SearchSnippetFormatter.MaxSnippetLines}]"); + if (snippetLinesValue.HasValue) + { + var surroundingLines = snippetLinesValue.Value - 1; + if (!beforeValue.HasValue) + before = surroundingLines / 2; + if (!afterValue.HasValue) + after = surroundingLines - before; + } if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var exact = args?["exact"]?.GetValue() ?? false; @@ -1844,6 +1855,8 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) ["fileCount"] = results.Select(r => r.Path).Distinct().Count(), ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions), }; + if (snippetLinesValue.HasValue) + structured["snippetLines"] = snippetLinesValue.Value; if (results.Count == 0) { AddFreshnessHint(structured, reader); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index c317e8cfd3..595547025b 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5604,6 +5604,24 @@ public void ToolsCall_FindInFile_BeforeAfterAboveCapClampContext() Assert.True(structured["contextTruncated"]!.GetValue()); } + [Fact] + public void ToolsCall_FindInFile_SnippetLinesControlsMatchContext() + { + InsertIndexedFile("dist/search-snippet-lines.txt", "text", "line one\nline two\ntarget\nline four\nline five"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_in_file","arguments":{"query":"target","path":"dist/search-snippet-lines.txt","snippetLines":5}}}""")!; + var response = _server.HandleMessage(request)!; + Assert.False(response["result"]?["isError"]?.GetValue() ?? false, response.ToJsonString()); + var structured = response["result"]!["structuredContent"]!; + var result = structured["results"]![0]!; + + Assert.Equal(2, structured["before"]!.GetValue()); + Assert.Equal(2, structured["after"]!.GetValue()); + Assert.Equal(5, structured["snippetLines"]!.GetValue()); + Assert.Equal(1, result["startLine"]!.GetValue()); + Assert.Equal(5, result["endLine"]!.GetValue()); + } + [Fact] public void ToolsCall_AnalyzeSymbol_ClampsBundledReferenceContext() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 0d1e3062e8..c9189b84bb 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -29060,6 +29060,34 @@ public void RunFind_WithJsonOutputsLineColumnAndSnippet() } } + [Fact] + public void RunFind_SnippetLinesControlsMatchContext() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_find_snippet_lines"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Auth.cs", + "csharp", + "line one\nline two\nvoid Guard() {}\nline four\nline five\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["Guard", "--db", dbPath, "--path", "src/Auth.cs", "--snippet-lines", "5"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("line one", stdout); + Assert.Contains("line five", stdout); + Assert.Contains("1 matches in 1 file", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunFind_CountOnlyJsonIncludesVisibleMatchAndFileCounts() { From 33a6cec8b9b04662891368d00a421b5acaadfaf9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:49:58 +0900 Subject: [PATCH 3/6] Add find focus targeting for issue #1597 --- USER_GUIDE.md | 4 +-- changelog.d/unreleased/1597.fixed.md | 21 ++++++++++++++ src/CodeIndex/Cli/CliFlagSchema.cs | 4 +-- src/CodeIndex/Cli/ConsoleUi.cs | 6 ++-- src/CodeIndex/Cli/QueryCommandRunner.cs | 6 ++-- .../Database/DbReader.FilesStatus.cs | 9 +++++- src/CodeIndex/Mcp/McpToolDefinitions.cs | 2 ++ src/CodeIndex/Mcp/McpToolHandlers.cs | 14 ++++++++-- tests/CodeIndex.Tests/McpServerTests.cs | 17 +++++++++++ .../QueryCommandRunnerTests.cs | 28 +++++++++++++++++++ 10 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/1597.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 05bb551a2a..3537bda98e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -240,7 +240,7 @@ sections below show examples and option details for the most common workflows. | Index | `backfill-fold` | Upgrade Unicode folded-name metadata in an existing DB | `backfill_fold` | | Index | `hooks` | Install, remove, or inspect the optional git pre-commit hook | -- | | Search | `search` | Full-text search across indexed chunks | `search` | -| Search | `find` | Literal substring search inside one known indexed file; use `--snippet-lines` / `snippetLines` to widen match context | `find_in_file` | +| Search | `find` | Literal substring search inside one known indexed file; use `--snippet-lines` / `snippetLines` for context and focus-line/column to target a position | `find_in_file` | | Search | `excerpt` | Reconstruct a focused line range from indexed chunks | `excerpt` | | Navigation | `definition` | Resolve symbol definitions and optional bodies | `definition` | | Navigation | `symbols` | Search extracted symbols by name, kind, language, and path | `symbols` | @@ -2239,7 +2239,7 @@ cdidx index . --quiet | Index | `backfill-fold` | 既存 DB の Unicode folded-name metadata を更新 | `backfill_fold` | | Index | `hooks` | 任意の git pre-commit hook を install / remove / inspect | -- | | Search | `search` | indexed chunk の全文検索 | `search` | -| Search | `find` | 既知の indexed file 内で literal substring 検索。`--snippet-lines` / `snippetLines` で一致前後の文脈行数を調整 | `find_in_file` | +| Search | `find` | 既知の indexed file 内で literal substring 検索。`--snippet-lines` / `snippetLines` で文脈を調整し、focus-line/column で位置を指定 | `find_in_file` | | Search | `excerpt` | indexed chunk から指定行範囲を復元 | `excerpt` | | Navigation | `definition` | symbol definition と任意の body を解決 | `definition` | | Navigation | `symbols` | name / kind / language / path で symbol を検索 | `symbols` | diff --git a/changelog.d/unreleased/1597.fixed.md b/changelog.d/unreleased/1597.fixed.md new file mode 100644 index 0000000000..d35d07fe6b --- /dev/null +++ b/changelog.d/unreleased/1597.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 1597 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Database/DbReader.FilesStatus.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - USER_GUIDE.md +--- + +## English + +- **find now supports focus line and column targeting (#1597)** - CLI `find --focus-line` / `--focus-column` and MCP `find_in_file` focus arguments restrict matches to the requested source position. + +## 日本語 + +- **find が focus line / column 指定に対応しました (#1597)** - CLI の `find --focus-line` / `--focus-column` と MCP `find_in_file` の focus 引数で、指定したソース位置に一致する結果へ絞り込めます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index e10990b6e2..b287947e0b 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -250,8 +250,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--after", ValuePlaceholder = "", Description = "Context lines after", Commands = Set("find", "excerpt") }, new() { Name = "--start", ValuePlaceholder = "", Description = "Start line", Commands = Set("excerpt") }, new() { Name = "--end", ValuePlaceholder = "", Description = "End line", Commands = Set("excerpt") }, - new() { Name = "--focus-line", ValuePlaceholder = "", Description = "Focused line to keep visible when clamping", Commands = Set("excerpt") }, - new() { Name = "--focus-column", ValuePlaceholder = "", Description = "Focused column to keep visible when clamping", Commands = Set("excerpt") }, + new() { Name = "--focus-line", ValuePlaceholder = "", Description = "Focused line to keep visible when clamping", Commands = Set("find", "excerpt") }, + new() { Name = "--focus-column", ValuePlaceholder = "", Description = "Focused column to keep visible when clamping", Commands = Set("find", "excerpt") }, new() { Name = "--focus-length", ValuePlaceholder = "", Description = "Focused span width when clamping", Commands = Set("excerpt") }, new() { Name = "--max-hops", ValuePlaceholder = "", Description = "Impact: max BFS hops", Commands = Set("impact") }, new() { Name = "--depth", ValuePlaceholder = "", Description = "Impact: deprecated alias for --max-hops", Commands = Set("impact") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index b68ec26395..eac5025d66 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -76,7 +76,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("callees", "cdidx callees |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]"), ("files", "cdidx files [query|--query |-- ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), - ("find", "cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--max-line-width ] [--exact] [--count]"), + ("find", "cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--min-entrypoint-confidence <0.0..1.0>]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), @@ -817,8 +817,8 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" --snippet-lines search/find snippet length (1-20, default: search 8; find 1)"); Console.WriteLine(" --snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)"); WriteHelpLine($" --max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: {LineWidthFormatter.DefaultMaxLineWidth})"); - Console.WriteLine(" --focus-line excerpt: line whose focused column should stay visible (requires --focus-column)"); - Console.WriteLine(" --focus-column excerpt: column to keep centered when clamping (must be within the focused line)"); + Console.WriteLine(" --focus-line find/excerpt: focus a specific line"); + Console.WriteLine(" --focus-column find/excerpt: focus a specific 1-based column"); Console.WriteLine(" --focus-length excerpt: width of the focused span (default: 1, requires --focus-column)"); WriteHelpLine($" --fts Use raw FTS5 query syntax for search (content:term, NEAR(a b, 5), OR, NOT, groups, prefix*, \"phrase\"; search query max {QueryLimits.MaxQueryLength} chars; raw FTS parser max {DbReader.MaxRawFtsQueryLength} chars, {DbReader.MaxRawFtsBooleanOperators} boolean ops, {DbReader.MaxRawFtsNearOperators} NEAR ops; trailing * is a prefix shorthand in literal-safe mode)"); Console.WriteLine(" --exact Backward-compatible shorthand."); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 5c9f8a3d67..3b9bb1b3ee 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -201,7 +201,7 @@ private sealed record StatusReadinessField( private const string OutputFormatSarif = "sarif"; private static readonly HashSet InlineValueOptions = new(ValueTakingOptions.Concat(["--json"]), StringComparer.Ordinal); - private const string FindUsage = "Usage: cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--max-line-width ] [--exact] [--count]\n cdidx find --query --path [...]\n cdidx find [options] -- "; + private const string FindUsage = "Usage: cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--count]\n cdidx find --query --path [...]\n cdidx find [options] -- "; public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) { @@ -1957,7 +1957,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) } var (contextBefore, contextAfter, snippetLines) = ResolveFindContext(options, preparedFindArgs); - var results = reader.FindInFiles(options.Query, options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, contextBefore, contextAfter, options.Exact, options.MaxLineWidth); + var results = reader.FindInFiles(options.Query, options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, contextBefore, contextAfter, options.Exact, options.MaxLineWidth, options.FocusLine, options.FocusColumn); if (results.Count == 0) { var candidateFileCount = reader.CountFindCandidateFiles(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); @@ -2096,6 +2096,8 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) && NumericFlagUpperBounds.TryGetValue(arg, out var snippetLinesMax) && snippetLinesCeil > snippetLinesMax) return BuildPositiveIntegerUpperBoundError(arg, value, snippetLinesMax); + if ((arg == "--focus-line" || arg == "--focus-column") && (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var focus) || focus <= 0)) + return BuildPositiveIntegerError(arg, value, arg); if (arg == "--query") { queryCount++; diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index be104e8bad..9c27f76770 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -8,7 +8,7 @@ namespace CodeIndex.Database; public partial class DbReader { - public List FindInFiles(string query, int limit, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth) + public List FindInFiles(string query, int limit, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null) { if (string.IsNullOrWhiteSpace(query) || limit <= 0 || pathPatterns == null || pathPatterns.Count == 0) return []; @@ -45,6 +45,8 @@ public List FindInFiles(string query, int limit, string? lang = var searchQuery = exact ? ExactSourceSearchNormalizer.Normalize(query, fileLang) : query; for (int lineNumber = 1; lineNumber <= totalLines && results.Count < limit; lineNumber++) { + if (focusLine.HasValue && lineNumber != focusLine.Value) + continue; if (!lineMap.TryGetValue(lineNumber, out var lineText)) continue; @@ -73,6 +75,11 @@ public List FindInFiles(string query, int limit, string? lang = var rawMatchEndIndex = rawIndexMap[matchColumn + rawMatchLength - 1]; rawMatchLength = rawMatchEndIndex - rawMatchColumn + 1; } + if (focusColumn.HasValue && (focusColumn.Value < rawMatchColumn + 1 || focusColumn.Value > rawMatchColumn + rawMatchLength)) + { + searchStart = matchColumn + 1; + continue; + } var snippetLines = snippetLineNumbers.Select(line => lineMap[line]).ToList(); var clampedSnippet = LineWidthFormatter.ClampLines( diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 6cbaa0c6f8..3ddb7498d2 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -228,6 +228,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines before the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines after the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Total snippet lines around each match when before/after are not set (1-20)", ["default"] = 1, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, + ["focusLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based line that must contain the match", ["minimum"] = 1 }, + ["focusColumn"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based column that must be inside the match span", ["minimum"] = 1 }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive literal substring match. Default is case-insensitive literal substring matching.", ["default"] = false } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 1a2e98439f..3c792a6056 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -321,7 +321,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "project", "solution" }, "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" }, "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since" }, - "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "maxLineWidth", "exact" }, + "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact" }, "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth" }, "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "project", "solution" }, "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" }, @@ -1834,13 +1834,19 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) if (!afterValue.HasValue) after = surroundingLines - before; } + var focusLine = args?["focusLine"]?.GetValue(); + if (focusLine.HasValue && focusLine.Value <= 0) + return CreateToolErrorResponse(id, "focusLine must be greater than or equal to 1"); + var focusColumn = args?["focusColumn"]?.GetValue(); + if (focusColumn.HasValue && focusColumn.Value <= 0) + return CreateToolErrorResponse(id, "focusColumn must be greater than or equal to 1"); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var exact = args?["exact"]?.GetValue() ?? false; return WithDbReader(id, args, reader => { - var results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth); + var results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn); var structured = new JsonObject { ["query"] = query, @@ -1857,6 +1863,10 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) }; if (snippetLinesValue.HasValue) structured["snippetLines"] = snippetLinesValue.Value; + if (focusLine.HasValue) + structured["focusLine"] = focusLine.Value; + if (focusColumn.HasValue) + structured["focusColumn"] = focusColumn.Value; if (results.Count == 0) { AddFreshnessHint(structured, reader); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 595547025b..9217ee02ed 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5622,6 +5622,23 @@ public void ToolsCall_FindInFile_SnippetLinesControlsMatchContext() Assert.Equal(5, result["endLine"]!.GetValue()); } + [Fact] + public void ToolsCall_FindInFile_FocusLineAndColumnRestrictMatch() + { + InsertIndexedFile("dist/search-focus.txt", "text", "target here\nno match\nother target"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_in_file","arguments":{"query":"target","path":"dist/search-focus.txt","focusLine":3,"focusColumn":8}}}""")!; + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + var result = structured["results"]![0]!; + + Assert.Equal(1, structured["count"]!.GetValue()); + Assert.Equal(3, result["line"]!.GetValue()); + Assert.Equal(7, result["column"]!.GetValue()); + Assert.Equal(3, structured["focusLine"]!.GetValue()); + Assert.Equal(8, structured["focusColumn"]!.GetValue()); + } + [Fact] public void ToolsCall_AnalyzeSymbol_ClampsBundledReferenceContext() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index c9189b84bb..46d176b352 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -29088,6 +29088,34 @@ public void RunFind_SnippetLinesControlsMatchContext() } } + [Fact] + public void RunFind_FocusLineAndColumnRestrictMatch() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_find_focus"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Auth.cs", + "csharp", + "target here\nno match\nother target\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["target", "--db", dbPath, "--path", "src/Auth.cs", "--focus-line", "3", "--focus-column", "8"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("src/Auth.cs:3:7", stdout); + Assert.DoesNotContain("src/Auth.cs:1:1", stdout); + Assert.Contains("1 matches in 1 file", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunFind_CountOnlyJsonIncludesVisibleMatchAndFileCounts() { From d00116dadbf4b72a1d8c34fa69c6c90d9a7bbc0e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:54:03 +0900 Subject: [PATCH 4/6] Add find regex mode for issue #1930 --- USER_GUIDE.md | 4 +- changelog.d/unreleased/1930.fixed.md | 21 +++++++++++ src/CodeIndex/Cli/CliFlagSchema.cs | 1 + src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 20 +++++++++- .../Database/DbReader.FilesStatus.cs | 37 +++++++++++++------ src/CodeIndex/Mcp/McpToolDefinitions.cs | 3 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 14 ++++++- tests/CodeIndex.Tests/McpServerTests.cs | 16 ++++++++ .../QueryCommandRunnerTests.cs | 24 ++++++++++++ 10 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 changelog.d/unreleased/1930.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 3537bda98e..5882507e46 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -240,7 +240,7 @@ sections below show examples and option details for the most common workflows. | Index | `backfill-fold` | Upgrade Unicode folded-name metadata in an existing DB | `backfill_fold` | | Index | `hooks` | Install, remove, or inspect the optional git pre-commit hook | -- | | Search | `search` | Full-text search across indexed chunks | `search` | -| Search | `find` | Literal substring search inside one known indexed file; use `--snippet-lines` / `snippetLines` for context and focus-line/column to target a position | `find_in_file` | +| Search | `find` | Literal or opt-in regex search inside one known indexed file; use context and focus controls to target a position | `find_in_file` | | Search | `excerpt` | Reconstruct a focused line range from indexed chunks | `excerpt` | | Navigation | `definition` | Resolve symbol definitions and optional bodies | `definition` | | Navigation | `symbols` | Search extracted symbols by name, kind, language, and path | `symbols` | @@ -2239,7 +2239,7 @@ cdidx index . --quiet | Index | `backfill-fold` | 既存 DB の Unicode folded-name metadata を更新 | `backfill_fold` | | Index | `hooks` | 任意の git pre-commit hook を install / remove / inspect | -- | | Search | `search` | indexed chunk の全文検索 | `search` | -| Search | `find` | 既知の indexed file 内で literal substring 検索。`--snippet-lines` / `snippetLines` で文脈を調整し、focus-line/column で位置を指定 | `find_in_file` | +| Search | `find` | 既知の indexed file 内で literal または opt-in regex 検索。文脈と focus 指定で位置を絞り込み | `find_in_file` | | Search | `excerpt` | indexed chunk から指定行範囲を復元 | `excerpt` | | Navigation | `definition` | symbol definition と任意の body を解決 | `definition` | | Navigation | `symbols` | name / kind / language / path で symbol を検索 | `symbols` | diff --git a/changelog.d/unreleased/1930.fixed.md b/changelog.d/unreleased/1930.fixed.md new file mode 100644 index 0000000000..fd7d9f817a --- /dev/null +++ b/changelog.d/unreleased/1930.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 1930 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Database/DbReader.FilesStatus.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - USER_GUIDE.md +--- + +## English + +- **find_in_file now supports opt-in regex matching (#1930)** - CLI `find --regex` and MCP `find_in_file` `regex` treat the query as a regular expression with a timeout and report invalid patterns instead of silently returning literal misses. + +## 日本語 + +- **find_in_file が opt-in regex 検索に対応しました (#1930)** - CLI の `find --regex` と MCP `find_in_file` の `regex` はクエリをタイムアウト付き正規表現として扱い、不正なパターンは literal miss ではなくエラーとして返します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index b287947e0b..bf5f1eb1f1 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -236,6 +236,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--query", ValuePlaceholder = "", Description = "Literal query", Commands = Set(QueryCommands) }, new() { Name = "--body", Description = "Include body", Commands = Set(BodyCommands) }, new() { Name = "--exact", Description = "Backward-compatible exact shorthand", Commands = Set(ExactCommands) }, + new() { Name = "--regex", Description = "Use regular expression matching", Commands = Set("find") }, new() { Name = "--exact-name", Description = "Exact symbol-name equality", Commands = Set(ExactNameCommands), AlsoAcceptedBy = Set("search") }, new() { Name = "--exact-substring", Description = "Search-only exact substring match", Commands = Set("search"), AlsoAcceptedBy = Set(ExactSubstringAccepted) }, new() { Name = "--prefix", Description = "Trailing-asterisk prefix shorthand", Commands = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index eac5025d66..2018198972 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -76,7 +76,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("callees", "cdidx callees |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]"), ("files", "cdidx files [query|--query |-- ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), - ("find", "cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--count]"), + ("find", "cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--min-entrypoint-confidence <0.0..1.0>]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 3b9bb1b3ee..324fa63221 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -201,7 +201,7 @@ private sealed record StatusReadinessField( private const string OutputFormatSarif = "sarif"; private static readonly HashSet InlineValueOptions = new(ValueTakingOptions.Concat(["--json"]), StringComparer.Ordinal); - private const string FindUsage = "Usage: cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--count]\n cdidx find --query --path [...]\n cdidx find [options] -- "; + private const string FindUsage = "Usage: cdidx find --path [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]\n cdidx find --query --path [...]\n cdidx find [options] -- "; public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) { @@ -1957,7 +1957,16 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) } var (contextBefore, contextAfter, snippetLines) = ResolveFindContext(options, preparedFindArgs); - var results = reader.FindInFiles(options.Query, options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, contextBefore, contextAfter, options.Exact, options.MaxLineWidth, options.FocusLine, options.FocusColumn); + List results; + try + { + results = reader.FindInFiles(options.Query, options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, contextBefore, contextAfter, options.Exact, options.MaxLineWidth, options.FocusLine, options.FocusColumn, options.Regex); + } + catch (ArgumentException ex) when (options.Regex) + { + Console.Error.WriteLine($"Error: invalid regular expression: {ex.Message}"); + return CommandExitCodes.UsageError; + } if (results.Count == 0) { var candidateFileCount = reader.CountFindCandidateFiles(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); @@ -1975,6 +1984,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (snippetLines.HasValue) payload["snippet_lines"] = snippetLines.Value; payload["exact"] = options.Exact; + payload["regex"] = options.Regex; payload["file_count"] = candidateFileCount; }); Console.WriteLine(payload.ToJsonString(jsonOptions)); @@ -4428,6 +4438,7 @@ public static QueryCommandOptions ParseArgs( bool noDedup = false; bool noVisibilityRank = false; bool exact = false; + bool regex = false; bool prefix = false; List? parseErrors = null; bool exactName = false; @@ -4714,6 +4725,9 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) case "--exact": exact = true; break; + case "--regex": + regex = true; + break; case "--exact-name": exactName = true; break; @@ -5120,6 +5134,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) NoDedup = noDedup, NoVisibilityRank = noVisibilityRank, Exact = exact, + Regex = regex, Prefix = prefix, ExactName = exactName, ExactSubstring = exactSubstring, @@ -7744,6 +7759,7 @@ public sealed class QueryCommandOptions public bool NoDedup { get; init; } public bool NoVisibilityRank { get; init; } public bool Exact { get; init; } + public bool Regex { get; init; } public bool Prefix { get; init; } public bool ExactName { get; init; } public bool ExactSubstring { get; init; } diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index 9c27f76770..dd1784e333 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -8,7 +8,7 @@ namespace CodeIndex.Database; public partial class DbReader { - public List FindInFiles(string query, int limit, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null) + public List FindInFiles(string query, int limit, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null, bool regex = false) { if (string.IsNullOrWhiteSpace(query) || limit <= 0 || pathPatterns == null || pathPatterns.Count == 0) return []; @@ -17,6 +17,9 @@ public List FindInFiles(string query, int limit, string? lang = after = Math.Max(0, after); maxLineWidth = LineWidthFormatter.ClampMaxLineWidth(maxLineWidth); var comparison = exact ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + var regexMatcher = regex + ? new Regex(query, exact ? RegexOptions.None : RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(500)) + : null; using var fileCmd = _conn.CreateCommand(); var sql = "SELECT f.path, f.lang, f.lines FROM files f WHERE 1=1"; @@ -42,7 +45,7 @@ public List FindInFiles(string query, int limit, string? lang = if (!TryLoadIndexedFileLines(path, out _, out _, out var lineMap) || lineMap.Count == 0) continue; - var searchQuery = exact ? ExactSourceSearchNormalizer.Normalize(query, fileLang) : query; + var searchQuery = exact && !regex ? ExactSourceSearchNormalizer.Normalize(query, fileLang) : query; for (int lineNumber = 1; lineNumber <= totalLines && results.Count < limit; lineNumber++) { if (focusLine.HasValue && lineNumber != focusLine.Value) @@ -62,24 +65,37 @@ public List FindInFiles(string query, int limit, string? lang = if (snippetLineNumbers.Count == 0) continue; + if (regexMatcher != null) + { + foreach (Match match in regexMatcher.Matches(searchLine)) + { + if (!match.Success || results.Count >= limit) + break; + TryAddMatch(match.Index, Math.Max(1, match.Length)); + } + continue; + } + for (int searchStart = 0; searchStart < searchLine.Length && results.Count < limit;) { var matchColumn = searchLine.IndexOf(searchQuery, searchStart, comparison); if (matchColumn < 0) break; + TryAddMatch(matchColumn, searchQuery.Length); + searchStart = matchColumn + 1; + } + bool TryAddMatch(int matchColumn, int matchLength) + { var rawMatchColumn = rawIndexMap == null ? matchColumn : rawIndexMap[matchColumn]; - var rawMatchLength = searchQuery.Length; - if (rawIndexMap != null && rawMatchLength > 0) + var rawMatchLength = matchLength; + if (rawIndexMap != null && matchLength > 0) { - var rawMatchEndIndex = rawIndexMap[matchColumn + rawMatchLength - 1]; + var rawMatchEndIndex = rawIndexMap[matchColumn + matchLength - 1]; rawMatchLength = rawMatchEndIndex - rawMatchColumn + 1; } if (focusColumn.HasValue && (focusColumn.Value < rawMatchColumn + 1 || focusColumn.Value > rawMatchColumn + rawMatchLength)) - { - searchStart = matchColumn + 1; - continue; - } + return false; var snippetLines = snippetLineNumbers.Select(line => lineMap[line]).ToList(); var clampedSnippet = LineWidthFormatter.ClampLines( @@ -100,8 +116,7 @@ public List FindInFiles(string query, int limit, string? lang = Snippet = clampedSnippet.Text, SnippetTruncated = clampedSnippet.Truncated, }); - - searchStart = matchColumn + 1; + return true; } } } diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 3ddb7498d2..f87371b43b 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -231,7 +231,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["focusLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based line that must contain the match", ["minimum"] = 1 }, ["focusColumn"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based column that must be inside the match span", ["minimum"] = 1 }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive literal substring match. Default is case-insensitive literal substring matching.", ["default"] = false } + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive literal substring match. Default is case-insensitive literal substring matching.", ["default"] = false }, + ["regex"] = new JsonObject { ["type"] = "boolean", ["description"] = "Treat query as a .NET regular expression with a 500 ms timeout", ["default"] = false } }, ["required"] = new JsonArray { "query", "path" } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3c792a6056..8d358d8dea 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -321,7 +321,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "project", "solution" }, "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" }, "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since" }, - "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact" }, + "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth" }, "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "project", "solution" }, "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" }, @@ -1843,10 +1843,19 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var exact = args?["exact"]?.GetValue() ?? false; + var regex = args?["regex"]?.GetValue() ?? false; return WithDbReader(id, args, reader => { - var results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn); + List results; + try + { + results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn, regex); + } + catch (ArgumentException ex) when (regex) + { + return CreateToolErrorResponse(id, $"invalid regular expression: {ex.Message}"); + } var structured = new JsonObject { ["query"] = query, @@ -1857,6 +1866,7 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) ["contextTruncated"] = contextTruncated, ["maxLineWidth"] = maxLineWidth, ["exact"] = exact, + ["regex"] = regex, ["count"] = results.Count, ["fileCount"] = results.Select(r => r.Path).Distinct().Count(), ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions), diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 9217ee02ed..5e4010a921 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5639,6 +5639,22 @@ public void ToolsCall_FindInFile_FocusLineAndColumnRestrictMatch() Assert.Equal(8, structured["focusColumn"]!.GetValue()); } + [Fact] + public void ToolsCall_FindInFile_RegexMatchesAnchors() + { + InsertIndexedFile("dist/search-regex.txt", "text", "alpha\ntarget()\nnot target()"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_in_file","arguments":{"query":"^target","path":"dist/search-regex.txt","regex":true}}}""")!; + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + var result = structured["results"]![0]!; + + Assert.True(structured["regex"]!.GetValue()); + Assert.Equal(1, structured["count"]!.GetValue()); + Assert.Equal(2, result["line"]!.GetValue()); + Assert.Equal(1, result["column"]!.GetValue()); + } + [Fact] public void ToolsCall_AnalyzeSymbol_ClampsBundledReferenceContext() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 46d176b352..00e588239c 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -29116,6 +29116,30 @@ public void RunFind_FocusLineAndColumnRestrictMatch() } } + [Fact] + public void RunFind_RegexMatchesAnchors() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_find_regex"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Auth.cs", "csharp", "alpha\nGuard()\nnot Guard()\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["^Guard", "--regex", "--db", dbPath, "--path", "src/Auth.cs"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("src/Auth.cs:2:1", stdout); + Assert.DoesNotContain("src/Auth.cs:3:5", stdout); + Assert.Contains("1 matches in 1 file", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunFind_CountOnlyJsonIncludesVisibleMatchAndFileCounts() { From 8b58235b7726de23ec8ffce9471a347dcf628b0f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 15:02:27 +0900 Subject: [PATCH 5/6] Fix find count regex semantics for issue #1930 --- src/CodeIndex/Cli/QueryCommandRunner.cs | 17 ++++++- .../Database/DbReader.FilesStatus.cs | 45 ++++++++++++++++--- src/CodeIndex/Mcp/McpToolHandlers.cs | 3 +- .../QueryCommandRunnerTests.cs | 28 ++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 324fa63221..3800b20489 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.RegularExpressions; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; @@ -1932,7 +1933,16 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) { if (options.CountOnly) { - var counts = reader.CountFindInFiles(options.Query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.Exact); + QueryCountResult counts; + try + { + counts = reader.CountFindInFiles(options.Query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.Exact, options.FocusLine, options.FocusColumn, options.Regex); + } + catch (Exception ex) when (options.Regex && (ex is ArgumentException || ex is RegexMatchTimeoutException)) + { + Console.Error.WriteLine($"Error: invalid regular expression: {ex.Message}"); + return CommandExitCodes.UsageError; + } if (counts.Count == 0) { if (options.Json) @@ -1967,6 +1977,11 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine($"Error: invalid regular expression: {ex.Message}"); return CommandExitCodes.UsageError; } + catch (RegexMatchTimeoutException ex) when (options.Regex) + { + Console.Error.WriteLine($"Error: invalid regular expression: {ex.Message}"); + return CommandExitCodes.UsageError; + } if (results.Count == 0) { var candidateFileCount = reader.CountFindCandidateFiles(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index dd1784e333..9566f2ed5d 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -54,7 +54,7 @@ public List FindInFiles(string query, int limit, string? lang = continue; int[]? rawIndexMap = null; - var searchLine = exact + var searchLine = exact && !regex ? ExactSourceSearchNormalizer.Normalize(lineText, fileLang, out rawIndexMap) : lineText; var snippetStart = Math.Max(1, lineNumber - before); @@ -142,12 +142,15 @@ public int CountFindCandidateFiles(string? lang = null, IReadOnlyList? p return Convert.ToInt32(fileCmd.ExecuteScalar(), CultureInfo.InvariantCulture); } - public QueryCountResult CountFindInFiles(string query, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false) + public QueryCountResult CountFindInFiles(string query, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int? focusLine = null, int? focusColumn = null, bool regex = false) { if (string.IsNullOrWhiteSpace(query) || pathPatterns == null || pathPatterns.Count == 0) return new QueryCountResult(0, 0); var comparison = exact ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + var regexMatcher = regex + ? new Regex(query, exact ? RegexOptions.None : RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(500)) + : null; using var fileCmd = _conn.CreateCommand(); var sql = "SELECT f.path, f.lang, f.lines FROM files f WHERE 1=1"; if (lang != null) @@ -170,23 +173,55 @@ public QueryCountResult CountFindInFiles(string query, string? lang = null, IRea if (!TryLoadIndexedFileLines(path, out _, out _, out var lineMap) || lineMap.Count == 0) continue; - var searchQuery = exact ? ExactSourceSearchNormalizer.Normalize(query, fileLang) : query; + var searchQuery = exact && !regex ? ExactSourceSearchNormalizer.Normalize(query, fileLang) : query; var fileMatches = 0; for (int lineNumber = 1; lineNumber <= totalLines; lineNumber++) { + if (focusLine.HasValue && lineNumber != focusLine.Value) + continue; if (!lineMap.TryGetValue(lineNumber, out var lineText)) continue; - var searchLine = exact ? ExactSourceSearchNormalizer.Normalize(lineText, fileLang) : lineText; + int[]? rawIndexMap = null; + var searchLine = exact && !regex + ? ExactSourceSearchNormalizer.Normalize(lineText, fileLang, out rawIndexMap) + : lineText; + if (regexMatcher != null) + { + foreach (Match match in regexMatcher.Matches(searchLine)) + { + if (!match.Success) + continue; + if (IsFocusedMatch(match.Index, Math.Max(1, match.Length))) + fileMatches++; + } + continue; + } + for (int searchStart = 0; searchStart < searchLine.Length;) { var matchColumn = searchLine.IndexOf(searchQuery, searchStart, comparison); if (matchColumn < 0) break; - fileMatches++; + if (IsFocusedMatch(matchColumn, searchQuery.Length)) + fileMatches++; searchStart = matchColumn + 1; } + + bool IsFocusedMatch(int matchColumn, int matchLength) + { + if (!focusColumn.HasValue) + return true; + var rawMatchColumn = rawIndexMap == null ? matchColumn : rawIndexMap[matchColumn]; + var rawMatchLength = matchLength; + if (rawIndexMap != null && matchLength > 0) + { + var rawMatchEndIndex = rawIndexMap[matchColumn + matchLength - 1]; + rawMatchLength = rawMatchEndIndex - rawMatchColumn + 1; + } + return focusColumn.Value >= rawMatchColumn + 1 && focusColumn.Value <= rawMatchColumn + rawMatchLength; + } } if (fileMatches > 0) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 8d358d8dea..6f58ac190a 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Indexer; @@ -1852,7 +1853,7 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) { results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn, regex); } - catch (ArgumentException ex) when (regex) + catch (Exception ex) when (regex && (ex is ArgumentException || ex is RegexMatchTimeoutException)) { return CreateToolErrorResponse(id, $"invalid regular expression: {ex.Message}"); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 00e588239c..6b2b8e8795 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -29140,6 +29140,34 @@ public void RunFind_RegexMatchesAnchors() } } + [Fact] + public void RunFind_CountOnlyRegexAndFocusUseSameMatchingSemantics() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_find_count_regex_focus"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Auth.cs", "csharp", "Guard()\nnot Guard()\nGuardAgain()\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["^Guard", "--regex", "--db", dbPath, "--path", "src/Auth.cs", "--focus-line", "3", "--focus-column", "5", "--json", "--count"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(1, json.GetProperty("count").GetInt32()); + Assert.Equal(1, json.GetProperty("files").GetInt32()); + Assert.Equal(1, json.GetProperty("file_count").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunFind_CountOnlyJsonIncludesVisibleMatchAndFileCounts() { From c48fd1a6b2bccd70470c831a946278f71c0efcb6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 17:43:42 +0900 Subject: [PATCH 6/6] Update console UI expectations for find controls --- tests/CodeIndex.Tests/ConsoleUiTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 047c04ba8c..eb18fbd83e 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -73,7 +73,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]", output); - Assert.Contains("--snippet-lines Search snippet length (1-20, default: 8)", output); + Assert.Contains("--snippet-lines search/find snippet length (1-20, default: search 8; find 1)", output); Assert.Contains("--snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)", output); Assert.Contains("--max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: 512)", output); Assert.Contains("cdidx find --path ", output); @@ -99,8 +99,8 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("--duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms", output); Assert.Contains("--ascii Use ASCII spinner/progress glyphs", output); Assert.Contains("cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]", output); - Assert.Contains("--focus-column excerpt: column to keep centered when clamping (must be within the focused line)", output); - Assert.Contains("--focus-line excerpt: line whose focused column should stay visible", output); + Assert.Contains("--focus-column find/excerpt: focus a specific 1-based column", output); + Assert.Contains("--focus-line find/excerpt: focus a specific line", output); Assert.Contains("cdidx map [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes]", output); Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); @@ -708,7 +708,7 @@ public void PrintCompletions_FishIncludesFindOptions() [Theory] [InlineData("bash")] [InlineData("zsh")] - public void PrintCompletions_BashAndZshKeepFocusOptionsExcerptOnly(string shell) + public void PrintCompletions_BashAndZshIncludeFindFocusOptions(string shell) { var output = ConsoleUi.GetCompletionScript(shell); Assert.Contains("find", output); @@ -723,7 +723,7 @@ public void PrintCompletions_BashAndZshKeepFocusOptionsExcerptOnly(string shell) Assert.Contains("elif [ \"$cmd\" = \"excerpt\" ]; then", output); var findBranch = ExtractBetween(output, "if [ \"$cmd\" = \"find\" ]", "elif [ \"$cmd\" = \"excerpt\" ]; then"); var excerptBranch = ExtractBetween(output, "elif [ \"$cmd\" = \"excerpt\" ]; then", "elif [ \"$cmd\" = \"references\" ]; then"); - Assert.DoesNotContain("--focus-column", findBranch); + Assert.Contains("--focus-column", findBranch); Assert.Contains("--focus-column", excerptBranch); } else @@ -732,7 +732,7 @@ public void PrintCompletions_BashAndZshKeepFocusOptionsExcerptOnly(string shell) Assert.Contains("elif [[ $subcmd == excerpt ]]; then", output); var findBranch = ExtractBetween(output, "if [[ $subcmd == find ]]; then", "elif [[ $subcmd == excerpt ]]; then"); var excerptBranch = ExtractBetween(output, "elif [[ $subcmd == excerpt ]]; then", "elif [[ $subcmd == references ]]; then"); - Assert.DoesNotContain("focus-column", findBranch); + Assert.Contains("focus-column", findBranch); Assert.Contains("focus-column", excerptBranch); } }