From 48883f04c871f2bf8d8585b9ac6a8f0f74c69f65 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 20:14:44 +0900 Subject: [PATCH 1/4] Fix CLI option registry drift (#4861) --- DEVELOPER_GUIDE.md | 32 ++- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 20 +- changelog.d/unreleased/4861.fixed.md | 20 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 264 ++++++++++++++++-- .../Cli/CliOutputFormatCapabilities.cs | 3 - .../Cli/ConsoleCompletionRenderer.cs | 64 ++--- src/CodeIndex/Cli/ConsoleUi.Help.cs | 219 +++++++-------- src/CodeIndex/Cli/ConsoleUi.cs | 4 +- .../ProgramRunner.OutputFormatValidation.cs | 24 +- .../Cli/ProgramRunner.QueryArguments.cs | 55 +--- .../Cli/QueryCommandRunner.ArgParsing.cs | 97 +------ .../Cli/QueryCommandRunner.Options.cs | 147 +--------- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 79 ++++++ tests/CodeIndex.Tests/ConsoleUiTests.cs | 99 ++----- 15 files changed, 572 insertions(+), 559 deletions(-) create mode 100644 changelog.d/unreleased/4861.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 29f2f390a..4043289b4 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -201,13 +201,16 @@ git status --short -- '**/packages.lock.json' `CliCommandCatalog` owns command and nested-subcommand names, including whether a nested verb is optional and parent flags must remain available, while -`CliFlagSchema` owns primary flags, aliases, placeholders, descriptions, and -command applicability. Every shell completion renderer consumes those shared -definitions, and `ConsoleUi.PrintCommandUsage` uses them when the shared parser is -the authoritative option inventory. Dedicated or nested parsers retain exact -usage-specific syntax until their subcommand metadata is complete. Add new CLI -metadata there first so parser validation, help, completion, and generated -next-step flags stay aligned without emitting partial option lists. Every verb +`CliFlagSchema` owns primary flags, aliases, placeholders, descriptions, command +applicability, canonical value domains and value aliases, and safety/scope +classification. Command usage placeholders, command and shared flag help, +output-format validation, search origin/result-kind validation, and every shell +completion renderer consume those shared definitions. Dedicated or nested +parsers retain exact usage-specific syntax until their subcommand metadata is +complete. Add or change CLI options and accepted values in the schema first; +do not add parallel help, validation, or completion value lists. This keeps +runtime validation, help, completion, and generated next-step flags aligned +without emitting partial option lists. Every verb listed by `CliCommandCatalog.CommandSubcommands` must also resolve to a hidden, verb-specific `ConsoleUi` usage entry with its constraints, side effects, and an example; aggregate usage lines must enumerate accepted public flags. @@ -3413,12 +3416,15 @@ git status --short -- '**/packages.lock.json' command と nested subcommand の名前、および nested verb が optional で親 command の flag も維持するかは `CliCommandCatalog`、primary flag、alias、 -placeholder、description、command applicability は `CliFlagSchema` が管理します。 -全 shell completion renderer はこの共有定義を参照し、共有 parser が option 一覧の -正本である場合は `ConsoleUi.PrintCommandUsage` も参照します。専用 parser / nested parser -は subcommand metadata が揃うまで usage 固有の正確な構文を維持します。新しい CLI -metadata はまず共有定義へ追加し、不完全な option 一覧を出さずに parser validation、 -help、completion、生成される next-step flag の同期を維持してください。 +placeholder、description、command applicability、canonical value domain と value alias、 +safety / scope 分類は `CliFlagSchema` が管理します。command usage の placeholder、 +command / shared flag help、output-format validation、search の origin / result-kind +validation、全 shell completion renderer はこの共有定義を参照します。専用 parser / +nested parser は subcommand metadata が揃うまで usage 固有の正確な構文を維持します。 +CLI option や受理値を追加・変更するときは、まず schema を更新し、help、validation、 +completion に並行した value list を追加しないでください。これにより、不完全な option +一覧を出さずに runtime validation、help、completion、生成される next-step flag の同期を +維持します。 `CliCommandCatalog.CommandSubcommands` に掲載するすべての verb は、制約、副作用、例を 記載した hidden な verb 固有 `ConsoleUi` usage entry に解決させ、aggregate usage line には受理する公開 flag を漏れなく列挙してください。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 2841accfb..104223080 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -181,7 +181,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `IndexCommandRunner*Tests.cs`, `QueryCommandRunner*Tests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. - `ConsoleUiTests.cs` keeps each generated shell's complete long-flag catalog aligned with `CliFlagSchema` and checks every Fish command scope against the shared per-command completion projection. Add flags to the schema rather than weakening this Bash / Zsh / Fish / PowerShell parity contract. + `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. `ProgramRunnerTests.cs` enumerates `CliCommandCatalog.CommandSubcommands` and requires every valid nested verb to resolve to verb-specific usage with an example. It also pins destructive index confirmation and aggregate dependency-filter help, plus the read-only GitHub duplicate-preflight boundary for suggestion exports. Installer cancellation coverage must wait until the PID file contains a complete positive integer before cancelling, using a bounded dedicated waiter instead of a fixed-delay timer so parallel load cannot expose a redirection-created empty file. Global-tool-log home-shorthand coverage resolves the normalized first candidate through the test-only no-write-probe seam, so sandbox permissions cannot turn an expansion assertion into a writability-fallback assertion. Grouped-search limit coverage must seed more matches and files than the returned page, then assert query-wide matched/group/file totals separately from grouped, emitted, and omitted row counts so a bounded page cannot report itself as complete. @@ -1089,7 +1089,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `IndexCommandRunner*Tests.cs`、`QueryCommandRunner*Tests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 - `ConsoleUiTests.cs` は各生成シェルの long flag 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。この Bash / Zsh / Fish / PowerShell parity 契約を弱めず、新しい flag は schema に追加してください。 + `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 `ProgramRunnerTests.cs` は `CliCommandCatalog.CommandSubcommands` を列挙し、すべての有効な nested verb が例を含む verb 固有 usage に解決されることを必須とします。さらに destructive な index confirmation、aggregate dependency filter help、suggestion export の read-only GitHub duplicate-preflight 境界を固定します。installer cancellation coverage は固定時間タイマーではなく上限付きの専用 waiter を使い、PID ファイルが完全な正の整数を含むまで待ってから cancel してください。これにより並列負荷下でも redirection によって作成された空ファイルを読みません。 global tool log の home shorthand coverage は、test 専用の write probe なし seam から正規化済みの先頭 candidate を解決します。これにより sandbox permission の影響で、展開の assertion が writability fallback の assertion に変わることを防ぎます。 grouped search の limit coverage では、返却 page より多い match と file を seed し、query 全体の match/group/file 総数と grouped、emitted、omitted row 数を別々に検証して、上限付き page が完了済みと報告できないようにしてください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 829ada0bb..f9479dccc 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -177,6 +177,10 @@ The generated scripts complete subcommands, flags, and common flag values. `--lang` suggests supported languages, `--kind` suggests symbol/reference kinds, and path-like options such as `--db`, `--path`, and `--output` use shell file completion. +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`. Install the script in the startup file or completion directory for your shell: @@ -303,7 +307,9 @@ the same filtered file set. Stable since values are intentionally not repeated in this guide because the release changelog is the source of truth for when each command first shipped. -Run `cdidx --help` for the full syntax line for every command. For focused help, +Run `cdidx --help-all` for the full syntax line for every command and +`cdidx --help-flags` for global, safety/scope, index/update, and shared query +options. For focused help, `cdidx help [subcommand]` is equivalent to the existing `cdidx [subcommand] --help` form and never executes that command. Known public command targets and supported command or nested aliases exit `0`; @@ -3410,6 +3416,9 @@ read-write で mount してください。read-only query container では、fre 生成されたスクリプトは subcommand、flag、よく使う flag 値を補完します。 `--lang` は対応言語、`--kind` は symbol / reference kind を提示し、`--db`、 `--path`、`--output` など path 系 option は shell の file completion を使います。 +command 固有の `--format` 値、search origin filter、`--result-kind` 値は command +help と runtime validation と同じ registry から生成されます。たとえば audit の補完には +`sarif`、search の補完には `schema_description` と `unknown` が含まれます。 利用中の shell の startup file または completion directory にスクリプトを インストールしてください。 @@ -3524,8 +3533,10 @@ cdidx index . --quiet filter 済み file 集合を表します。 Stable since の値はこのガイドでは重複管理しません。各コマンドがいつ入ったかは -release changelog を source of truth とします。完全な syntax line は `cdidx --help` -を参照してください。個別の help は `cdidx help [subcommand]` を使え、既存の +release changelog を source of truth とします。全 command の完全な syntax line は +`cdidx --help-all`、global、safety / scope、index / update、共有 query option は +`cdidx --help-flags` を参照してください。個別の help は +`cdidx help [subcommand]` を使え、既存の `cdidx [subcommand] --help` と同じ内容を command を実行せずに表示します。 既知の公開 command target と対応する command / nested alias は終了コード `0` を返し、 内部 usage key は help target として受理しません。target の欠落・未検出は bounded な @@ -4245,6 +4256,9 @@ Bash、Zsh、Fish、PowerShell です。生成されたスクリプトは subcom よく使う flag value を補完し、`--lang` は対応言語、`--kind` は symbol/reference kind、`--db` / `--path` / `--output` のような path 系 option は shell の file completion を使います。 +command 固有の `--format` 値、search origin filter、`--result-kind` 値は command +help と runtime validation と同じ registry から生成されます。たとえば audit の補完には +`sarif`、search の補完には `schema_description` と `unknown` が含まれます。 使っている shell の startup file または completion directory に保存してください: diff --git a/changelog.d/unreleased/4861.fixed.md b/changelog.d/unreleased/4861.fixed.md new file mode 100644 index 000000000..3c5e31a3c --- /dev/null +++ b/changelog.d/unreleased/4861.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4861 +affected: + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleCompletionRenderer.cs + - tests/CodeIndex.Tests/CliFlagSchemaTests.cs + - USER_GUIDE.md +--- + +## English + +- Generate command-specific accepted option values from the CLI flag registry across usage/help, output-format and search projection validation, and Bash, Zsh, Fish, and PowerShell completions. +- Surface audit SARIF, all search origin and result-kind values, hook-install dry-run, and global safety/scope flags, and stop advertising the rejected `goto --exact` option. + +## 日本語 + +- command 固有の受理 option 値を CLI flag registry から生成し、usage / help、output-format と search projection の validation、Bash / Zsh / Fish / PowerShell completion を同期しました。 +- audit の SARIF、search の全 origin / result-kind 値、hook install の dry-run、global safety / scope flag を表示し、拒否される `goto --exact` の案内を削除しました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 881c5d1d0..5a33d00f2 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -3,6 +3,49 @@ namespace CodeIndex.Cli; +internal enum CliOptionSafety +{ + None = 0, + ReadOnly, + Preview, + Destructive, + Override, + Scope, + Diagnostics, + StrictFailure, +} + +internal sealed record CliOptionValueDomain +{ + public required IReadOnlyList CanonicalValues { get; init; } + public IReadOnlyDictionary Aliases { get; init; } = + new Dictionary(StringComparer.Ordinal); + + public string ValuePlaceholder => $"<{string.Join('|', CanonicalValues)}>"; + + public bool TryNormalize(string rawValue, out string normalizedValue) + { + var lookup = NormalizeLookup(rawValue); + foreach (var canonicalValue in CanonicalValues) + { + if (string.Equals(NormalizeLookup(canonicalValue), lookup, StringComparison.Ordinal)) + { + normalizedValue = canonicalValue; + return true; + } + } + + if (Aliases.TryGetValue(lookup, out normalizedValue!)) + return true; + + normalizedValue = string.Empty; + return false; + } + + private static string NormalizeLookup(string value) => + value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); +} + /// /// Single source of truth for cdidx CLI flags. Drives the unsupported-option allowlists /// (`TryWriteUnsupportedOptionError`, `ValidateFindArgs`) and the generated shell @@ -22,6 +65,12 @@ internal sealed record CliFlag public string? ShortName { get; init; } public string? ValuePlaceholder { get; init; } public required string Description { get; init; } + public CliOptionSafety Safety { get; init; } + public CliOptionValueDomain? ValueDomain { get; init; } + public IReadOnlyDictionary CommandValueDomains { get; init; } = + new Dictionary(StringComparer.Ordinal); + public IReadOnlyDictionary CommandValuePlaceholders { get; init; } = + new Dictionary(StringComparer.Ordinal); /// /// Commands for which this flag is "primary" — accepted by the parser AND surfaced @@ -48,9 +97,49 @@ internal sealed record CliFlag /// public IReadOnlySet AlsoAcceptedBy { get; init; } = new HashSet(StringComparer.Ordinal); - public bool IsValueBearing => ValuePlaceholder is not null; + public bool IsValueBearing => + ValuePlaceholder is not null + || ValueDomain is not null + || CommandValueDomains.Count > 0 + || CommandValuePlaceholders.Count > 0; public bool AppliesTo(string command) => PrimaryCommands.Contains(command); public bool IsAcceptedBy(string command) => AppliesTo(command) || AlsoAcceptedBy.Contains(command); + + public CliOptionValueDomain? GetValueDomain(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 TryBuildPlaceholderDomain(ValuePlaceholder); + } + + public string? GetValuePlaceholder(string command) + { + if (CommandValuePlaceholders.TryGetValue(command, out var commandPlaceholder)) + 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 @@ -67,7 +156,7 @@ internal static class CliFlagSchema // 専用 parser / nested parser を持つ command family は subcommand metadata が揃うまで // CommandUsageLines の正確な構文を使い、誤解を招く不完全な親 command の一覧は出さない。 private static readonly IReadOnlySet AuthoritativeHelpOptionCommands = Set( - "index", "backfill-fold", "optimize", "vacuum", + "index", "hooks", "backfill-fold", "optimize", "vacuum", "search", "recipes", "audit", "definition", "goto", "references", "callers", "callees", "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", "validate", "deps", "impact", "unused", "hotspots", "languages"); @@ -230,6 +319,48 @@ public static bool HasAuthoritativeHelpOptions(string command) => private static readonly string[] VerboseQueryCommands = ProfileCommands; private static readonly string[] TraceCommands = ProfileCommands; + private static readonly CliOptionValueDomain SearchOriginValueDomain = Values( + ["code", "comment", "string_literal", "regex_literal", "help_text", "schema_description", "unknown"], + ("string", "string_literal"), + ("regex", "regex_literal"), + ("help", "help_text"), + ("schema", "schema_description")); + + private static readonly CliOptionValueDomain SearchResultKindValueDomain = Values( + ["call_site", "declaration", "identifier", "code", "comment", "string_literal", "regex_literal", "help_text", "schema_description", "unknown"], + ("call", "call_site"), + ("callsite", "call_site"), + ("decl", "declaration"), + ("ident", "identifier"), + ("string", "string_literal"), + ("regex", "regex_literal"), + ("help", "help_text"), + ("schema", "schema_description")); + + private static readonly IReadOnlyDictionary OutputFormatValueDomains = + new Dictionary(StringComparer.Ordinal) + { + ["search"] = Values(["text", "json", "count", "compact", "grouped", "csv", "tsv", "lsp", "qf", "sarif", "issue-drafts"]), + ["recipes"] = Values(["text", "json", "compact"]), + ["audit"] = Values(["text", "json", "count", "compact", "sarif", "issue-drafts"]), + ["definition"] = Values(["text", "json", "count", "compact", "csv", "tsv", "lsp", "qf", "sarif"]), + ["references"] = Values(["text", "json", "count", "compact", "csv", "tsv", "lsp", "qf", "sarif"]), + ["callers"] = Values(["text", "json", "count", "compact", "csv", "tsv", "lsp", "qf", "sarif"]), + ["callees"] = Values(["text", "json", "count", "compact", "csv", "tsv", "lsp", "qf", "sarif"]), + ["find"] = Values(["text", "json", "count", "compact", "csv", "tsv", "lsp", "qf", "sarif"]), + ["validate"] = Values(["text", "json", "count", "compact", "csv", "tsv", "lsp", "qf", "sarif"]), + ["symbols"] = Values(["text", "json", "count", "compact", "lsp", "qf", "sarif"]), + ["files"] = Values(["text", "json", "count", "compact"]), + ["map"] = Values(["text", "json", "compact", "issue-drafts"]), + ["inspect"] = Values(["text", "json", "compact"]), + ["status"] = Values(["text", "json", "compact"]), + ["deps"] = Values(["dot", "graphml", "json-graph", "edgelist"]), + ["impact"] = Values(["text", "json", "compact"]), + ["hotspots"] = Values(["text", "json", "count", "compact"]), + ["suggestions"] = Values(["json", "markdown", "issue-drafts"]), + ["languages"] = Values(["text", "json", "count"]), + }; + public static IReadOnlyList All { get; } = BuildAll(); private static IReadOnlyList BuildAll() @@ -237,22 +368,22 @@ private static IReadOnlyList BuildAll() return new List { new() { Name = "--db", ValuePlaceholder = "", Description = "Database path", PrimaryCommands = Set(DbPathCommands) }, - new() { Name = "--read-only", Description = "Open the query database as immutable read-only storage", PrimaryCommands = Set(ReadOnlyDbCommands) }, - new() { Name = "--immutable", Description = "Alias for --read-only", PrimaryCommands = Set(ReadOnlyDbCommands) }, + 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) }, - new() { Name = "--json", Description = "JSON output; search/symbols/files/validate also accept --json=array for a single JSON array", PrimaryCommands = Set(JsonCommands) }, + 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 = "--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 = "--max-input-lines", ValuePlaceholder = "", Description = $"Batch: input-line budget (default {QueryCommandRunner.BatchDefaultInputLines}, max {QueryCommandRunner.BatchMaxInputLines})", PrimaryCommands = Set("batch") }, new() { Name = "--max-output-chars", ValuePlaceholder = "", Description = $"Batch JSON-summary output budget (default {QueryCommandRunner.BatchDefaultTotalOutputChars}, max {QueryCommandRunner.BatchMaxTotalOutputChars})", PrimaryCommands = Set("batch") }, new() { Name = "--parallel", ValuePlaceholder = "", Description = $"Batch JSON-summary worker count (default 1, max {QueryCommandRunner.BatchMaxParallelism}); results retain input order", PrimaryCommands = Set("batch") }, new() { Name = "--pretty", Description = CliOutputFormatCapabilities.PrettyDescription, PrimaryCommands = Set(JsonCommands), TopLevel = true }, new() { Name = "--compact", Description = "AI-oriented compact JSON with capped list sections and truncation metadata", PrimaryCommands = Set(CompactJsonCommands) }, - new() { Name = "--format", ValuePlaceholder = CliOutputFormatCapabilities.FormatValuePlaceholder, Description = CliOutputFormatCapabilities.FormatDescription, PrimaryCommands = Set(FormatCommands) }, + 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 mode", PrimaryCommands = Set(), TopLevel = true }, - new() { Name = "--palette", ValuePlaceholder = "", Description = "ANSI color palette", PrimaryCommands = Set(), 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 = "--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 = "--debug-unsafe", Description = "Allow raw debug dumps when CDIDX_DEBUG=unsafe is also set", PrimaryCommands = Set(), TopLevel = true }, @@ -264,7 +395,7 @@ private static IReadOnlyList BuildAll() 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 = "--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) }, + 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 = "--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) }, @@ -275,8 +406,19 @@ private static IReadOnlyList BuildAll() 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 = "--project", ValuePlaceholder = "", Description = "Filter to a .sln/.csproj project", PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index" }).ToArray()) }, - new() { Name = "--solution", ValuePlaceholder = "", Description = "Solution file used to resolve --project", PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index" }).ToArray()) }, + new() + { + Name = "--project", + ValuePlaceholder = "", + Description = "Filter to a .sln/.csproj project", + PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index", "hooks" }).ToArray()), + CommandValuePlaceholders = new Dictionary(StringComparer.Ordinal) + { + ["hooks"] = "", + }, + 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 = "--exclude-tests", Description = "Exclude tests", PrimaryCommands = Set(ExcludeFilterCommands) }, new() { Name = "--include-generated", Description = "Include generated files", PrimaryCommands = Set(ExcludeFilterCommands) }, @@ -297,7 +439,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--raw-kinds", Description = "Show raw reference kinds instead of logical graph kinds", PrimaryCommands = Set(RawKindsCommands) }, new() { Name = "--count", Description = "Count only; result limits are ignored by count modes, but scan caps can still mark approximate counts as degraded", PrimaryCommands = Set(CountCommands) }, new() { Name = "--group-partials", Description = "Definition/Symbols/Inspect: collapse C# partial-type declarations into logical families", PrimaryCommands = Set("definition", "symbols", "inspect") }, - new() { Name = "--strict-not-found", Description = "Return exit code 2 when a valid query has zero rows", PrimaryCommands = Set(StrictNotFoundCommands) }, + new() { Name = "--strict-not-found", Description = "Return exit code 2 when a valid query has zero rows", PrimaryCommands = Set(StrictNotFoundCommands), Safety = CliOptionSafety.StrictFailure }, new() { Name = "--allow-partial", Description = "Return exit code 0 instead of 11 for accepted partial query output or an incomplete index generation", PrimaryCommands = Set(AllowPartialCommands) }, new() { Name = "--strict", Description = "Return exit code 4 when impact preconditions are unmet", PrimaryCommands = Set("impact") }, new() { Name = "--since", ValuePlaceholder = "", Description = "Filter by modified-since timestamp", PrimaryCommands = Set(SinceCommands) }, @@ -360,10 +502,10 @@ private static IReadOnlyList BuildAll() new() { Name = "--guard-scope", ValuePlaceholder = "", 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 = "", Description = "Search/Audit recipes: emit unique aggregation rows", PrimaryCommands = Set("search", "audit") }, new() { Name = "--count-by", ValuePlaceholder = "", Description = "Search/Audit recipes: count matches grouped by path/file, symbol, origin, enclosing return type, or subsystem", PrimaryCommands = Set("search", "audit") }, - new() { Name = "--origin", ValuePlaceholder = "", Description = "Search: alias for --match-origin; keep only matches from selected origins", PrimaryCommands = Set("search") }, - new() { Name = "--match-origin", ValuePlaceholder = "", Description = "Search: keep only matches from selected origins; repeat or comma-separate values", PrimaryCommands = Set("search") }, - new() { Name = "--exclude-origin", ValuePlaceholder = "", Description = "Search: drop matches from selected origins; repeat or comma-separate values", PrimaryCommands = Set("search") }, - new() { Name = "--result-kind", ValuePlaceholder = "", Description = "Search: keep only projected result kinds; repeat or comma-separate values", PrimaryCommands = Set("search") }, + 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 }, + new() { Name = "--result-kind", Description = "Search: keep only projected result kinds; repeat or comma-separate values", PrimaryCommands = Set("search"), ValueDomain = SearchResultKindValueDomain }, new() { Name = "--search-fields", ValuePlaceholder = "", Description = "Search/Audit: project JSON/NDJSON result fields for audit pipelines", PrimaryCommands = Set("search", "audit") }, new() { Name = "--outline-fields", ValuePlaceholder = "", Description = "Outline JSON: project symbol fields for audit pipelines", PrimaryCommands = Set("outline") }, new() { Name = "--results-only", Description = "Search/Symbols/Files/Audit: emit result-only NDJSON without stream terminal records", PrimaryCommands = Set("search", "symbols", "files", "audit") }, @@ -417,14 +559,14 @@ private static IReadOnlyList BuildAll() new() { Name = "--prerelease", Description = "Upgrade: select the newest prerelease", PrimaryCommands = Set("upgrade") }, new() { Name = "--version", ValuePlaceholder = "", 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") }, - new() { Name = "--rebuild", Description = "Delete existing DB and rebuild from scratch", PrimaryCommands = Set("index") }, + new() { Name = "--rebuild", Description = "Delete existing DB and rebuild from scratch", PrimaryCommands = Set("index"), Safety = CliOptionSafety.Destructive }, new() { Name = "--yes", Description = "Confirm --rebuild when stdin is redirected; interactive terminals prompt instead", PrimaryCommands = Set("index") }, new() { Name = "--optimize", Description = "Optimize the existing FTS5 table without scanning files", PrimaryCommands = Set("index") }, new() { Name = "--symbols-only", Description = "Build chunks and symbols while skipping reference graph extraction", PrimaryCommands = Set("index") }, - new() { Name = "--dry-run", Description = "Preview without writing", PrimaryCommands = Set("index", "backfill-fold", "optimize", "vacuum") }, + new() { Name = "--dry-run", Description = "Preview without writing; hooks supports it only for install", PrimaryCommands = Set("index", "hooks", "backfill-fold", "optimize", "vacuum"), Safety = CliOptionSafety.Preview }, new() { Name = "--dry-run-path-limit", ValuePlaceholder = "", Description = "Dry run only: candidate path processing limit before truncated lower-bound estimates", PrimaryCommands = Set("index") }, new() { Name = "--no-checkpoint", Description = "Skip the automatic DB checkpoint before maintenance", PrimaryCommands = Set("backfill-fold") }, - new() { Name = "--force", Description = "Bypass the per-database index lock", PrimaryCommands = Set("index") }, + new() { Name = "--force", Description = "Bypass the normal safety guard for index or hook installation", PrimaryCommands = Set("index", "hooks"), Safety = CliOptionSafety.Override }, new() { Name = "--duration-format", ValuePlaceholder = "", Description = "Index elapsed time display format", PrimaryCommands = Set("index") }, new() { Name = "--max-file-bytes", ValuePlaceholder = "", Description = "Override the per-file indexing size limit", PrimaryCommands = Set("index") }, new() { Name = "--max-symbols-per-file", ValuePlaceholder = "", Description = "Skip file content, symbols, and references when one file emits too many symbols (max 50000)", PrimaryCommands = Set("index") }, @@ -477,6 +619,72 @@ public static IReadOnlyList GetCompletionFlagsForCommand(string command return All.Where(f => f.AppliesTo(command)).ToList(); } + public static IReadOnlyList<(string Heading, IReadOnlyList Flags)> GetSharedHelpSections() + { + var emitted = new HashSet(StringComparer.Ordinal); + IReadOnlyList Select(Func predicate) => + All.Where(flag => predicate(flag) && emitted.Add(flag.Name)).ToList(); + + return + [ + ("Global options:", Select(flag => flag.TopLevel)), + ("Safety and scope options:", Select(flag => flag.Safety != CliOptionSafety.None)), + ("Index and update options:", Select(flag => flag.AppliesTo("index"))), + ("Query options:", Select(flag => + flag.PrimaryCommands.Count > 1 + && flag.PrimaryCommands.Any(command => QueryCommands.Contains(command, StringComparer.Ordinal)))), + ]; + } + + public static CliFlag? GetFlag(string command, string flagName) => + All.FirstOrDefault(flag => + string.Equals(flag.Name, flagName, StringComparison.Ordinal) + && flag.IsAcceptedBy(command)); + + public static IReadOnlyList GetCanonicalValuesForCommand(string command, string flagName) => + GetFlag(command, flagName)?.GetValueDomain(command)?.CanonicalValues ?? []; + + public static string? GetValuePlaceholderForCommand(string command, string flagName) => + GetFlag(command, flagName)?.GetValuePlaceholder(command); + + public static bool TryNormalizeOptionValue( + string command, + string flagName, + string rawValue, + out string normalizedValue) + { + var domain = GetFlag(command, flagName)?.GetValueDomain(command); + if (domain is not null) + return domain.TryNormalize(rawValue, out normalizedValue); + + normalizedValue = string.Empty; + return false; + } + + public static HashSet GetAllValueTakingOptionNames() + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var flag in All.Where(flag => flag.IsValueBearing)) + { + names.Add(flag.Name); + if (flag.ShortName is not null) + names.Add(flag.ShortName); + } + return names; + } + + public static HashSet GetAllFlagOnlyOptionNames() + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var flag in All.Where(flag => !flag.IsValueBearing)) + { + names.Add(flag.Name); + if (flag.ShortName is not null) + names.Add(flag.ShortName); + } + return names; + } + /// /// Render a next-step/help token from the same flag metadata used by parsing and /// completion. Callers may narrow a generic placeholder for their recovery context. @@ -494,7 +702,7 @@ public static string GetUsageTokenForCommand( if (flag is null) throw new ArgumentException($"{flagName} is not a documented option for {command}.", nameof(flagName)); - var placeholder = valuePlaceholderOverride ?? flag.ValuePlaceholder; + var placeholder = valuePlaceholderOverride ?? flag.GetValuePlaceholder(command); return placeholder is null ? flag.Name : $"{flag.Name} {placeholder}"; } @@ -559,4 +767,18 @@ public static (HashSet WithValues, HashSet FlagOnly) GetParserFl private static HashSet Set(params string[] items) => new(items, StringComparer.Ordinal); + + private static CliOptionValueDomain Values( + IReadOnlyList canonicalValues, + params (string Alias, string Canonical)[] aliases) + { + var aliasMap = new Dictionary(StringComparer.Ordinal); + foreach (var (alias, canonical) in aliases) + aliasMap[alias.ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal)] = canonical; + return new CliOptionValueDomain + { + CanonicalValues = canonicalValues, + Aliases = aliasMap, + }; + } } diff --git a/src/CodeIndex/Cli/CliOutputFormatCapabilities.cs b/src/CodeIndex/Cli/CliOutputFormatCapabilities.cs index 609175417..035a374b8 100644 --- a/src/CodeIndex/Cli/CliOutputFormatCapabilities.cs +++ b/src/CodeIndex/Cli/CliOutputFormatCapabilities.cs @@ -31,9 +31,6 @@ internal static class CliOutputFormatCapabilities private static readonly IReadOnlyDictionary ByName = Ordered.ToDictionary(capability => capability.Name, StringComparer.OrdinalIgnoreCase); - internal static string FormatValuePlaceholder { get; } = - $"<{string.Join('|', Ordered.Select(capability => capability.Name))}>"; - internal static string FormatDescription { get; } = $"Standard output format for token budgets, editor integrations, and CI; JSON contracts: {string.Join(", ", Ordered.Where(capability => capability.IsJsonContract).Select(capability => capability.Name))}; supported values vary by command"; diff --git a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs index 3c20963ee..612b20ff0 100644 --- a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs +++ b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs @@ -98,9 +98,6 @@ private static string GetBashCompletions() 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(" --color) COMPREPLY=($(compgen -W \"auto always never\" -- \"$cur\")) ;;\n"); - sb.Append(" --palette) COMPREPLY=($(compgen -W \"basic 256 truecolor\" -- \"$cur\")) ;;\n"); - sb.Append(" --log-format) COMPREPLY=($(compgen -W \"text json\" -- \"$cur\")) ;;\n"); sb.Append($" --lang) COMPREPLY=($(compgen -W \"{langs}\" -- \"$cur\")) ;;\n"); sb.Append($" --kind) COMPREPLY=($(compgen -W \"{kinds}\" -- \"$cur\")) ;;\n"); foreach (var (flag, values) in GetEnumValueCompletions().Where(item => item.Flag != "--format")) @@ -296,7 +293,8 @@ private static string FormatZshArgument(string name, CliFlag flag, string langs, if (!flag.IsValueBearing) return $"'{name}[{desc}]'"; - var valueSpec = flag.ValuePlaceholder switch + var valuePlaceholder = flag.GetValuePlaceholder(command ?? string.Empty); + var valueSpec = valuePlaceholder switch { "" => "file:_files", "" => "pattern", @@ -314,7 +312,7 @@ private static string FormatZshArgument(string name, CliFlag flag, string langs, "" => "address", "" => "transport:(stdio http)", _ when flag.Name == "--format" && command is not null && GetFormatValues(command) is { } formats => $"value:({string.Join(' ', formats)})", - _ when GetEnumValues(flag) is { } values => $"value:({string.Join(' ', values)})", + _ when GetEnumValues(flag, command) is { } values => $"value:({string.Join(' ', values)})", _ => "value", }; return $"'{name}[{desc}]:{valueSpec}'"; @@ -352,11 +350,9 @@ 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 argSpec = flag.ValuePlaceholder switch + var valuePlaceholder = flag.GetValuePlaceholder(string.Empty); + var argSpec = valuePlaceholder switch { - "" => " -a 'auto always never'", - "" => " -a 'basic 256 truecolor'", - "" => " -a 'text json'", _ when GetEnumValues(flag) is { } values => $" -a '{string.Join(' ', values)}'", _ => "", }; @@ -397,11 +393,11 @@ _ when GetEnumValues(flag) is { } values => $" -a '{string.Join(' ', values)}'", "group-by-name" => "Collapse same-name rows across files", _ => flag.Description, }; - var argSpec = flag.ValuePlaceholder switch + var valuePlaceholder = flag.GetValuePlaceholder(string.Empty); + var argSpec = valuePlaceholder switch { "" => $" -a '{langs}'", "" => $" -a '{kinds}'", - "" => " -a 'stdio http'", _ when GetEnumValues(flag) is { } values => $" -a '{string.Join(' ', values)}'", _ => "", }; @@ -425,9 +421,6 @@ private static string GetPowerShellCompletions() sb.AppendLine($" $commands = @({cmds})"); sb.AppendLine($" $langs = @({langs})"); sb.AppendLine($" $kinds = @({kinds})"); - sb.AppendLine(" $colorModes = @('auto', 'always', 'never')"); - sb.AppendLine(" $palettes = @('basic', '256', 'truecolor')"); - sb.AppendLine(" $logFormats = @('text', 'json')"); sb.AppendLine(" $enumValues = @{"); foreach (var (flag, values) in GetEnumValueCompletions().Where(item => item.Flag != "--format")) sb.AppendLine($" '{EscapePowerShellSingleQuoted(flag)}' = @({FormatPowerShellArray(values)})"); @@ -459,9 +452,6 @@ private static string GetPowerShellCompletions() sb.AppendLine(" Get-ChildItem -Name \"$wordToComplete*\" -ErrorAction SilentlyContinue | ForEach-Object { New-CdidxCompletion $_ 'ProviderItem' }"); sb.AppendLine(" return"); sb.AppendLine(" }"); - sb.AppendLine(" '--color' { $colorModes | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }"); - sb.AppendLine(" '--palette' { $palettes | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }"); - sb.AppendLine(" '--log-format' { $logFormats | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }"); 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(" '--format' { if ($formatValues.ContainsKey($subcmd)) { $formatValues[$subcmd] | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ } }; return }"); @@ -546,37 +536,39 @@ private static string FormatPowerShellArray(IEnumerable values) => private static string EscapePowerShellSingleQuoted(string value) => value.Replace("'", "''", StringComparison.Ordinal); - private static string[]? GetEnumValues(CliFlag flag) + private static string[]? GetEnumValues(CliFlag flag, string? command = null) { - var placeholder = flag.ValuePlaceholder; - if (placeholder is null || placeholder.Length < 3 || placeholder[0] != '<' || placeholder[^1] != '>' || !placeholder.Contains('|')) - return null; - return placeholder[1..^1].Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var domain = flag.GetValueDomain(command ?? string.Empty); + return domain?.CanonicalValues.ToArray(); } private static IEnumerable<(string Flag, string[] Values)> GetEnumValueCompletions() => CliFlagSchema.All - .Select(flag => (Flag: flag.Name, Values: GetEnumValues(flag))) + .Where(flag => flag.Name != "--format") + .Select(flag => ( + Flag: flag.Name, + Values: flag.CommandValueDomains.Count == 0 + ? GetEnumValues(flag) + : flag.CommandValueDomains.Values + .SelectMany(domain => domain.CanonicalValues) + .Distinct(StringComparer.Ordinal) + .ToArray())) .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[] Values)> GetFormatValueCompletions() { - yield return ("search", ["text", "json", "count", "compact", "grouped", "csv", "tsv", "lsp", "qf", "sarif", "issue-drafts"]); - yield return ("recipes", ["text", "json", "compact"]); - yield return ("audit", ["text", "json", "count", "compact", "issue-drafts"]); - foreach (var command in new[] { "definition", "references", "callers", "callees", "find", "validate" }) - yield return (command, ["text", "json", "count", "compact", "csv", "tsv", "lsp", "qf", "sarif"]); - yield return ("symbols", ["text", "json", "count", "compact", "lsp", "qf", "sarif"]); - yield return ("files", ["text", "json", "count", "compact"]); - yield return ("map", ["text", "json", "compact", "issue-drafts"]); - yield return ("inspect", ["text", "json", "compact"]); - yield return ("deps", ["dot", "graphml", "json-graph", "edgelist"]); - yield return ("suggestions", ["json", "markdown", "issue-drafts"]); - yield return ("languages", ["text", "json", "count"]); + foreach (var command in CliFlagSchema.AllCommands) + { + var values = CliFlagSchema.GetCanonicalValuesForCommand(command, "--format"); + if (values.Count > 0) + yield return (command, values.ToArray()); + } } private static string[]? GetFormatValues(string command) => - GetFormatValueCompletions().FirstOrDefault(item => item.Command == command).Values; + CliFlagSchema.GetCanonicalValuesForCommand(command, "--format") is { Count: > 0 } values + ? values.ToArray() + : null; } diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 18b5267e0..668384a45 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -64,7 +64,7 @@ void WriteHelpLine(string line = "") if (HiddenCommandUsageNames.Contains(name)) continue; - WriteHelpLine($" {usage}"); + WriteHelpLine($" {RenderUsageLineFromSchema(name, usage)}"); } Console.WriteLine(); PrintCommandSummary(); @@ -155,132 +155,57 @@ private static void PrintCommandSummary() private static void PrintFlagReference(Action WriteHelpLine) { + foreach (var (heading, flags) in CliFlagSchema.GetSharedHelpSections()) + { + if (flags.Count == 0) + continue; + + Console.WriteLine(); + Console.WriteLine(heading); + foreach (var flag in flags) + { + WriteHelpLine($" {FormatSharedFlagToken(flag)}"); + WriteHelpLine($" {flag.Description}"); + } + + if (string.Equals(heading, "Index and update options:", StringComparison.Ordinal)) + WriteHelpLine(" .cdidxignore Optional project-local ignore file; loaded after .gitignore in each directory"); + if (string.Equals(heading, "Query options:", StringComparison.Ordinal)) + WriteHelpLine($" {FormatRelatedOptionTokens("search", "--limit", "--top", "--max-results")} Equivalent result-limit options"); + } + Console.WriteLine(); - Console.WriteLine("Index and update options:"); - Console.WriteLine(" --db Database file path (default for index: /.cdidx/codeindex.db)"); - WriteHelpLine(" .cdidxignore Optional project-local ignore file; loaded after .gitignore in each directory"); - Console.WriteLine(" --rebuild Delete existing DB and rebuild from scratch"); - Console.WriteLine(" --verbose Show per-file status ([OK ]/[SKIP]/[DEL ]/[ERR ])"); - Console.WriteLine(" --dry-run Scan files without writing to the database"); - WriteHelpLine($" --dry-run-path-limit Dry run only: process at most candidate paths before returning truncated lower-bound estimates (default: {IndexCommandRunner.DefaultDryRunPathLimit}, max: {IndexCommandRunner.MaxDryRunPathLimit})"); - Console.WriteLine(" --force Bypass the per-database index lock; only use when no other cdidx index is active"); - WriteHelpLine(" --symbols-only Build chunks and symbols but skip reference extraction; graph queries stay degraded until a normal index run"); - Console.WriteLine(" --json Output results as JSON (for AI/machine use)"); - Console.WriteLine(" --memory-trace Include phase memory samples in index JSON output"); - Console.WriteLine(" --quiet, -q, --silent Suppress informational stderr output; errors still print (also honors CDIDX_QUIET=1)"); - Console.WriteLine(" --duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms"); - WriteHelpLine(" --notify Long index completion signal: auto, bell, osc9, desktop, or none (also honors CDIDX_NOTIFY; quiet/json suppress it)"); - WriteHelpLine(" --max-file-bytes Index only files up to this size (default: 4MiB; also honors CDIDX_MAX_FILE_BYTES; accepts K/M/G suffixes)"); - WriteHelpLine(" --max-symbols-per-file Skip file content, symbols, and references when one file emits too many symbols (default: 5000; max: 50000)"); - WriteHelpLine(" --max-references-per-file Skip references when one file emits too many references (default: 100000; max: 1000000)"); - WriteHelpLine(" --parallelism Full-scan extraction workers (default: CPU count capped at 8; explicit max: 16; also honors CDIDX_INDEX_PARALLELISM)"); - WriteHelpLine(" --follow-symlinks Symlink policy for directories and files: none (default), internal, or all"); - WriteHelpLine(" --include-symbol-kind [,] Keep only matching symbol kinds during indexing"); - WriteHelpLine(" --exclude-symbol-kind [,] Drop matching symbol kinds during indexing"); - Console.WriteLine(" --commits [commit-ref ...]"); - Console.WriteLine($" Update only files changed in the specified git commits (preferred after commits; max {IndexCommandRunner.MaxCommitRefCount} refs, {IndexCommandRunner.MaxCommitRefLength} chars each)"); - Console.WriteLine(" --changed-between "); - Console.WriteLine(" Update only files changed between two git refs (useful after branch switches)"); - Console.WriteLine(" --files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed"); - WriteHelpLine(" --watch After the initial scan, stay running and reindex on file changes (FileSystemWatcher / inotify / FSEvents); rejects --commits / --changed-between / --files / --dry-run"); - Console.WriteLine($" --debounce Watch only: coalesce bursts of file events into one update after of quiet (default: {IndexWatchRunner.DefaultDebounceMs}, max {IndexWatchRunner.MaxDebounceMs})"); - WriteHelpLine($" --watch-pending-path-limit Watch only: pending changed-path queue limit before falling back to a full rescan (default: {IndexWatchRunner.DefaultWatchPendingPathLimit}, max: {IndexWatchRunner.MaxWatchPendingPathLimit}; also honors {IndexCommandRunner.WatchPendingPathLimitEnvironmentVariable})"); - Console.WriteLine(" --optimize index only: optimize the existing FTS5 table for this project's DB without scanning files"); - WriteHelpLine(" --color Color output: `auto` (default), `always`, or `never`; flag wins over `CLICOLOR_FORCE` / `NO_COLOR` / `CLICOLOR` env vars, which win over TTY auto-detect"); - WriteHelpLine(" --palette ANSI palette: `basic` (8-color, default fallback), `256`, or `truecolor`; flag wins over `CDIDX_COLOR_PALETTE` env var, which wins over `COLORTERM` / `TERM` auto-detect"); - WriteHelpLine(" --ascii Use ASCII spinner/progress glyphs instead of Unicode glyphs (also honors CDIDX_ASCII=1, NO_UNICODE, TERM=dumb, accessibility env hints, and non-UTF-8 locales)"); - WriteHelpLine(" --no-progress Disable animated progress/spinner output (also honors CDIDX_DISABLE_PROGRESS=1 and PREFERS_REDUCED_MOTION)"); - Console.WriteLine(" --metrics Append one JSONL record per CLI command / MCP tool call to (also honors CDIDX_METRICS=)"); - Console.WriteLine(" --log-format Persistent stderr log format (also honors CDIDX_LOG_FORMAT)"); - Console.WriteLine(" --log-retain-count Persistent stderr log file retention count (also honors CDIDX_LOG_RETAIN)"); - Console.WriteLine(" --log-max-size-mb Persistent stderr log rotation size cap in MiB (also honors CDIDX_LOG_MAX_SIZE_MB)"); - WriteHelpLine(" --debug-unsafe Allow raw debug dumps only when CDIDX_DEBUG=unsafe is also set; local troubleshooting only"); - WriteHelpLine(" --strict-version Treat workspace version pin mismatches as exit code 64 instead of warnings"); - Console.WriteLine(" --help, -h Show this help message"); - Console.WriteLine(" --version, -V Show version information"); - Console.WriteLine(" --license Show licensing, trademark, and commercial-use summary"); - Console.WriteLine(" --completions Generate shell completions (bash, zsh, fish, powershell)"); + Console.WriteLine("Built-in help options:"); + WriteHelpLine(" --help, -h Show help"); + WriteHelpLine(" --help-all Show every command, usage form, and shared option"); + WriteHelpLine(" --help-flags Show shared options"); + WriteHelpLine(" --version, -V Show version information"); + WriteHelpLine(" --license Show licensing, trademark, and commercial-use summary"); + WriteHelpLine(" --completions Generate a shell completion script"); + Console.WriteLine(); Console.WriteLine("Update workflows:"); - Console.WriteLine(" Use --commits with a project path after normal commits; git diff sees rename/delete paths too."); - Console.WriteLine(" Use --changed-between after switching branches to refresh only changed files."); - Console.WriteLine(" Use --files only for known in-place edits or new files; old rename/delete paths stay indexed unless also listed."); - Console.WriteLine(" Incremental writes optimize FTS5 opportunistically after a small maintenance threshold; run `cdidx optimize` for manual maintenance."); + WriteHelpLine(" Use --commits with a project path after normal commits; git diff sees rename/delete paths too."); + WriteHelpLine(" Use --changed-between after switching branches to refresh only changed files."); + WriteHelpLine(" Use --files only for known in-place edits or new files; old rename/delete paths stay indexed unless also listed."); + WriteHelpLine(" Incremental writes optimize FTS5 opportunistically after a small maintenance threshold; run `cdidx optimize` for manual maintenance."); Console.WriteLine(); - Console.WriteLine("Query options:"); - Console.WriteLine(" --db Database file path (default: .cdidx/codeindex.db in current directory)"); - WriteHelpLine(" --json Output as JSON (search/symbols/files stream ndjson by default; search/symbols/files/validate accept --json=array for one array)"); - WriteHelpLine(" --verbose Query commands: emit debug diagnostics to stderr; with --json, append an _debug JSON object"); - 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 Read commands: log profiled SQL statements that take at least ms (use 0 to log every statement)"); - Console.WriteLine(" --limit , --top , --max-results "); - Console.WriteLine(" Max results to return (default: 20)"); - Console.WriteLine(" --lang Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)"); - Console.WriteLine(" --allow-unknown-lang Allow an unregistered plugin language ID and preserve its exact spelling"); - Console.WriteLine(" --path Restrict matches to glob-style path patterns (* and ?)"); - WriteHelpLine($" --query Pass a query literal, useful when the query starts with '-' (`search`/`find` max {QueryLimits.MaxQueryLength} chars)"); - WriteHelpLine(" --named-query = search only: add a named ad hoc batch query; repeat to run related searches with grouped compact results"); - Console.WriteLine(" --exclude-path Exclude glob-style path patterns (* and ?) (repeatable)"); - Console.WriteLine(" --exclude-tests Exclude likely test files"); - WriteHelpLine(" --audit-scope search/unused: source uses production-code cleanup defaults; all disables source-scope defaults"); - Console.WriteLine(" --source-only search only: shorthand for --audit-scope source on ad hoc and named searches"); - Console.WriteLine(" --exclude-comments search only: suppress comment-only matches"); - Console.WriteLine(" --exclude-strings search only: suppress string, regex, and help-text matches"); - Console.WriteLine(" --exclude-fixtures search only: suppress fixture-only matches in tests"); - WriteHelpLine(" --origin/--match-origin search only: keep only matches from selected origins (code, comment, string_literal, regex_literal, help_text, unknown; repeatable or comma-separated)"); - WriteHelpLine(" --exclude-origin search only: drop matches from selected origins while keeping other origins in the same result"); - Console.WriteLine(" --include-generated Include generated files in query results"); - Console.WriteLine(" --snippet-lines search/find snippet length (1-20, default: search 8; find 1)"); - Console.WriteLine(" --snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)"); - WriteHelpLine($" --max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: {LineWidthFormatter.DefaultMaxLineWidth})"); - WriteHelpLine(" --focus-line find/excerpt: focus a line; excerpt keeps the leading window when no column is supplied"); - Console.WriteLine(" --focus-column find/excerpt: focus a specific 1-based column"); - Console.WriteLine(" --focus-length excerpt: width of the focused span (default: 1, requires --focus-column)"); - Console.WriteLine(" --no-semantic-tokens excerpt JSON: omit semantic_tokens for compact line/window payloads"); - WriteHelpLine($" --fts Use raw FTS5 query syntax for search (content:term, NEAR(a b, 5), OR, NOT, groups, prefix*, \"phrase\"; search query max {QueryLimits.MaxQueryLength} chars; raw FTS parser max {DbReader.MaxRawFtsQueryLength} chars, {DbReader.MaxRawFtsBooleanOperators} boolean ops, {DbReader.MaxRawFtsNearOperators} NEAR ops; trailing * is a prefix shorthand in literal-safe mode)"); - Console.WriteLine(" --exact Backward-compatible shorthand."); - Console.WriteLine(" Prefer --exact-substring for search,"); - Console.WriteLine(" --exact for find,"); - Console.WriteLine(" and --exact-name for symbol/graph lookups."); - Console.WriteLine(" Combining exact-match flags is rejected."); - Console.WriteLine(" --exact-substring Search only: case-sensitive exact substring"); - Console.WriteLine(" (no FTS5)"); - Console.WriteLine(" --token-boundary Search only: exact substring plus code-token"); - Console.WriteLine(" boundaries; excludes longer identifiers."); - Console.WriteLine(" --exact-name Exact name match for symbols, definition,"); - Console.WriteLine(" references, callers, callees, and inspect."); - Console.WriteLine(" Uses NFKC + Unicode CaseFold when ready."); - Console.WriteLine(" Legacy/stale-fold DBs fall back to ASCII NOCASE;"); - Console.WriteLine(" run `cdidx backfill-fold` or check fold_ready."); - WriteHelpLine(" --kind definition/symbols/outline/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation/type_tag/bcl_regex_without_timeout); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); - WriteHelpLine(" --sort Symbols/outline: order audit output by a ranking signal; outline also accepts source, kind, references, size, complexity, path, and name"); - Console.WriteLine(" --severity validate only: filter issues by severity: info, warning, error"); - Console.WriteLine(" --visibility Filter symbols/definitions/unused/hotspots by visibility: public, protected, internal, private"); - WriteHelpLine(" --exclude-visibility Exclude symbols/definitions/unused/hotspots by visibility"); - WriteHelpLine(" --count Count only; result limits are ignored by count modes, but scan caps can still mark approximate counts as degraded"); - WriteHelpLine(" --group-partials definition/symbols/inspect symbol mode: collapse partial type declarations into logical families while preserving physical counts and definition_sites"); - WriteHelpLine(" --bucket unused only: filter one unused confidence bucket"); - WriteHelpLine(" --min-confidence unused only: filter medium or low confidence candidates; --confidence is an alias"); - WriteHelpLine(" --all unused only: include low-confidence contract-domain candidates suppressed by default"); - WriteHelpLine(" --actionable unused only: preset for private medium-confidence cleanup candidates"); - Console.WriteLine(" --since Filter to files modified since this timestamp (ISO 8601)"); - Console.WriteLine(" --no-dedup search only: return every raw overlapping chunk hit (debug/density)"); - WriteHelpLine($" --require-before/--require-after search only: keep primary matches only when the guard query appears within --guard-window lines before/after the match (default {DbReader.DefaultSearchGuardWindow}, max {DbReader.MaxSearchGuardWindow})"); - WriteHelpLine(" --reject-before/--reject-after search only: drop primary matches when the guard query appears within the same before/after window; useful for finding API calls missing nearby checks"); - WriteHelpLine(" --guard-scope search only: evaluate guards in the line window (default) or only on the same line before/after the primary match"); - WriteHelpLine(" --bytes files: sort by size and show raw byte counts in human output; map: show raw byte counts; JSON always keeps raw integer bytes"); - Console.WriteLine(" --min-entrypoint-confidence map only: omit entrypoint candidates below this 0.0..1.0 confidence"); - WriteHelpLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); - Console.WriteLine(" --depth Deprecated alias for --max-hops"); - Console.WriteLine(" --reverse Reverse direction for deps (show dependents)"); - WriteHelpLine(" --group-by search: with --count, group rows by file, symbol, origin, return-type, or subsystem; hotspots: group by symbol or file, or by statement only with --lang sql"); - WriteHelpLine(" --group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON keeps capped paths plus full definition_site_details"); - WriteHelpLine(" --with-paths impact: also emit `paths` per caller — the shortest call chains [root, ..., caller] (diamond graphs surface every converging route, capped per row)"); - WriteHelpLine(" unused reflection note C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed; dynamically constructed reflection names may need manual review"); WriteHelpLine(" Note: if a query itself starts with '-', pass it with --query or -- ; for option values that start with '--', use --opt=."); } + private static string FormatSharedFlagToken(CliFlag flag) + { + var names = flag.ShortName is null ? flag.Name : $"{flag.Name}, {flag.ShortName}"; + var placeholder = flag.CommandValueDomains.Count > 0 + ? "" + : flag.GetValuePlaceholder(string.Empty); + return placeholder is null ? names : $"{names} {placeholder}"; + } + + private static string FormatRelatedOptionTokens(string command, params string[] flagNames) => + string.Join(", ", flagNames.Select(flagName => + CliFlagSchema.GetUsageTokenForCommand(command, flagName))); + private static void PrintExamples() { Console.WriteLine("Examples:"); @@ -446,7 +371,7 @@ internal static LicenseJsonResult BuildLicenseJsonResult() => foreach (var (name, usage) in CommandUsageLines) { if (string.Equals(name, command, StringComparison.Ordinal)) - return usage; + return RenderUsageLineFromSchema(command, usage); } return null; @@ -478,7 +403,7 @@ public static bool PrintCommandUsage(string command) && ProjectionFieldRegistry.SupportsCommand(schemaCommand); var valuePlaceholder = projectionFields ? ProjectionFieldRegistry.GetHelpValuePlaceholder(schemaCommand) - : flag.ValuePlaceholder; + : flag.GetValuePlaceholder(schemaCommand); var description = projectionFields ? ProjectionFieldRegistry.GetHelpDescription(schemaCommand) : flag.Description; @@ -509,13 +434,61 @@ private static IReadOnlyList GetCommandUsageLines(string command) if (string.Equals(name, command, StringComparison.Ordinal) || string.Equals(command, "index", StringComparison.Ordinal) && name.StartsWith("index-", StringComparison.Ordinal)) { - usages.Add(usage); + usages.Add(RenderUsageLineFromSchema(command, usage)); } } return usages; } + private static string RenderUsageLineFromSchema(string command, string usage) + { + var schemaCommand = GetFlagSchemaCommandName(command); + foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(schemaCommand)) + { + if (flag.ValueDomain is null + && flag.CommandValueDomains.Count == 0 + && flag.CommandValuePlaceholders.Count == 0) + continue; + + var placeholder = flag.GetValuePlaceholder(schemaCommand); + if (placeholder is null) + continue; + + usage = ReplaceUsagePlaceholder(usage, flag.Name, placeholder); + } + + return usage; + } + + private static string ReplaceUsagePlaceholder(string usage, string flagName, string replacement) + { + var searchStart = 0; + var prefix = $"{flagName} "; + while (searchStart < usage.Length) + { + var flagStart = usage.IndexOf(prefix, searchStart, StringComparison.Ordinal); + if (flagStart < 0) + break; + + var valueStart = flagStart + prefix.Length; + if (valueStart >= usage.Length || usage[valueStart] != '<') + { + searchStart = valueStart; + continue; + } + + var valueEnd = usage.IndexOf('>', valueStart); + if (valueEnd < 0) + break; + + usage = usage[..valueStart] + replacement + usage[(valueEnd + 1)..]; + searchStart = valueStart + replacement.Length; + } + + return usage; + } + private static IReadOnlyList GetCommandUsageNotes(string command) { command = NormalizeCommandUsageName(command); diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 3555ddc6d..8831bdd95 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -76,7 +76,7 @@ public static partial class ConsoleUi private static readonly (string Command, string Usage)[] CommandUsageLines = [ ("index", "cdidx index [--db ] [--rebuild [--yes]] [--optimize] [--symbols-only] [--verbose] [--dry-run [--dry-run-path-limit ]] [--force] [--quiet] [--json] [--allow-partial] [--memory-trace] [--duration-format ] [--notify ] [--max-file-bytes ] [--max-symbols-per-file ] [--max-references-per-file ] [--follow-symlinks ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]] [--watch [--debounce ] [--watch-pending-path-limit ]]"), - ("hooks", "cdidx hooks [--project ] [--force] [--json]"), + ("hooks", "cdidx hooks [--project ] [--force] [--dry-run] [--json]"), ("backfill-fold", "cdidx backfill-fold [--db ] [--dry-run] [--no-checkpoint] [--json]"), ("optimize", "cdidx optimize [--db ] [--dry-run] [--json]"), ("vacuum", "cdidx vacuum [--db ] [--dry-run] [--json]"), @@ -88,7 +88,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("recipes-list", "cdidx recipes list [--query ] [--names|--summary-only] [--json] [--pretty] [--format ] [--max-json-bytes ]"), ("audit", "cdidx audit [search filters] [--json[=ndjson]] [--format ] [--summary-only] [--limit ] [--total-limit ] [--results-only] [--search-fields ] [--first-per-file] [--sample ] [--max-json-bytes ] [--snippet-lines ]"), ("definition", "cdidx definition |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]"), - ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--all]"), + ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact-name] [--all]"), ("references", "cdidx references |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("callers", "cdidx callers |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("callees", "cdidx callees |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), diff --git a/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs b/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs index 0c29d1f0d..84b57b11d 100644 --- a/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs +++ b/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs @@ -230,25 +230,11 @@ private static bool ShouldDeferOutputCombinationValidation( } private static bool CommandAcceptsOutputFormat(string commandName, string outputFormat) - { - var usage = ConsoleUi.GetUsageLine(commandName); - if (usage == null) - return false; - - const string formatPrefix = "--format <"; - var formatStart = usage.IndexOf(formatPrefix, StringComparison.Ordinal); - if (formatStart < 0) - return false; - - formatStart += formatPrefix.Length; - var formatEnd = usage.IndexOf('>', formatStart); - if (formatEnd < 0) - return false; - - return usage[formatStart..formatEnd] - .Split('|', StringSplitOptions.RemoveEmptyEntries) - .Contains(outputFormat, StringComparer.Ordinal); - } + => CliFlagSchema.TryNormalizeOptionValue( + commandName, + "--format", + outputFormat, + out _); private static bool TryResolveOutputValidationCommand( string[] args, diff --git a/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs b/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs index c15c0b2c1..8cc9d5387 100644 --- a/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs +++ b/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs @@ -539,53 +539,14 @@ private static bool TryClassifySearchValueTakingOption(string arg, out bool hasI } private static readonly HashSet SearchValueTakingOptions = - [ - "--db", - "--color", - "--data-dir", - "--metrics", - "--palette", - "--trace", - "--limit", - "--top", - "--lang", - "--kind", - "--visibility", - "--exclude-visibility", - "--since", - "--start", - "--end", - "--before", - "--after", - "--name", - "--snippet-lines", - "--snippet-focus", - "--path", - "--require-before", - "--require-after", - "--reject-before", - "--reject-after", - "--guard-window", - "--guard-scope", - "--project", - "--solution", - "--exclude-path", - "--max-hops", - "--depth", - "--query", - "--group-by", - "--focus-line", - "--focus-column", - "--focus-length", - "--max-line-width", - "--stale-after", - "--explain", - "--rank-by", - "--slow-query-ms", - "--format", - "--min-entrypoint-confidence", - "--sections", - ]; + BuildSearchValueTakingOptions(); + + private static HashSet BuildSearchValueTakingOptions() + { + var (commandValues, _) = CliFlagSchema.GetParserFlagsPartitionedByValueBearing("search"); + commandValues.UnionWith(CliFlagSchema.GetTopLevelValueOptionNames()); + return commandValues; + } private static bool TryConsumeValueFlag(string[] args, ref int index, string arg, string flag, out string value) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs index 3e83dc3a4..10b95d4a2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs @@ -430,11 +430,12 @@ private static void AddSearchMatchOrigins(string optionName, string rawValue, Li { if (!ValidateCsvBounds(optionName, rawValue, MaxSearchProjectionFieldsCsvLength, MaxSearchProjectionFieldsCsvEntries, addParseError)) return; + var acceptedValues = CliFlagSchema.GetCanonicalValuesForCommand("search", optionName); foreach (var rawOrigin in rawValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - if (!TryNormalizeSearchMatchOrigin(rawOrigin, out var origin)) + if (!CliFlagSchema.TryNormalizeOptionValue("search", optionName, rawOrigin, out var origin)) { - addParseError($"Error: unsupported {optionName} value '{ConsoleUi.FormatBoundedValue(rawOrigin)}'. Use code, comment, string_literal, regex_literal, help_text, schema_description, or unknown."); + addParseError($"Error: unsupported {optionName} value '{ConsoleUi.FormatBoundedValue(rawOrigin)}'. Use {FormatOptionValueList(acceptedValues)}."); continue; } if (!origins.Contains(origin, StringComparer.Ordinal)) @@ -450,50 +451,16 @@ private static void AddSourceOnlyDefaultExcludeOrigin(List excludeOrigin excludeOrigins.Add(origin); } - private static bool TryNormalizeSearchMatchOrigin(string rawOrigin, out string origin) - { - switch (rawOrigin.ToLowerInvariant().Replace("-", "_")) - { - case SearchMatchClassifier.Code: - origin = SearchMatchClassifier.Code; - return true; - case SearchMatchClassifier.Comment: - origin = SearchMatchClassifier.Comment; - return true; - case "string": - case SearchMatchClassifier.StringLiteral: - origin = SearchMatchClassifier.StringLiteral; - return true; - case "regex": - case SearchMatchClassifier.RegexLiteral: - origin = SearchMatchClassifier.RegexLiteral; - return true; - case "help": - case SearchMatchClassifier.HelpText: - origin = SearchMatchClassifier.HelpText; - return true; - case "schema": - case SearchMatchClassifier.SchemaDescription: - origin = SearchMatchClassifier.SchemaDescription; - return true; - case SearchMatchClassifier.Unknown: - origin = SearchMatchClassifier.Unknown; - return true; - default: - origin = string.Empty; - return false; - } - } - private static void AddSearchResultKinds(string rawValue, List resultKinds, Action addParseError) { if (!ValidateCsvBounds("--result-kind", rawValue, MaxSearchProjectionFieldsCsvLength, MaxSearchProjectionFieldsCsvEntries, addParseError)) return; + var acceptedValues = CliFlagSchema.GetCanonicalValuesForCommand("search", "--result-kind"); foreach (var rawKind in rawValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - if (!TryNormalizeSearchResultKind(rawKind, out var kind)) + if (!CliFlagSchema.TryNormalizeOptionValue("search", "--result-kind", rawKind, out var kind)) { - addParseError($"Error: unsupported --result-kind value '{ConsoleUi.FormatBoundedValue(rawKind)}'. Use call_site, declaration, identifier, code, comment, string_literal, regex_literal, help_text, schema_description, or unknown."); + addParseError($"Error: unsupported --result-kind value '{ConsoleUi.FormatBoundedValue(rawKind)}'. Use {FormatOptionValueList(acceptedValues)}."); continue; } if (!resultKinds.Contains(kind, StringComparer.Ordinal)) @@ -501,53 +468,13 @@ private static void AddSearchResultKinds(string rawValue, List resultKin } } - private static bool TryNormalizeSearchResultKind(string rawKind, out string kind) - { - switch (rawKind.ToLowerInvariant().Replace("-", "_")) + private static string FormatOptionValueList(IReadOnlyList values) => + values.Count switch { - case "call": - case "callsite": - case "call_site": - kind = "call_site"; - return true; - case "decl": - case "declaration": - kind = "declaration"; - return true; - case "identifier": - case "ident": - kind = "identifier"; - return true; - case SearchMatchClassifier.Code: - kind = SearchMatchClassifier.Code; - return true; - case SearchMatchClassifier.Comment: - kind = SearchMatchClassifier.Comment; - return true; - case "string": - case SearchMatchClassifier.StringLiteral: - kind = SearchMatchClassifier.StringLiteral; - return true; - case "regex": - case SearchMatchClassifier.RegexLiteral: - kind = SearchMatchClassifier.RegexLiteral; - return true; - case "help": - case SearchMatchClassifier.HelpText: - kind = SearchMatchClassifier.HelpText; - return true; - case "schema": - case SearchMatchClassifier.SchemaDescription: - kind = SearchMatchClassifier.SchemaDescription; - return true; - case SearchMatchClassifier.Unknown: - kind = SearchMatchClassifier.Unknown; - return true; - default: - kind = string.Empty; - return false; - } - } + 0 => "a documented value", + 1 => values[0], + _ => $"{string.Join(", ", values.Take(values.Count - 1))}, or {values[values.Count - 1]}", + }; private static bool ValidateCsvBounds( string optionName, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Options.cs b/src/CodeIndex/Cli/QueryCommandRunner.Options.cs index bdd72931d..db6882ff4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Options.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Options.cs @@ -2,145 +2,18 @@ namespace CodeIndex.Cli; public static partial class QueryCommandRunner { - private static readonly HashSet FlagOnlyOptions = - [ - "--json", - "--fts", - "--body", - "--count", - "--strict-not-found", - "--strict", - "--no-dedup", - "--no-visibility-rank", - "--exact", - "--exact-name", - "--exact-substring", - "--token-boundary", - "--prefix", - "--reverse", - "--help", - "-h", - "--version", - "-V", - "--verbose", - "--quiet", - "-q", - "--silent", - "--actionable", - "--by-bucket", - "--all", - "--names", - "--summary-only", - "--cycles", - "--group-by-name", - "--with-paths", - "--bytes", - "--profile", - "--check-updates", - "--list-recipes", - "--read-only", - "--immutable", - "--dry-run", - "--pretty", - "--compact", - "--body-only", - "--outline-only", - "--first-per-file", - "--results-only", - "--next-steps", - "--source-only", - "--generated", - "--no-semantic-tokens", - ]; + private static readonly HashSet FlagOnlyOptions = BuildFlagOnlyOptions(); private static readonly HashSet ValueTakingOptions = - [ - "--db", - "--data-dir", - "--limit", - "--max-results", - "--top", - "--graph-budget", - "--lang", - "--language", - "--extension", - "--alias", - "--kind", - "--bucket", - "--confidence", - "--min-confidence", - "--severity", - "--visibility", - "--exclude-visibility", - "--since", - "--line", - "--start", - "--start-line", - "--end", - "--end-line", - "--context", - "--before", - "--after", - "--body-start", - "--body-lines", - "--body-line-count", - "--name", - "--snippet-lines", - "--snippet-focus", - "--path", - "--require-before", - "--require-after", - "--reject-before", - "--reject-after", - "--guard-window", - "--guard-scope", - "--project", - "--solution", - "--exclude-path", - "--max-hops", - "--depth", - "--query", - "--recipe", - "--include-query", - "--exclude-query", - "--named-query", - "--open-issues", - "--repo", - "--duplicate-confidence", - "--duplicate-threshold", - "--issue-title", - "--issue-label", - "--cursor", - "--group-by", - "--unique", - "--count-by", - "--origin", - "--match-origin", - "--exclude-origin", - "--result-kind", - "--sample", - "--per-file-limit", - "--total-limit", - "--max-json-bytes", - "--search-fields", - "--outline-fields", - "--focus-line", - "--focus-column", - "--focus-length", - "--max-line-width", - "--stale-after", - "--explain", - "--rank-by", - "--sort", - "--slow-query-ms", - "--format", - "--min-entrypoint-confidence", - "--sections", - "--fields", - ]; + CliFlagSchema.GetAllValueTakingOptionNames(); private static readonly HashSet InlineValueOptions = - new( - ValueTakingOptions.Concat(["--json", "--log-format", "--log-retain-count", "--log-max-size-mb"]), - StringComparer.Ordinal); + new(ValueTakingOptions.Concat(["--json"]), StringComparer.Ordinal); + + private static HashSet BuildFlagOnlyOptions() + { + var options = CliFlagSchema.GetAllFlagOnlyOptionNames(); + options.UnionWith(["--help", "-h", "--version", "-V"]); + return options; + } } diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index e48a184e0..9eb47b06a 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -357,6 +357,85 @@ public void EveryFlagInFishCompletion_IsBackedBySchemaEntry() } } + [Fact] + public void CanonicalValueRegistry_DrivesHelpValidationAndCompletions_Issue4861() + { + var auditFormats = CliFlagSchema.GetCanonicalValuesForCommand("audit", "--format"); + Assert.Contains("sarif", auditFormats); + Assert.DoesNotContain("lsp", auditFormats); + foreach (var command in CliFlagSchema.AllCommands) + { + foreach (var format in CliFlagSchema.GetCanonicalValuesForCommand(command, "--format")) + Assert.True(CliOutputFormatCapabilities.TryGet(format, out _), $"{command} registers unknown format {format}."); + } + + var origins = CliFlagSchema.GetCanonicalValuesForCommand("search", "--origin"); + Assert.Equal( + ["code", "comment", "string_literal", "regex_literal", "help_text", "schema_description", "unknown"], + origins); + var resultKinds = CliFlagSchema.GetCanonicalValuesForCommand("search", "--result-kind"); + Assert.Equal( + ["call_site", "declaration", "identifier", "code", "comment", "string_literal", "regex_literal", "help_text", "schema_description", "unknown"], + resultKinds); + Assert.True(CliFlagSchema.TryNormalizeOptionValue("search", "--origin", "schema", out var normalizedOrigin)); + Assert.Equal("schema_description", normalizedOrigin); + Assert.True(CliFlagSchema.TryNormalizeOptionValue("search", "--result-kind", "callsite", out var normalizedKind)); + Assert.Equal("call_site", normalizedKind); + + Assert.Contains("--format ", ConsoleUi.GetUsageLine("audit")); + var searchUsage = ConsoleUi.GetUsageLine("search"); + Assert.NotNull(searchUsage); + Assert.Contains("--origin ", searchUsage); + Assert.Contains("--result-kind ", searchUsage); + + var acceptsFormat = typeof(ProgramRunner).GetMethod( + "CommandAcceptsOutputFormat", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(acceptsFormat); + Assert.Equal(true, acceptsFormat!.Invoke(null, ["audit", "sarif"])); + Assert.Equal(false, acceptsFormat.Invoke(null, ["audit", "lsp"])); + + var bash = ConsoleCompletionRenderer.GetCompletionScript("bash"); + Assert.Contains("audit) COMPREPLY=($(compgen -W \"text json count compact sarif issue-drafts\"", bash); + Assert.Contains("--origin) COMPREPLY=($(compgen -W \"code comment string_literal regex_literal help_text schema_description unknown\"", bash); + + var fish = ConsoleCompletionRenderer.GetCompletionScript("fish"); + Assert.Contains("__fish_seen_subcommand_from audit' -l format -r -a 'text json count compact sarif issue-drafts'", fish); + Assert.Contains("__fish_seen_subcommand_from search' -l result-kind -r -a 'call_site declaration identifier code comment string_literal regex_literal help_text schema_description unknown'", fish); + } + + [Fact] + public void RegistrySurfacesSafetyAndAcceptedOptionsWithoutAdvertisingRejectedGotoExact_Issue4861() + { + var hookFlags = CliFlagSchema.GetAcceptedFlagNamesForCommand("hooks"); + Assert.Contains("--dry-run", hookFlags); + Assert.Contains(CliFlagSchema.GetCompletionFlagsForCommand("hooks"), flag => flag.Name == "--dry-run"); + + var gotoFlags = CliFlagSchema.GetAcceptedFlagNamesForCommand("goto"); + Assert.DoesNotContain("--exact", gotoFlags); + Assert.Contains("--exact-name", gotoFlags); + Assert.DoesNotContain("--exact|--exact-name", ConsoleUi.GetUsageLine("goto"), StringComparison.Ordinal); + + var (_, hookHelp, _) = ConsoleCapture.Capture(() => + ConsoleUi.PrintCommandUsage("hooks") ? 1 : 0); + Assert.Contains("--dry-run", hookHelp, StringComparison.Ordinal); + Assert.Contains("--project ", hookHelp, StringComparison.Ordinal); + Assert.DoesNotContain("--pretty", hookHelp, StringComparison.Ordinal); + + using var capture = ConsoleCapture.Start(captureOut: true); + ConsoleUi.PrintFlagUsage(showBanner: false); + var flagHelp = capture.Out!.ToString(); + Assert.Contains("Safety and scope options:", flagHelp, StringComparison.Ordinal); + foreach (var flag in new[] + { + "--read-only", "--immutable", "--data-dir", "--trace", + "--strict-not-found", "--project", "--solution", + }) + { + Assert.Contains(flag, flagHelp, StringComparison.Ordinal); + } + } + // Mirrors the EnumeratedCompletionCommands list inside ConsoleCompletionRenderer - the only commands // that get their own bash/zsh branch (everything else falls into the generic else branch). // ConsoleCompletionRenderer 側の EnumeratedCompletionCommands に対応する一覧。 diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 97a21dc64..6dd38a0b8 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -15,7 +15,7 @@ namespace CodeIndex.Tests; public class ConsoleUiTests { [Fact] - public void PrintCommandUsage_DedicatedParsersDoNotEmitPartialParentOptionLists_Issue4571() + public void PrintCommandUsage_DedicatedParsersOnlyEmitAuthoritativeParentOptionLists_Issues4571_4861() { var (_, dbSchemaHelp, _) = ConsoleCapture.Capture(() => ConsoleUi.PrintCommandUsage("db-schema") ? 1 : 0); @@ -27,8 +27,9 @@ public void PrintCommandUsage_DedicatedParsersDoNotEmitPartialParentOptionLists_ Assert.DoesNotContain("\nOptions:\n", dbSchemaHelp, StringComparison.Ordinal); Assert.Contains("--project ", hooksHelp, StringComparison.Ordinal); Assert.Contains("--force", hooksHelp, StringComparison.Ordinal); + Assert.Contains("--dry-run", hooksHelp, StringComparison.Ordinal); Assert.Contains("--json", hooksHelp, StringComparison.Ordinal); - Assert.DoesNotContain("\nOptions:\n", hooksHelp, StringComparison.Ordinal); + Assert.Contains("\nOptions:\n", hooksHelp, StringComparison.Ordinal); } [Fact] @@ -389,7 +390,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.DoesNotContain("██████╗", output); Assert.Contains("Usage:", output); Assert.Contains("cdidx index [--db ] [--rebuild [--yes]] [--optimize] [--symbols-only] [--verbose] [--dry-run [--dry-run-path-limit ]] [--force] [--quiet] [--json] [--allow-partial] [--memory-trace] [--duration-format ] [--notify ] [--max-file-bytes ] [--max-symbols-per-file ] [--max-references-per-file ] [--follow-symlinks ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]] [--watch [--debounce ] [--watch-pending-path-limit ]]", output); - Assert.Contains("cdidx hooks [--project ] [--force] [--json]", output); + Assert.Contains("cdidx hooks [--project ] [--force] [--dry-run] [--json]", output); Assert.Contains("cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run [--dry-run-path-limit ]] [--json] [--allow-partial] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]", output); Assert.Contains("cdidx index --changed-between [--db ] [--verbose] [--dry-run [--dry-run-path-limit ]] [--json] [--allow-partial] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]", output); Assert.Contains("cdidx index --files [path ...] [--db ] [--verbose] [--dry-run [--dry-run-path-limit ]] [--json] [--allow-partial] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]", output); @@ -407,43 +408,19 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); Assert.Contains("cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--sort ] [--kind ] [--outline-fields ]", output); - Assert.Contains("--snippet-lines search/find snippet length (1-20, default: search 8; find 1)", output); - Assert.Contains("--snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)", output); - Assert.Contains("--max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: 512)", output); + Assert.Contains("--snippet-lines ", output); + Assert.Contains("Snippet length; issue-drafts accept 0 for path/line-only evidence", output); Assert.Contains("cdidx find (--path |--all)", output); - Assert.Contains("--fts Use raw FTS5 query syntax for search (content:term, NEAR(a b, 5), OR, NOT, groups, prefix*, \"phrase\"; search query max 1000 chars; raw FTS parser max 2000 chars, 64 boolean ops, 16 NEAR ops", output); - Assert.Contains("--exact Backward-compatible shorthand.", output); - Assert.Contains(" Prefer --exact-substring for search,", output); - Assert.Contains(" --exact for find,", output); - Assert.Contains(" and --exact-name for symbol/graph lookups.", output); - Assert.Contains(" Combining exact-match flags is rejected.", output); - Assert.Contains("--exact-substring Search only: case-sensitive exact substring", output); - Assert.Contains(" (no FTS5)", output); - Assert.Contains("--exact-name Exact name match for symbols, definition,", output); - Assert.Contains(" references, callers, callees, and inspect.", output); - Assert.Contains(" Uses NFKC + Unicode CaseFold when ready.", output); - Assert.Contains(" Legacy/stale-fold DBs fall back to ASCII NOCASE;", output); - Assert.Contains(" run `cdidx backfill-fold` or check fold_ready.", output); - Assert.Contains("--kind definition/symbols/outline/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation/type_tag/bcl_regex_without_timeout); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind", output); - Assert.Contains("--severity validate only: filter issues by severity: info, warning, error", output); - Assert.Contains("--count Count only; result limits are ignored by count", output); + Assert.Contains("--count", output); Assert.Contains("scan caps can still mark approximate counts as degraded", output); - Assert.Contains("--no-dedup search only: return every raw overlapping chunk hit (debug/density)", output); Assert.Contains("--commits [commit-ref ...]", output); - Assert.Contains("Update only files changed in the specified git", output); - Assert.Contains("commits (preferred after commits; max 64 refs, 256 chars each)", output); - Assert.Contains("--files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed", output); - Assert.Contains("--optimize index only: optimize the existing FTS5 table for this project's DB without scanning files", output); - Assert.Contains("--duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms", output); - Assert.Contains("--ascii Use ASCII spinner/progress glyphs", output); + Assert.Contains("--files ", output); + Assert.Contains("--optimize", output); + Assert.Contains("--duration-format ", output); + Assert.Contains("--ascii", output); Assert.Contains("cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]", output); - Assert.Contains("--focus-column find/excerpt: focus a specific 1-based column", output); - Assert.Contains("--focus-line find/excerpt: focus a line", output); - Assert.Contains("excerpt keeps the leading window when no column is supplied", output); Assert.Contains("cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--cursor ] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>] [--max-json-bytes ]", output); Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json[=ndjson|array]] [--compact] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--sort ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--group-partials] [--since ]", output); - Assert.Contains("--sort Symbols/outline: order audit output by a ranking", output); - Assert.Contains("source, kind, references, size, complexity, path, and name", output); Assert.Contains("cdidx files [query||--query |-- ] [--db ] [--json[=ndjson|array]] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); Assert.Contains("cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]", output); Assert.Contains("Note: if a query itself starts with '-', pass it with --query or -- ", output); @@ -451,12 +428,10 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx unused [--db ] [--json] [--compact]", output); Assert.Contains("[--cursor ] [--audit-scope ]", output); Assert.Contains("cdidx hotspots [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--summary-only] [--max-json-bytes ] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]", output); - Assert.Contains("--json Output as JSON (search/symbols/files stream", output); - Assert.Contains("search/symbols/files/validate accept --json=array for one", output); - Assert.Contains("--lang Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)", output); - Assert.Contains("--bytes files: sort by size and show raw byte counts in human", output); - Assert.Contains("map: show raw byte counts; JSON always keeps raw", output); - Assert.Contains("--group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON keeps capped paths plus full definition_site_details", output); + Assert.Contains("--json", output); + Assert.Contains("--lang ", output); + Assert.Contains("--bytes", output); + Assert.Contains("--group-by-name", output); Assert.Contains("cdidx search \"Run();\" --exact-substring Case-sensitive exact substring search", output); Assert.Contains("cdidx search --query --path --path README.md Search for a literal option token", output); Assert.Contains("cdidx hotspots --group-by-name --exclude-tests", output); @@ -465,8 +440,6 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("backfill-fold", output); Assert.Contains("optimize Optimize FTS5 segments in an existing index DB", output); Assert.Contains("find Find literal substring matches inside known indexed files", output); - Assert.Contains("Prefer --exact-substring for search", output); - Assert.Contains("--exact for find", output); Assert.Contains("impact Show transitive callers; type queries may return heuristic file-level dependency hints", output); Assert.Contains("hotspots Find high-impact symbols; duplicate-name families may fall back conservatively", output); Assert.Contains("cdidx find guard --path src/Auth.cs --after 2", output); @@ -474,9 +447,9 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx hotspots --lang csharp --exclude-tests Find high-impact symbols with conservative duplicate fallback", output); Assert.Contains("cdidx impact FolderDiffService --json Type query may return heuristic file-level dependency hints", output); Assert.Contains("license Show licensing, trademark, and commercial-use summary", output); - Assert.Contains("--license Show licensing, trademark, and commercial-use summary", output); + Assert.Contains("--license", output); Assert.Contains("completions Generate shell completions for bash, zsh, fish, or PowerShell", output); - Assert.Contains("--completions Generate shell completions (bash, zsh, fish, powershell)", output); + Assert.Contains("--completions ", output); Assert.Contains("cdidx --completions zsh > ~/.zfunc/_cdidx Generate a zsh completion script", output); Assert.Contains("cdidx license Show licensing and commercial-use terms", output); Assert.DoesNotContain("Easter eggs", output); @@ -485,26 +458,16 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() } [Fact] - public void PrintUsage_ExactMatchOptionLines_FitWithinEightyColumns() + public void PrintUsage_RegistryOptionTokens_FitWithinEightyColumns() { var output = CaptureFullUsageOutput(showBanner: false); var lines = output.Split(Environment.NewLine); - var exactStart = Array.FindIndex(lines, line => line.StartsWith(" --exact ", StringComparison.Ordinal)); - var exactSubstringStart = Array.FindIndex(lines, line => line.StartsWith(" --exact-substring", StringComparison.Ordinal)); - var exactNameStart = Array.FindIndex(lines, line => line.StartsWith(" --exact-name", StringComparison.Ordinal)); - var kindStart = Array.FindIndex(lines, line => line.StartsWith(" --kind ", StringComparison.Ordinal)); - - Assert.True(exactStart >= 0); - Assert.True(exactSubstringStart > exactStart); - Assert.True(exactNameStart > exactSubstringStart); - Assert.True(kindStart > exactNameStart); - - var exactMatchLines = lines[exactStart..kindStart] - .Where(line => line.Length > 0) - .ToArray(); - - Assert.NotEmpty(exactMatchLines); - Assert.All(exactMatchLines, line => Assert.True(line.Length <= 80, $"Line exceeds 80 columns ({line.Length}): {line}")); + foreach (var token in new[] { "--exact", "--exact-name", "--kind " }) + { + var line = Assert.Single(lines, line => + string.Equals(line.Trim(), token, StringComparison.Ordinal)); + Assert.True(line.Length <= 80, $"Line exceeds 80 columns ({line.Length}): {line}"); + } } [Fact] @@ -1137,7 +1100,7 @@ public void CompletionRenderer_TopLevelGlobalLogFlagsAcrossShells() Assert.Contains("--log-format) COMPREPLY=($(compgen -W \"text json\" -- \"$cur\"))", bash); Assert.Contains("'--log-format[Persistent stderr log format]:format:(text json)'", zsh); Assert.Contains("-l log-format -r -a 'text json'", fish); - Assert.Contains("'--log-format' { $logFormats", powershell); + Assert.Contains("'--log-format' = @('text', 'json')", powershell); } [Fact] @@ -2310,7 +2273,7 @@ public void ColorizeKind_ColorModeNever_OmitsAnsi() public void PrintUsage_DocumentsColorFlag() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("--color ", output); + Assert.Contains("--color ", output); Assert.Contains("`auto`", output); Assert.Contains("`always`", output); Assert.Contains("`never`", output); @@ -2322,7 +2285,7 @@ public void PrintUsage_DocumentsColorFlag() public void PrintUsage_DocumentsPaletteFlag() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("--palette ", output); + Assert.Contains("--palette ", output); Assert.Contains("`basic`", output); Assert.Contains("`256`", output); Assert.Contains("`truecolor`", output); @@ -2746,10 +2709,10 @@ private static void AssertSearchUsageFragments(string output) "[--group-by ]", "[--unique ]", "[--count-by ]", - "[--origin ]", - "[--match-origin ]", - "[--exclude-origin ]", - "[--result-kind ]", + "[--origin ]", + "[--match-origin ]", + "[--exclude-origin ]", + "[--result-kind ]", "[--search-fields ]", "[--results-only]", "[--first-per-file]", From f858bd41eb42bc06471429295af3d9aa6f0ec440 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 21:36:32 +0900 Subject: [PATCH 2/4] Align nested CLI completion contexts (#4861) --- src/CodeIndex/Cli/CliFlagSchema.cs | 78 ++++++++- .../Cli/ConsoleCompletionRenderer.cs | 161 ++++++++++++++---- src/CodeIndex/Cli/ConsoleUi.Help.cs | 6 +- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 58 ++++++- 4 files changed, 251 insertions(+), 52 deletions(-) diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index f87187dd6..887d3fcb2 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -20,6 +20,7 @@ internal sealed record CliOptionValueDomain public required IReadOnlyList CanonicalValues { get; init; } public IReadOnlyDictionary Aliases { get; init; } = new Dictionary(StringComparer.Ordinal); + public bool NormalizeDashAndUnderscore { get; init; } public string ValuePlaceholder => $"<{string.Join('|', CanonicalValues)}>"; @@ -42,8 +43,13 @@ public bool TryNormalize(string rawValue, out string normalizedValue) return false; } - private static string NormalizeLookup(string value) => - value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + private string NormalizeLookup(string value) + { + var normalized = value.Trim().ToLowerInvariant(); + return NormalizeDashAndUnderscore + ? normalized.Replace("-", "_", StringComparison.Ordinal) + : normalized; + } } /// @@ -64,13 +70,18 @@ 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 required string Description { get; init; } + public IReadOnlyDictionary CommandDescriptions { get; init; } = + new Dictionary(StringComparer.Ordinal); public CliOptionSafety Safety { get; init; } public CliOptionValueDomain? ValueDomain { get; init; } public IReadOnlyDictionary CommandValueDomains { get; init; } = new Dictionary(StringComparer.Ordinal); public IReadOnlyDictionary CommandValuePlaceholders { get; init; } = new Dictionary(StringComparer.Ordinal); + public IReadOnlyDictionary> CompletionSubcommands { get; init; } = + new Dictionary>(StringComparer.Ordinal); /// /// Commands for which this flag is "primary" — accepted by the parser AND surfaced @@ -104,6 +115,18 @@ ValuePlaceholder is not null || CommandValuePlaceholders.Count > 0; public bool AppliesTo(string command) => PrimaryCommands.Contains(command); public bool IsAcceptedBy(string command) => AppliesTo(command) || AlsoAcceptedBy.Contains(command); + public bool AppliesToCompletionContext(string command, string? subcommand) + { + if (!AppliesTo(command)) + return false; + return !CompletionSubcommands.TryGetValue(command, out var allowedSubcommands) + || subcommand is not null && allowedSubcommands.Contains(subcommand); + } + + public string GetDescription(string command) => + CommandDescriptions.TryGetValue(command, out var commandDescription) + ? commandDescription + : Description; public CliOptionValueDomain? GetValueDomain(string command) { @@ -113,7 +136,9 @@ ValuePlaceholder is not null return TryBuildPlaceholderDomain(commandPlaceholder); if (ValueDomain is not null) return ValueDomain; - return TryBuildPlaceholderDomain(ValuePlaceholder); + return PlaceholderDefinesValueDomain + ? TryBuildPlaceholderDomain(ValuePlaceholder) + : null; } public string? GetValuePlaceholder(string command) @@ -319,14 +344,14 @@ public static bool HasAuthoritativeHelpOptions(string command) => private static readonly string[] VerboseQueryCommands = ProfileCommands; private static readonly string[] TraceCommands = ProfileCommands; - private static readonly CliOptionValueDomain SearchOriginValueDomain = Values( + private static readonly CliOptionValueDomain SearchOriginValueDomain = ValuesWithSeparatorNormalization( ["code", "comment", "string_literal", "regex_literal", "help_text", "schema_description", "unknown"], ("string", "string_literal"), ("regex", "regex_literal"), ("help", "help_text"), ("schema", "schema_description")); - private static readonly CliOptionValueDomain SearchResultKindValueDomain = Values( + private static readonly CliOptionValueDomain SearchResultKindValueDomain = ValuesWithSeparatorNormalization( ["call_site", "declaration", "identifier", "code", "comment", "string_literal", "regex_literal", "help_text", "schema_description", "unknown"], ("call", "call_site"), ("callsite", "call_site"), @@ -410,7 +435,12 @@ private static IReadOnlyList BuildAll() { Name = "--project", ValuePlaceholder = "", + PlaceholderDefinesValueDomain = false, Description = "Filter to a .sln/.csproj project", + CommandDescriptions = new Dictionary(StringComparer.Ordinal) + { + ["hooks"] = "Repository/worktree directory used to resolve Git metadata for the managed hook", + }, PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index", "hooks" }).ToArray()), CommandValuePlaceholders = new Dictionary(StringComparer.Ordinal) { @@ -563,7 +593,17 @@ private static IReadOnlyList BuildAll() new() { Name = "--yes", Description = "Confirm --rebuild when stdin is redirected; interactive terminals prompt instead", PrimaryCommands = Set("index") }, new() { Name = "--optimize", Description = "Optimize the existing FTS5 table without scanning files", PrimaryCommands = Set("index") }, new() { Name = "--symbols-only", Description = "Build chunks and symbols while skipping reference graph extraction", PrimaryCommands = Set("index") }, - new() { Name = "--dry-run", Description = "Preview without writing; hooks supports it only for install", PrimaryCommands = Set("index", "hooks", "backfill-fold", "optimize", "vacuum"), Safety = CliOptionSafety.Preview }, + new() + { + Name = "--dry-run", + Description = "Preview without writing; hooks supports it only for install", + PrimaryCommands = Set("index", "hooks", "backfill-fold", "optimize", "vacuum"), + CompletionSubcommands = new Dictionary>(StringComparer.Ordinal) + { + ["hooks"] = Set("install"), + }, + Safety = CliOptionSafety.Preview, + }, new() { Name = "--show-paths", Description = "Show full database paths in maintenance diagnostics; paths are redacted by default", PrimaryCommands = Set("index", "backfill-fold", "optimize", "vacuum", "db") }, new() { Name = "--dry-run-path-limit", ValuePlaceholder = "", Description = "Dry run only: candidate path processing limit before truncated lower-bound estimates", PrimaryCommands = Set("index") }, new() { Name = "--no-checkpoint", Description = "Skip the automatic DB checkpoint before maintenance", PrimaryCommands = Set("backfill-fold") }, @@ -615,11 +655,14 @@ public static IReadOnlySet GetAcceptedFlagNamesForCommand(string command /// zsh / fish completion generators. /// 指定コマンドの補完候補に出すべきフラグ集合(`AlsoAcceptedBy` は含めない)。 /// - public static IReadOnlyList GetCompletionFlagsForCommand(string command) + public static IReadOnlyList GetCompletionFlagsForCommand(string command, string? subcommand = null) { - return All.Where(f => f.AppliesTo(command)).ToList(); + return All.Where(f => f.AppliesToCompletionContext(command, subcommand)).ToList(); } + public static IReadOnlyList GetHelpFlagsForCommand(string command) => + All.Where(flag => flag.AppliesTo(command)).ToList(); + public static IReadOnlyList<(string Heading, IReadOnlyList Flags)> GetSharedHelpSections() { var emitted = new HashSet(StringComparer.Ordinal); @@ -772,14 +815,31 @@ private static HashSet Set(params string[] items) => private static CliOptionValueDomain Values( IReadOnlyList canonicalValues, params (string Alias, string Canonical)[] aliases) + => CreateValues(canonicalValues, normalizeDashAndUnderscore: false, aliases); + + private static CliOptionValueDomain ValuesWithSeparatorNormalization( + IReadOnlyList canonicalValues, + params (string Alias, string Canonical)[] aliases) + => CreateValues(canonicalValues, normalizeDashAndUnderscore: true, aliases); + + private static CliOptionValueDomain CreateValues( + IReadOnlyList canonicalValues, + bool normalizeDashAndUnderscore, + params (string Alias, string Canonical)[] aliases) { var aliasMap = new Dictionary(StringComparer.Ordinal); foreach (var (alias, canonical) in aliases) - aliasMap[alias.ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal)] = canonical; + { + var normalizedAlias = alias.ToLowerInvariant(); + if (normalizeDashAndUnderscore) + normalizedAlias = normalizedAlias.Replace("-", "_", StringComparison.Ordinal); + aliasMap[normalizedAlias] = canonical; + } return new CliOptionValueDomain { CanonicalValues = canonicalValues, Aliases = aliasMap, + NormalizeDashAndUnderscore = normalizeDashAndUnderscore, }; } } diff --git a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs index 612b20ff0..1d5a1f68c 100644 --- a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs +++ b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs @@ -78,10 +78,11 @@ private static string GetBashCompletions() sb.Append("# Regenerate this script after upgrading cdidx.\n"); sb.Append("_cdidx() {\n"); sb.Append(" local cur prev commands\n"); - sb.Append(" local cmd\n"); + sb.Append(" local cmd nested\n"); sb.Append(" cur=\"${COMP_WORDS[COMP_CWORD]}\"\n"); sb.Append(" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n"); sb.Append(" cmd=\"${COMP_WORDS[1]}\"\n"); + sb.Append(" nested=\"${COMP_WORDS[2]}\"\n"); sb.Append($" commands=\"{cmds}\"\n"); sb.Append("\n"); sb.Append(" if [ $COMP_CWORD -eq 1 ]; then\n"); @@ -98,6 +99,7 @@ private static string GetBashCompletions() 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, values) in GetEnumValueCompletions().Where(item => item.Flag != "--format")) @@ -112,8 +114,18 @@ private static string GetBashCompletions() { var command = EnumeratedCompletionCommands[i]; var keyword = i == 0 ? "if" : "elif"; - sb.Append($" {keyword} [ \"$cmd\" = \"{command}\" ]; then\n"); - sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList(command)}\" -- \"$cur\"))\n"); + if (command == "hooks") + { + sb.Append($" {keyword} [ \"$cmd\" = \"hooks\" ] && [ \"$nested\" = \"install\" ]; then\n"); + sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList("hooks", "install")}\" -- \"$cur\"))\n"); + sb.Append(" elif [ \"$cmd\" = \"hooks\" ]; then\n"); + sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList("hooks", "status")}\" -- \"$cur\"))\n"); + } + else + { + sb.Append($" {keyword} [ \"$cmd\" = \"{command}\" ]; then\n"); + sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList(command)}\" -- \"$cur\"))\n"); + } } sb.Append(" else\n"); sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashGenericFlagList()}\" -- \"$cur\"))\n"); @@ -125,13 +137,13 @@ private static string GetBashCompletions() return sb.ToString(); } - private static string BuildBashFlagList(string command) + private static string BuildBashFlagList(string command, string? subcommand = null) { // Per-command branch: schema flags + universal --help. `find` additionally surfaces // `--` as the end-of-options marker so users can pass literal queries starting with `-`. // schema のフラグに `--help` を加え、`find` のみ `--` end-of-options マーカーも露出させる。 var tokens = new List(); - foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(command)) + foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(command, subcommand)) { tokens.Add(flag.Name); if (flag.ShortName is not null) @@ -217,8 +229,18 @@ private static string GetZshCompletions() { var command = EnumeratedCompletionCommands[i]; var keyword = i == 0 ? "if" : "elif"; - sb.Append($" {keyword} [[ $subcmd == {command} ]]; then\n"); - AppendZshArguments(sb, BuildZshArgsForCommand(command, langs, kinds)); + if (command == "hooks") + { + sb.Append($" {keyword} [[ $subcmd == hooks && $words[3] == install ]]; then\n"); + AppendZshArguments(sb, BuildZshArgsForCommand("hooks", langs, kinds, "install")); + sb.Append(" elif [[ $subcmd == hooks ]]; then\n"); + AppendZshArguments(sb, BuildZshArgsForCommand("hooks", langs, kinds, "status")); + } + else + { + sb.Append($" {keyword} [[ $subcmd == {command} ]]; then\n"); + AppendZshArguments(sb, BuildZshArgsForCommand(command, langs, kinds)); + } } sb.Append(" else\n"); AppendZshArguments(sb, BuildZshGenericArgs(langs, kinds)); @@ -230,10 +252,14 @@ private static string GetZshCompletions() return sb.ToString(); } - private static List BuildZshArgsForCommand(string command, string langs, string kinds) + private static List BuildZshArgsForCommand( + string command, + string langs, + string kinds, + string? subcommand = null) { var args = new List(); - foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(command)) + foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(command, subcommand)) args.AddRange(FormatZshArguments(flag, langs, kinds, command)); // Append a trailing positional placeholder so zsh suggests path/query completion after // the flags - but only for commands that actually accept a positional argument. `status`, @@ -289,7 +315,7 @@ private static IEnumerable FormatZshArguments(CliFlag flag, string langs private static string FormatZshArgument(string name, CliFlag flag, string langs, string kinds, string? command) { - var desc = flag.Description.Replace("'", "''"); + var desc = flag.GetDescription(command ?? string.Empty).Replace("'", "''"); if (!flag.IsValueBearing) return $"'{name}[{desc}]'"; @@ -379,34 +405,81 @@ _ when GetEnumValues(flag) is { } values => $" -a '{string.Join(' ', values)}'", } continue; } - var commands = string.Join(' ', flag.PrimaryCommands.OrderBy(c => Array.IndexOf(ShellCommandNames, c))); - var name = flag.Name.TrimStart('-'); - // Token order is `-l name (-r)? (-a 'values')? -d 'description'` - matches the - // pre-refactor hand-written script so the ConsoleUiTests fish-extractor regex - // (`' -l `) keeps working for value-bearing flags too. - // トークン順は旧スクリプトと同じ `-l name (-r)? (-a) -d` を維持する。 - // ConsoleUiTests の fish 抽出正規表現が -l の直前に値マーカーを期待していないため。 - var requiresArg = flag.IsValueBearing ? " -r" : ""; - var shortName = flag.ShortName is null ? "" : $" -s {flag.ShortName.TrimStart('-')}"; - var description = name switch + var contextualCommands = flag.CompletionSubcommands.Keys + .Concat(flag.CommandDescriptions.Keys) + .Concat(flag.CommandValuePlaceholders.Keys) + .ToHashSet(StringComparer.Ordinal); + var sharedCommands = flag.PrimaryCommands + .Where(command => !contextualCommands.Contains(command)) + .OrderBy(command => Array.IndexOf(ShellCommandNames, command)) + .ToArray(); + if (sharedCommands.Length > 0) { - "group-by-name" => "Collapse same-name rows across files", - _ => flag.Description, - }; - var valuePlaceholder = flag.GetValuePlaceholder(string.Empty); - var argSpec = valuePlaceholder switch + lines.Add(BuildFishFlagCompletion( + flag, + $"__fish_seen_subcommand_from {string.Join(' ', sharedCommands)}", + command: string.Empty, + langs, + kinds)); + } + + foreach (var command in flag.PrimaryCommands + .Where(contextualCommands.Contains) + .OrderBy(command => Array.IndexOf(ShellCommandNames, command))) { - "" => $" -a '{langs}'", - "" => $" -a '{kinds}'", - _ when GetEnumValues(flag) is { } values => $" -a '{string.Join(' ', values)}'", - _ => "", - }; - description = description.Replace("'", "\\'"); - lines.Add($"complete -c cdidx -n '__fish_seen_subcommand_from {commands}' -l {name}{shortName}{requiresArg}{argSpec} -d '{description}'"); + if (flag.CompletionSubcommands.TryGetValue(command, out var nestedSubcommands)) + { + foreach (var nestedSubcommand in nestedSubcommands.OrderBy(value => value, StringComparer.Ordinal)) + { + lines.Add(BuildFishFlagCompletion( + flag, + $"__fish_seen_subcommand_from {command}; and __fish_seen_subcommand_from {nestedSubcommand}", + command, + langs, + kinds)); + } + } + else + { + lines.Add(BuildFishFlagCompletion( + flag, + $"__fish_seen_subcommand_from {command}", + command, + langs, + kinds)); + } + } } return string.Join(Environment.NewLine, lines); } + private static string BuildFishFlagCompletion( + CliFlag flag, + string predicate, + string command, + string langs, + string kinds) + { + var name = flag.Name.TrimStart('-'); + var requiresArg = flag.IsValueBearing ? " -r" : ""; + var shortName = flag.ShortName is null ? "" : $" -s {flag.ShortName.TrimStart('-')}"; + var description = name switch + { + "group-by-name" => "Collapse same-name rows across files", + _ => flag.GetDescription(command), + }; + var valuePlaceholder = flag.GetValuePlaceholder(command); + var argSpec = valuePlaceholder switch + { + "" => $" -a '{langs}'", + "" => $" -a '{kinds}'", + _ when GetEnumValues(flag, command) is { } values => $" -a '{string.Join(' ', values)}'", + _ => "", + }; + description = description.Replace("'", "\\'"); + return $"complete -c cdidx -n '{predicate}' -l {name}{shortName}{requiresArg}{argSpec} -d '{description}'"; + } + private static string GetPowerShellCompletions() { var cmds = FormatPowerShellArray(ShellCommandNames); @@ -452,6 +525,10 @@ private static string GetPowerShellCompletions() 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' }"); + 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(" '--format' { if ($formatValues.ContainsKey($subcmd)) { $formatValues[$subcmd] | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ } }; return }"); @@ -463,6 +540,7 @@ private static string GetPowerShellCompletions() sb.AppendLine(" }"); sb.AppendLine(" if ($subcommands.ContainsKey($subcmd)) {"); sb.AppendLine(" $subcmdIndex = [Array]::IndexOf($tokens, $subcmd)"); + sb.AppendLine(" $nested = $null"); sb.AppendLine(" $nested = $tokens | Select-Object -Skip ($subcmdIndex + 1) | Where-Object { $_ -and -not $_.StartsWith('-') } | Select-Object -First 1"); sb.AppendLine(" if (-not $nested -or ($tokens.Count -le ($subcmdIndex + 2) -and -not $afterLastToken)) {"); sb.AppendLine(" $candidates = @($subcommands[$subcmd])"); @@ -471,20 +549,29 @@ private static string GetPowerShellCompletions() sb.AppendLine(" return"); sb.AppendLine(" }"); sb.AppendLine(" }"); - sb.AppendLine(" switch ($subcmd) {"); + sb.AppendLine(" if ($subcmd -eq 'hooks' -and $nested -eq 'install') {"); + sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "install"))})"); + sb.AppendLine(" } elseif ($subcmd -eq 'hooks') {"); + sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "status"))})"); + sb.AppendLine(" } else {"); + sb.AppendLine(" switch ($subcmd) {"); foreach (var command in EnumeratedCompletionCommands) - sb.AppendLine($" '{EscapePowerShellSingleQuoted(command)}' {{ $flags = @({FormatPowerShellArray(BuildPowerShellFlagList(command))}) }}"); - sb.AppendLine($" default {{ $flags = @({FormatPowerShellArray(BuildPowerShellGenericFlagList())}) }}"); + { + if (command != "hooks") + sb.AppendLine($" '{EscapePowerShellSingleQuoted(command)}' {{ $flags = @({FormatPowerShellArray(BuildPowerShellFlagList(command))}) }}"); + } + sb.AppendLine($" default {{ $flags = @({FormatPowerShellArray(BuildPowerShellGenericFlagList())}) }}"); + sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine(" $flags | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ 'ParameterName' }"); sb.Append("}"); return sb.ToString(); } - private static List BuildPowerShellFlagList(string command) + private static List BuildPowerShellFlagList(string command, string? subcommand = null) { var tokens = new List(); - foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(command)) + foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(command, subcommand)) { tokens.Add(flag.Name); if (flag.ShortName is not null) diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 668384a45..499ebd1ca 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -390,7 +390,7 @@ public static bool PrintCommandUsage(string command) var schemaCommand = GetFlagSchemaCommandName(command); var helpFlags = string.Equals(command, schemaCommand, StringComparison.Ordinal) && CliFlagSchema.HasAuthoritativeHelpOptions(schemaCommand) - ? CliFlagSchema.GetCompletionFlagsForCommand(schemaCommand) + ? CliFlagSchema.GetHelpFlagsForCommand(schemaCommand) : []; if (helpFlags.Count > 0) { @@ -406,7 +406,7 @@ public static bool PrintCommandUsage(string command) : flag.GetValuePlaceholder(schemaCommand); var description = projectionFields ? ProjectionFieldRegistry.GetHelpDescription(schemaCommand) - : flag.Description; + : flag.GetDescription(schemaCommand); var token = valuePlaceholder is null ? names : $"{names} {valuePlaceholder}"; Console.WriteLine($" {token}"); Console.WriteLine($" {description}"); @@ -444,7 +444,7 @@ private static IReadOnlyList GetCommandUsageLines(string command) private static string RenderUsageLineFromSchema(string command, string usage) { var schemaCommand = GetFlagSchemaCommandName(command); - foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(schemaCommand)) + foreach (var flag in CliFlagSchema.GetHelpFlagsForCommand(schemaCommand)) { if (flag.ValueDomain is null && flag.CommandValueDomains.Count == 0 diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index 9eb47b06a..c98506d31 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -45,6 +45,8 @@ public void EveryFlagCommandsSet_OnlyReferencesKnownSubcommands() Assert.True(known.Contains(command), $"{flag.Name} Commands references unknown subcommand '{command}'"); foreach (var command in flag.AlsoAcceptedBy) Assert.True(known.Contains(command), $"{flag.Name} AlsoAcceptedBy references unknown subcommand '{command}'"); + foreach (var command in flag.CompletionSubcommands.Keys) + Assert.True(known.Contains(command), $"{flag.Name} CompletionSubcommands references unknown command '{command}'"); } } @@ -339,7 +341,17 @@ public void EveryFlagInFishCompletion_IsBackedBySchemaEntry() { var name = flag.Name.TrimStart('-'); foreach (var command in flag.PrimaryCommands) - expected.Add((name, command)); + { + if (flag.CompletionSubcommands.TryGetValue(command, out var nestedSubcommands)) + { + foreach (var nestedSubcommand in nestedSubcommands) + expected.Add((name, $"{command}:{nestedSubcommand}")); + } + else + { + expected.Add((name, command)); + } + } } foreach (var pair in expected) @@ -379,8 +391,12 @@ public void CanonicalValueRegistry_DrivesHelpValidationAndCompletions_Issue4861( resultKinds); Assert.True(CliFlagSchema.TryNormalizeOptionValue("search", "--origin", "schema", out var normalizedOrigin)); Assert.Equal("schema_description", normalizedOrigin); + Assert.True(CliFlagSchema.TryNormalizeOptionValue("search", "--origin", "schema-description", out normalizedOrigin)); + Assert.Equal("schema_description", normalizedOrigin); Assert.True(CliFlagSchema.TryNormalizeOptionValue("search", "--result-kind", "callsite", out var normalizedKind)); Assert.Equal("call_site", normalizedKind); + Assert.False(CliFlagSchema.TryNormalizeOptionValue("deps", "--format", "json_graph", out _)); + Assert.False(CliFlagSchema.TryNormalizeOptionValue("search", "--format", "issue_drafts", out _)); Assert.Contains("--format ", ConsoleUi.GetUsageLine("audit")); var searchUsage = ConsoleUi.GetUsageLine("search"); @@ -394,6 +410,8 @@ public void CanonicalValueRegistry_DrivesHelpValidationAndCompletions_Issue4861( Assert.NotNull(acceptsFormat); Assert.Equal(true, acceptsFormat!.Invoke(null, ["audit", "sarif"])); Assert.Equal(false, acceptsFormat.Invoke(null, ["audit", "lsp"])); + Assert.Equal(false, acceptsFormat.Invoke(null, ["deps", "json_graph"])); + Assert.Equal(false, acceptsFormat.Invoke(null, ["search", "issue_drafts"])); var bash = ConsoleCompletionRenderer.GetCompletionScript("bash"); Assert.Contains("audit) COMPREPLY=($(compgen -W \"text json count compact sarif issue-drafts\"", bash); @@ -409,7 +427,10 @@ public void RegistrySurfacesSafetyAndAcceptedOptionsWithoutAdvertisingRejectedGo { var hookFlags = CliFlagSchema.GetAcceptedFlagNamesForCommand("hooks"); Assert.Contains("--dry-run", hookFlags); - Assert.Contains(CliFlagSchema.GetCompletionFlagsForCommand("hooks"), flag => flag.Name == "--dry-run"); + Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("hooks"), flag => flag.Name == "--dry-run"); + Assert.Contains(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "install"), flag => flag.Name == "--dry-run"); + Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "uninstall"), flag => flag.Name == "--dry-run"); + Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "status"), flag => flag.Name == "--dry-run"); var gotoFlags = CliFlagSchema.GetAcceptedFlagNamesForCommand("goto"); Assert.DoesNotContain("--exact", gotoFlags); @@ -420,8 +441,28 @@ public void RegistrySurfacesSafetyAndAcceptedOptionsWithoutAdvertisingRejectedGo ConsoleUi.PrintCommandUsage("hooks") ? 1 : 0); Assert.Contains("--dry-run", hookHelp, StringComparison.Ordinal); Assert.Contains("--project ", hookHelp, StringComparison.Ordinal); + Assert.Contains("Repository/worktree directory used to resolve Git metadata", hookHelp, StringComparison.Ordinal); Assert.DoesNotContain("--pretty", hookHelp, StringComparison.Ordinal); + var bash = ConsoleCompletionRenderer.GetCompletionScript("bash"); + Assert.Contains("[ \"$cmd\" = \"hooks\" ] && [ \"$nested\" = \"install\" ]", bash, StringComparison.Ordinal); + Assert.Contains("--project) if [ \"$cmd\" = \"hooks\" ]; then COMPREPLY=($(compgen -f", bash, StringComparison.Ordinal); + Assert.DoesNotContain("--project) COMPREPLY=($(compgen -W \"name path\"", bash, StringComparison.Ordinal); + + var zsh = ConsoleCompletionRenderer.GetCompletionScript("zsh"); + Assert.Contains("$subcmd == hooks && $words[3] == install", zsh, StringComparison.Ordinal); + Assert.Contains("--project[Repository/worktree directory used to resolve Git metadata for the managed hook]:file:_files", zsh, StringComparison.Ordinal); + + var fish = ConsoleCompletionRenderer.GetCompletionScript("fish"); + Assert.Contains("__fish_seen_subcommand_from hooks; and __fish_seen_subcommand_from install' -l dry-run", fish, StringComparison.Ordinal); + Assert.Contains("__fish_seen_subcommand_from hooks' -l project -r -d 'Repository/worktree directory used to resolve Git metadata", fish, StringComparison.Ordinal); + Assert.DoesNotContain("__fish_seen_subcommand_from hooks' -l project -r -a 'name path'", fish, StringComparison.Ordinal); + + var powershell = ConsoleCompletionRenderer.GetCompletionScript("powershell"); + Assert.Contains("$subcmd -eq 'hooks' -and $nested -eq 'install'", powershell, StringComparison.Ordinal); + Assert.Contains("$_ -eq '--project' -and $subcmd -eq 'hooks'", powershell, StringComparison.Ordinal); + Assert.DoesNotContain("'--project' = @('name', 'path')", powershell, StringComparison.Ordinal); + using var capture = ConsoleCapture.Start(captureOut: true); ConsoleUi.PrintFlagUsage(showBanner: false); var flagHelp = capture.Out!.ToString(); @@ -481,7 +522,18 @@ private static SortedSet ExtractBashSubcommandFlags(string script, strin if (!match.Success) continue; var flag = match.Groups["flag"].Value; - foreach (var subcmd in match.Groups["list"].Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + var predicate = match.Groups["list"].Value; + const string nestedSeparator = "; and __fish_seen_subcommand_from "; + var nestedSeparatorIndex = predicate.IndexOf(nestedSeparator, StringComparison.Ordinal); + if (nestedSeparatorIndex >= 0) + { + var command = predicate[..nestedSeparatorIndex].Trim(); + var nestedSubcommand = predicate[(nestedSeparatorIndex + nestedSeparator.Length)..].Trim(); + result.Add((flag, $"{command}:{nestedSubcommand}")); + continue; + } + + foreach (var subcmd in predicate.Split(' ', StringSplitOptions.RemoveEmptyEntries)) result.Add((flag, subcmd)); } return result; From 3dee742e65934809291ff73f619127b782251b69 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 22:03:08 +0900 Subject: [PATCH 3/4] Address final CLI review findings (#4861) --- src/CodeIndex/Cli/CliFlagSchema.cs | 16 +++- .../Cli/ConsoleCompletionRenderer.cs | 86 +++++++++++++++++-- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 24 +++++- 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 887d3fcb2..89185be85 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -607,7 +607,21 @@ private static IReadOnlyList BuildAll() new() { Name = "--show-paths", Description = "Show full database paths in maintenance diagnostics; paths are redacted by default", PrimaryCommands = Set("index", "backfill-fold", "optimize", "vacuum", "db") }, new() { Name = "--dry-run-path-limit", ValuePlaceholder = "", Description = "Dry run only: candidate path processing limit before truncated lower-bound estimates", PrimaryCommands = Set("index") }, new() { Name = "--no-checkpoint", Description = "Skip the automatic DB checkpoint before maintenance", PrimaryCommands = Set("backfill-fold") }, - new() { Name = "--force", Description = "Bypass the normal safety guard for index or hook installation", PrimaryCommands = Set("index", "hooks"), Safety = CliOptionSafety.Override }, + new() + { + Name = "--force", + Description = "Bypass the per-database index lock; only use when no other cdidx index is active", + CommandDescriptions = new Dictionary(StringComparer.Ordinal) + { + ["hooks"] = "Install: replace an existing chained-hook backup; uninstall: remove an unmanaged pre-commit hook", + }, + PrimaryCommands = Set("index", "hooks"), + CompletionSubcommands = new Dictionary>(StringComparer.Ordinal) + { + ["hooks"] = Set("install", "uninstall"), + }, + Safety = CliOptionSafety.Override, + }, new() { Name = "--duration-format", ValuePlaceholder = "", Description = "Index elapsed time display format", PrimaryCommands = Set("index") }, new() { Name = "--max-file-bytes", ValuePlaceholder = "", Description = "Override the per-file indexing size limit", PrimaryCommands = Set("index") }, new() { Name = "--max-symbols-per-file", ValuePlaceholder = "", Description = "Skip file content, symbols, and references when one file emits too many symbols (max 50000)", PrimaryCommands = Set("index") }, diff --git a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs index 1d5a1f68c..2d62a3802 100644 --- a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs +++ b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs @@ -78,11 +78,26 @@ private static string GetBashCompletions() sb.Append("# Regenerate this script after upgrading cdidx.\n"); sb.Append("_cdidx() {\n"); sb.Append(" local cur prev commands\n"); - sb.Append(" local cmd nested\n"); + sb.Append(" local cmd nested i skip_next\n"); sb.Append(" cur=\"${COMP_WORDS[COMP_CWORD]}\"\n"); sb.Append(" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n"); sb.Append(" cmd=\"${COMP_WORDS[1]}\"\n"); - sb.Append(" nested=\"${COMP_WORDS[2]}\"\n"); + sb.Append(" nested=\"\"\n"); + sb.Append(" if [ \"$cmd\" = \"hooks\" ]; then\n"); + sb.Append(" nested=\"\"\n"); + sb.Append(" skip_next=0\n"); + sb.Append(" for ((i=2; i $"'{subcommand}:{subcommand} subcommand'"))}\n"); @@ -231,8 +270,10 @@ private static string GetZshCompletions() var keyword = i == 0 ? "if" : "elif"; if (command == "hooks") { - sb.Append($" {keyword} [[ $subcmd == hooks && $words[3] == install ]]; then\n"); + sb.Append($" {keyword} [[ $subcmd == hooks && $nested == install ]]; then\n"); AppendZshArguments(sb, BuildZshArgsForCommand("hooks", langs, kinds, "install")); + sb.Append(" elif [[ $subcmd == hooks && $nested == uninstall ]]; then\n"); + AppendZshArguments(sb, BuildZshArgsForCommand("hooks", langs, kinds, "uninstall")); sb.Append(" elif [[ $subcmd == hooks ]]; then\n"); AppendZshArguments(sb, BuildZshArgsForCommand("hooks", langs, kinds, "status")); } @@ -507,6 +548,10 @@ private static string GetPowerShellCompletions() foreach (var (command, subcommands) in CliCommandMetadata.CommandSubcommands) sb.AppendLine($" '{EscapePowerShellSingleQuoted(command)}' = @({FormatPowerShellArray(subcommands)})"); sb.AppendLine(" }"); + sb.AppendLine(" $nestedValueFlags = @{"); + foreach (var (command, _) in CliCommandMetadata.CommandSubcommands) + sb.AppendLine($" '{EscapePowerShellSingleQuoted(command)}' = @({FormatPowerShellArray(GetValueTakingFlagNamesForNestedCommand(command))})"); + sb.AppendLine(" }"); sb.AppendLine(" $optionalSubcommandFlags = @{"); foreach (var command in CliCommandMetadata.OptionalSubcommandCommands) sb.AppendLine($" '{EscapePowerShellSingleQuoted(command)}' = @({FormatPowerShellArray(BuildPowerShellFlagList(command))})"); @@ -541,7 +586,17 @@ private static string GetPowerShellCompletions() sb.AppendLine(" if ($subcommands.ContainsKey($subcmd)) {"); sb.AppendLine(" $subcmdIndex = [Array]::IndexOf($tokens, $subcmd)"); sb.AppendLine(" $nested = $null"); - sb.AppendLine(" $nested = $tokens | Select-Object -Skip ($subcmdIndex + 1) | Where-Object { $_ -and -not $_.StartsWith('-') } | Select-Object -First 1"); + sb.AppendLine(" $skipNestedValue = $false"); + sb.AppendLine(" for ($i = $subcmdIndex + 1; $i -lt $tokens.Count; $i++) {"); + sb.AppendLine(" $token = $tokens[$i]"); + sb.AppendLine(" if ($skipNestedValue) { $skipNestedValue = $false; continue }"); + sb.AppendLine(" $valueFlag = $nestedValueFlags[$subcmd] | Where-Object { $token -eq $_ -or $token.StartsWith(($_ + '='), [System.StringComparison]::Ordinal) } | Select-Object -First 1"); + sb.AppendLine(" if ($valueFlag) {"); + sb.AppendLine(" if ($token -eq $valueFlag) { $skipNestedValue = $true }"); + sb.AppendLine(" continue"); + sb.AppendLine(" }"); + sb.AppendLine(" if ($subcommands[$subcmd] -contains $token) { $nested = $token; break }"); + sb.AppendLine(" }"); sb.AppendLine(" if (-not $nested -or ($tokens.Count -le ($subcmdIndex + 2) -and -not $afterLastToken)) {"); sb.AppendLine(" $candidates = @($subcommands[$subcmd])"); sb.AppendLine(" if ($optionalSubcommandFlags.ContainsKey($subcmd)) { $candidates += $optionalSubcommandFlags[$subcmd] }"); @@ -551,6 +606,8 @@ private static string GetPowerShellCompletions() sb.AppendLine(" }"); sb.AppendLine(" if ($subcmd -eq 'hooks' -and $nested -eq 'install') {"); sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "install"))})"); + sb.AppendLine(" } elseif ($subcmd -eq 'hooks' -and $nested -eq 'uninstall') {"); + sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "uninstall"))})"); sb.AppendLine(" } elseif ($subcmd -eq 'hooks') {"); sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "status"))})"); sb.AppendLine(" } else {"); @@ -583,6 +640,23 @@ private static List BuildPowerShellFlagList(string command, string? subc return tokens; } + private static IReadOnlyList GetValueTakingFlagNamesForNestedCommand(string command) + { + var names = new List(); + foreach (var flag in CliFlagSchema.GetHelpFlagsForCommand(command).Where(flag => flag.IsValueBearing)) + { + names.Add(flag.Name); + if (flag.ShortName is not null) + names.Add(flag.ShortName); + } + return names; + } + + private static IReadOnlyList GetNestedSubcommandNames(string command) => + CliCommandMetadata.CommandSubcommands + .First(entry => string.Equals(entry.Command, command, StringComparison.Ordinal)) + .Subcommands; + private static List BuildTopLevelFlagList() { var tokens = new List(); diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index c98506d31..10de6f8cb 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -431,6 +431,9 @@ public void RegistrySurfacesSafetyAndAcceptedOptionsWithoutAdvertisingRejectedGo Assert.Contains(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "install"), flag => flag.Name == "--dry-run"); Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "uninstall"), flag => flag.Name == "--dry-run"); Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "status"), flag => flag.Name == "--dry-run"); + Assert.Contains(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "install"), flag => flag.Name == "--force"); + Assert.Contains(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "uninstall"), flag => flag.Name == "--force"); + Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("hooks", "status"), flag => flag.Name == "--force"); var gotoFlags = CliFlagSchema.GetAcceptedFlagNamesForCommand("goto"); Assert.DoesNotContain("--exact", gotoFlags); @@ -442,24 +445,43 @@ public void RegistrySurfacesSafetyAndAcceptedOptionsWithoutAdvertisingRejectedGo Assert.Contains("--dry-run", hookHelp, StringComparison.Ordinal); Assert.Contains("--project ", hookHelp, StringComparison.Ordinal); Assert.Contains("Repository/worktree directory used to resolve Git metadata", hookHelp, StringComparison.Ordinal); + Assert.Contains("Install: replace an existing chained-hook backup; uninstall: remove an unmanaged pre-commit hook", hookHelp, StringComparison.Ordinal); Assert.DoesNotContain("--pretty", hookHelp, StringComparison.Ordinal); + var (_, indexHelp, _) = ConsoleCapture.Capture(() => + ConsoleUi.PrintCommandUsage("index") ? 1 : 0); + Assert.Contains("Bypass the per-database index lock; only use when no other cdidx index is active", indexHelp, StringComparison.Ordinal); + var bash = ConsoleCompletionRenderer.GetCompletionScript("bash"); Assert.Contains("[ \"$cmd\" = \"hooks\" ] && [ \"$nested\" = \"install\" ]", bash, StringComparison.Ordinal); + Assert.Contains("[ \"$cmd\" = \"hooks\" ] && [ \"$nested\" = \"uninstall\" ]", bash, StringComparison.Ordinal); + Assert.Contains("for ((i=2; i Date: Wed, 29 Jul 2026 02:28:39 +0900 Subject: [PATCH 4/4] Normalize console help newlines in tests (#4861) --- tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 26345320c..fdebe9455 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -21,6 +21,8 @@ public void PrintCommandUsage_DedicatedParsersOnlyEmitAuthoritativeParentOptionL ConsoleUi.PrintCommandUsage("db-schema") ? 1 : 0); var (_, hooksHelp, _) = ConsoleCapture.Capture(() => ConsoleUi.PrintCommandUsage("hooks") ? 1 : 0); + dbSchemaHelp = dbSchemaHelp.ReplaceLineEndings("\n"); + hooksHelp = hooksHelp.ReplaceLineEndings("\n"); Assert.Contains("--type ", dbSchemaHelp, StringComparison.Ordinal); Assert.DoesNotContain("--integrity-check", dbSchemaHelp, StringComparison.Ordinal);