From 5d7f07e686b7dc2dec07d4e4146c3b154e375621 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 31 Jul 2026 13:21:43 +0900 Subject: [PATCH] Fix shell completion value metadata (#4902) --- DEVELOPER_GUIDE.md | 4 +- USER_GUIDE.md | 21 +++ changelog.d/unreleased/4902.fixed.md | 20 +++ src/CodeIndex/Cli/CliFlagSchema.cs | 165 ++++++++++------- .../Cli/ConsoleCompletionRenderer.cs | 168 +++++++++++++----- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 84 ++++++++- tests/CodeIndex.Tests/ConsoleUiTests.cs | 14 +- 7 files changed, 353 insertions(+), 123 deletions(-) create mode 100644 changelog.d/unreleased/4902.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index b19fe1d19..457760d71 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -37,7 +37,7 @@ Development contracts: | `cdidx validate` | This is the user-facing integrity scan for indexed content issues such as replacement characters, BOMs, NUL bytes, mixed line endings, UTF-16 BOMs, and likely non-UTF8 content. Keep its CLI usage, README entry, and help summary in sync when adding validation issue kinds or filters. | | `cdidx doctor` | This is the copy-pasteable environment summary for support requests. Keep it redacted by default: secret-like `CDIDX_*` values must not be printed, and new diagnostic fields should be stable enough for issue triage. Full environment inventory filters (`--env-domain`, `--env-category`, and `--env-sensitivity`) use case-insensitive exact values and compose with AND; filtered JSON summaries describe the returned inventory rather than the global catalog. `--max-json-bytes` is valid only with `--json --env-inventory=full`, counts the serialized UTF-8 document plus its newline, and returns a structured usage error rather than an oversized successful document. The `github` block reports `proxy_default_credentials` as `enabled` / `disabled` and the bounded `max_request_timeout_s`; never print proxy credential material or raw secret values. `license --json` returns the versioned `license`, `commercial_use`, `trademark`, and controlling `documents` contract. | | Exception diagnostics | User-facing CLI, JSON, MCP, file-issue, and local diagnostic output must not echo raw `ex.Message` directly. Route exception prose through `CommandErrorWriter.FormatSanitizedExceptionMessage`, `DiagnosticSanitizer.ForMessage`, or an existing bounded `DiagnosticRedactor` helper, and use stable error codes/categories when the message is not needed for recovery. Intentional broad catches should match the `risky-code/broad-exception-catch` taxonomy and normalize to bounded diagnostics, private best-effort suppression, or a documented fallback. | -| Shell completions | Generated shell completion scripts include a comment with the `cdidx` version that produced them. When command or flag schema changes, update completion tests and keep the README guidance that installed completions should be regenerated after upgrades. | +| Shell completions | Generated shell completion scripts include a comment with the `cdidx` version that produced them. Completion candidates come from `CliFlagSchema`: `ValueKind` / `CommandValueKinds` select contextual path, project, repository, language, and symbol-kind behavior; `ValueDomain` / `CommandValueDomains` define exhaustive finite choices; and `SupplementalCompletionValues` preserves real reserved literals for mixed inputs such as path-or-`github`. Display placeholders such as `` are metavariables and must never be parsed into candidates. When command or flag schema changes, update completion tests and keep the README guidance that installed completions should be regenerated after upgrades. | | Target frameworks | The production CLI and NuGet tool packaging target `net8.0`. The test project multi-targets `net8.0;net9.0`, and CI runs the test suite on both frameworks across Linux, Windows, and macOS. Use a .NET SDK that can restore and run both target frameworks when validating the full CI-equivalent test matrix. | | SDK selection | `global.json` pins the repository SDK to `9.0.301` with `rollForward` disabled. CI installs both `8.0.413` and `9.0.301` explicitly: `8.0.413` provides the `net8.0` runtime lane, while `9.0.301` is the selected SDK for restore, build, test, publish, and changelog validation. When rolling SDKs, update `global.json`, every `actions/setup-dotnet` version list, the Docker build image, and this guide together. | | GitHub Actions policy | Workflows pin hosted runners to versioned labels (`ubuntu-24.04`, `windows-2022`, `macos-14`), keep the top-level `contents` permission read-only by default, limit `continue-on-error` to failure-path diagnostic artifact upload, give every upload artifact explicit retention, bound every artifact download by pattern and path, and scope cache keys to workflow + runner OS + `packages.lock.json` / `global.json` without broad restore-key fallbacks. `CiWorkflowTests.GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPolicy` enforces this checklist. | @@ -3429,7 +3429,7 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま | `cdidx validate` | replacement character、BOM、NUL byte、混在改行、UTF-16 BOM、非 UTF-8 らしい内容など、indexed content の問題を user-facing に検査する integrity scan です。validation issue の種別や filter を追加する場合は、CLI usage、README entry、help summary を同期してください。 | | `cdidx doctor` | support request 向けにコピーしやすい environment summary です。既定では redacted に保ち、secret 風の `CDIDX_*` 値は出力しないでください。新しい diagnostic field は issue triage に使える程度に安定したものだけにします。full environment inventory の filter(`--env-domain`、`--env-category`、`--env-sensitivity`)は大文字小文字を区別しない完全一致で AND 合成し、filtered JSON summary は global catalog ではなく返却 inventory を表します。`--max-json-bytes` は `--json --env-inventory=full` とだけ組み合わせ、serialize した UTF-8 文書と改行を数え、上限を超える成功文書の代わりに structured usage error を返します。`github` block は `proxy_default_credentials` を `enabled` / `disabled` として出力し、bounded な `max_request_timeout_s` も出します。proxy credential material や raw secret value は出力しないでください。`license --json` は version 付きの `license`、`commercial_use`、`trademark`、controlling `documents` contract を返します。 | | 例外診断 | user-facing な CLI / JSON / MCP / file issue / local diagnostic output では raw `ex.Message` を直接 echo しないでください。例外の prose は `CommandErrorWriter.FormatSanitizedExceptionMessage`、`DiagnosticSanitizer.ForMessage`、または既存の bounded な `DiagnosticRedactor` helper を通し、回復に message が不要な場合は安定した error code/category を使ってください。意図的に残す broad catch は `risky-code/broad-exception-catch` taxonomy に沿い、bounded diagnostic、private な best-effort suppression、または documented fallback に正規化してください。 | -| shell completion | 生成された shell completion script には、生成元の `cdidx` version comment が含まれます。command や flag の schema を変えた場合は completion test を更新し、upgrade 後に installed completion を再生成する README guidance も保ってください。 | +| shell completion | 生成された shell completion script には、生成元の `cdidx` version comment が含まれます。completion candidate は `CliFlagSchema` を基準にし、`ValueKind` / `CommandValueKinds` が path、project、repository、language、symbol kind の文脈別動作を選び、`ValueDomain` / `CommandValueDomains` は網羅的な有限候補を定義し、`SupplementalCompletionValues` は path または `github` のような混合入力で実在する予約 literal を維持します。`` のような表示用 placeholder は metavariable であり、候補へ分解してはいけません。command や flag の schema を変えた場合は completion test を更新し、upgrade 後に installed completion を再生成する README guidance も保ってください。 | | target framework | 製品版 CLI と NuGet tool packaging は `net8.0` を対象にしています。test project は `net8.0;net9.0` の multi-target で、CI は Linux、Windows、macOS の各 lane で両方の framework に対して test suite を実行します。CI 相当の full matrix を検証する場合は、両方の target framework を restore / 実行できる .NET SDK を使ってください。 | | SDK selection | `global.json` は repository SDK を `9.0.301` に固定し、`rollForward` を無効化します。CI は `8.0.413` と `9.0.301` を明示的に install します。`8.0.413` は `net8.0` runtime lane を提供し、`9.0.301` は restore、build、test、publish、changelog 検証で選択される SDK です。SDK を更新する場合は、`global.json`、すべての `actions/setup-dotnet` version list、Docker build image、この guide を同じ変更で更新してください。 | | GitHub Actions policy | workflow は hosted runner を version 付き label(`ubuntu-24.04`、`windows-2022`、`macos-14`)に固定し、top-level の `contents` permission は既定で read-only に保ちます。`continue-on-error` は failure path の diagnostic artifact upload に限定し、すべての upload artifact に明示的な retention を付け、artifact download は pattern と path で境界を絞ります。cache key は workflow + runner OS + `packages.lock.json` / `global.json` に scope し、広い restore-key fallback は使いません。`CiWorkflowTests.GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPolicy` がこの checklist を強制します。 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f5f9b2c57..875a09eae 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -181,6 +181,13 @@ Command-specific `--format` values, search origin filters, and `--result-kind` values come from the same registry as command help and runtime validation. For example, audit completion includes `sarif`, and search completion includes `schema_description` and `unknown`. +Completion behavior uses canonical value-kind metadata rather than display +metavariables: finite domains are suggested, while free-form placeholders such +as `` remain help text and are never emitted as literal candidates. +Path/project options use shell file completion where supported; repository and +free-text options accept user input without invented placeholder choices. +Mixed options retain real reserved values—for example, `--open-issues` suggests +`github` alongside file completion without suggesting `path` or `github:owner/name`. Install the script in the startup file or completion directory for your shell: @@ -3527,6 +3534,13 @@ read-write で mount してください。read-only query container では、fre command 固有の `--format` 値、search origin filter、`--result-kind` 値は command help と runtime validation と同じ registry から生成されます。たとえば audit の補完には `sarif`、search の補完には `schema_description` と `unknown` が含まれます。 +補完動作は表示用 metavariable ではなく canonical な value kind metadata を使います。 +有限 domain の値だけを提示し、`` のような自由入力 placeholder は help 表示 +だけに使われ、literal 候補にはなりません。path / project 系 option は対応 shell で +file completion を使い、repository / free-text option は架空の placeholder 候補を出さずに +ユーザー入力を受け付けます。混合型 option は実在する予約値を維持し、たとえば +`--open-issues` は file completion と併せて `github` を提示しますが、`path` や +`github:owner/name` は literal 候補にしません。 利用中の shell の startup file または completion directory にスクリプトを インストールしてください。 @@ -4418,6 +4432,13 @@ completion を使います。 command 固有の `--format` 値、search origin filter、`--result-kind` 値は command help と runtime validation と同じ registry から生成されます。たとえば audit の補完には `sarif`、search の補完には `schema_description` と `unknown` が含まれます。 +補完動作は表示用 metavariable ではなく canonical な value kind metadata を使います。 +有限 domain の値だけを提示し、`` のような自由入力 placeholder は help 表示 +だけに使われ、literal 候補にはなりません。path / project 系 option は対応 shell で +file completion を使い、repository / free-text option は架空の placeholder 候補を出さずに +ユーザー入力を受け付けます。混合型 option は実在する予約値を維持し、たとえば +`--open-issues` は file completion と併せて `github` を提示しますが、`path` や +`github:owner/name` は literal 候補にしません。 使っている shell の startup file または completion directory に保存してください: diff --git a/changelog.d/unreleased/4902.fixed.md b/changelog.d/unreleased/4902.fixed.md new file mode 100644 index 000000000..d5da7b44a --- /dev/null +++ b/changelog.d/unreleased/4902.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4902 +affected: + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleCompletionRenderer.cs + - tests/CodeIndex.Tests/CliFlagSchemaTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Shell completions no longer offer display metavariables as literal option values (#4902)** — canonical option metadata now separates finite domains, supplemental reserved values, paths, projects, repositories, dynamic language/kind sources, and free text from help placeholders, so Bash, Zsh, Fish, and PowerShell retain real choices such as `github` plus contextual path completion without suggesting literals such as `name` or `path`. + +## 日本語 + +- **shell 補完が表示用 metavariable を literal な option 値として提示しなくなりました (#4902)** — canonical option metadata で有限 domain、補助的な予約値、path、project、repository、動的な language / kind source、自由入力を help placeholder から分離し、Bash、Zsh、Fish、PowerShell は `github` などの実在する候補と文脈に応じた path 補完を維持しながら、`name` や `path` のような literal を候補に出さなくなりました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 96b9fff8c..18db63cd1 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -15,6 +15,19 @@ internal enum CliOptionSafety StrictFailure, } +internal enum CliOptionValueKind +{ + FreeText = 0, + Finite, + FilePath, + DirectoryPath, + PathPattern, + Project, + Repository, + Language, + SymbolKind, +} + internal sealed record CliOptionValueDomain { public required IReadOnlyList CanonicalValues { get; init; } @@ -22,6 +35,7 @@ internal sealed record CliOptionValueDomain new Dictionary(StringComparer.Ordinal); public bool NormalizeDashAndUnderscore { get; init; } public string? DisplayPlaceholder { get; init; } + public string CompletionLabel { get; init; } = "value"; public string ValuePlaceholder => DisplayPlaceholder ?? $"<{string.Join('|', CanonicalValues)}>"; @@ -72,7 +86,7 @@ internal sealed record CliFlag public required string Name { get; init; } public string? ShortName { get; init; } public string? ValuePlaceholder { get; init; } - public bool PlaceholderDefinesValueDomain { get; init; } = true; + public CliOptionValueKind ValueKind { get; init; } = CliOptionValueKind.FreeText; public required string Description { get; init; } public IReadOnlyDictionary CommandDescriptions { get; init; } = new Dictionary(StringComparer.Ordinal); @@ -82,6 +96,9 @@ internal sealed record CliFlag new Dictionary(StringComparer.Ordinal); public IReadOnlyDictionary CommandValuePlaceholders { get; init; } = new Dictionary(StringComparer.Ordinal); + public IReadOnlyDictionary CommandValueKinds { get; init; } = + new Dictionary(StringComparer.Ordinal); + public IReadOnlyList SupplementalCompletionValues { get; init; } = []; public IReadOnlyDictionary> CompletionSubcommands { get; init; } = new Dictionary>(StringComparer.Ordinal); @@ -134,13 +151,16 @@ public string GetDescription(string command) => { if (CommandValueDomains.TryGetValue(command, out var commandDomain)) return commandDomain; - if (CommandValuePlaceholders.TryGetValue(command, out var commandPlaceholder)) - return TryBuildPlaceholderDomain(commandPlaceholder); - if (ValueDomain is not null) - return ValueDomain; - return PlaceholderDefinesValueDomain - ? TryBuildPlaceholderDomain(ValuePlaceholder) - : null; + return ValueDomain; + } + + public CliOptionValueKind GetValueKind(string command) + { + if (CommandValueKinds.TryGetValue(command, out var commandValueKind)) + return commandValueKind; + return GetValueDomain(command) is null + ? ValueKind + : CliOptionValueKind.Finite; } public string? GetValuePlaceholder(string command) @@ -149,24 +169,6 @@ public string GetDescription(string command) => return commandPlaceholder; return GetValueDomain(command)?.ValuePlaceholder ?? ValuePlaceholder; } - - private static CliOptionValueDomain? TryBuildPlaceholderDomain(string? placeholder) - { - if (placeholder is null - || placeholder.Length < 3 - || placeholder[0] != '<' - || placeholder[^1] != '>' - || !placeholder.Contains('|')) - { - return null; - } - - return new CliOptionValueDomain - { - CanonicalValues = placeholder[1..^1] - .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), - }; - } } internal static class CliFlagSchema @@ -404,11 +406,11 @@ private static IReadOnlyList BuildAll() { return new List { - new() { Name = "--db", ValuePlaceholder = "", Description = "Database path", PrimaryCommands = Set(DbPathCommands) }, + new() { Name = "--db", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Database path", PrimaryCommands = Set(DbPathCommands) }, new() { Name = "--read-only", Description = "Open the query database as immutable read-only storage", PrimaryCommands = Set(ReadOnlyDbCommands), Safety = CliOptionSafety.ReadOnly }, new() { Name = "--immutable", Description = "Alias for --read-only", PrimaryCommands = Set(ReadOnlyDbCommands), Safety = CliOptionSafety.ReadOnly }, - new() { Name = "--workspace-db", ValuePlaceholder = "", Description = "Additional workspace member database path for dependency aggregation; repeat up to 7 distinct additional DBs", PrimaryCommands = Set(WorkspaceDbCommands) }, - new() { Name = "--data-dir", ValuePlaceholder = "", Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", PrimaryCommands = Set(DataDirCommands), Safety = CliOptionSafety.Scope }, + new() { Name = "--workspace-db", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Additional workspace member database path for dependency aggregation; repeat up to 7 distinct additional DBs", PrimaryCommands = Set(WorkspaceDbCommands) }, + new() { Name = "--data-dir", ValuePlaceholder = "", ValueKind = CliOptionValueKind.DirectoryPath, Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", PrimaryCommands = Set(DataDirCommands), Safety = CliOptionSafety.Scope }, new() { Name = "--json", Description = "JSON output; search/symbols/files/validate also accept --json=array for a single JSON array", PrimaryCommands = Set(JsonCommands.Concat(["hooks"]).ToArray()) }, new() { Name = "--json-summary", Description = "Batch: emit one typed result/error record per input plus a final summary", PrimaryCommands = Set("batch") }, new() { Name = "--include-raw-streams", Description = "Batch JSON-summary: attach bounded child stdout/stderr to failed records", PrimaryCommands = Set("batch") }, @@ -420,35 +422,35 @@ private static IReadOnlyList BuildAll() new() { Name = "--format", Description = CliOutputFormatCapabilities.FormatDescription, PrimaryCommands = Set(FormatCommands), CommandValueDomains = OutputFormatValueDomains }, new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", PrimaryCommands = Set(AllCommands.ToArray()), TopLevel = true }, new() { Name = "--silent", Description = "Alias for --quiet", PrimaryCommands = Set(AllCommands.ToArray()), TopLevel = true }, - new() { Name = "--color", ValuePlaceholder = "", Description = "Color output: `auto` (default), `always`, or `never`; the flag overrides CLICOLOR_FORCE / NO_COLOR / CLICOLOR and TTY detection", PrimaryCommands = Set(), TopLevel = true }, - new() { Name = "--palette", ValuePlaceholder = "", Description = "ANSI palette: `basic`, `256`, or `truecolor`; the flag overrides CDIDX_COLOR_PALETTE and COLORTERM / TERM detection", PrimaryCommands = Set(), TopLevel = true }, + new() { Name = "--color", ValueDomain = Values(["auto", "always", "never"]) with { CompletionLabel = "mode" }, Description = "Color output: `auto` (default), `always`, or `never`; the flag overrides CLICOLOR_FORCE / NO_COLOR / CLICOLOR and TTY detection", PrimaryCommands = Set(), TopLevel = true }, + new() { Name = "--palette", ValueDomain = Values(["basic", "256", "truecolor"]) with { CompletionLabel = "palette" }, Description = "ANSI palette: `basic`, `256`, or `truecolor`; the flag overrides CDIDX_COLOR_PALETTE and COLORTERM / TERM detection", PrimaryCommands = Set(), TopLevel = true }, new() { Name = "--ascii", Description = "Use ASCII progress glyphs", PrimaryCommands = Set(), TopLevel = true }, - new() { Name = "--metrics", ValuePlaceholder = "", Description = "Append command metrics JSONL to a file", PrimaryCommands = Set(), TopLevel = true }, + new() { Name = "--metrics", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Append command metrics JSONL to a file", PrimaryCommands = Set(), TopLevel = true }, new() { Name = "--debug-unsafe", Description = "Allow raw debug dumps when CDIDX_DEBUG=unsafe is also set", PrimaryCommands = Set(), TopLevel = true }, new() { Name = "--strict-version", Description = "Fail when the workspace version pin does not match this binary", PrimaryCommands = Set(), TopLevel = true }, - new() { Name = "--log-format", ValuePlaceholder = "", Description = "Persistent stderr log format", PrimaryCommands = Set(), TopLevel = true }, + new() { Name = "--log-format", ValueDomain = Values(["text", "json"]) with { CompletionLabel = "format" }, Description = "Persistent stderr log format", PrimaryCommands = Set(), TopLevel = true }, new() { Name = "--log-retain-count", ValuePlaceholder = "", Description = "Persistent stderr log file retention count", PrimaryCommands = Set(), TopLevel = true }, new() { Name = "--log-max-size-mb", ValuePlaceholder = "", Description = "Persistent stderr log rotation size cap in MiB", PrimaryCommands = Set(), TopLevel = true }, new() { Name = "--profile", Description = "Emit SQL timing and EXPLAIN QUERY PLAN profile JSON after the normal result", PrimaryCommands = Set(ProfileCommands) }, new() { Name = "--verbose", Description = "Emit query debug diagnostics to stderr, or _debug JSON when combined with --json", PrimaryCommands = Set(VerboseQueryCommands.Concat(new[] { "index" }).ToArray()) }, - new() { Name = "--notify", ValuePlaceholder = "", Description = "Signal long index completion; desktop currently emits OSC 9 terminal notification", PrimaryCommands = Set("index") }, + new() { Name = "--notify", ValueDomain = Values(["auto", "bell", "osc9", "desktop", "none"]), Description = "Signal long index completion; desktop currently emits OSC 9 terminal notification", PrimaryCommands = Set("index") }, new() { Name = "--slow-query-ms", ValuePlaceholder = "", Description = "Log profiled SQL statements at or above this millisecond threshold", PrimaryCommands = Set(ProfileCommands) }, - new() { Name = "--trace", ValuePlaceholder = "", Description = "Emit one structured JSON query trace line to stderr or a daily log file", PrimaryCommands = Set(TraceCommands), Safety = CliOptionSafety.Diagnostics }, + new() { Name = "--trace", ValueDomain = Values(["none", "stderr", "file"]), Description = "Emit one structured JSON query trace line to stderr or a daily log file", PrimaryCommands = Set(TraceCommands), Safety = CliOptionSafety.Diagnostics }, new() { Name = "--limit", ValuePlaceholder = "", Description = "Max results", PrimaryCommands = Set(LimitCapableCommands.Concat(new[] { "suggestions" }).ToArray()) }, new() { Name = "--max-results", ValuePlaceholder = "", Description = "Search alias for --limit", PrimaryCommands = Set("search") }, new() { Name = "--top", ValuePlaceholder = "", Description = "Max results", PrimaryCommands = Set(LimitCapableCommands) }, new() { Name = "--offset", ValuePlaceholder = "", Description = "Suggestions: skip this many filtered rows before output", PrimaryCommands = Set("suggestions") }, - new() { Name = "--lang", ValuePlaceholder = "", Description = "Filter by a registered language, alias, or extension-like spelling", PrimaryCommands = Set(LangCapableCommands), AlsoAcceptedBy = Set("suggestions") }, + new() { Name = "--lang", ValuePlaceholder = "", ValueKind = CliOptionValueKind.Language, Description = "Filter by a registered language, alias, or extension-like spelling", PrimaryCommands = Set(LangCapableCommands), AlsoAcceptedBy = Set("suggestions") }, new() { Name = "--allow-unknown-lang", Description = "Allow an unregistered plugin language ID and preserve its exact spelling", PrimaryCommands = Set(LangCapableCommands) }, - new() { Name = "--language", ValuePlaceholder = "", Description = "Suggestions: filter by language; languages: look up one language by canonical name or recognized language spelling", PrimaryCommands = Set("suggestions", "languages") }, + new() { Name = "--language", ValuePlaceholder = "", ValueKind = CliOptionValueKind.Language, Description = "Suggestions: filter by language; languages: look up one language by canonical name or recognized language spelling", PrimaryCommands = Set("suggestions", "languages") }, new() { Name = "--extension", ValuePlaceholder = "", Description = "Languages: look up language support by extension or recognized filename pattern", PrimaryCommands = Set(LanguagesFilterCommands) }, new() { Name = "--alias", ValuePlaceholder = "", Description = "Languages: look up language support by display alias", PrimaryCommands = Set(LanguagesFilterCommands) }, - new() { Name = "--path", ValuePlaceholder = "", Description = "Path filter", PrimaryCommands = Set(PathFilterCommands) }, + new() { Name = "--path", ValuePlaceholder = "", ValueKind = CliOptionValueKind.PathPattern, Description = "Path filter", PrimaryCommands = Set(PathFilterCommands) }, new() { Name = "--project", ValuePlaceholder = "", - PlaceholderDefinesValueDomain = false, + ValueKind = CliOptionValueKind.Project, Description = "Filter to a .sln/.csproj project", CommandDescriptions = new Dictionary(StringComparer.Ordinal) { @@ -459,25 +461,29 @@ private static IReadOnlyList BuildAll() { ["hooks"] = "", }, + CommandValueKinds = new Dictionary(StringComparer.Ordinal) + { + ["hooks"] = CliOptionValueKind.DirectoryPath, + }, Safety = CliOptionSafety.Scope, }, - new() { Name = "--solution", ValuePlaceholder = "", Description = "Solution file used to resolve --project", PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index" }).ToArray()), Safety = CliOptionSafety.Scope }, - new() { Name = "--exclude-path", ValuePlaceholder = "", Description = "Exclude path", PrimaryCommands = Set(ExcludeFilterCommands) }, + new() { Name = "--solution", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Solution file used to resolve --project", PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index" }).ToArray()), Safety = CliOptionSafety.Scope }, + new() { Name = "--exclude-path", ValuePlaceholder = "", ValueKind = CliOptionValueKind.PathPattern, Description = "Exclude path", PrimaryCommands = Set(ExcludeFilterCommands) }, new() { Name = "--exclude-tests", Description = "Exclude tests", PrimaryCommands = Set(ExcludeFilterCommands) }, new() { Name = "--include-generated", Description = "Include generated files", PrimaryCommands = Set(ExcludeFilterCommands) }, new() { Name = "--generated", Description = "Files alias for --include-generated", PrimaryCommands = Set("files") }, - new() { Name = "--kind", ValuePlaceholder = "", Description = "Filter by kind", PrimaryCommands = Set(KindCommands) }, - new() { Name = "--severity", ValuePlaceholder = "", Description = "Validate: filter validation issues by severity", PrimaryCommands = Set(SeverityCommands) }, + new() { Name = "--kind", ValuePlaceholder = "", ValueKind = CliOptionValueKind.SymbolKind, Description = "Filter by kind", PrimaryCommands = Set(KindCommands) }, + new() { Name = "--severity", ValueDomain = Values(["info", "warning", "error"]), Description = "Validate: filter validation issues by severity", PrimaryCommands = Set(SeverityCommands) }, new() { Name = "--visibility", ValuePlaceholder = "", Description = "Filter by symbol visibility", PrimaryCommands = Set(VisibilityCommands) }, new() { Name = "--exclude-visibility", ValuePlaceholder = "", Description = "Exclude symbol visibility", PrimaryCommands = Set(VisibilityCommands) }, new() { Name = "--by-bucket", Description = "Unused: include per-bucket grouped result arrays in JSON output, or count/representative summaries with --compact", PrimaryCommands = Set(ByBucketCommands) }, new() { Name = "--bucket", ValuePlaceholder = "", Description = "Unused: return only one confidence bucket", PrimaryCommands = Set(UnusedFilterCommands) }, - new() { Name = "--confidence", ValuePlaceholder = "", Description = "Unused: alias for --min-confidence", PrimaryCommands = Set(UnusedFilterCommands) }, - new() { Name = "--min-confidence", ValuePlaceholder = "", Description = "Unused: return symbols at or above this confidence", PrimaryCommands = Set(UnusedFilterCommands) }, + new() { Name = "--confidence", ValueDomain = Values(["medium", "low"]), Description = "Unused: alias for --min-confidence", PrimaryCommands = Set(UnusedFilterCommands) }, + new() { Name = "--min-confidence", ValueDomain = Values(["medium", "low"]), Description = "Unused: return symbols at or above this confidence", PrimaryCommands = Set(UnusedFilterCommands) }, new() { Name = "--actionable", Description = "Unused: preset for private medium-confidence cleanup candidates", PrimaryCommands = Set(UnusedFilterCommands) }, new() { Name = "--all", Description = "goto: return all matching LSP locations; find: search all indexed files instead of requiring --path; unused: include low-confidence contract-domain candidates suppressed by default", PrimaryCommands = Set(AllResultCommands) }, new() { Name = "--line-scan-limit", ValuePlaceholder = "", Description = "Find: override the --all indexed-line scan cap", PrimaryCommands = Set("find") }, - new() { Name = "--rank-by", ValuePlaceholder = "", Description = "Rank callers/callees by the selected primary recipe, then exact-name relevance, production/test/docs path category, and stable location/name tie-breakers", PrimaryCommands = Set(RankByCommands) }, + new() { Name = "--rank-by", ValueDomain = Values(["weighted", "count", "kind"]), Description = "Rank callers/callees by the selected primary recipe, then exact-name relevance, production/test/docs path category, and stable location/name tie-breakers", PrimaryCommands = Set(RankByCommands) }, new() { Name = "--sort", ValuePlaceholder = "", Description = "Symbols/outline: order audit output by a ranking signal; outline also accepts source, kind, references, size, complexity, path, and name", PrimaryCommands = Set(SymbolSortCommands) }, new() { Name = "--raw-kinds", Description = "Show raw reference kinds instead of logical graph kinds", PrimaryCommands = Set(RawKindsCommands) }, new() { Name = "--include-qualified-common-calls", Description = "Include unresolved receiver/type-qualified C# calls with common member names", PrimaryCommands = Set("references", "callers", "callees") }, @@ -490,7 +496,13 @@ private static IReadOnlyList BuildAll() new() { Name = "--since", ValuePlaceholder = "", Description = "Filter by modified-since timestamp", PrimaryCommands = Set(SinceCommands) }, new() { Name = "--bytes", Description = "Files: sort by size and show raw byte counts in human output; map: show raw byte counts", PrimaryCommands = Set(ByteFormatCommands) }, new() { Name = "--min-entrypoint-confidence", ValuePlaceholder = "<0.0..1.0>", Description = "Map: omit entrypoint candidates below this confidence", PrimaryCommands = Set(EntrypointConfidenceCommands) }, - new() { Name = "--sections", ValuePlaceholder = "", Description = "Map: comma-separated response sections to include, or list to discover sections", PrimaryCommands = Set(MapSectionCommands) }, + new() + { + Name = "--sections", + ValueDomain = Values(["summary,tree,languages,hotspots,metrics", "list"]), + Description = "Map: comma-separated response sections to include, or list to discover sections", + PrimaryCommands = Set(MapSectionCommands), + }, new() { Name = "--summary-only", Description = "Map/Diff/Recipes/Audit/Files/Symbols/Deps/Hotspots/Languages: return only aggregate summary fields where supported", PrimaryCommands = Set(SummaryOnlyCommands) }, new() { Name = "--data-only", Description = "Diff: include indexed data and schema in identity while excluding readiness/provenance and volatile telemetry", PrimaryCommands = Set("diff") }, new() { Name = "--include-telemetry", Description = "Diff: include volatile index-run and FTS maintenance telemetry in identity", PrimaryCommands = Set("diff") }, @@ -500,33 +512,33 @@ private static IReadOnlyList BuildAll() new() { Name = "--symbol", ValuePlaceholder = "", Description = "Deps: keep only edges with an exact sampled symbol name", PrimaryCommands = Set("deps") }, new() { Name = "--symbol-family", ValuePlaceholder = "", Description = "Deps: keep only edges with a sampled symbol prefix/family", PrimaryCommands = Set("deps") }, new() { Name = "--indexed-only", Description = "Languages: list only languages present in the current index", PrimaryCommands = Set(LanguagesFilterCommands) }, - new() { Name = "--capability", ValuePlaceholder = "", Description = "Languages: filter by language capability or capability gap", PrimaryCommands = Set(LanguagesFilterCommands) }, + new() { Name = "--capability", ValueDomain = Values(["all", "none", "graph", "references", "symbols", "missing-any", "missing-graph", "missing-references", "missing-symbols", "search-only"]), Description = "Languages: filter by language capability or capability gap", PrimaryCommands = Set(LanguagesFilterCommands) }, new() { Name = "--query", ValuePlaceholder = "", Description = "Literal query", PrimaryCommands = Set(QueryCommands) }, - new() { Name = "--recipe", ValuePlaceholder = "", Description = "Search: run a built-in audit recipe query set, optionally selecting one child query", PrimaryCommands = Set("search") }, + new() { Name = "--recipe", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FreeText, Description = "Search: run a built-in audit recipe query set, optionally selecting one child query", PrimaryCommands = Set("search") }, new() { Name = "--include-query", ValuePlaceholder = "", Description = "Search recipe: include one child query; repeat or comma-separate values", PrimaryCommands = Set("search") }, new() { Name = "--exclude-query", ValuePlaceholder = "", Description = "Search recipe: exclude one child query; repeat or comma-separate values", PrimaryCommands = Set("search") }, new() { Name = "--list-recipes", Description = "Search: list built-in audit recipes", PrimaryCommands = Set("search") }, new() { Name = "--names", Description = "Recipes: emit only deterministic recipe names", PrimaryCommands = Set("search", "recipes") }, - new() { Name = "--audit-scope", ValuePlaceholder = "", Description = "Search/Unused: use production source defaults or include all indexed paths", PrimaryCommands = Set("search", "unused") }, + new() { Name = "--audit-scope", ValueDomain = Values(["source", "all"]), Description = "Search/Unused: use production source defaults or include all indexed paths", PrimaryCommands = Set("search", "unused") }, new() { Name = "--source-only", Description = "Search: alias for --audit-scope source on ad hoc and named searches", PrimaryCommands = Set("search") }, new() { Name = "--show-excluded", Description = "Search recipes: include effective scope and exclusion diagnostics in recipe output", PrimaryCommands = Set("search") }, new() { Name = "--named-query", ValuePlaceholder = "=", Description = "Search: add one named ad hoc batch query", PrimaryCommands = Set("search") }, - new() { Name = "--open-issues", ValuePlaceholder = "", Description = "Preflight issue drafts against issue JSON or GitHub issues", PrimaryCommands = Set("search", "map", "suggestions") }, - new() { Name = "--repo", ValuePlaceholder = "", Description = "Issue-drafts: GitHub repository for --open-issues github", PrimaryCommands = Set("search", "map", "suggestions") }, - new() { Name = "--issue-state", ValuePlaceholder = "", Description = "Issue-drafts: GitHub issue history state to inspect", PrimaryCommands = Set("search", "map", "suggestions") }, - new() { Name = "--duplicate-confidence", ValuePlaceholder = "", Description = "Issue-drafts: preset duplicate-preflight match threshold", PrimaryCommands = Set("search", "suggestions") }, + new() { Name = "--open-issues", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, SupplementalCompletionValues = ["github"], Description = "Preflight issue drafts against issue JSON or GitHub issues", PrimaryCommands = Set("search", "map", "suggestions") }, + new() { Name = "--repo", ValuePlaceholder = "", ValueKind = CliOptionValueKind.Repository, Description = "Issue-drafts: GitHub repository for --open-issues github", PrimaryCommands = Set("search", "map", "suggestions") }, + new() { Name = "--issue-state", ValueDomain = Values(["open", "closed", "all"]), Description = "Issue-drafts: GitHub issue history state to inspect", PrimaryCommands = Set("search", "map", "suggestions") }, + new() { Name = "--duplicate-confidence", ValueDomain = Values(["low", "medium", "high"]), Description = "Issue-drafts: preset duplicate-preflight match threshold", PrimaryCommands = Set("search", "suggestions") }, new() { Name = "--duplicate-threshold", ValuePlaceholder = "", Description = "Issue-drafts: explicit duplicate-preflight minimum score from 0 to 1", PrimaryCommands = Set("search", "suggestions") }, new() { Name = "--issue-title", ValuePlaceholder = "", Description = "Search issue-drafts: override the title for an ad hoc search draft", PrimaryCommands = Set("search") }, new() { Name = "--issue-label", ValuePlaceholder = "<label>", Description = "Search issue-drafts: add a label hint; repeat or comma-separate values", PrimaryCommands = Set("search") }, new() { Name = "--cursor", ValuePlaceholder = "<cursor>", Description = "Opaque continuation cursor returned as next_cursor and bound to its query, options, and index generation; find cursors resume at match boundaries", PrimaryCommands = Set(CursorCommands) }, - new() { Name = "--status", ValuePlaceholder = "<all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>", Description = "Suggestions: filter by suggestion status", PrimaryCommands = Set("suggestions") }, - new() { Name = "--category", ValuePlaceholder = "<symbol_extraction|reference_extraction|search_ranking|language_support|output_format|crash_report|unexpected_error|other>", Description = "Suggestions: filter by category", PrimaryCommands = Set("suggestions") }, + new() { Name = "--status", ValueDomain = Values(["all", "draft", "submitted_pending_triage", "open_in_upstream", "resolved_in_upstream", "wont_fix", "duplicate", "superseded", "submitted", "unsubmitted"]), Description = "Suggestions: filter by suggestion status", PrimaryCommands = Set("suggestions") }, + new() { Name = "--category", ValueDomain = Values(["symbol_extraction", "reference_extraction", "search_ranking", "language_support", "output_format", "crash_report", "unexpected_error", "other"]), Description = "Suggestions: filter by category", PrimaryCommands = Set("suggestions") }, new() { Name = "--agent", ValuePlaceholder = "<agent>", Description = "Suggestions: filter by agent", PrimaryCommands = Set("suggestions") }, new() { Name = "--actor", ValuePlaceholder = "<name>", Description = "Suggestions update: actor recorded for a manual status transition", PrimaryCommands = Set("suggestions") }, new() { Name = "--reason", ValuePlaceholder = "<text>", Description = "Suggestions update: optional reason recorded for a manual status transition", PrimaryCommands = Set("suggestions") }, new() { Name = "--description", ValuePlaceholder = "<text>", Description = "Suggestions add: local suggestion description", PrimaryCommands = Set("suggestions") }, new() { Name = "--title", ValuePlaceholder = "<title>", Description = "Suggestions add: optional issue-draft title source", PrimaryCommands = Set("suggestions") }, - new() { Name = "--evidence-path", ValuePlaceholder = "<path>", Description = "Suggestions add: repository-relative evidence path; repeat for multiple paths", PrimaryCommands = Set("suggestions") }, + new() { Name = "--evidence-path", ValuePlaceholder = "<path>", ValueKind = CliOptionValueKind.FilePath, Description = "Suggestions add: repository-relative evidence path; repeat for multiple paths", PrimaryCommands = Set("suggestions") }, new() { Name = "--overwrite", Description = "Portable archive, report bundle, or suggestions export: atomically replace an existing output file", PrimaryCommands = Set("export", "report", "suggestions") }, new() { Name = "--restore", ValuePlaceholder = "<id>", Description = "DB restore-backups: select a managed backup ID to validate and restore atomically", PrimaryCommands = Set("db") }, new() { Name = "--no-backup", Description = "Import/DB restore: explicitly skip creating managed rollback material before replacement", PrimaryCommands = Set("import", "db") }, @@ -548,9 +560,9 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--reject-before", ValuePlaceholder = "<query>", Description = "Search: reject primary matches with a nearby guard query before them", PrimaryCommands = Set("search") }, new() { Name = "--reject-after", ValuePlaceholder = "<query>", Description = "Search: reject primary matches with a nearby guard query after them", PrimaryCommands = Set("search") }, new() { Name = "--guard-window", ValuePlaceholder = "<n>", Description = "Search: line window for require/reject guard queries", PrimaryCommands = Set("search") }, - new() { Name = "--guard-scope", ValuePlaceholder = "<window|same-line>", Description = "Search: evaluate guard queries in the line window or on the same line as the primary match", PrimaryCommands = Set("search") }, - new() { Name = "--unique", ValuePlaceholder = "<path|file|symbol|origin|return-type|subsystem>", Description = "Search/Audit recipes: emit unique aggregation rows", PrimaryCommands = Set("search", "audit") }, - new() { Name = "--count-by", ValuePlaceholder = "<path|file|symbol|origin|return-type|subsystem>", Description = "Search/Audit recipes: count matches grouped by path/file, symbol, origin, enclosing return type, or subsystem", PrimaryCommands = Set("search", "audit") }, + new() { Name = "--guard-scope", ValueDomain = Values(["window", "same-line"]), Description = "Search: evaluate guard queries in the line window or on the same line as the primary match", PrimaryCommands = Set("search") }, + new() { Name = "--unique", ValueDomain = Values(["path", "file", "symbol", "origin", "return-type", "subsystem"]), Description = "Search/Audit recipes: emit unique aggregation rows", PrimaryCommands = Set("search", "audit") }, + new() { Name = "--count-by", ValueDomain = Values(["path", "file", "symbol", "origin", "return-type", "subsystem"]), Description = "Search/Audit recipes: count matches grouped by path/file, symbol, origin, enclosing return type, or subsystem", PrimaryCommands = Set("search", "audit") }, new() { Name = "--origin", Description = "Search: alias for --match-origin; keep only matches from selected origins", PrimaryCommands = Set("search"), ValueDomain = SearchOriginValueDomain }, new() { Name = "--match-origin", Description = "Search: keep only matches from selected origins; repeat or comma-separate values", PrimaryCommands = Set("search"), ValueDomain = SearchOriginValueDomain }, new() { Name = "--exclude-origin", Description = "Search: drop matches from selected origins; repeat or comma-separate values", PrimaryCommands = Set("search"), ValueDomain = SearchOriginValueDomain }, @@ -575,7 +587,7 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--name", ValuePlaceholder = "<name>", Description = "Exact symbol name", PrimaryCommands = Set("symbols") }, new() { Name = "--max-line-width", ValuePlaceholder = "<n>", Description = "Clamp long single-line payloads (0 disables clamping)", PrimaryCommands = Set(MaxLineWidthCommands) }, new() { Name = "--snippet-lines", ValuePlaceholder = "<n>", Description = "Snippet length; graph queries require --body with text/JSON output; issue-drafts accept 0 for path/line-only evidence", PrimaryCommands = Set("search", "audit", "find", "references", "callers", "callees", "impact") }, - new() { Name = "--snippet-focus", ValuePlaceholder = "<leftmost|quality|proximity>", Description = "Search snippet long-line focus mode", PrimaryCommands = Set("search") }, + new() { Name = "--snippet-focus", ValueDomain = Values(["leftmost", "quality", "proximity"]), Description = "Search snippet long-line focus mode", PrimaryCommands = Set("search") }, new() { Name = "--fts", Description = "Raw FTS5 syntax; incompatible with search exact/literal modes", PrimaryCommands = Set("search") }, new() { Name = "--no-dedup", Description = "Show duplicate chunks", PrimaryCommands = Set("search") }, new() { Name = "--no-visibility-rank", Description = "Keep legacy search ranking without symbol visibility weighting", PrimaryCommands = Set("search") }, @@ -596,7 +608,18 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--depth", ValuePlaceholder = "<n>", Description = "Map: cap module depth; impact: deprecated alias for --max-hops", PrimaryCommands = Set("impact", "map") }, new() { Name = "--with-paths", Description = "Impact: include shortest call chains per caller", PrimaryCommands = Set("impact") }, new() { Name = "--reverse", Description = "Reverse direction (show dependents)", PrimaryCommands = Set("deps") }, - new() { Name = "--group-by", ValuePlaceholder = "<file|symbol|origin|return-type|subsystem|statement>", Description = "Search/Audit: group --count rows by file, symbol, origin, enclosing return type, or subsystem; hotspots: symbol/file grouping, with statement only for --lang sql", PrimaryCommands = Set("hotspots", "search", "audit") }, + new() + { + Name = "--group-by", + Description = "Search/Audit: group --count rows by file, symbol, origin, enclosing return type, or subsystem; hotspots: symbol/file grouping, with statement only for --lang sql", + PrimaryCommands = Set("hotspots", "search", "audit"), + CommandValueDomains = new Dictionary<string, CliOptionValueDomain>(StringComparer.Ordinal) + { + ["hotspots"] = Values(["symbol", "file", "statement"]), + ["search"] = Values(["file", "symbol", "origin", "return-type", "subsystem"]), + ["audit"] = Values(["file", "symbol", "origin", "return-type", "subsystem"]), + }, + }, new() { Name = "--group-by-name", Description = "Hotspots: collapse same-name rows; JSON keeps capped paths plus full definition details", PrimaryCommands = Set("hotspots") }, new() { Name = "--check", Description = "Verify status freshness/readiness", PrimaryCommands = Set("status") }, new() { Name = "--config", Description = "Print effective configuration with source attribution", PrimaryCommands = Set("status") }, @@ -605,7 +628,7 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--log-path", Description = "Print the active persistent log directory", PrimaryCommands = Set("status") }, new() { Name = "--check-updates", Description = "Check whether a newer cdidx release is available", PrimaryCommands = Set("status", "upgrade") }, new() { Name = "--check-only", Description = "Upgrade: only report whether an upgrade is available", PrimaryCommands = Set("upgrade") }, - new() { Name = "--channel", ValuePlaceholder = "<stable|latest|prerelease>", Description = "Upgrade: select stable/latest or prerelease releases", PrimaryCommands = Set("upgrade") }, + new() { Name = "--channel", ValueDomain = Values(["stable", "latest", "prerelease"]), Description = "Upgrade: select stable/latest or prerelease releases", PrimaryCommands = Set("upgrade") }, new() { Name = "--prerelease", Description = "Upgrade: select the newest prerelease", PrimaryCommands = Set("upgrade") }, new() { Name = "--version", ValuePlaceholder = "<tag>", Description = "Upgrade: install a specific release tag", PrimaryCommands = Set("upgrade") }, new() { Name = "--integrity-check", Description = "Run PRAGMA integrity_check on the database", PrimaryCommands = Set("db") }, @@ -643,7 +666,7 @@ private static IReadOnlyList<CliFlag> BuildAll() }, Safety = CliOptionSafety.Override, }, - new() { Name = "--duration-format", ValuePlaceholder = "<auto|seconds|hms>", Description = "Index elapsed time display format", PrimaryCommands = Set("index") }, + new() { Name = "--duration-format", ValueDomain = Values(["auto", "seconds", "hms"]), Description = "Index elapsed time display format", PrimaryCommands = Set("index") }, new() { Name = "--max-file-bytes", ValuePlaceholder = "<bytes>", Description = "Override the per-file indexing size limit", PrimaryCommands = Set("index") }, new() { Name = "--max-symbols-per-file", ValuePlaceholder = "<n>", Description = "Skip file content, symbols, and references when one file emits too many symbols (max 50000)", PrimaryCommands = Set("index") }, new() { Name = "--max-references-per-file", ValuePlaceholder = "<n>", Description = "Skip references when one file emits too many references (max 1000000)", PrimaryCommands = Set("index") }, @@ -651,16 +674,16 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--memory-trace", Description = "Include phase memory samples in index JSON output", PrimaryCommands = Set("index") }, new() { Name = "--commits", ValuePlaceholder = "<commit-ref>", Description = "Update files changed in given git commits", PrimaryCommands = Set("index") }, new() { Name = "--changed-between", ValuePlaceholder = "<old-ref> <new-ref>", Description = "Update files changed between two git refs", PrimaryCommands = Set("index") }, - new() { Name = "--files", ValuePlaceholder = "<path>", Description = "Update only the specified files", PrimaryCommands = Set("index") }, + new() { Name = "--files", ValuePlaceholder = "<path>", ValueKind = CliOptionValueKind.FilePath, Description = "Update only the specified files", PrimaryCommands = Set("index") }, new() { Name = "--watch", Description = "Continuous reindex on file changes (rejects --commits / --changed-between / --files / --dry-run)", PrimaryCommands = Set("index") }, new() { Name = "--debounce", ValuePlaceholder = "<ms>", Description = "Watch only: coalesce file events into one update after <ms> of quiet (default 500)", PrimaryCommands = Set("index") }, new() { Name = "--watch-pending-path-limit", ValuePlaceholder = "<n>", Description = "Watch only: changed-path queue limit before full-rescan fallback", PrimaryCommands = Set("index") }, - new() { Name = "--output", ShortName = "-o", ValuePlaceholder = "<path>", Description = "Report bundle or suggestions export output path", PrimaryCommands = Set("report", "suggestions") }, + new() { Name = "--output", ShortName = "-o", ValuePlaceholder = "<path>", ValueKind = CliOptionValueKind.FilePath, Description = "Report bundle or suggestions export output path", PrimaryCommands = Set("report", "suggestions") }, new() { Name = "--redact-paths", Description = "Redact machine-specific paths (the default for recovery/config JSON and reports)", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report"]).ToArray()) }, new() { Name = "--no-log", Description = "Exclude global tool log from bundle", PrimaryCommands = Set("report") }, new() { Name = "--include-args", Description = "Include args in bundle log", PrimaryCommands = Set("report") }, new() { Name = "--log-lines", ValuePlaceholder = "<n>", Description = "Number of log lines to include in bundle (clamped to 2000)", PrimaryCommands = Set("report") }, - new() { Name = "--transport", ValuePlaceholder = "<stdio|http>", Description = "MCP transport", PrimaryCommands = Set("mcp") }, + new() { Name = "--transport", ValueDomain = Values(["stdio", "http"]) with { CompletionLabel = "transport" }, Description = "MCP transport", PrimaryCommands = Set("mcp") }, new() { Name = "--http-listen", ValuePlaceholder = "<host:port>", Description = "MCP HTTP listen address", PrimaryCommands = Set("mcp") }, new() { Name = "--allow-unauthenticated-http", Description = "MCP HTTP: explicitly allow unsafe unauthenticated loopback mode", PrimaryCommands = Set("mcp") }, }; @@ -726,6 +749,14 @@ public static IReadOnlyList<string> GetCanonicalValuesForCommand(string command, public static string? GetValuePlaceholderForCommand(string command, string flagName) => GetFlag(command, flagName)?.GetValuePlaceholder(command); + public static CliOptionValueKind? GetValueKindForCommand(string command, string flagName) + { + var flag = GetFlag(command, flagName); + return flag is { IsValueBearing: true } + ? flag.GetValueKind(command) + : null; + } + public static bool TryNormalizeOptionValue( string command, string flagName, diff --git a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs index 2d62a3802..6ff1676b2 100644 --- a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs +++ b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs @@ -113,12 +113,23 @@ private static string GetBashCompletions() candidates += $" {BuildBashFlagList(command)}"; sb.Append($" {command}) COMPREPLY=($(compgen -W \"{candidates}\" -- \"$cur\")); return ;;\n"); } - sb.Append(" --db|--path|--exclude-path|--open-issues|--output|-o|--metrics) COMPREPLY=($(compgen -f -- \"$cur\")) ;;\n"); - sb.Append(" --project) if [ \"$cmd\" = \"hooks\" ]; then COMPREPLY=($(compgen -f -- \"$cur\")); fi ;;\n"); - sb.Append($" --lang) COMPREPLY=($(compgen -W \"{langs}\" -- \"$cur\")) ;;\n"); - sb.Append($" --kind) COMPREPLY=($(compgen -W \"{kinds}\" -- \"$cur\")) ;;\n"); + foreach (var flag in GetValueFlags(IsPathCompletionKind).Where(flag => flag.SupplementalCompletionValues.Count > 0)) + { + sb.Append($" {BuildBashFlagPattern(flag)}) COMPREPLY=($(compgen -W \"{string.Join(' ', flag.SupplementalCompletionValues)}\" -- \"$cur\") $(compgen -f -- \"$cur\")) ;;\n"); + } + sb.Append($" {BuildBashValueFlagPattern(IsPathCompletionKind, flag => flag.SupplementalCompletionValues.Count == 0)}) COMPREPLY=($(compgen -f -- \"$cur\")) ;;\n"); + sb.Append($" {BuildBashValueFlagPattern(kind => kind == CliOptionValueKind.Language)}) COMPREPLY=($(compgen -W \"{langs}\" -- \"$cur\")) ;;\n"); + sb.Append($" {BuildBashValueFlagPattern(kind => kind == CliOptionValueKind.SymbolKind)}) COMPREPLY=($(compgen -W \"{kinds}\" -- \"$cur\")) ;;\n"); foreach (var (flag, values) in GetEnumValueCompletions().Where(item => item.Flag != "--format")) sb.Append($" {flag}) COMPREPLY=($(compgen -W \"{string.Join(' ', values)}\" -- \"$cur\")) ;;\n"); + foreach (var flagGroup in GetCommandEnumValueCompletions().GroupBy(item => item.Flag, StringComparer.Ordinal)) + { + sb.Append($" {flagGroup.Key})\n"); + sb.Append(" case \"$cmd\" in\n"); + foreach (var (command, _, values) in flagGroup) + sb.Append($" {command}) COMPREPLY=($(compgen -W \"{string.Join(' ', values)}\" -- \"$cur\")) ;;\n"); + sb.Append(" esac ;;\n"); + } sb.Append(" --format)\n"); sb.Append(" case \"$cmd\" in\n"); foreach (var (command, values) in GetFormatValueCompletions()) @@ -206,6 +217,16 @@ flagName is "--max-line-width" or "--snippet-lines" or "--snippet-focus" or "--f or "--start" or "--end" or "--focus-line" or "--focus-column" or "--focus-length" or "--before" or "--after" or "--group-by-name"; + private static string BuildBashValueFlagPattern( + Func<CliOptionValueKind, bool> predicate, + Func<CliFlag, bool>? flagPredicate = null) => + string.Join('|', GetValueFlagNames(predicate, flagPredicate)); + + private static string BuildBashFlagPattern(CliFlag flag) => + flag.ShortName is null + ? flag.Name + : $"{flag.Name}|{flag.ShortName}"; + private static string GetZshCompletions() { var cmds = string.Join(" ", ShellCommandNames.Select(c => $"'{c}:{c} command'")); @@ -360,26 +381,25 @@ private static string FormatZshArgument(string name, CliFlag flag, string langs, if (!flag.IsValueBearing) return $"'{name}[{desc}]'"; + var valueKind = flag.GetValueKind(command ?? string.Empty); var valuePlaceholder = flag.GetValuePlaceholder(command ?? string.Empty); - var valueSpec = valuePlaceholder switch + var valueSpec = valueKind switch { - "<path>" => "file:_files", - "<glob>" => "pattern", - "<n>" => "number", - "<line>" => "number", - "<id>" => "id", - "<datetime>" => "datetime", - "<lang>" => $"language:({langs})", - "<kind>" => $"kind:({kinds})", - "<auto|always|never>" => "mode:(auto always never)", - "<basic|256|truecolor>" => "palette:(basic 256 truecolor)", - "<text|json>" => "format:(text json)", - "<query>" => "query", - "<name>" => "name", - "<host:port>" => "address", - "<stdio|http>" => "transport:(stdio http)", + _ when IsPathCompletionKind(valueKind) && flag.SupplementalCompletionValues.Count > 0 => + $"value:_alternative \"files:file:_files\" \"values:value:({string.Join(' ', flag.SupplementalCompletionValues)})\"", + _ when IsPathCompletionKind(valueKind) => "file:_files", + CliOptionValueKind.Language => $"language:({langs})", + CliOptionValueKind.SymbolKind => $"kind:({kinds})", _ when flag.Name == "--format" && command is not null && GetFormatValues(command) is { } formats => $"value:({string.Join(' ', formats)})", - _ when GetEnumValues(flag, command) is { } values => $"value:({string.Join(' ', values)})", + CliOptionValueKind.Finite when GetEnumValues(flag, command) is { } values => + $"{flag.GetValueDomain(command ?? string.Empty)?.CompletionLabel ?? "value"}:({string.Join(' ', values)})", + CliOptionValueKind.Repository => "repository", + _ when valuePlaceholder is "<n>" or "<line>" => "number", + _ when valuePlaceholder == "<id>" => "id", + _ when valuePlaceholder == "<datetime>" => "datetime", + _ when valuePlaceholder == "<query>" => "query", + _ when valuePlaceholder == "<name>" => "name", + _ when valuePlaceholder == "<host:port>" => "address", _ => "value", }; return $"'{name}[{desc}]:{valueSpec}'"; @@ -417,10 +437,13 @@ private static string GetFishCompletions() var name = flag.Name.TrimStart('-'); var shortName = flag.ShortName is null ? "" : $" -s {flag.ShortName.TrimStart('-')}"; var requiresArg = flag.IsValueBearing ? " -r" : ""; - var valuePlaceholder = flag.GetValuePlaceholder(string.Empty); - var argSpec = valuePlaceholder switch + var valueKind = flag.GetValueKind(string.Empty); + var argSpec = valueKind switch { - _ when GetEnumValues(flag) is { } values => $" -a '{string.Join(' ', values)}'", + CliOptionValueKind.Language => $" -a '{langs}'", + CliOptionValueKind.SymbolKind => $" -a '{kinds}'", + CliOptionValueKind.Finite when GetEnumValues(flag) is { } values => $" -a '{string.Join(' ', values)}'", + _ when flag.SupplementalCompletionValues.Count > 0 => $" -a '{string.Join(' ', flag.SupplementalCompletionValues)}'", _ => "", }; var description = flag.Description.Replace("'", "\\'"); @@ -449,6 +472,8 @@ _ when GetEnumValues(flag) is { } values => $" -a '{string.Join(' ', values)}'", var contextualCommands = flag.CompletionSubcommands.Keys .Concat(flag.CommandDescriptions.Keys) .Concat(flag.CommandValuePlaceholders.Keys) + .Concat(flag.CommandValueKinds.Keys) + .Concat(flag.CommandValueDomains.Keys) .ToHashSet(StringComparer.Ordinal); var sharedCommands = flag.PrimaryCommands .Where(command => !contextualCommands.Contains(command)) @@ -509,12 +534,13 @@ private static string BuildFishFlagCompletion( "group-by-name" => "Collapse same-name rows across files", _ => flag.GetDescription(command), }; - var valuePlaceholder = flag.GetValuePlaceholder(command); - var argSpec = valuePlaceholder switch + var valueKind = flag.GetValueKind(command); + var argSpec = valueKind switch { - "<lang>" => $" -a '{langs}'", - "<kind>" => $" -a '{kinds}'", - _ when GetEnumValues(flag, command) is { } values => $" -a '{string.Join(' ', values)}'", + CliOptionValueKind.Language => $" -a '{langs}'", + CliOptionValueKind.SymbolKind => $" -a '{kinds}'", + CliOptionValueKind.Finite when GetEnumValues(flag, command) is { } values => $" -a '{string.Join(' ', values)}'", + _ when flag.SupplementalCompletionValues.Count > 0 => $" -a '{string.Join(' ', flag.SupplementalCompletionValues)}'", _ => "", }; description = description.Replace("'", "\\'"); @@ -543,6 +569,10 @@ private static string GetPowerShellCompletions() foreach (var (command, values) in GetFormatValueCompletions()) sb.AppendLine($" '{EscapePowerShellSingleQuoted(command)}' = @({FormatPowerShellArray(values)})"); sb.AppendLine(" }"); + sb.AppendLine(" $commandEnumValues = @{"); + foreach (var (command, flag, values) in GetCommandEnumValueCompletions()) + sb.AppendLine($" '{EscapePowerShellSingleQuoted($"{command}|{flag}")}' = @({FormatPowerShellArray(values)})"); + sb.AppendLine(" }"); sb.AppendLine($" $topLevelFlags = @({topLevelFlags})"); sb.AppendLine(" $subcommands = @{"); foreach (var (command, subcommands) in CliCommandMetadata.CommandSubcommands) @@ -566,17 +596,22 @@ private static string GetPowerShellCompletions() sb.AppendLine(" [System.Management.Automation.CompletionResult]::new($value, $value, $kind, $value)"); sb.AppendLine(" }"); sb.AppendLine(" switch ($prev) {"); - sb.AppendLine(" { $_ -in @('--db', '--path', '--exclude-path', '--open-issues', '--output', '-o', '--metrics') } {"); - sb.AppendLine(" Get-ChildItem -Name \"$wordToComplete*\" -ErrorAction SilentlyContinue | ForEach-Object { New-CdidxCompletion $_ 'ProviderItem' }"); - sb.AppendLine(" return"); - sb.AppendLine(" }"); - sb.AppendLine(" { $_ -eq '--project' -and $subcmd -eq 'hooks' } {"); - sb.AppendLine(" Get-ChildItem -Name \"$wordToComplete*\" -ErrorAction SilentlyContinue | ForEach-Object { New-CdidxCompletion $_ 'ProviderItem' }"); + foreach (var flag in GetValueFlags(IsPathCompletionKind).Where(flag => flag.SupplementalCompletionValues.Count > 0)) + { + sb.AppendLine($" {{ $_ -in @({FormatPowerShellArray(GetFlagNames(flag))}) }} {{"); + sb.AppendLine($" @({FormatPowerShellArray(flag.SupplementalCompletionValues)}) | Where-Object {{ $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) }} | ForEach-Object {{ New-CdidxCompletion $_ }}"); + sb.AppendLine(" [System.Management.Automation.CompletionCompleters]::CompleteFilename($wordToComplete)"); + sb.AppendLine(" return"); + sb.AppendLine(" }"); + } + sb.AppendLine($" {{ $_ -in @({FormatPowerShellArray(GetValueFlagNames(IsPathCompletionKind, flag => flag.SupplementalCompletionValues.Count == 0))}) }} {{"); + sb.AppendLine(" [System.Management.Automation.CompletionCompleters]::CompleteFilename($wordToComplete)"); sb.AppendLine(" return"); sb.AppendLine(" }"); - sb.AppendLine(" '--lang' { $langs | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }"); - sb.AppendLine(" '--kind' { $kinds | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }"); + sb.AppendLine($" {{ $_ -in @({FormatPowerShellArray(GetValueFlagNames(kind => kind == CliOptionValueKind.Language))}) }} {{ $langs | Where-Object {{ $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) }} | ForEach-Object {{ New-CdidxCompletion $_ }}; return }}"); + sb.AppendLine($" {{ $_ -in @({FormatPowerShellArray(GetValueFlagNames(kind => kind == CliOptionValueKind.SymbolKind))}) }} {{ $kinds | Where-Object {{ $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) }} | ForEach-Object {{ New-CdidxCompletion $_ }}; return }}"); sb.AppendLine(" '--format' { if ($formatValues.ContainsKey($subcmd)) { $formatValues[$subcmd] | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ } }; return }"); + sb.AppendLine(" { $commandEnumValues.ContainsKey(\"$subcmd|$_\") } { $commandEnumValues[\"$subcmd|$_\"] | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }"); sb.AppendLine(" { $enumValues.ContainsKey($_) } { $enumValues[$_] | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }"); sb.AppendLine(" }"); sb.AppendLine(" if (-not $subcmd -or ($tokens.Count -le 2 -and -not ([string]::IsNullOrEmpty($wordToComplete)) -and -not $afterLastToken)) {"); @@ -691,6 +726,47 @@ private static List<string> BuildPowerShellGenericFlagList() return tokens; } + private static IReadOnlyList<CliFlag> GetValueFlags(Func<CliOptionValueKind, bool> predicate) + { + var flags = new List<CliFlag>(); + foreach (var flag in CliFlagSchema.All) + { + if (!flag.IsValueBearing) + continue; + + var kinds = flag.PrimaryCommands + .Select(flag.GetValueKind) + .Append(flag.GetValueKind(string.Empty)) + .Concat(flag.CommandValueKinds.Values); + if (!kinds.Any(predicate)) + continue; + + flags.Add(flag); + } + return flags; + } + + private static IReadOnlyList<string> GetValueFlagNames( + Func<CliOptionValueKind, bool> predicate, + Func<CliFlag, bool>? flagPredicate = null) => + GetValueFlags(predicate) + .Where(flag => flagPredicate?.Invoke(flag) ?? true) + .SelectMany(GetFlagNames) + .ToArray(); + + private static IEnumerable<string> GetFlagNames(CliFlag flag) + { + yield return flag.Name; + if (flag.ShortName is not null) + yield return flag.ShortName; + } + + private static bool IsPathCompletionKind(CliOptionValueKind kind) => + kind is CliOptionValueKind.FilePath + or CliOptionValueKind.DirectoryPath + or CliOptionValueKind.PathPattern + or CliOptionValueKind.Project; + private static string FormatPowerShellArray(IEnumerable<string> values) => string.Join(", ", values.Select(value => $"'{EscapePowerShellSingleQuoted(value)}'")); @@ -705,19 +781,23 @@ private static string EscapePowerShellSingleQuoted(string value) => private static IEnumerable<(string Flag, string[] Values)> GetEnumValueCompletions() => CliFlagSchema.All - .Where(flag => flag.Name != "--format") + .Where(flag => flag.Name != "--format" && flag.CommandValueDomains.Count == 0) .Select(flag => ( Flag: flag.Name, - Values: flag.CommandValueDomains.Count == 0 - ? GetEnumValues(flag) - : flag.CommandValueDomains.Values - .SelectMany(domain => domain.CanonicalValues) - .Distinct(StringComparer.Ordinal) - .ToArray())) + Values: GetEnumValues(flag))) .Where(item => item.Values is not null) .GroupBy(item => item.Flag, StringComparer.Ordinal) .Select(group => (group.Key, group.SelectMany(item => item.Values!).Distinct(StringComparer.Ordinal).ToArray())); + private static IEnumerable<(string Command, string Flag, string[] Values)> GetCommandEnumValueCompletions() + { + foreach (var flag in CliFlagSchema.All.Where(flag => flag.Name != "--format")) + { + foreach (var (command, domain) in flag.CommandValueDomains) + yield return (command, flag.Name, domain.CanonicalValues.ToArray()); + } + } + private static IEnumerable<(string Command, string[] Values)> GetFormatValueCompletions() { foreach (var command in CliFlagSchema.AllCommands) diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index e9568f76e..fa1ee2988 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -172,7 +172,12 @@ public void AuditAggregationFlags_SurfaceDocumentedRecipeGrouping_Issues4301_433 var countBy = Assert.Single(completionFlags, flag => flag.Name == "--count-by"); var unique = Assert.Single(completionFlags, flag => flag.Name == "--unique"); - foreach (var placeholder in new[] { groupBy.ValuePlaceholder, countBy.ValuePlaceholder, unique.ValuePlaceholder }) + foreach (var placeholder in new[] + { + groupBy.GetValuePlaceholder("audit"), + countBy.GetValuePlaceholder("audit"), + unique.GetValuePlaceholder("audit"), + }) { Assert.Contains("return-type", placeholder, StringComparison.Ordinal); Assert.Contains("subsystem", placeholder, StringComparison.Ordinal); @@ -203,7 +208,7 @@ public void UpgradeFlags_SurfaceImplementedSelectionAndJsonOptions() Assert.Contains("--version", accepted); var channel = Assert.Single(CliFlagSchema.GetCompletionFlagsForCommand("upgrade"), f => f.Name == "--channel"); - Assert.Equal("<stable|latest|prerelease>", channel.ValuePlaceholder); + Assert.Equal("<stable|latest|prerelease>", channel.GetValuePlaceholder("upgrade")); Assert.DoesNotContain("reserved", channel.Description, StringComparison.OrdinalIgnoreCase); var prerelease = Assert.Single(CliFlagSchema.GetCompletionFlagsForCommand("upgrade"), f => f.Name == "--prerelease"); @@ -471,6 +476,77 @@ public void CanonicalValueRegistry_DrivesHelpValidationAndCompletions_Issue4861( Assert.Contains("'--end' = @('eof')", powershell); } + [Fact] + public void CompletionValueKinds_KeepMetavariablesOutOfFiniteChoicesAcrossShells_Issue4902() + { + Assert.Empty(CliFlagSchema.GetCanonicalValuesForCommand("search", "--project")); + Assert.Empty(CliFlagSchema.GetCanonicalValuesForCommand("search", "--recipe")); + Assert.Empty(CliFlagSchema.GetCanonicalValuesForCommand("search", "--open-issues")); + Assert.Equal(CliOptionValueKind.Project, CliFlagSchema.GetValueKindForCommand("search", "--project")); + Assert.Equal(CliOptionValueKind.DirectoryPath, CliFlagSchema.GetValueKindForCommand("hooks", "--project")); + Assert.Equal(CliOptionValueKind.FreeText, CliFlagSchema.GetValueKindForCommand("search", "--recipe")); + Assert.Equal(CliOptionValueKind.FilePath, CliFlagSchema.GetValueKindForCommand("search", "--open-issues")); + Assert.Equal(["github"], CliFlagSchema.GetFlag("search", "--open-issues")!.SupplementalCompletionValues); + Assert.Equal(CliOptionValueKind.Repository, CliFlagSchema.GetValueKindForCommand("search", "--repo")); + Assert.Equal(CliOptionValueKind.Language, CliFlagSchema.GetValueKindForCommand("languages", "--language")); + Assert.Equal(CliOptionValueKind.SymbolKind, CliFlagSchema.GetValueKindForCommand("symbols", "--kind")); + Assert.Equal(CliOptionValueKind.FilePath, CliFlagSchema.GetValueKindForCommand("suggestions", "--output")); + Assert.Equal(CliOptionValueKind.Finite, CliFlagSchema.GetValueKindForCommand("search", "--issue-state")); + Assert.Equal(["open", "closed", "all"], CliFlagSchema.GetCanonicalValuesForCommand("search", "--issue-state")); + Assert.False(CliFlagSchema.GetFlag("search", "--json")!.IsValueBearing); + + var displayOnlyAlternatives = CliFlagSchema.All + .Where(flag => + flag.ValuePlaceholder?.Contains('|', StringComparison.Ordinal) == true + && flag.ValueDomain is null + && flag.CommandValueDomains.Count == 0) + .Select(flag => flag.Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(["--open-issues", "--project", "--recipe"], displayOnlyAlternatives); + + var bash = ConsoleCompletionRenderer.GetCompletionScript("bash"); + Assert.Contains("--workspace-db|--data-dir|--metrics|--path|--project|--solution|--exclude-path", bash, StringComparison.Ordinal); + Assert.Contains("--open-issues) COMPREPLY=($(compgen -W \"github\" -- \"$cur\") $(compgen -f -- \"$cur\"))", bash, StringComparison.Ordinal); + Assert.Contains("--evidence-path|--files|--output|-o", bash, StringComparison.Ordinal); + Assert.Contains("--lang|--language) COMPREPLY=($(compgen -W", bash, StringComparison.Ordinal); + Assert.Contains("--issue-state) COMPREPLY=($(compgen -W \"open closed all\"", bash, StringComparison.Ordinal); + Assert.DoesNotContain("--project) COMPREPLY=($(compgen -W \"name path\"", bash, StringComparison.Ordinal); + Assert.DoesNotContain("--recipe) COMPREPLY=($(compgen -W \"name name/query\"", bash, StringComparison.Ordinal); + Assert.DoesNotContain("path github github:owner/name", bash, StringComparison.Ordinal); + + var zsh = ConsoleCompletionRenderer.GetCompletionScript("zsh"); + Assert.Contains("--project[Filter to a .sln/.csproj project]:file:_files", zsh, StringComparison.Ordinal); + Assert.Contains("--open-issues[Preflight issue drafts against issue JSON or GitHub issues]:value:_alternative \"files:file:_files\" \"values:value:(github)\"", zsh, StringComparison.Ordinal); + Assert.Contains("--repo[Issue-drafts: GitHub repository for --open-issues github]:repository", zsh, StringComparison.Ordinal); + Assert.Contains("--recipe[Search: run a built-in audit recipe query set, optionally selecting one child query]:value", zsh, StringComparison.Ordinal); + Assert.Contains("--language[Suggestions: filter by language; languages: look up one language by canonical name or recognized language spelling]:language:(", zsh, StringComparison.Ordinal); + Assert.DoesNotContain(":value:(name name/query)", zsh, StringComparison.Ordinal); + Assert.DoesNotContain(":value:(path github github:owner/name)", zsh, StringComparison.Ordinal); + + var fish = ConsoleCompletionRenderer.GetCompletionScript("fish"); + Assert.Contains("-l project -r -d 'Filter to a .sln/.csproj project'", fish, StringComparison.Ordinal); + Assert.Contains("-l open-issues -r -a 'github' -d 'Preflight issue drafts against issue JSON or GitHub issues'", fish, StringComparison.Ordinal); + Assert.Contains("-l language -r -a '", fish, StringComparison.Ordinal); + Assert.Contains("-l issue-state -r -a 'open closed all'", fish, StringComparison.Ordinal); + Assert.Contains("__fish_seen_subcommand_from search' -l group-by -r -a 'file symbol origin return-type subsystem'", fish, StringComparison.Ordinal); + Assert.Contains("__fish_seen_subcommand_from audit' -l group-by -r -a 'file symbol origin return-type subsystem'", fish, StringComparison.Ordinal); + Assert.Contains("__fish_seen_subcommand_from hotspots' -l group-by -r -a 'symbol file statement'", fish, StringComparison.Ordinal); + Assert.DoesNotContain("-l project -r -a 'name path'", fish, StringComparison.Ordinal); + Assert.DoesNotContain("-l recipe -r -a 'name name/query'", fish, StringComparison.Ordinal); + Assert.DoesNotContain("-a 'path github github:owner/name'", fish, StringComparison.Ordinal); + + var powershell = ConsoleCompletionRenderer.GetCompletionScript("powershell"); + Assert.Contains("'--workspace-db', '--data-dir', '--metrics', '--path', '--project', '--solution', '--exclude-path', '--evidence-path', '--files', '--output', '-o'", powershell, StringComparison.Ordinal); + Assert.Contains("{ $_ -in @('--open-issues') }", powershell, StringComparison.Ordinal); + Assert.Contains("@('github') | Where-Object", powershell, StringComparison.Ordinal); + Assert.Contains("{ $_ -in @('--lang', '--language') } { $langs", powershell, StringComparison.Ordinal); + Assert.Contains("'--issue-state' = @('open', 'closed', 'all')", powershell, StringComparison.Ordinal); + Assert.DoesNotContain("'--project' = @('name', 'path')", powershell, StringComparison.Ordinal); + Assert.DoesNotContain("'--recipe' = @('name', 'name/query')", powershell, StringComparison.Ordinal); + Assert.DoesNotContain("'--open-issues' = @('path', 'github', 'github:owner/name')", powershell, StringComparison.Ordinal); + } + [Fact] public void RegistrySurfacesSafetyAndAcceptedOptionsWithoutAdvertisingRejectedGotoExact_Issue4861() { @@ -507,7 +583,7 @@ public void RegistrySurfacesSafetyAndAcceptedOptionsWithoutAdvertisingRejectedGo Assert.Contains("for ((i=2; i<COMP_CWORD; i++)); do", bash, StringComparison.Ordinal); Assert.Contains("--project) skip_next=1", bash, StringComparison.Ordinal); Assert.DoesNotContain("nested=\"${COMP_WORDS[2]}\"", bash, StringComparison.Ordinal); - Assert.Contains("--project) if [ \"$cmd\" = \"hooks\" ]; then COMPREPLY=($(compgen -f", bash, StringComparison.Ordinal); + Assert.Contains("--project|--solution|--exclude-path", bash, StringComparison.Ordinal); Assert.DoesNotContain("--project) COMPREPLY=($(compgen -W \"name path\"", bash, StringComparison.Ordinal); var zsh = ConsoleCompletionRenderer.GetCompletionScript("zsh"); @@ -531,7 +607,7 @@ public void RegistrySurfacesSafetyAndAcceptedOptionsWithoutAdvertisingRejectedGo Assert.Contains("$subcmd -eq 'hooks' -and $nested -eq 'uninstall'", powershell, StringComparison.Ordinal); Assert.Contains("if ($skipNestedValue) { $skipNestedValue = $false; continue }", powershell, StringComparison.Ordinal); Assert.Contains("if ($subcommands[$subcmd] -contains $token) { $nested = $token; break }", powershell, StringComparison.Ordinal); - Assert.Contains("$_ -eq '--project' -and $subcmd -eq 'hooks'", powershell, StringComparison.Ordinal); + Assert.Contains("'--project'", powershell, StringComparison.Ordinal); Assert.DoesNotContain("'--project' = @('name', 'path')", powershell, StringComparison.Ordinal); using var capture = ConsoleCapture.Start(captureOut: true); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 6dbb46ef4..492454124 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -1072,8 +1072,9 @@ public void CompletionRenderer_BashCompletesFlagValues() { var output = ConsoleCompletionRenderer.GetCompletionScript("bash"); - Assert.Contains("--db|--path|--exclude-path|--open-issues|--output|-o|--metrics) COMPREPLY=($(compgen -f -- \"$cur\"))", output); - Assert.Contains("--lang) COMPREPLY=($(compgen -W \"", output); + Assert.Contains("--db|--workspace-db|--data-dir|--metrics|--path|--project|--solution|--exclude-path", output); + Assert.Contains("--files|--output|-o) COMPREPLY=($(compgen -f -- \"$cur\"))", output); + Assert.Contains("--lang|--language) COMPREPLY=($(compgen -W \"", output); Assert.Contains("csharp", output); Assert.Contains("python", output); Assert.Contains("--kind) COMPREPLY=($(compgen -W \"", output); @@ -1121,7 +1122,7 @@ public void CompletionRenderer_ZshAndFishCompleteKindValuesFromSharedSet() } [Fact] - public void CompletionRenderer_PowerShellRegistersNativeCompleter() + public void CompletionRenderer_PowerShellRegistersNativeCompleterAndPreservesNestedPaths_Issue4902() { var output = ConsoleCompletionRenderer.GetCompletionScript("powershell"); @@ -1131,9 +1132,10 @@ public void CompletionRenderer_PowerShellRegistersNativeCompleter() Assert.Contains("$commands = @('index', 'hooks', 'backfill-fold'", output); Assert.Contains("'--help', '--version', '--license'", output); Assert.Contains("'search' { $flags = @(", output); - Assert.Contains("'--lang' { $langs", output); - Assert.Contains("'--kind' { $kinds", output); - Assert.Contains("Get-ChildItem -Name \"$wordToComplete*\"", output); + Assert.Contains("{ $_ -in @('--lang', '--language') } { $langs", output); + Assert.Contains("{ $_ -in @('--kind') } { $kinds", output); + Assert.Contains("[System.Management.Automation.CompletionCompleters]::CompleteFilename($wordToComplete)", output); + Assert.DoesNotContain("Get-ChildItem -Name \"$wordToComplete*\"", output); Assert.Contains("CompletionResult", output); Assert.Contains("[string]::IsNullOrEmpty($wordToComplete) -and $tokens.Count -ge 1", output); Assert.Contains("$afterLastToken = $lastElement -and $cursorPosition -gt $lastElement.Extent.EndOffset", output);