diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 634497ba96..eaf1c6417e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -408,7 +408,7 @@ be audited whenever the matching help text changes. | Setting | Current default | Source of truth | |---|---|---| -| Query/diff result limit | `20` (`--limit`, alias `--top` for query commands), max `10000` | CLI help and query runners | +| Query/diff result limit | `20` (`--limit`, aliases `--top` for query commands and `--max-results` for `search`), max `10000` | CLI help and query runners | | Search snippet lines | `8` (`--snippet-lines`, max `20`) | CLI help and search runner | | Max line width | `512` (`--max-line-width`, `0` disables) | `LineWidthFormatter.DefaultMaxLineWidth` | | Index max file size | `4MiB` unless `CDIDX_MAX_FILE_BYTES` is set | index runner help | @@ -1272,6 +1272,10 @@ Default `cdidx search` is literal-safe unless you explicitly opt into raw FTS5: | Raw FTS5 mode | With `--fts`, the query is passed to FTS5 as raw syntax. Supported operators include `content:term` column filters, `NEAR(foo bar, 5)`, `foo OR bar`, `foo NOT bar`, parenthesized groups, prefix tokens such as `foo*`, and quoted phrases such as `"foo bar"`. | | No `--fts` | Operator-like characters are treated as literal query content except for cdidx's documented literal-safe prefix shorthand. | +For punctuation-heavy code phrases such as `catch { }`, normal search may emit a rerun hint. Use `--exact-substring` when braces, operators, punctuation, and case need byte-for-byte matching. + +For whitespace-containing literal queries passed as one argument, such as `cdidx search "not supported"`, normal search still uses FTS token matching but ranks chunks containing the exact phrase ahead of token-only matches. + Search case behavior depends on the selected mode: | Mode or text | Case and token behavior | @@ -1314,11 +1318,11 @@ same source location. | `--issue-title ` / `--issue-label <label>` | `search --format issue-drafts` | Set the title for an ad hoc search draft and add label hints. `--issue-label` can be repeated or comma-separated. | | `--check` | `status` | Verify that `.cdidx/codeindex.db` exactly matches the current indexable workspace by comparing DB file paths/checksums against a fresh filesystem scan. Matching indexes exit `0`; stale indexes exit `5`. | | `--dry-run` | `index` | Scan files and report what would change without writing to the database | -| `--limit <n>` | Query result commands except `suggestions` | Max results (default: 20, max: 10000; `map` uses it per section) | +| `--limit <n>` / `--max-results <n>` | Query result commands except `suggestions`; `--max-results` is `search` only | Max results (default: 20, max: 10000; `map` uses it per section) | | `--lang <lang>` | Query commands | Filter by language (case-insensitive; `--lang Python` is treated as `--lang python`). Common aliases such as `c#`, `cs`, `kt`, and `kts` are also accepted. Unknown values emit an `Available: <languages>` hint on zero-result responses in human-readable output. | | `--visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | Include only symbols with the requested visibility values: `public`, `protected`, `internal`, `private`. `public` matches stored exported aliases such as `pub`, `open`, and `export`; `private` also matches `fileprivate`. | | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | Exclude symbols with the requested visibility values. Accepts the same comma-separated values and alias expansion as `--visibility`. | -| `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | Restrict results to glob-style path patterns. `*` and `?` are wildcards. Repeatable; multiple values are OR'd together | +| `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | Restrict results to glob-style path patterns. `*` and `?` are wildcards. Repeatable; multiple values are OR'd together. Quote shell globs such as `--path 'src/**'` so the shell passes one literal pattern. | | `--query <query>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | Pass a query literal explicitly, useful when the query starts with `-`. Query commands except `find` also accept `-- <query>` as a one-token query escape while continuing to parse later options. | | `--recipe <name>` | `search` | Run a reusable audit recipe such as `risky-code`. Use `recipe/query` form, such as `risky-code/raw-diagnostic-echo`, to run one child query directly. Recipe runs default to `--audit-scope source`, applying the recipe's production-code path and exclusion metadata before normal search filters and snippet controls; `--limit` / `--top` is per child query. Text, `--json` / `--format json`, `--format compact`, and `--format issue-drafts` are supported, and issue drafts include a replay command. | | `--include-query <name>` / `--exclude-query <name>` | `search --recipe <name>` | Include or exclude child recipe queries by name. Repeatable and comma-separated; names are listed by `cdidx search --list-recipes`. | @@ -1372,6 +1376,7 @@ same source location. | `--reverse` | `deps` | Reverse lookup: show files that depend ON the matched path | | `--strict-not-found` | Query commands | Return exit code `2` when a valid query produces zero rows. Without this flag, zero-result queries exit `0` and keep their normal empty/zero-result output. | | `--top <n>` | Query commands | Alias for `--limit` | +| `--max-results <n>` | `search` | Alias for `--limit` | | `--color <when>` | All commands | Control ANSI color output. Accepts `auto` (default), `always`, or `never`. Precedence: `--color` flag > `CLICOLOR_FORCE` > `NO_COLOR` > `CLICOLOR=0` > terminal capability auto-detect. Auto mode treats redirected stdout and StringWriter-style test capture as non-ANSI; on Windows it also accepts ConPTY/Windows Terminal virtual-terminal support and terminal hints such as `WT_SESSION`, `WT_PROFILE_ID`, `TERM_PROGRAM`, or non-`dumb` `TERM`. Use `--color=always` to keep colored kind labels through a pager such as `cdidx symbols Foo \| less -R`; use `--color=never` (or `NO_COLOR=1`) to suppress ANSI even on a TTY. | | `--palette <name>` | All commands | Choose the ANSI palette used when color output is enabled. Accepts `basic` (8-color SGR 30–37, the default fallback for minimal SSH/CI terminals), `256` (256-color `\x1b[38;5;Nm`), or `truecolor` (24-bit RGB `\x1b[38;2;R;G;Bm`). Precedence: `--palette` flag > `CDIDX_COLOR_PALETTE` env var > `COLORTERM` / `TERM` auto-detect. The basic palette avoids `\x1b[90m` (bright-black / dim), which is unreadable on many minimal terminals. | | `--metrics <path>` | All commands (and MCP tool calls) | Append one JSONL metrics record per CLI command / MCP tool call to `<path>`. The `CDIDX_METRICS=<path>` environment variable provides the same destination as a fallback when the flag is not passed. Best-effort: any IO failure (missing directory, read-only mount, etc.) is swallowed silently and never breaks the underlying command. | @@ -2828,7 +2833,7 @@ render できます。 | 設定 | 現在の既定値 | Source of truth | |---|---|---| -| Query/diff result limit | `20`(`--limit`、query command では alias `--top`)、最大 `10000` | CLI help と query runners | +| Query/diff result limit | `20`(`--limit`、query command では alias `--top`、`search` では `--max-results`)、最大 `10000` | CLI help と query runners | | Search snippet lines | `8`(`--snippet-lines`、最大 `20`) | CLI help と search runner | | Max line width | `512`(`--max-line-width`、`0` で無効) | `LineWidthFormatter.DefaultMaxLineWidth` | | Index max file size | `CDIDX_MAX_FILE_BYTES` 未設定時は `4MiB` | index runner help | @@ -3712,6 +3717,10 @@ cdidx report --output report.tgz --json | raw FTS5 mode | `--fts` 付きでは query を raw FTS5 構文としてそのまま渡します。利用できる演算子には `content:term` の列 filter、`NEAR(foo bar, 5)`、`foo OR bar`、`foo NOT bar`、括弧 grouping、`foo*` のような prefix token、`"foo bar"` のような quoted phrase があります。 | | `--fts` なし | cdidx が明示している literal-safe prefix shorthand を除き、operator に見える文字も literal な query 内容として扱います。 | +`catch { }` のように記号の多いコード片では、通常検索が再実行ヒントを出す場合があります。brace、operator、punctuation、大文字小文字まで byte-for-byte に一致させたい場合は `--exact-substring` を使います。 + +`cdidx search "not supported"` のように空白を含む literal query を 1 引数で渡した場合、通常検索は引き続き FTS token matching を使いますが、exact phrase を含む chunk を token-only match より前に並べます。 + 検索の大小文字の扱いは mode ごとに異なります: | mode / text | 大小文字と token の扱い | @@ -3753,10 +3762,10 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--issue-title <title>` / `--issue-label <label>` | `search --format issue-drafts` | ad hoc search draft の title を指定し、label hint を追加します。`--issue-label` は繰り返し指定またはカンマ区切りに対応します。 | | `--check` | `status` | DB のファイル path/checksum と現在の index 対象 workspace を比較し、`.cdidx/codeindex.db` が完全一致するか確認。完全一致なら終了コード `0`、stale なら `5` | | `--dry-run` | `index` | DB に書き込まず、どの変更が発生するかだけを走査して報告 | -| `--limit <n>` | `suggestions` 以外のクエリ結果コマンド | 最大結果数(デフォルト: 20、最大: 10000。`map` では各セクションごとの件数) | +| `--limit <n>` / `--max-results <n>` | `suggestions` 以外のクエリ結果コマンド。`--max-results` は `search` のみ | 最大結果数(デフォルト: 20、最大: 10000。`map` では各セクションごとの件数) | | `--visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | `public`, `protected`, `internal`, `private` の可視性でシンボルを絞り込む。`public` は `pub`、`open`、`export` などの保存済み exported alias にも一致し、`private` は `fileprivate` にも一致 | | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | 指定した可視性のシンボルを除外する。値と alias 展開は `--visibility` と同じ | -| `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | glob 形式のパスパターンで結果を絞る。`*` と `?` がワイルドカード。繰り返し指定可(複数値は OR で結合) | +| `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | glob 形式のパスパターンで結果を絞る。`*` と `?` がワイルドカード。繰り返し指定可(複数値は OR で結合)。`--path 'src/**'` のように shell glob を引用し、shell が 1 つの literal pattern として渡すようにする。 | | `--query <query>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | クエリを明示的なリテラルとして渡す。クエリが `-` で始まる場合に有用。`find` 以外のクエリ系コマンドでは `-- <query>` も1トークンのクエリエスケープとして受け付け、その後のオプション解析を続ける。 | | `--recipe <name>` | `search` | `risky-code` などの再利用可能な audit recipe を実行する。`risky-code/raw-diagnostic-echo` のような `recipe/query` 形式で child query を1つだけ直接実行できる。Recipe 実行は既定で `--audit-scope source` になり、recipe の本番コード向け path / exclusion metadata を適用したうえで、通常の search filter と snippet control を選択された各 query に適用する。`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format compact`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | | `--include-query <name>` / `--exclude-query <name>` | `search --recipe <name>` | recipe 内の child query を名前で含める、または除外する。繰り返し指定とカンマ区切りに対応し、名前は `cdidx search --list-recipes` で確認できる。 | @@ -3808,6 +3817,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--reverse` | `deps` | 逆引き: 指定パスに依存しているファイルを表示 | | `--workspace-db <path>` | `deps` | file dependency query に別の CodeIndex DB を追加する。最大 7 個の distinct な追加 DB(`--db` を含め合計 8 個)まで繰り返し指定でき、JSON edge には同じ相対パスを区別できるよう `source_db` / `target_db` が含まれる。 | | `--top <n>` | クエリ系 | `--limit` のエイリアス | +| `--max-results <n>` | `search` | `--limit` のエイリアス | | `--color <when>` | 全コマンド | ANSI カラー出力の制御。`auto`(既定)、`always`、`never` を受け付ける。優先順位: `--color` フラグ > `CLICOLOR_FORCE` > `NO_COLOR` > `CLICOLOR=0` > 端末能力の自動判定。auto では redirected stdout と StringWriter 風のテスト capture を非 ANSI とみなし、Windows では ConPTY / Windows Terminal の virtual-terminal 対応と `WT_SESSION`、`WT_PROFILE_ID`、`TERM_PROGRAM`、非 `dumb` の `TERM` などの端末ヒントも見る。`cdidx symbols Foo \| less -R` のような pager pipe でも色を維持したい場合は `--color=always`、TTY 上でも ANSI を抑止したい場合は `--color=never`(または `NO_COLOR=1`)を指定する。 | | `--palette <name>` | 全コマンド | カラー出力が有効なときに用いる ANSI パレットを選択する。`basic`(標準8色 SGR 30–37、最小 SSH/CI 端末向けの既定フォールバック)、`256`(256色 `\x1b[38;5;Nm`)、`truecolor`(24ビット RGB `\x1b[38;2;R;G;Bm`)を受け付ける。優先順位: `--palette` フラグ > `CDIDX_COLOR_PALETTE` 環境変数 > `COLORTERM` / `TERM` 自動判定。`basic` パレットは最小端末で読みにくい `\x1b[90m`(暗灰 / dim)を避ける。 | | `--metrics <path>` | 全コマンド(および MCP ツール呼び出し) | CLI コマンド / MCP ツール呼び出し 1 回ごとに JSONL レコードを 1 行ずつ `<path>` に追記する。フラグ未指定時のフォールバックとして `CDIDX_METRICS=<path>` 環境変数でも同じ出力先を指定できる。Best-effort のため、ディレクトリが無い・read-only マウント等の IO 失敗は黙って握り潰し、本体コマンドを壊さない。 | diff --git a/changelog.d/unreleased/3386.fixed.md b/changelog.d/unreleased/3386.fixed.md new file mode 100644 index 0000000000..01fdfd200e --- /dev/null +++ b/changelog.d/unreleased/3386.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3386 +affected: + - src/CodeIndex/Cli/SearchQueryAdvisor.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchHintTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **Punctuation-heavy search hints now include a concrete exact-substring rerun path (#3386)** — CLI and MCP search recovery hints now tell users to rerun punctuation-heavy code phrases with `--exact-substring` or `exactSubstring=true`. + +## 日本語 + +- **記号の多い検索ヒントが exact-substring の再実行方法を具体的に案内するようになりました (#3386)** — CLI と MCP の search recovery hint は、記号の多いコード片を `--exact-substring` または `exactSubstring=true` で再実行するよう案内します。 diff --git a/changelog.d/unreleased/3389.fixed.md b/changelog.d/unreleased/3389.fixed.md new file mode 100644 index 0000000000..2d83bbc017 --- /dev/null +++ b/changelog.d/unreleased/3389.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3389 +affected: + - src/CodeIndex/Database/DbSearchReader.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md +--- + +## English + +- **Multiword literal searches rank exact phrase chunks first (#3389)** — normal `search` now boosts chunks containing the exact whitespace phrase, such as `not supported`, ahead of token-only matches. + +## 日本語 + +- **複数語の literal search が exact phrase を含む chunk を先に並べるようになりました (#3389)** — 通常の `search` は `not supported` のような空白を含む exact phrase を持つ chunk を、token-only match より前に並べます。 diff --git a/changelog.d/unreleased/3445.fixed.md b/changelog.d/unreleased/3445.fixed.md new file mode 100644 index 0000000000..c6d5676d8a --- /dev/null +++ b/changelog.d/unreleased/3445.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3445 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md +--- + +## English + +- **Search explains expanded `--path` globs (#3445)** — when a shell-expanded path glob leaves extra path-like positional arguments, `cdidx search` now suggests quoting `--path` so one literal glob reaches the CLI. + +## 日本語 + +- **`search` が展開済み `--path` glob の対処を案内するようになりました (#3445)** — shell 展開された path glob が余剰の path 風 positional argument として残った場合、`cdidx search` は `--path` を引用して 1 つの literal glob として渡すよう案内します。 diff --git a/changelog.d/unreleased/3521.fixed.md b/changelog.d/unreleased/3521.fixed.md new file mode 100644 index 0000000000..3c2e2062e0 --- /dev/null +++ b/changelog.d/unreleased/3521.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3521 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md +--- + +## English + +- **Search accepts `--max-results` and guides dash-prefixed queries (#3521)** — `cdidx search` now treats `--max-results` as a `--limit` alias and tells users how to pass a literal query such as `--profile` through `--query` or `--`. + +## 日本語 + +- **`search` が `--max-results` と dash 始まりの query を案内するようになりました (#3521)** — `cdidx search` は `--max-results` を `--limit` のエイリアスとして扱い、`--profile` のような literal query を `--query` または `--` で渡す方法を案内します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index d3172c3a37..02f5bb5ca3 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -244,6 +244,7 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--slow-query-ms", ValuePlaceholder = "<n>", Description = "Log profiled SQL statements at or above this millisecond threshold", Commands = Set(ProfileCommands) }, new() { Name = "--trace", ValuePlaceholder = "<none|stderr|file>", Description = "Emit one structured JSON query trace line to stderr or a daily log file", Commands = Set(TraceCommands) }, new() { Name = "--limit", ValuePlaceholder = "<n>", Description = "Max results", Commands = Set(LimitCapableCommands.Concat(new[] { "suggestions" }).ToArray()) }, + new() { Name = "--max-results", ValuePlaceholder = "<n>", Description = "Search alias for --limit", Commands = Set("search") }, new() { Name = "--top", ValuePlaceholder = "<n>", Description = "Max results", Commands = Set(LimitCapableCommands) }, new() { Name = "--offset", ValuePlaceholder = "<n>", Description = "Suggestions: skip this many filtered rows before output", Commands = Set("suggestions") }, new() { Name = "--lang", ValuePlaceholder = "<lang>", Description = "Filter by language", Commands = Set(LangCapableCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index b201f99386..ab6b88f794 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -87,7 +87,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("index-commits", "cdidx index <projectPath> --commits <commit-ref> [commit-ref ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-changed-between", "cdidx index <projectPath> --changed-between <old-ref> <new-ref> [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-files", "cdidx index <projectPath> --files <path> [path ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), - ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), + ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), ("definition", "cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]"), ("goto", "cdidx goto <query>|--query <query>|-- <query> [--db <path>] [--json] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--all]"), ("references", "cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]"), @@ -1028,7 +1028,8 @@ private static void PrintFlagReference(Action<string> WriteHelpLine) WriteHelpLine(" --quiet, -q, --silent Query commands: suppress informational stderr output, including zero-result hints and summaries; errors still print. Overrides --verbose stderr text."); WriteHelpLine(" --profile Read commands: append SQL timing, row-count, and EXPLAIN QUERY PLAN JSON after the normal result"); WriteHelpLine(" --slow-query-ms <n> Read commands: log profiled SQL statements that take at least <n> ms (use 0 to log every statement)"); - Console.WriteLine(" --limit <n>, --top <n> Max results to return (default: 20)"); + Console.WriteLine(" --limit <n>, --top <n>, --max-results <n>"); + Console.WriteLine(" Max results to return (default: 20)"); Console.WriteLine(" --lang <lang> Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)"); Console.WriteLine(" --path <glob> Restrict matches to glob-style path patterns (* and ?)"); WriteHelpLine($" --query <query> Pass a query literal, useful when the query starts with '-' (`search`/`find` max {QueryLimits.MaxQueryLength} chars)"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index d0ce9782a3..579366165d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -139,6 +139,7 @@ public static class QueryCommandRunner "--db", "--data-dir", "--limit", + "--max-results", "--top", "--lang", "--kind", @@ -793,7 +794,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) WriteUsageError( "search requires a query argument", GetUsageLineOrThrow("search"), - "Add the text you want to search for after the command, for example: `cdidx search authenticate`."); + BuildMissingSearchQueryHint(cmdArgs)); return CommandExitCodes.UsageError; } if (options.Query.Length > QueryLimits.MaxQueryLength) @@ -7830,10 +7831,12 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) } break; case "--limit": + case "--max-results": case "--top": - if (!TryReadRawOptionValue(args, ref i, "--limit", inlineValue, out var limitValue, out var missingLimitError)) + var limitOptionName = normalizedArg == "--top" ? "--limit" : normalizedArg; + if (!TryReadRawOptionValue(args, ref i, limitOptionName, inlineValue, out var limitValue, out var missingLimitError)) AddParseError(missingLimitError!); - else if (TryParsePositiveInt(limitValue!, "--limit", out var parsedLimit, out var limitError)) + else if (TryParsePositiveInt(limitValue!, limitOptionName, out var parsedLimit, out var limitError)) { WarnIfDuplicateSingleValueOption("--limit", limitValue!); limit = parsedLimit; @@ -9870,11 +9873,26 @@ private static bool TryWriteUnexpectedExtraPositionals(string commandName, Query CommandErrorWriter.Write( $"unexpected extra positional {ConsoleUi.Counted(options.ExtraNames.Count, "argument")} for {commandName}: {string.Join(", ", options.ExtraNames.Select(name => $"`{name}`"))}.", - "quote multi-word queries as a single argument, or remove the extra positional values.", + BuildUnexpectedExtraPositionalsHint(commandName, options), GetUsageLineOrThrow(commandName)); return true; } + private static string BuildUnexpectedExtraPositionalsHint(string commandName, QueryCommandOptions options) + { + if (string.Equals(commandName, "search", StringComparison.Ordinal) + && options.PathPatterns.Count > 0 + && options.ExtraNames.Any(IsPathLikeArgument)) + { + return "quote --path globs so the shell passes one literal pattern, e.g. `--path 'src/CodeIndex/**'`; remove the expanded path arguments and rerun."; + } + + return "quote multi-word queries as a single argument, or remove the extra positional values."; + } + + private static bool IsPathLikeArgument(string value) => + value.Contains('/') || value.Contains('\\'); + private static bool TryWriteUnexpectedPositionals(string commandName, QueryCommandOptions options) { var unexpected = new List<string>(); @@ -9891,6 +9909,69 @@ private static bool TryWriteUnexpectedPositionals(string commandName, QueryComma return true; } + private static string BuildMissingSearchQueryHint(string[] cmdArgs) + { + var candidate = FindOptionLookingSearchLiteralCandidate(cmdArgs); + if (candidate != null) + { + var display = ConsoleUi.FormatBoundedValue(candidate); + return $"Add the text you want to search for after the command. If you meant to search for `{display}`, pass it as `--query \"{display}\"` or after `--`, for example: `cdidx search -- \"{display}\"`."; + } + + return "Add the text you want to search for after the command, for example: `cdidx search authenticate`. If the query itself starts with `--`, pass it as `--query \"--profile\"` or after `--`, for example: `cdidx search -- \"--profile\"`."; + } + + private static string? FindOptionLookingSearchLiteralCandidate(string[] cmdArgs) + { + for (var i = 0; i < cmdArgs.Length; i++) + { + var arg = cmdArgs[i]; + if (arg == "--") + return i + 1 < cmdArgs.Length && cmdArgs[i + 1].StartsWith("-", StringComparison.Ordinal) + ? cmdArgs[i + 1] + : null; + + var inlineValue = TrySplitInlineOptionValue(arg, out var inlineOptionName) + ? arg[(inlineOptionName!.Length + 1)..] + : null; + var normalizedArg = inlineOptionName ?? arg; + if (ValueTakingOptions.Contains(normalizedArg)) + { + if (inlineValue == null) + i++; + continue; + } + + if (!arg.StartsWith("--", StringComparison.Ordinal)) + continue; + if (SearchMissingQueryControlFlags.Contains(normalizedArg)) + continue; + + return arg; + } + + return null; + } + + private static readonly HashSet<string> SearchMissingQueryControlFlags = + [ + "--exact", + "--exact-name", + "--exact-substring", + "--prefix", + "--fts", + "--json", + "--pretty", + "--count", + "--no-dedup", + "--no-visibility-rank", + "--exclude-tests", + "--strict-not-found", + "--verbose", + "--quiet", + "--silent", + ]; + private static string GetUsageLineOrThrow(string commandName) => ConsoleUi.GetUsageLine(commandName) ?? throw new InvalidOperationException($"Missing usage line for command '{commandName}'."); @@ -11535,6 +11616,7 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont new Dictionary<string, int>(StringComparer.Ordinal) { ["--limit"] = 10_000, + ["--max-results"] = 10_000, ["--snippet-lines"] = SearchSnippetFormatter.MaxSnippetLines, ["--max-line-width"] = LineWidthFormatter.MaxAllowedLineWidth, ["--slow-query-ms"] = 3_600_000, diff --git a/src/CodeIndex/Cli/SearchQueryAdvisor.cs b/src/CodeIndex/Cli/SearchQueryAdvisor.cs index 99fe2df2c5..e480bcdf26 100644 --- a/src/CodeIndex/Cli/SearchQueryAdvisor.cs +++ b/src/CodeIndex/Cli/SearchQueryAdvisor.cs @@ -3,8 +3,10 @@ namespace CodeIndex.Cli; internal static class SearchQueryAdvisor { internal const string ExactSubstringHintReason = "punctuation_heavy_query"; - internal const string CliExactSubstringSuggestedAction = "This looks like a literal code phrase; try --exact-substring for punctuation-sensitive matching."; - internal const string McpExactSubstringSuggestedAction = "This looks like a literal code phrase; try exactSubstring for punctuation-sensitive matching."; + internal const string CliExactSubstringSuggestedAction = + "This looks like a literal code phrase; rerun with `--exact-substring`, for example: `cdidx search --exact-substring --query \"...\"`, for punctuation-sensitive matching."; + internal const string McpExactSubstringSuggestedAction = + "This looks like a literal code phrase; rerun the search with exactSubstring=true for punctuation-sensitive matching."; internal static SearchQueryHint? BuildExactSubstringHint(string? query, bool rawQuery, bool exact, bool prefix) => ShouldSuggestExactSubstring(query, rawQuery, exact, prefix) diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 76f555b9f1..df6548201f 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -123,7 +123,7 @@ public List<SearchResult> Search(string query, int limit = 20, string? lang = nu var coverageTokens = exact ? new List<string>() : GetSearchCoverageTokens(normalizedQuery, rawQuery); var hasGuardFilters = guardFilters is { Count: > 0 }; var searchMatchLineContext = SearchMatchLineContext.Create(query, lang, exact); - var exactSubstringBoost = !exact && !rawQuery && IsPunctuationHeavyLiteralQuery(query); + var exactLiteralBoost = !exact && !rawQuery && ShouldBoostExactLiteralSearch(query); var guardedRequestedLimit = Math.Max(0, guardRequestedLimit ?? limit); var guardedCandidateLimit = hasGuardFilters ? GetGuardedSearchCandidateLimit(guardedRequestedLimit, cursor) : 0; using var cmd = _conn.CreateCommand(); @@ -166,7 +166,7 @@ FROM fts_chunks if (since != null && _fileColumns.Contains("modified")) sql += " AND f.modified >= @since"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); - sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count, exactSubstringBoost)}"; + sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count, exactLiteralBoost)}"; if (hasGuardFilters) sql += " LIMIT @candidateFetchLimit"; else @@ -460,7 +460,7 @@ FROM fts_chunks sql += " AND f.modified >= @since"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); - sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count, exactSubstringBoost: false)}"; + sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count, exactLiteralBoost: false)}"; cmd.CommandText = sql; if (exact) @@ -1365,13 +1365,22 @@ private static bool OverlapsOrTouches((int Start, int End) interval, int start, } } - private static string GetSearchOrderSql(int coverageTokenCount, bool exactSubstringBoost) + private static string GetSearchOrderSql(int coverageTokenCount, bool exactLiteralBoost) { var coverageOrder = GetSearchCoverageOrderSql(coverageTokenCount); - var exactSubstringOrder = exactSubstringBoost + var exactLiteralOrder = exactLiteralBoost ? $"CASE WHEN instr({GetExactSearchTextSql("c.content", "f.lang")}, {GetExactSearchTextSql("@rankingQuery", "f.lang")}) > 0 THEN 0 ELSE 1 END, " : string.Empty; - return $"{PathBucketOrder}, {exactSubstringOrder}{ExactSymbolMatchOrder}, {PrefixSymbolMatchOrder}, {SearchVisibilityOrder}, {PathTextMatchOrder}, {ChunkTextMatchOrder}, {ChunkStructuredFieldOrder}, {ChunkSymbolKindOrder}, {ChunkSymbolDepthOrder}, {coverageOrder}rank, f.modified DESC, f.path, c.id ASC"; + return $"{PathBucketOrder}, {exactLiteralOrder}{ExactSymbolMatchOrder}, {PrefixSymbolMatchOrder}, {SearchVisibilityOrder}, {PathTextMatchOrder}, {ChunkTextMatchOrder}, {ChunkStructuredFieldOrder}, {ChunkSymbolKindOrder}, {ChunkSymbolDepthOrder}, {coverageOrder}rank, f.modified DESC, f.path, c.id ASC"; + } + + private static bool ShouldBoostExactLiteralSearch(string query) + { + var trimmed = query.Trim(); + if (trimmed.Length == 0 || !trimmed.Any(char.IsLetterOrDigit)) + return false; + + return IsPunctuationHeavyLiteralQuery(trimmed) || IsMultiwordLiteralPhrase(trimmed); } private static bool IsPunctuationHeavyLiteralQuery(string query) @@ -1385,6 +1394,12 @@ private static bool IsPunctuationHeavyLiteralQuery(string query) return punctuationCount >= 2 || tokens.Any(IsStandaloneCodeOperatorToken); } + private static bool IsMultiwordLiteralPhrase(string query) + { + var tokens = query.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return tokens.Length > 1 && tokens.All(token => token.Any(char.IsLetterOrDigit)); + } + private static bool IsStandaloneCodeOperatorToken(string token) => token.Length > 0 && token.All(ch => !char.IsLetterOrDigit(ch) && !char.IsWhiteSpace(ch) && ch != '_') diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index ae0ad16c21..2d9a2e141b 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -115,7 +115,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx references <query>|--query <query>|-- <query>", output); Assert.Contains("cdidx callers <query>|--query <query>|-- <query>", output); Assert.Contains("cdidx callees <query>|--query <query>|-- <query>", output); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|compact>] [--pretty] [--compact] [--fields <csv>] [--body-only] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--body-start <line>] [--body-lines <n>] [--max-line-width <n>] [--exact|--exact-name]", output); @@ -269,7 +269,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx symbols [query|--query <query>|-- <query>] [--name <name>] [--db <path>] [--json] [--format <text|json|count>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx files [query|--query <query>|-- <query>] [--db <path>] [--json[=ndjson|array]] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--since <datetime>] [--bytes]", output); Assert.Contains("cdidx hotspots [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count]", output); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index f074dfae28..29917108d5 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4827,6 +4827,9 @@ public void ToolsCall_Search_PunctuationHeavyQueryAddsExactSubstringRecoveryHint var recoveryHint = response["result"]!["structuredContent"]!["recovery_hint"]!; Assert.Equal("punctuation_heavy_query", recoveryHint["reason"]!.GetValue<string>()); + Assert.Equal( + "This looks like a literal code phrase; rerun the search with exactSubstring=true for punctuation-sensitive matching.", + recoveryHint["suggested_action"]!.GetValue<string>()); Assert.Equal("search", recoveryHint["tool"]!.GetValue<string>()); Assert.Equal("CommandText = $", recoveryHint["args"]!["query"]!.GetValue<string>()); Assert.True(recoveryHint["args"]!["exactSubstring"]!.GetValue<bool>()); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchHintTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchHintTests.cs index 5103decb8f..8df0fbcf69 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchHintTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchHintTests.cs @@ -137,7 +137,9 @@ public void RunSearch_PunctuationHeavyTextSuggestsExactSubstring() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Contains("src/sql.cs", stdout); - Assert.Contains("Hint: This looks like a literal code phrase; try --exact-substring for punctuation-sensitive matching.", stderr); + Assert.Contains( + "Hint: This looks like a literal code phrase; rerun with `--exact-substring`, for example: `cdidx search --exact-substring --query \"...\"`, for punctuation-sensitive matching.", + stderr); } finally { @@ -168,6 +170,9 @@ public void RunSearch_PunctuationHeavyJsonAddsExactSubstringHint() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); Assert.Equal("punctuation_heavy_query", hint.GetProperty("reason").GetString()); + Assert.Equal( + "This looks like a literal code phrase; rerun with `--exact-substring`, for example: `cdidx search --exact-substring --query \"...\"`, for punctuation-sensitive matching.", + hint.GetProperty("suggested_action").GetString()); Assert.Equal("--exact-substring", hint.GetProperty("flag").GetString()); Assert.Equal("exactSubstring", hint.GetProperty("mcp_argument").GetString()); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 4dfa284b89..f688bf414f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -460,6 +460,94 @@ public void RunSearch_FormatCompactEmitsBoundedSnippet_Issue3481() } } + [Fact] + public void RunSearch_MaxResultsAliasLimitsSearchResults_Issue3521() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_search_max_results_3521"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/one.cs", "csharp", "class One { string Value = \"needle\"; }"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/two.cs", "csharp", "class Two { string Value = \"needle\"; }"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["needle", "--db", dbPath, "--json=array", "--max-results", "1"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Single(document.RootElement.EnumerateArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_MissingDashLiteralQuerySuggestsEscapes_Issue3521() + { + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--exact", "--profile", "--limit", "5"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("Error: search requires a query argument", stderr); + Assert.Contains("`--query \"--profile\"`", stderr); + Assert.Contains("`cdidx search -- \"--profile\"`", stderr); + } + + [Fact] + public void RunSearch_PathGlobExpansionHintSuggestsQuotedPath_Issue3445() + { + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["needle", "--path", "src/CodeIndex/Cli", "src/CodeIndex/Database"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("Error: unexpected extra positional 1 argument for search: `src/CodeIndex/Database`.", stderr); + Assert.Contains("quote --path globs so the shell passes one literal pattern", stderr); + Assert.Contains("`--path 'src/CodeIndex/**'`", stderr); + } + + [Fact] + public void RunSearch_MultiwordLiteralRanksExactPhraseContentFirst_Issue3389() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_multiword_phrase_3389"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/not supported noise.cs", + "csharp", + "class Noise { string Message = \"not every platform is supported\"; }"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/z_phrase.cs", + "csharp", + "class Phrase { string Message = \"feature is not supported here\"; }"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["not supported", "--db", dbPath, "--json=array", "--limit", "2"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var rows = document.RootElement.EnumerateArray().ToList(); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(2, rows.Count); + Assert.Equal("src/z_phrase.cs", rows[0].GetProperty("path").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_NamedQueriesReturnGroupedCompactResults_Issue3481() {