diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 1bccffccc..4914f8d4f 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..209022d41 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,71 @@ 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 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) + { + 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 +385,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..9a365527a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs @@ -21,9 +21,11 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti allowNamedQuery: true, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); 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; @@ -297,9 +299,11 @@ 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.Lang); 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..7c366e05f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -24,9 +24,11 @@ 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.Lang); 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; @@ -464,9 +466,11 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); 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..b7a112b81 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.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.")) @@ -32,7 +34,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; @@ -413,13 +415,15 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false, positionalGlobAsPath: true); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + 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.")) 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..47d00aca4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs @@ -42,8 +42,12 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) jsonDefault: false, allowNamedQuery: true, validateDefaultSnippetLines: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); 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..b150cc5fc 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -9,9 +9,11 @@ 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.Lang); 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 +145,9 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions { if (!TryParseGraphCommandOptions("callers", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; - if (TryWriteParseError(options, "callers")) + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); + if (TryWriteParseError(options, "callers", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callers")) return CommandExitCodes.UsageError; @@ -280,7 +284,9 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions { if (!TryParseGraphCommandOptions("callees", cmdArgs, out var options, out var optionExitCode)) return optionExitCode; - if (TryWriteParseError(options, "callees")) + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); + 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..e5254bfb9 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Hotspots.cs @@ -29,9 +29,11 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); 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..f47a30711 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -19,11 +19,13 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions jsonDefault: false, allowNamedQuery: true, validateDefaultSnippetLines: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + 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")) 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..2f77c91be 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs @@ -22,9 +22,11 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) allowIssueDraftsFormat: true, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); 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..95e11ace4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -52,9 +52,14 @@ private static int RunSearchCore( allowNamedQuery: true, allowIssueDraftsFormat: true, applySearchSourceDefaults: true); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); 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..1b1470921 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Unused.cs @@ -23,9 +23,11 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); + using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( + options.Lang); 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 fbe7ac284..6a9a34dd0 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; @@ -1675,6 +1677,8 @@ 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) @@ -1682,6 +1686,42 @@ internal static bool IsSqlLanguage(string? lang) : 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) + { + 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); + internal static string FoldNameForLanguage(string value, string? lang) => string.Equals(NormalizeQueryLanguage(lang), "nim", StringComparison.Ordinal) ? NimIdentifierIdentity.Fold(value) ?? value @@ -1707,12 +1747,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/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/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..b7d722979 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] @@ -1665,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")] @@ -2578,6 +2649,180 @@ 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_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(); + + 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_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() + { + 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)