From 7b17dde2767d567eae031933524056285e9ed348 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 00:32:34 +0900 Subject: [PATCH 1/3] Reject misspelled query languages (#4842) --- DEVELOPER_GUIDE.md | 15 ++ USER_GUIDE.md | 15 ++ changelog.d/unreleased/4842.fixed.md | 37 +++++ src/CodeIndex/Cli/CliFlagSchema.cs | 3 +- src/CodeIndex/Cli/ConsoleUi.Help.cs | 1 + ...ueryCommandRunner.ArgumentParser.Search.cs | 4 + .../Cli/QueryCommandRunner.ArgumentParser.cs | 60 +++++++ .../Cli/QueryCommandRunner.Definition.cs | 4 +- .../Cli/QueryCommandRunner.Dependencies.cs | 4 +- .../Cli/QueryCommandRunner.Discovery.cs | 4 +- src/CodeIndex/Cli/QueryCommandRunner.Find.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 6 +- .../Cli/QueryCommandRunner.Hotspots.cs | 2 +- .../Cli/QueryCommandRunner.Inspect.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Map.cs | 2 +- .../Cli/QueryCommandRunner.Search.cs | 5 +- .../Cli/QueryCommandRunner.Unused.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 2 + src/CodeIndex/Database/DbReader.cs | 23 ++- .../QueryCommandRunnerInspectTests.cs | 2 +- .../QueryCommandRunnerReferencesTests.cs | 2 +- .../QueryCommandRunnerSearchTests.cs | 23 ++- .../QueryCommandRunnerSymbolTests.cs | 26 +-- .../QueryCommandRunnerTests.cs | 157 ++++++++++++++++++ 24 files changed, 359 insertions(+), 44 deletions(-) create mode 100644 changelog.d/unreleased/4842.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 561b4efbf..5d75b4774 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -3093,6 +3093,14 @@ Downstream users can add lightweight language support without rebuilding the extracted JSON to a fixture. The source and expectation files are capped at 4 MiB each. +Query-side `--lang` resolution uses this same workspace-aware extension and +extractor registry rather than a separate built-in list. Registered language +IDs, aliases, and extension-like spellings resolve to the registry's canonical +ID. Unknown values fail with `E010_USAGE_ERROR` and bounded edit-distance +suggestions; `--allow-unknown-lang` is the explicit escape hatch for an +unregistered plugin ID and preserves its trimmed spelling through the database +filter. + Minimal examples: ```yaml @@ -5693,6 +5701,13 @@ cleared range を証明するテストが必要です。Bounded accumulation pat symbol extraction だけを実行し、`--expect-symbols ` で fixture JSON と比較できます。 source と expectation file はそれぞれ 4 MiB に制限されます。 +query 側の `--lang` 解決は、別の組み込み一覧ではなく、この workspace-aware な +extension / extractor registry を共有します。登録済み language ID、alias、拡張子形式の +表記は registry の canonical ID に解決されます。未知の値は上限付き edit-distance 候補を +伴う `E010_USAGE_ERROR` になり、未登録 plugin ID には明示的な escape hatch +`--allow-unknown-lang` を使います。この場合、前後空白を除いた表記を database filter まで +保持します。 + 最小例: ```yaml diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 65e538490..53aa3c58a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1251,6 +1251,14 @@ cdidx search "--open-reports" --path README.md --count # quoted literal that st cdidx search --query "--path" --path README.md # search for an option-looking literal ``` +`--lang` validates built-in language names, recognized aliases, extension-like +spellings such as `.cs`, and language IDs registered for the indexed workspace. +A misspelling such as `cshrap` is a usage error (`E010_USAGE_ERROR`) and reports +up to three nearby canonical IDs instead of silently returning zero results. +Use `--allow-unknown-lang` only when querying an unregistered plugin language +ID; that escape hatch trims surrounding whitespace but otherwise preserves the +ID's case and punctuation exactly. + Search normalizes literal FTS queries to Unicode NFC before matching. If every literal token exceeds SQLite FTS5 unicode61's 1000-character token cap, zero-result JSON includes `query_degraded_reason` and `tokens_dropped`. Index @@ -4391,6 +4399,13 @@ cdidx search "--open-reports" --path README.md --count # `--` で始まる引 cdidx search --query "--path" --path README.md # オプションに見えるリテラルを検索 ``` +`--lang` は組み込み言語名、認識済み alias、`.cs` のような拡張子形式の表記、 +および indexed workspace に登録された language ID を検証します。`cshrap` のような +入力ミスは 0 件として黙って成功せず、usage error(`E010_USAGE_ERROR`)として近い +canonical ID を最大3件報告します。未登録 plugin の language ID を検索する場合だけ +`--allow-unknown-lang` を使ってください。この escape hatch は前後の空白のみを除去し、 +ID の大小文字と句読点はそれ以外そのまま保持します。 + literal FTS クエリは照合前に Unicode NFC へ正規化されます。すべての literal token が SQLite FTS5 unicode61 の 1000 文字 token 上限を超える場合、0 件 JSON には `query_degraded_reason` と `tokens_dropped` が含まれます。index diff --git a/changelog.d/unreleased/4842.fixed.md b/changelog.d/unreleased/4842.fixed.md new file mode 100644 index 000000000..501e36cfe --- /dev/null +++ b/changelog.d/unreleased/4842.fixed.md @@ -0,0 +1,37 @@ +--- +category: fixed +issues: + - 4842 +affected: + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs + - src/CodeIndex/Cli/QueryCommandRunner.Search.cs + - src/CodeIndex/Cli/QueryCommandRunner.Definition.cs + - src/CodeIndex/Cli/QueryCommandRunner.Graph.cs + - src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs + - src/CodeIndex/Cli/QueryCommandRunner.Find.cs + - src/CodeIndex/Cli/QueryCommandRunner.Map.cs + - src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs + - src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs + - src/CodeIndex/Cli/QueryCommandRunner.Unused.cs + - src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.Help.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Misspelled query language filters now fail with bounded suggestions (#4842)** — query commands validate `--lang` against the same workspace-aware language and plugin registry used for indexing, accept aliases and extension-like spellings, and return `E010_USAGE_ERROR` in human or JSON output instead of a silent empty result. `--allow-unknown-lang` explicitly permits an unregistered plugin ID while preserving its case and punctuation. + +## 日本語 + +- **query の言語フィルタ入力ミスを上限付き候補とともに拒否するようになりました (#4842)** — query command は indexing と同じ workspace-aware な language / plugin registry に対して `--lang` を検証し、alias と拡張子形式の表記を受理します。入力ミスは空結果として黙って成功せず、人間向け/JSON 出力で `E010_USAGE_ERROR` を返します。`--allow-unknown-lang` を明示すると、未登録 plugin ID の大小文字と句読点を保持したまま検索できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 0fb3a1a07..881c5d1d0 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -269,7 +269,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--max-results", ValuePlaceholder = "", Description = "Search alias for --limit", PrimaryCommands = Set("search") }, new() { Name = "--top", ValuePlaceholder = "", Description = "Max results", PrimaryCommands = Set(LimitCapableCommands) }, new() { Name = "--offset", ValuePlaceholder = "", Description = "Suggestions: skip this many filtered rows before output", PrimaryCommands = Set("suggestions") }, - new() { Name = "--lang", ValuePlaceholder = "", Description = "Filter by language", PrimaryCommands = Set(LangCapableCommands), AlsoAcceptedBy = Set("suggestions") }, + new() { Name = "--lang", ValuePlaceholder = "", Description = "Filter by a registered language, alias, or extension-like spelling", PrimaryCommands = Set(LangCapableCommands), AlsoAcceptedBy = Set("suggestions") }, + new() { Name = "--allow-unknown-lang", Description = "Allow an unregistered plugin language ID and preserve its exact spelling", PrimaryCommands = Set(LangCapableCommands) }, new() { Name = "--language", ValuePlaceholder = "", Description = "Suggestions: filter by language; languages: look up one language by canonical name or recognized language spelling", PrimaryCommands = Set("suggestions", "languages") }, new() { Name = "--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) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 33dd9e1ab..18b5267e0 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -217,6 +217,7 @@ private static void PrintFlagReference(Action WriteHelpLine) 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"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs index 758cae0e5..0d7cfc0be 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs @@ -24,11 +24,15 @@ private bool TryParseSearchOption(string normalizedArg, string currentArg, strin // DB の `files.lang` はすべて lowercase なので、SQL filter と WriteLangHint が一致する。 // さらに `py` のような短縮エイリアスを正規名へ畳み込み、Python 利用時の慣用入力で // 意図せず 0 件になる事故を避ける。 + rawLang = langValue; lang = NormalizeLangFilterValue(langValue); } else AddParseError(langError!); break; + case "--allow-unknown-lang": + allowUnknownLang = true; + break; case "--query": if (!allowNamedQuery) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index ce22b1664..489686b26 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -27,6 +27,9 @@ private sealed partial class QueryArgumentParser private readonly string? defaultLimitError; private int? totalLimit; private string? lang; + private string? rawLang; + private bool allowUnknownLang; + private bool languageValidationError; private string? kind; private string? unusedBucket; private string? minUnusedConfidence; @@ -218,6 +221,7 @@ internal QueryCommandOptions Parse(string[] args) var resolvedDbPath = dbResolution.DbPath; ResolveProjectFilters(resolvedDbPath); + ResolveLanguageFilter(resolvedDbPath); ValidateParsedOptions(); ApplySearchSourceOptionDefaults(); ValidateEnvironmentDefaults(); @@ -267,6 +271,60 @@ private void ValidateParsedOptions() AddParseError("Error: --duplicate-confidence and --duplicate-threshold cannot be combined; use the preset or the explicit score threshold."); } + private void ResolveLanguageFilter(string resolvedDbPath) + { + if (rawLang == null) + { + if (allowUnknownLang) + { + languageValidationError = true; + AddParseError($"Error [{CommandErrorCodes.UsageError}]: --allow-unknown-lang requires --lang ."); + } + return; + } + + var input = rawLang.Trim(); + var workspaceRoot = ResolveProjectFilterRoot(resolvedDbPath, dbPathExplicit).Root; + var aliases = DbReader.GetQueryLanguageAliases(workspaceRoot); + var lookupKey = DbReader.NormalizeQueryLanguageLookupKey(input); + if (lookupKey.Length == 0) + { + languageValidationError = true; + AddParseError( + $"Error [{CommandErrorCodes.UsageError}]: --lang must contain at least one letter or digit; got '{ConsoleUi.FormatBoundedValue(input)}'."); + return; + } + if (aliases.TryGetValue(lookupKey, out var canonical)) + { + lang = canonical; + return; + } + + if (allowUnknownLang) + { + // An unregistered plugin ID is an explicit escape hatch. Keep its spelling + // intact (apart from surrounding whitespace) so punctuation and case still + // match the exact value stored in files.lang. + // 未登録 plugin ID は明示的な escape hatch として扱い、前後の空白以外は + // 変更しない。句読点と大小文字を files.lang の保存値へ正確に一致させる。 + lang = input; + return; + } + + var suggestions = ConsoleUi.FindClosestMatches(lookupKey, aliases.Keys) + .Select(candidate => aliases[candidate]) + .Distinct(StringComparer.Ordinal) + .Take(3) + .ToArray(); + var suggestionText = suggestions.Length == 0 + ? string.Empty + : $" Did you mean {string.Join(", ", suggestions.Select(value => $"'{ConsoleUi.FormatBoundedValue(value)}'"))}?"; + languageValidationError = true; + AddParseError( + $"Error [{CommandErrorCodes.UsageError}]: unknown language identifier '{ConsoleUi.FormatBoundedValue(input)}'.{suggestionText} " + + "Use --allow-unknown-lang only for an unregistered plugin language ID."); + } + private void ApplySearchSourceOptionDefaults() { if (parseErrors != null @@ -316,6 +374,8 @@ private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string r TotalLimit = totalLimit, LimitExplicit = limitExplicit, Lang = lang, + AllowUnknownLang = allowUnknownLang, + LanguageValidationError = languageValidationError, Kind = kind, UnusedBucket = unusedBucket, MinUnusedConfidence = minUnusedConfidence, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs index b16d9101a..0a2e97a29 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs @@ -23,7 +23,7 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("definition", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("definition"), options.Query)) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "definition")) + if (TryWriteParseError(options, "definition", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "definition", KnownSymbolKindFilters)) return CommandExitCodes.InvalidArgument; @@ -299,7 +299,7 @@ public static int RunGoto(string[] cmdArgs, JsonSerializerOptions jsonOptions) var options = ParseArgs(filteredArgs, jsonDefault: true, allowNamedQuery: true); if (TryWriteUnsupportedOptionError("goto", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("goto"), options.Query)) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "goto")) + if (TryWriteParseError(options, "goto", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "goto", KnownSymbolKindFilters)) return CommandExitCodes.InvalidArgument; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 4230553b8..1aaba5182 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -26,7 +26,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); if (TryWriteUnsupportedOptionError("impact", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("impact"), options.Query)) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "impact")) + if (TryWriteParseError(options, "impact", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "impact")) return CommandExitCodes.UsageError; @@ -466,7 +466,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("deps", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("deps"))) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "deps")) + if (TryWriteParseError(options, "deps", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteUnexpectedPositionals("deps", options)) return CommandExitCodes.UsageError; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs b/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs index dbe74e9e2..ec2ff65c4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs @@ -32,7 +32,7 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "symbols", KnownSymbolKindFilters)) return CommandExitCodes.InvalidArgument; - if (TryWriteParseError(options, "symbols")) + if (TryWriteParseError(options, "symbols", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteBlankQueryError(options, "symbols")) return CommandExitCodes.UsageError; @@ -419,7 +419,7 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; if (TryWriteDiscoveryOutputControlUsageError("files", options)) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "files")) + if (TryWriteParseError(options, "files", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteUnexpectedExtraPositionals("files", options)) return CommandExitCodes.UsageError; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs index 38aa4c3ee..c072f4992 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs @@ -44,6 +44,8 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultSnippetLines: false); if (options.ParseError != null) { + if (options.LanguageValidationError && TryWriteParseError(options, "find", jsonOptions)) + return CommandExitCodes.UsageError; CommandErrorWriter.WriteStderr(options.ParseError); CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index e23fbf2a6..6d5f14819 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -11,7 +11,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti return optionExitCode; if (TryWriteInvalidKindFilterError(options, "references", AllValidReferenceKinds, AllValidKinds)) return CommandExitCodes.InvalidArgument; - if (TryWriteParseError(options, "references")) + if (TryWriteParseError(options, "references", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "references")) return CommandExitCodes.UsageError; @@ -143,7 +143,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions { if (!TryParseGraphCommandOptions("callers", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; - if (TryWriteParseError(options, "callers")) + if (TryWriteParseError(options, "callers", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callers")) return CommandExitCodes.UsageError; @@ -280,7 +280,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions { if (!TryParseGraphCommandOptions("callees", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; - if (TryWriteParseError(options, "callees")) + if (TryWriteParseError(options, "callees", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callees")) return CommandExitCodes.UsageError; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs b/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs index 966f72a08..3b46d066c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs @@ -31,7 +31,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("hotspots", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("hotspots"))) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "hotspots")) + if (TryWriteParseError(options, "hotspots", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "hotspots", KnownSymbolKindFilters)) return CommandExitCodes.InvalidArgument; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index 0bf44ef1d..ae3a6c3c0 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -23,7 +23,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (TryWriteNonPositiveCoordinateJsonError(options, jsonOptions, "--line", "--start", "--start-line", "--end", "--end-line")) return CommandExitCodes.InvalidArgument; - if (TryWriteParseError(options, "inspect")) + if (TryWriteParseError(options, "inspect", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteUnsupportedOutputFormat("inspect", options, InspectOutputFormats, "Use `--format json` or `--format compact` for inspect bundles; count output is not meaningful for one inspect bundle.")) return CommandExitCodes.UsageError; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs index 433275b71..64e03c57c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs @@ -24,7 +24,7 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("map", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("map"))) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "map")) + if (TryWriteParseError(options, "map", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteUnsupportedOutputFormat("map", options, RepoMapOutputFormats, "Use `--format json`, `--format compact`, or `--format issue-drafts` for map output; use `cdidx files --count` when you need only a file count.")) return CommandExitCodes.UsageError; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index a8918bc98..db1c93e64 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -54,7 +54,10 @@ private static int RunSearchCore( applySearchSourceDefaults: true); if (TryWriteUnsupportedOptionError(usageCommandName, validationArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand(usageCommandName), options.Query)) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, usageCommandName)) + if (TryWriteParseError( + options, + usageCommandName, + options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (!TryResolveSearchExactMode(options, out var exact, out var exactError)) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs b/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs index bee8dad1f..4b86fbf4c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs @@ -25,7 +25,7 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("unused", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("unused"))) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "unused")) + if (TryWriteParseError(options, "unused", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "unused", KnownSymbolKindFilters)) return CommandExitCodes.InvalidArgument; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index ea6a1ccd8..85e3c1967 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -82,6 +82,8 @@ public sealed class QueryCommandOptions public int? TotalLimit { get; init; } public bool LimitExplicit { get; init; } public string? Lang { get; init; } + public bool AllowUnknownLang { get; init; } + public bool LanguageValidationError { get; init; } public string? Kind { get; init; } public string? UnusedBucket { get; init; } public string? MinUnusedConfidence { get; init; } diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index ab9efb2e3..cb3bca76e 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -1671,9 +1671,19 @@ internal static bool IsSqlLanguage(string? lang) var normalized = NormalizeQueryLanguageKey(lang); return QueryLanguageAliases.TryGetValue(normalized, out var canonical) ? canonical - : normalized; + : normalized.Length == 0 + ? normalized + : lang.Trim(); } + internal static IReadOnlyDictionary GetQueryLanguageAliases(string? workspaceRoot) + => string.IsNullOrWhiteSpace(workspaceRoot) + ? QueryLanguageAliases + : BuildQueryLanguageAliases(workspaceRoot); + + internal static string NormalizeQueryLanguageLookupKey(string lang) + => NormalizeQueryLanguageKey(lang); + internal static string FoldNameForLanguage(string value, string? lang) => string.Equals(NormalizeQueryLanguage(lang), "nim", StringComparison.Ordinal) ? NimIdentifierIdentity.Fold(value) ?? value @@ -1699,12 +1709,17 @@ private static void AddPersistedFoldedNameQueryParameters( internal static bool ContainsSqlLanguage(IEnumerable langs) => langs.Any(IsSqlLanguage); - private static IReadOnlyDictionary BuildQueryLanguageAliases() + private static IReadOnlyDictionary BuildQueryLanguageAliases(string? workspaceRoot = null) { - var languageExtensions = FileIndexer.GetLanguageExtensions(); + var languageExtensions = workspaceRoot == null + ? FileIndexer.GetLanguageExtensions() + : FileIndexer.GetLanguageExtensions(workspaceRoot); var aliases = new Dictionary(languageExtensions.Count * 2 + 32, StringComparer.OrdinalIgnoreCase); - foreach (var lang in FileIndexer.GetDetectedLanguageNames()) + var detectedLanguages = workspaceRoot == null + ? FileIndexer.GetDetectedLanguageNames() + : FileIndexer.GetDetectedLanguageNames(workspaceRoot); + foreach (var lang in detectedLanguages) AddQueryLanguageAlias(aliases, lang, lang); foreach (var (pattern, lang) in languageExtensions) AddQueryLanguageAlias(aliases, pattern, lang); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 87ac821b1..40ddafbd5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -5830,7 +5830,7 @@ public void RunInspect_ExactOnReadOnlyLegacyDb_UnsupportedGraphLanguage_DoesNotR var readOnlyUri = new Uri(dbPath).AbsoluteUri + "?immutable=1"; var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( - ["Heading", "--db", readOnlyUri, "--exact", "--lang", "text", "--json"], + ["Heading", "--db", readOnlyUri, "--exact", "--lang", "text", "--allow-unknown-lang", "--json"], _jsonOptions)); using var document = ParseJsonOutput(stdout); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs index a83631cf6..a17baccb2 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs @@ -1899,7 +1899,7 @@ public void RunReferences_UnsupportedLanguageWithoutMatches_PrintsGraphSupportHi var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunReferences( - ["MissingSymbol", "--db", dbPath, "--lang", "text"], + ["MissingSymbol", "--db", dbPath, "--lang", "text", "--allow-unknown-lang"], _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index a8a0dcfb3..628027faa 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -9448,16 +9448,14 @@ public void RunExcerpt_RejectsInvalidFocusColumnValues() } } - // `search --lang csarp` previously emitted "No results found." with no language hint - // — RunSearch never called WriteLangHint. Round-2 wires WriteLangHint into the zero- - // result branch and lets it fall back to ReferenceExtractor.GetSupportedLanguages() - // when the typo'd value matches no indexed language (#1582). - // 従来 `search --lang csarp` は "No results found." だけ表示し、RunSearch から - // WriteLangHint を呼んでいなかった。round-2 で zero-result 分岐に WriteLangHint を - // 配線し、index 済み言語にマッチしない場合は ReferenceExtractor.GetSupportedLanguages() - // にフォールバックして提案を出すようにした (#1582)。 + // `search --lang csarp` originally gained a post-query hint in #1582. Query language + // validation now fails before execution and preserves the closest-language guidance + // without reporting a misleading successful zero-result search (#4842). + // `search --lang csarp` は #1582 で query 後の hint を得た。現在は query 実行前の + // language validation で失敗し、成功した 0 件検索と誤認させずに近い言語候補を + // 維持する (#4842)。 [Fact] - public void RunSearch_LangTypo_SuggestsClosestLanguage_Issue1582() + public void RunSearch_LangTypo_FailsWithClosestLanguage_Issue1582_Issue4842() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_lang_typo"); try @@ -9473,10 +9471,11 @@ public void RunSearch_LangTypo_SuggestsClosestLanguage_Issue1582() ["nothing_matches_xyzzy", "--db", dbPath, "--lang", "csarp"], _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(CommandExitCodes.UsageError, exitCode); Assert.Equal(string.Empty, stdout); - Assert.Contains("No results found.", stderr); - Assert.Contains("Did you mean: --lang csharp?", stderr); + Assert.Contains($"Error [{CommandErrorCodes.UsageError}]", stderr); + Assert.Contains("unknown language identifier 'csarp'", stderr); + Assert.Contains("Did you mean 'csharp'", stderr); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index 092203462..c18f04700 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -2919,7 +2919,7 @@ public void RunUnused_WithJsonIncludesGraphSupportMetadataForUnsupportedLanguage try { var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--lang", "text"], + ["--db", dbPath, "--json", "--lang", "text", "--allow-unknown-lang"], _jsonOptions)); using var document = ParseJsonOutput(stdout); @@ -2945,7 +2945,7 @@ public void RunUnused_CountJsonUnsupportedLanguage_ReturnsZero() try { var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--lang", "text", "--count"], + ["--db", dbPath, "--json", "--lang", "text", "--allow-unknown-lang", "--count"], _jsonOptions)); using var document = ParseJsonOutput(stdout); @@ -2988,7 +2988,7 @@ public void RunUnused_IndexedAndUnsupportedZeroResultsShareFixture() Assert.False(json.TryGetProperty("unused", out _)); var (unsupportedExitCode, unsupportedStdout, unsupportedStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--lang", "text"], + ["--db", dbPath, "--json", "--lang", "text", "--allow-unknown-lang"], _jsonOptions)); using var unsupportedDocument = ParseJsonOutput(unsupportedStdout); @@ -7246,7 +7246,7 @@ public void RunSymbols_PreservesCommonJsMultilineBraceBodyRanges() } [Fact] - public void RunSymbols_EmitsLangHintForUnknownLang() + public void RunSymbols_RejectsUnknownLangWithSuggestion_Issue4842() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_symbols_lang_hint"); try @@ -7255,11 +7255,13 @@ public void RunSymbols_EmitsLangHintForUnknownLang() TestProjectHelper.InsertIndexedFile(dbPath, "a.py", "python", "def hello(): pass\n"); var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSymbols( - ["--db", dbPath, "--lang", "nonexistent"], + ["--db", dbPath, "--lang", "pyhton"], _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Contains("'nonexistent' not found in index. Available: python", stderr); + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains($"Error [{CommandErrorCodes.UsageError}]", stderr); + Assert.Contains("unknown language identifier 'pyhton'", stderr); + Assert.Contains("Did you mean 'python'", stderr); } finally { @@ -7268,7 +7270,7 @@ public void RunSymbols_EmitsLangHintForUnknownLang() } [Fact] - public void RunDefinition_EmitsLangHintForUnknownLang() + public void RunDefinition_RejectsUnknownLangWithSuggestion_Issue4842() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_definition_lang_hint"); try @@ -7277,11 +7279,13 @@ public void RunDefinition_EmitsLangHintForUnknownLang() TestProjectHelper.InsertIndexedFile(dbPath, "a.py", "python", "def hello(): pass\n"); var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunDefinition( - ["hello", "--db", dbPath, "--lang", "nonexistent"], + ["hello", "--db", dbPath, "--lang", "pyhton"], _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Contains("'nonexistent' not found in index. Available: python", stderr); + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains($"Error [{CommandErrorCodes.UsageError}]", stderr); + Assert.Contains("unknown language identifier 'pyhton'", stderr); + Assert.Contains("Did you mean 'python'", stderr); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 75bdc0025..2dee56e87 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1517,12 +1517,72 @@ public void ParseArgs_AllowsZeroMaxLineWidthForNoTruncation() [InlineData("asm", "assembly")] [InlineData("assembler", "assembly")] [InlineData("GNU assembler", "assembly")] + [InlineData("CSharp", "csharp")] + [InlineData(".cs", "csharp")] public void ParseArgs_NormalizesCommonLangAliases(string input, string expected) { var options = QueryCommandRunner.ParseArgs(["RunSearch", "--lang", input], jsonDefault: false, allowNamedQuery: true); Assert.Equal("RunSearch", options.Query); Assert.Equal(expected, options.Lang); + Assert.Null(options.ParseError); + } + + [Fact] + public void ParseArgs_RejectsMisspelledLanguageAndSuggestsCanonicalValue_Issue4842() + { + var options = QueryCommandRunner.ParseArgs( + ["RunSearch", "--lang", "cshrap"], + jsonDefault: false, + allowNamedQuery: true); + + Assert.NotNull(options.ParseError); + Assert.Contains(CommandErrorCodes.UsageError, options.ParseError, StringComparison.Ordinal); + Assert.Contains("unknown language identifier 'cshrap'", options.ParseError, StringComparison.Ordinal); + Assert.Contains("Did you mean 'csharp'", options.ParseError, StringComparison.Ordinal); + Assert.Contains("--allow-unknown-lang", options.ParseError, StringComparison.Ordinal); + } + + [Fact] + public void ParseArgs_AllowUnknownLanguagePreservesExactPluginId_Issue4842() + { + var options = QueryCommandRunner.ParseArgs( + ["RunSearch", "--lang", " Unregistered-Plugin.ID ", "--allow-unknown-lang"], + jsonDefault: false, + allowNamedQuery: true); + + Assert.Null(options.ParseError); + Assert.True(options.AllowUnknownLang); + Assert.Equal("Unregistered-Plugin.ID", options.Lang); + } + + [Fact] + public void ParseArgs_AllowUnknownLanguageRequiresLanguageValue_Issue4842() + { + var options = QueryCommandRunner.ParseArgs( + ["RunSearch", "--allow-unknown-lang"], + jsonDefault: false, + allowNamedQuery: true); + + Assert.NotNull(options.ParseError); + Assert.Contains(CommandErrorCodes.UsageError, options.ParseError, StringComparison.Ordinal); + Assert.Contains("--allow-unknown-lang requires --lang ", options.ParseError, StringComparison.Ordinal); + } + + [Fact] + public void LanguageValidationEscapeAppearsInSearchHelpAndCompletions_Issue4842() + { + Assert.Contains("--allow-unknown-lang", CliFlagSchema.GetAcceptedFlagNamesForCommand("search")); + Assert.DoesNotContain("--allow-unknown-lang", CliFlagSchema.GetAcceptedFlagNamesForCommand("status")); + + var (printed, stdout, stderr) = CaptureConsole(() => + ConsoleUi.PrintCommandUsage("search") ? 1 : 0); + Assert.Equal(1, printed); + Assert.Equal(string.Empty, stderr); + Assert.Contains("--allow-unknown-lang", stdout, StringComparison.Ordinal); + + foreach (var shell in new[] { "bash", "zsh", "fish", "powershell" }) + Assert.Contains("allow-unknown-lang", ConsoleCompletionRenderer.GetCompletionScript(shell), StringComparison.Ordinal); } [Fact] @@ -2578,6 +2638,103 @@ public IReadOnlyList Extract(long fileId, string source, Extractio => []; } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RunSearch_MisspelledLanguageFailsOnEmptyIndexInHumanAndJsonModes_Issue4842(bool json) + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_lang_typo_4842"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + var args = new List { "needle", "--db", dbPath, "--lang", "cshrap" }; + if (json) + args.Add("--json"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => + QueryCommandRunner.RunSearch([.. args], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + if (!json) + { + Assert.Equal(string.Empty, stdout); + Assert.Contains($"Error [{CommandErrorCodes.UsageError}]", stderr, StringComparison.Ordinal); + Assert.Contains("unknown language identifier 'cshrap'", stderr, StringComparison.Ordinal); + Assert.Contains("Did you mean 'csharp'", stderr, StringComparison.Ordinal); + return; + } + + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var error = document.RootElement; + Assert.Equal("error", error.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Equal("usage", error.GetProperty("category").GetString()); + Assert.Contains("unknown language identifier 'cshrap'", error.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Contains("Did you mean 'csharp'", error.GetProperty("message").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void ParseArgs_UsesWorkspacePluginRegistryForIdsAndExtensionSpellings_Issue4842() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_lang_plugin_4842"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + lock (TestConsoleLock.Gate) + { + try + { + ExtractorPluginRegistry.ResetForTests(); + ExtractorPluginRegistry.RegisterForWorkspaceForTests( + project.Root, + new WorkspaceCatalogSymbolExtractor("My-Plugin.ID", ".plugx")); + + var byId = QueryCommandRunner.ParseArgs( + ["needle", "--db", dbPath, "--lang", "My-Plugin.ID"], + jsonDefault: false, + allowNamedQuery: true); + var byExtension = QueryCommandRunner.ParseArgs( + ["needle", "--db", dbPath, "--lang", ".plugx"], + jsonDefault: false, + allowNamedQuery: true); + + Assert.Null(byId.ParseError); + Assert.Equal("my-plugin.id", byId.Lang); + Assert.Null(byExtension.ParseError); + Assert.Equal("my-plugin.id", byExtension.Lang); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + } + } + } + + [Fact] + public void RunSearch_AllowUnknownLanguageMatchesExactUnregisteredPluginId_Issue4842() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_lang_escape_4842"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/example.custom", + "Unregistered-Plugin.ID", + "needle\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => + QueryCommandRunner.RunSearch( + [ + "needle", + "--db", dbPath, + "--lang", "Unregistered-Plugin.ID", + "--allow-unknown-lang", + "--json", + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("\"lang\":\"Unregistered-Plugin.ID\"", stdout, StringComparison.Ordinal); + Assert.Contains("\"path\":\"src/example.custom\"", stdout, StringComparison.Ordinal); + } + private static TestProjectHelper.TempProjectScope CreateSearchOnlyLanguageProject( string prefix, out string dbPath) From 9b0e032c3c0eb5f4de6c9db15686edca2a4a074f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 01:06:19 +0900 Subject: [PATCH 2/3] Address query language review findings (#4842) --- .../Cli/QueryCommandRunner.Definition.cs | 4 ++ .../Cli/QueryCommandRunner.Dependencies.cs | 4 ++ .../Cli/QueryCommandRunner.Discovery.cs | 4 ++ src/CodeIndex/Cli/QueryCommandRunner.Find.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 6 +++ .../Cli/QueryCommandRunner.Hotspots.cs | 2 + .../Cli/QueryCommandRunner.Inspect.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.Map.cs | 2 + .../Cli/QueryCommandRunner.Search.cs | 2 + .../Cli/QueryCommandRunner.Unused.cs | 2 + src/CodeIndex/Database/DbReader.cs | 42 ++++++++++++++++--- .../QueryCommandRunnerTests.cs | 21 ++++++++-- 12 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs index 0a2e97a29..4b212952d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs @@ -21,6 +21,8 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti allowNamedQuery: true, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("definition", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("definition"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "definition", options.LanguageValidationError ? jsonOptions : null)) @@ -297,6 +299,8 @@ public static int RunGoto(string[] cmdArgs, JsonSerializerOptions jsonOptions) var all = cmdArgs.Any(arg => arg == "--all"); var filteredArgs = cmdArgs.Where(arg => arg != "--all").ToArray(); var options = ParseArgs(filteredArgs, jsonDefault: true, allowNamedQuery: true); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("goto", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("goto"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "goto", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 1aaba5182..e99438380 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -24,6 +24,8 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; } var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("impact", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("impact"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "impact", options.LanguageValidationError ? jsonOptions : null)) @@ -464,6 +466,8 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("deps", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("deps"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "deps", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs b/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs index ec2ff65c4..abd29a139 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs @@ -24,6 +24,8 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions allowNamedQuery: true, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("symbols", effectiveCmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("symbols"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteUnsupportedOutputFormat("symbols", options, SymbolOutputFormats, "Use `--format json` for symbol rows, `--format compact` for bounded compact rows, `--format count` for symbol totals, or `--format lsp|qf|sarif` for editor/diagnostic locations.")) @@ -413,6 +415,8 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false, positionalGlobAsPath: true); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("files", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("files"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteUnsupportedOutputFormat("files", options, FilesOutputFormats, "Use `--format json` for file rows, `--format compact` for bounded compact rows, or `--format count` for file totals.")) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs index c072f4992..c4e16c1ee 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs @@ -42,6 +42,8 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) jsonDefault: false, allowNamedQuery: true, validateDefaultSnippetLines: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (options.ParseError != null) { if (options.LanguageValidationError && TryWriteParseError(options, "find", jsonOptions)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 6d5f14819..0f2cd38ed 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -9,6 +9,8 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti { if (!TryParseGraphCommandOptions("references", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteInvalidKindFilterError(options, "references", AllValidReferenceKinds, AllValidKinds)) return CommandExitCodes.InvalidArgument; if (TryWriteParseError(options, "references", options.LanguageValidationError ? jsonOptions : null)) @@ -143,6 +145,8 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions { if (!TryParseGraphCommandOptions("callers", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteParseError(options, "callers", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callers")) @@ -280,6 +284,8 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions { if (!TryParseGraphCommandOptions("callees", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteParseError(options, "callees", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callees")) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs b/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs index 3b46d066c..d1e37b906 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs @@ -29,6 +29,8 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("hotspots", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("hotspots"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "hotspots", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index ae3a6c3c0..bf69e4878 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -19,6 +19,8 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions jsonDefault: false, allowNamedQuery: true, validateDefaultSnippetLines: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("inspect", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("inspect"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteNonPositiveCoordinateJsonError(options, jsonOptions, "--line", "--start", "--start-line", "--end", "--end-line")) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs index 64e03c57c..58d2fe79e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs @@ -22,6 +22,8 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) allowIssueDraftsFormat: true, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("map", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("map"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "map", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index db1c93e64..4a35970d0 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -52,6 +52,8 @@ private static int RunSearchCore( allowNamedQuery: true, allowIssueDraftsFormat: true, applySearchSourceDefaults: true); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError(usageCommandName, validationArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand(usageCommandName), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs b/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs index 4b86fbf4c..d8a47a296 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs @@ -23,6 +23,8 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.AllowUnknownLang ? options.Lang : null); if (TryWriteUnsupportedOptionError("unused", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("unused"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "unused", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index cb3bca76e..524ada05f 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -44,6 +44,8 @@ public readonly record struct HdlGraphContractSignal( /// public partial class DbReader : IDisposable { + private static readonly AsyncLocal ExactQueryLanguage = new(); + public const string VerifyFoldReadyRowsEnvironmentVariable = "CDIDX_VERIFY_FOLD_READY_ROWS"; internal const int MaxReferenceKindAggregateCharacters = 16 * 1024; private const int SqliteInterruptErrorCode = 9; @@ -1667,19 +1669,47 @@ internal static bool IsSqlLanguage(string? lang) { if (lang == null) return null; + if (string.Equals(lang, ExactQueryLanguage.Value, StringComparison.Ordinal)) + return lang; var normalized = NormalizeQueryLanguageKey(lang); return QueryLanguageAliases.TryGetValue(normalized, out var canonical) ? canonical - : normalized.Length == 0 - ? normalized - : lang.Trim(); + : normalized; + } + + internal static IDisposable BeginExactQueryLanguageScope(string? lang) + { + var previous = ExactQueryLanguage.Value; + ExactQueryLanguage.Value = lang; + return new ExactQueryLanguageScope(previous); + } + + private sealed class ExactQueryLanguageScope(string? previous) : IDisposable + { + private bool disposed; + + public void Dispose() + { + if (disposed) + return; + ExactQueryLanguage.Value = previous; + disposed = true; + } } internal static IReadOnlyDictionary GetQueryLanguageAliases(string? workspaceRoot) - => string.IsNullOrWhiteSpace(workspaceRoot) - ? QueryLanguageAliases - : BuildQueryLanguageAliases(workspaceRoot); + { + if (string.IsNullOrWhiteSpace(workspaceRoot)) + return QueryLanguageAliases; + + // Query commands run in a fresh process after indexing, so hydrate the same + // workspace pattern/plugin registry before validating the language filter. + // query command は indexing 後の新しい process で実行されるため、言語 filter の + // 検証前に同じ workspace pattern/plugin registry を読み込む。 + ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(workspaceRoot); + return BuildQueryLanguageAliases(workspaceRoot); + } internal static string NormalizeQueryLanguageLookupKey(string lang) => NormalizeQueryLanguageKey(lang); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 2dee56e87..6f8ccb3e7 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1725,6 +1725,17 @@ public void NormalizeQueryLanguage_MapsCommonAliasesToCanonicalLanguages(string Assert.Equal(expected == "sql", DbReader.IsSqlLanguage(input)); } + [Fact] + public void NormalizeQueryLanguage_PreservesUnknownSpellingOnlyInsideExplicitScope_Issue4842() + { + Assert.Equal("toydsl", DbReader.NormalizeQueryLanguage("ToyDsl")); + + using (DbReader.BeginExactQueryLanguageScope("ToyDsl")) + Assert.Equal("ToyDsl", DbReader.NormalizeQueryLanguage("ToyDsl")); + + Assert.Equal("toydsl", DbReader.NormalizeQueryLanguage("ToyDsl")); + } + [Theory] [InlineData("ts")] [InlineData("tsx")] @@ -2673,18 +2684,20 @@ public void RunSearch_MisspelledLanguageFailsOnEmptyIndexInHumanAndJsonModes_Iss } [Fact] - public void ParseArgs_UsesWorkspacePluginRegistryForIdsAndExtensionSpellings_Issue4842() + public void ParseArgs_LoadsWorkspacePatternRegistryForIdsAndExtensionSpellings_Issue4842() { using var project = TestProjectHelper.CreateTempProjectScope("cdidx_lang_plugin_4842"); var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + var patternDirectory = Path.Combine(project.Root, ".cdidx", "patterns"); + Directory.CreateDirectory(patternDirectory); + File.WriteAllText( + Path.Combine(patternDirectory, "plugin.yaml"), + "language: \"My-Plugin.ID\"\nextensions:\n - extension: \".plugx\"\npatterns:\n - kind: \"class\"\n regex: \"^entity (?\\\\w+)\"\n"); lock (TestConsoleLock.Gate) { try { ExtractorPluginRegistry.ResetForTests(); - ExtractorPluginRegistry.RegisterForWorkspaceForTests( - project.Root, - new WorkspaceCatalogSymbolExtractor("My-Plugin.ID", ".plugx")); var byId = QueryCommandRunner.ParseArgs( ["needle", "--db", dbPath, "--lang", "My-Plugin.ID"], From 03b0248b55fe6726068bdd36a5b2ed03c0f4872e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 01:46:17 +0900 Subject: [PATCH 3/3] Preserve plugin language filters across query roots (#4842) --- .../Cli/QueryCommandRunner.ArgumentParser.cs | 15 +++- .../Cli/QueryCommandRunner.Definition.cs | 4 +- .../Cli/QueryCommandRunner.Dependencies.cs | 4 +- .../Cli/QueryCommandRunner.Discovery.cs | 4 +- src/CodeIndex/Cli/QueryCommandRunner.Find.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 6 +- .../Cli/QueryCommandRunner.Hotspots.cs | 2 +- .../Cli/QueryCommandRunner.Inspect.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Map.cs | 2 +- .../Cli/QueryCommandRunner.Search.cs | 2 +- .../Cli/QueryCommandRunner.Unused.cs | 2 +- .../QueryCommandRunnerBatchDbTests.cs | 50 +++++++++++++ .../QueryCommandRunnerTests.cs | 75 +++++++++++++++++++ 13 files changed, 153 insertions(+), 17 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index 489686b26..209022d41 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -284,8 +284,19 @@ private void ResolveLanguageFilter(string resolvedDbPath) } var input = rawLang.Trim(); - var workspaceRoot = ResolveProjectFilterRoot(resolvedDbPath, dbPathExplicit).Root; - var aliases = DbReader.GetQueryLanguageAliases(workspaceRoot); + var primaryRoot = s_batchReader != null && dbPathExplicit + ? ResolveProjectRootForDbPath(resolvedDbPath, dbPathExplicit).Root + : ResolveProjectFilterRoot(resolvedDbPath, dbPathExplicit).Root; + var queryRoots = workspaceDbPaths + .Select(path => ResolveProjectRootForDbPath(DbPathResolver.NormalizeDbPath(path), dbPathExplicit: true).Root) + .Prepend(primaryRoot) + .Distinct(StringComparer.OrdinalIgnoreCase); + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var queryRoot in queryRoots) + { + foreach (var (alias, registeredCanonical) in DbReader.GetQueryLanguageAliases(queryRoot)) + aliases.TryAdd(alias, registeredCanonical); + } var lookupKey = DbReader.NormalizeQueryLanguageLookupKey(input); if (lookupKey.Length == 0) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs index 4b212952d..9a365527a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs @@ -22,7 +22,7 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("definition", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("definition"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "definition", options.LanguageValidationError ? jsonOptions : null)) @@ -300,7 +300,7 @@ public static int RunGoto(string[] cmdArgs, JsonSerializerOptions jsonOptions) var filteredArgs = cmdArgs.Where(arg => arg != "--all").ToArray(); var options = ParseArgs(filteredArgs, jsonDefault: true, allowNamedQuery: true); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("goto", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("goto"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "goto", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index e99438380..7c366e05f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -25,7 +25,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) } var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("impact", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("impact"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "impact", options.LanguageValidationError ? jsonOptions : null)) @@ -467,7 +467,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("deps", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("deps"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "deps", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs b/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs index abd29a139..b7a112b81 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs @@ -25,7 +25,7 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("symbols", effectiveCmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("symbols"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteUnsupportedOutputFormat("symbols", options, SymbolOutputFormats, "Use `--format json` for symbol rows, `--format compact` for bounded compact rows, `--format count` for symbol totals, or `--format lsp|qf|sarif` for editor/diagnostic locations.")) @@ -416,7 +416,7 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultMaxLineWidth: false, positionalGlobAsPath: true); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("files", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("files"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteUnsupportedOutputFormat("files", options, FilesOutputFormats, "Use `--format json` for file rows, `--format compact` for bounded compact rows, or `--format count` for file totals.")) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs index c4e16c1ee..47d00aca4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs @@ -43,7 +43,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) allowNamedQuery: true, validateDefaultSnippetLines: false); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (options.ParseError != null) { if (options.LanguageValidationError && TryWriteParseError(options, "find", jsonOptions)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 0f2cd38ed..b150cc5fc 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -10,7 +10,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti if (!TryParseGraphCommandOptions("references", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteInvalidKindFilterError(options, "references", AllValidReferenceKinds, AllValidKinds)) return CommandExitCodes.InvalidArgument; if (TryWriteParseError(options, "references", options.LanguageValidationError ? jsonOptions : null)) @@ -146,7 +146,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions if (!TryParseGraphCommandOptions("callers", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteParseError(options, "callers", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callers")) @@ -285,7 +285,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions if (!TryParseGraphCommandOptions("callees", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteParseError(options, "callees", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callees")) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs b/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs index d1e37b906..e5254bfb9 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs @@ -30,7 +30,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("hotspots", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("hotspots"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "hotspots", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index bf69e4878..f47a30711 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -20,7 +20,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions allowNamedQuery: true, validateDefaultSnippetLines: false); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("inspect", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("inspect"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteNonPositiveCoordinateJsonError(options, jsonOptions, "--line", "--start", "--start-line", "--end", "--end-line")) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs index 58d2fe79e..2f77c91be 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs @@ -23,7 +23,7 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("map", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("map"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "map", options.LanguageValidationError ? jsonOptions : null)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index 4a35970d0..95e11ace4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -53,7 +53,7 @@ private static int RunSearchCore( allowIssueDraftsFormat: true, applySearchSourceDefaults: true); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError(usageCommandName, validationArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand(usageCommandName), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs b/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs index d8a47a296..1b1470921 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs @@ -24,7 +24,7 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( - options.AllowUnknownLang ? options.Lang : null); + options.Lang); if (TryWriteUnsupportedOptionError("unused", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("unused"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "unused", options.LanguageValidationError ? jsonOptions : null)) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerBatchDbTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerBatchDbTests.cs index 06b86c72c..9959caf11 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerBatchDbTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerBatchDbTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using CodeIndex.Cli; +using CodeIndex.Indexer.Extensibility; namespace CodeIndex.Tests; @@ -64,4 +65,53 @@ public void RunBatch_ChildExplicitDbUsesChildDatabaseAndProjectRoot_Issue4354() TestProjectHelper.DeleteDirectory(childRoot); } } + + [Fact] + public void RunBatch_ChildExplicitDbUsesChildLanguageRegistry_Issue4842() + { + var parentRoot = TestProjectHelper.CreateTempProject("cdidx_batch_lang_parent"); + var childRoot = TestProjectHelper.CreateTempProject("cdidx_batch_lang_child"); + try + { + var parentDbPath = TestProjectHelper.CreateProjectDb(parentRoot); + var childDbPath = TestProjectHelper.CreateProjectDb(childRoot); + var patternDirectory = Path.Combine(childRoot, ".cdidx", "patterns"); + Directory.CreateDirectory(patternDirectory); + File.WriteAllText( + Path.Combine(patternDirectory, "child.yaml"), + "language: \"child-dsl\"\nextensions:\n - extension: \".child\"\npatterns:\n - kind: \"class\"\n regex: \"^entity (?\\\\w+)\"\n"); + TestProjectHelper.InsertIndexedFile(childDbPath, "src/example.child", "child-dsl", "needle\n"); + var input = JsonSerializer.Serialize( + new[] { "search", "needle", "--db", childDbPath, "--lang", "child-dsl", "--json=array" }) + "\n"; + + lock (TestConsoleLock.Gate) + { + try + { + ExtractorPluginRegistry.ResetForTests(); + + var (exitCode, stdout, stderr) = CaptureConsoleWithInput( + input, + () => QueryCommandRunner.RunBatch(["--db", parentDbPath, "--json-summary"], _jsonOptions)); + var lines = ParseJsonLines(stdout); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal("ok", lines[0].RootElement.GetProperty("status").GetString()); + Assert.Equal( + "src/example.child", + lines[0].RootElement.GetProperty("result")[0].GetProperty("path").GetString()); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(parentRoot); + TestProjectHelper.DeleteDirectory(childRoot); + } + } } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 6f8ccb3e7..b7d722979 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -2720,6 +2720,81 @@ public void ParseArgs_LoadsWorkspacePatternRegistryForIdsAndExtensionSpellings_I } } + [Fact] + public void RunSearch_RegisteredExtensionPreservesPunctuatedCanonicalPluginId_Issue4842() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_lang_plugin_collision_4842"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + var patternDirectory = Path.Combine(project.Root, ".cdidx", "patterns"); + Directory.CreateDirectory(patternDirectory); + File.WriteAllText( + Path.Combine(patternDirectory, "plugin.yaml"), + "language: \"c-sharp\"\nextensions:\n - extension: \".plugx\"\npatterns:\n - kind: \"class\"\n regex: \"^entity (?\\\\w+)\"\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/plugin.plugx", "c-sharp", "needle plugin\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/builtin.cs", "csharp", "needle builtin\n"); + + lock (TestConsoleLock.Gate) + { + try + { + ExtractorPluginRegistry.ResetForTests(); + + var (exitCode, stdout, stderr) = CaptureConsole(() => + QueryCommandRunner.RunSearch( + ["needle", "--db", dbPath, "--lang", ".plugx", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("\"path\":\"src/plugin.plugx\"", stdout, StringComparison.Ordinal); + Assert.DoesNotContain("\"path\":\"src/builtin.cs\"", stdout, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + } + } + } + + [Fact] + public void ParseArgs_LoadsLanguageRegistryFromWorkspaceDatabaseRoots_Issue4842() + { + using var primary = TestProjectHelper.CreateTempProjectScope("cdidx_lang_primary_4842"); + using var member = TestProjectHelper.CreateTempProjectScope("cdidx_lang_member_4842"); + var primaryDbPath = TestProjectHelper.CreateProjectDb(primary.Root); + var memberDbPath = TestProjectHelper.CreateProjectDb(member.Root); + var patternDirectory = Path.Combine(member.Root, ".cdidx", "patterns"); + Directory.CreateDirectory(patternDirectory); + File.WriteAllText( + Path.Combine(patternDirectory, "member.yaml"), + "language: \"member-dsl\"\nextensions:\n - extension: \".member\"\npatterns:\n - kind: \"class\"\n regex: \"^entity (?\\\\w+)\"\n"); + + lock (TestConsoleLock.Gate) + { + try + { + ExtractorPluginRegistry.ResetForTests(); + + var options = QueryCommandRunner.ParseArgs( + [ + "needle", + "--db", primaryDbPath, + "--workspace-db", memberDbPath, + "--lang", "member-dsl", + ], + jsonDefault: false, + allowNamedQuery: true); + + Assert.Null(options.ParseError); + Assert.Equal("member-dsl", options.Lang); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + } + } + } + [Fact] public void RunSearch_AllowUnknownLanguageMatchesExactUnregisteredPluginId_Issue4842() {