diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 334374973..124c304b4 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -199,6 +199,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Argument-validation variants that only differ by invalid scalar input share one database fixture and iterate within a fact when no per-case state or discovery identity is required. Positional `files` glob coverage shares one indexed-file fixture and iterates `*`, `?`, and recursive `**` patterns in a fact, matching the exact tokens that a quoted shell argument passes to the CLI. Excerpt focus coverage reuses one indexed fixture for line-only leading-window behavior, the focus-length dependency, and focus-column range validation; zero and non-numeric focus-column values share one indexed Markdown fixture. + Excerpt range coverage reuses one indexed fixture for requested/effective context bounds, explicit EOF and clamp modes, Unicode content, and terminal-newline variants; strict numeric recovery, empty files, and inline zero/negative coordinates share one invalid-range fixture. Inspect path-line exact/enclosing-symbol cases reuse one indexed source fixture and iterate read-only line queries within a fact. Definition and symbols exact-mode conflict validation share one empty database and a cross-command flag-pair table. Symbols compact flag/alias and summary-only JSON envelopes share one editor-format fixture. @@ -1125,6 +1126,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" invalid scalar input だけが異なる argument-validation variant は、case ごとの state や discovery identity が不要なら1つの database fixture を共有し、fact 内で反復してください。 `files` の positional glob coverage は1つの indexed-file fixture を共有し、quote された shell 引数が CLI に渡す token と同じ `*`、`?`、recursive `**` pattern を fact 内で反復してください。 excerpt の focus coverage は、line-only 時の先頭側 window、focus-length の依存関係、focus-column の範囲検証を1つの indexed fixture で共有してください。focus-column の zero / non-numeric value も1つの indexed Markdown fixture を再利用してください。 + excerpt の range coverage は、requested / effective context 境界、明示的な EOF / clamp mode、Unicode content、末尾改行の有無を1つの indexed fixture で共有してください。strict numeric recovery、空ファイル、inline の zero / negative 座標は1つの invalid-range fixture を共有してください。 inspect path-line の exact/enclosing-symbol case は1つの indexed source fixture を再利用し、read-only line query を fact 内で反復してください。 definition と symbols の exact-mode conflict validation は1つの空databaseとcross-command flag-pair tableを共有してください。 symbols compact flag/aliasとsummary-only JSON envelopeは1つのeditor-format fixtureを共有してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 326243213..b2b256833 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1763,8 +1763,14 @@ For large files, `outline --json` supports `--kind `, `--sort Aliases { get; init; } = new Dictionary(StringComparer.Ordinal); public bool NormalizeDashAndUnderscore { get; init; } + public string? DisplayPlaceholder { get; init; } - public string ValuePlaceholder => $"<{string.Join('|', CanonicalValues)}>"; + public string ValuePlaceholder => + DisplayPlaceholder ?? $"<{string.Join('|', CanonicalValues)}>"; public bool TryNormalize(string rawValue, out string normalizedValue) { @@ -366,6 +368,12 @@ public static bool HasAuthoritativeHelpOptions(string command) => ("help", "help_text"), ("schema", "schema_description")); + private static readonly CliOptionValueDomain ExcerptEndValueDomain = new() + { + CanonicalValues = ["eof"], + DisplayPlaceholder = "", + }; + private static readonly IReadOnlyDictionary OutputFormatValueDomains = new Dictionary(StringComparer.Ordinal) { @@ -574,8 +582,9 @@ private static IReadOnlyList BuildAll() new() { Name = "--after", ValuePlaceholder = "", Description = "Context lines after", PrimaryCommands = Set("find", "excerpt", "inspect") }, new() { Name = "--start", ValuePlaceholder = "", Description = "Start line", PrimaryCommands = Set("excerpt") }, new() { Name = "--start-line", ValuePlaceholder = "", Description = "Alias for --start; inspect source_excerpt start line", PrimaryCommands = Set("excerpt", "inspect") }, - new() { Name = "--end", ValuePlaceholder = "", Description = "End line", PrimaryCommands = Set("excerpt") }, + new() { Name = "--end", ValueDomain = ExcerptEndValueDomain, Description = "Excerpt end line; eof reads through the indexed end of file", PrimaryCommands = Set("excerpt") }, new() { Name = "--end-line", ValuePlaceholder = "", Description = "Alias for --end; inspect source_excerpt end line", PrimaryCommands = Set("excerpt", "inspect") }, + new() { Name = "--clamp", Description = "Excerpt: explicitly clamp numeric range overshoot to file boundaries", PrimaryCommands = Set("excerpt") }, new() { Name = "--focus-line", ValuePlaceholder = "", Description = "Focused line to keep visible when clamping", PrimaryCommands = Set("find", "excerpt") }, new() { Name = "--focus-column", ValuePlaceholder = "", Description = "Focused column to keep visible when clamping", PrimaryCommands = Set("find", "excerpt") }, new() { Name = "--focus-length", ValuePlaceholder = "", Description = "Focused span width when clamping", PrimaryCommands = Set("excerpt") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index dff6b8f71..98093e78b 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -95,7 +95,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json[=ndjson|array]] [--compact] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--sort ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--group-partials] [--since ]"), ("files", "cdidx files [query||--query |-- ] [--db ] [--json[=ndjson|array]] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), ("find", "cdidx find (--path |--all) [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--context ] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--line-scan-limit ] [--allow-partial] [--exact] [--regex] [--count]"), - ("excerpt", "cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--redact-paths|--show-paths] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]"), + ("excerpt", "cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--clamp] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--redact-paths|--show-paths] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--cursor ] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>] [--max-json-bytes ]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Excerpt.cs b/src/CodeIndex/Cli/QueryCommandRunner.Excerpt.cs index a8a0cd93f..d1f0caa8a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Excerpt.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Excerpt.cs @@ -16,14 +16,23 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } + var preparedArguments = PrepareExcerptArguments(cmdArgs); var options = ParseArgs( - cmdArgs, + preparedArguments.Args, jsonDefault: false, validateDefaultLimit: false, validateDefaultSnippetLines: false); if (TryWriteUnsupportedOptionError("excerpt", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("excerpt"))) return CommandExitCodes.UsageError; - if (TryWriteNonPositiveCoordinateJsonError(options, jsonOptions, "--line", "--start", "--start-line", "--end", "--end-line")) + if (TryWriteNonPositiveCoordinateRangeError( + options, + jsonOptions, + includeHumanOutput: true, + "--line", + "--start", + "--start-line", + "--end", + "--end-line")) return CommandExitCodes.InvalidArgument; if (TryWriteParseError(options, "excerpt")) return CommandExitCodes.UsageError; @@ -49,12 +58,27 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions var filePathArgument = options.Query; var startLine = options.StartLine; var endLine = options.EndLine; - if (startLine == null - && TryParseExcerptLocationArgument(options.Query, out var parsedPath, out var parsedStartLine, out var parsedEndLine)) + if (startLine == null) { - filePathArgument = parsedPath; - startLine = parsedStartLine; - endLine ??= parsedEndLine; + var locationParsed = TryParseExcerptLocationArgument( + options.Query, + out var parsedPath, + out var parsedStartLine, + out var parsedEndLine, + out var invalidLineValue); + if (invalidLineValue != null) + { + return WriteExcerptOneBasedRangeError( + options, + jsonOptions, + invalidLineValue); + } + if (locationParsed) + { + filePathArgument = parsedPath; + startLine = parsedStartLine; + endLine ??= parsedEndLine; + } } if (startLine == null) @@ -67,7 +91,7 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions var startLineValue = startLine.Value; var endLineValue = endLine ?? startLineValue; - if (endLineValue < startLineValue) + if (!preparedArguments.EndAtEof && endLineValue < startLineValue) { WriteValidationError( $"--start ({startLineValue}) must be less than or equal to --end ({endLineValue}).", @@ -91,22 +115,42 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions category: "not_found"); } - if (startLineValue > indexedFile.Lines || endLineValue > indexedFile.Lines) + var requestedEndLine = preparedArguments.EndAtEof + ? indexedFile.Lines + : endLineValue; + if (indexedFile.Lines <= 0) { - return CommandErrorWriter.WriteJsonOrHuman( - options.Json, + return WriteExcerptRangeOutsideFileError( + options, + jsonOptions, + startLineValue, + requestedEndLine, + indexedFile.Lines, + "the indexed file is empty"); + } + + var effectiveStartLine = startLineValue; + var effectiveEndLine = requestedEndLine; + if (preparedArguments.ClampRange) + { + effectiveStartLine = Math.Min(effectiveStartLine, indexedFile.Lines); + effectiveEndLine = Math.Min(effectiveEndLine, indexedFile.Lines); + } + else if (startLineValue > indexedFile.Lines || requestedEndLine > indexedFile.Lines) + { + return WriteExcerptRangeOutsideFileError( + options, jsonOptions, - $"requested excerpt range {startLineValue}-{endLineValue} is outside {filePath} (1-{indexedFile.Lines}).", - CommandExitCodes.InvalidArgument, - $"Use a line range between 1 and {indexedFile.Lines}.", - errorCode: CommandErrorCodes.LineOutOfRange, - category: "range"); + startLineValue, + requestedEndLine, + indexedFile.Lines, + $"requested excerpt range {startLineValue}-{requestedEndLine} is outside {filePath} (1-{indexedFile.Lines})"); } if (options.FocusLine.HasValue) { - var requestedStart = Math.Max(1, startLineValue - options.ContextBefore); - var requestedEnd = Math.Min(indexedFile.Lines, endLineValue + options.ContextAfter); + var requestedStart = Math.Max(1, effectiveStartLine - options.ContextBefore); + var requestedEnd = Math.Min(indexedFile.Lines, effectiveEndLine + options.ContextAfter); if (options.FocusLine.Value < requestedStart || options.FocusLine.Value > requestedEnd) { CommandErrorWriter.WriteStderr($"Error: --focus-line ({options.FocusLine.Value}) must be within the returned excerpt range ({requestedStart}-{requestedEnd})."); @@ -117,11 +161,11 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions { var focusLineLength = reader.GetExcerptFocusLineLength( filePath, - startLineValue, - endLineValue, + effectiveStartLine, + effectiveEndLine, options.ContextBefore, options.ContextAfter, - options.FocusLine ?? startLineValue); + options.FocusLine ?? effectiveStartLine); if (focusLineLength.HasValue && options.FocusColumn.Value > focusLineLength.Value) { CommandErrorWriter.WriteStderr($"Error: --focus-column ({options.FocusColumn.Value}) must be within the focused line length ({focusLineLength.Value})."); @@ -131,12 +175,12 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions var excerpt = reader.GetExcerpt( filePath, - startLineValue, - endLineValue, + effectiveStartLine, + effectiveEndLine, options.ContextBefore, options.ContextAfter, options.MaxLineWidth, - options.FocusLine ?? startLineValue, + options.FocusLine ?? effectiveStartLine, options.FocusColumn, options.FocusLength); if (excerpt == null) @@ -145,6 +189,8 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions CommandErrorWriter.WriteStderr("No excerpt found."); return ZeroResultExitCode(options); } + excerpt.RequestedStartLine = startLineValue; + excerpt.RequestedEndLine = requestedEndLine; if (options.Json) { ExcerptRecoveryCommandFormatter.ApplyDbPath(excerpt, options.DbPath, options.RedactPaths ?? true); @@ -155,6 +201,10 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions if (options.Json) { var payload = JsonSerializer.SerializeToNode(excerpt, CliJsonSerializerContextFactory.Create(jsonOptions).FileExcerptResult)!.AsObject(); + payload["requested_end_mode"] = preparedArguments.EndAtEof ? "eof" : "numeric"; + payload["range_clamped"] = + effectiveStartLine != startLineValue || + effectiveEndLine != requestedEndLine; if (!options.NoSemanticTokens && excerpt.SemanticTokens is { Count: > 0 }) payload["semantic_tokens_hint"] = "Use --no-semantic-tokens to omit semantic_tokens for compact JSON."; var writeExitCode = WriteJsonPayloadWithOptionalByteLimit( @@ -180,18 +230,28 @@ private static bool TryParseExcerptLocationArgument( string value, out string path, out int startLine, - out int? endLine) + out int? endLine, + out string? invalidLineValue) { path = string.Empty; startLine = 0; endLine = null; + invalidLineValue = null; var separator = value.LastIndexOf(':'); if (separator <= 0 || separator == value.Length - 1) return false; var range = value[(separator + 1)..]; - var dash = range.IndexOf('-'); + if (long.TryParse(range, NumberStyles.Integer, CultureInfo.InvariantCulture, out var singleLine) + && singleLine <= 0) + { + invalidLineValue = range; + return false; + } + var dash = range.StartsWith("-", StringComparison.Ordinal) + ? range.IndexOf('-', 1) + : range.IndexOf('-'); if (dash < 0) { if (!TryParsePositiveLine(range, out startLine)) @@ -201,11 +261,25 @@ private static bool TryParseExcerptLocationArgument( return true; } - if (dash == 0 || dash == range.Length - 1 || range.IndexOf('-', dash + 1) >= 0) + if (dash == range.Length - 1) return false; - if (!TryParsePositiveLine(range[..dash], out startLine) - || !TryParsePositiveLine(range[(dash + 1)..], out var parsedEndLine)) + var startText = range[..dash]; + var endText = range[(dash + 1)..]; + if (long.TryParse(startText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var invalidStart) + && invalidStart <= 0) + { + invalidLineValue = startText; + return false; + } + if (long.TryParse(endText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var invalidEnd) + && invalidEnd <= 0) + { + invalidLineValue = endText; + return false; + } + if (!TryParsePositiveLine(startText, out startLine) + || !TryParsePositiveLine(endText, out var parsedEndLine)) { return false; } @@ -218,6 +292,121 @@ private static bool TryParseExcerptLocationArgument( private static bool TryParsePositiveLine(string value, out int line) => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out line) && line > 0; + private static (string[] Args, bool EndAtEof, bool ClampRange) PrepareExcerptArguments(string[] args) + { + const string eofSentinel = "10000000"; + var prepared = new List(args.Length); + var endAtEof = false; + var clampRange = false; + var positionalOnly = false; + for (var i = 0; i < args.Length; i++) + { + var argument = args[i]; + if (positionalOnly) + { + prepared.Add(argument); + continue; + } + if (argument == "--") + { + positionalOnly = true; + prepared.Add(argument); + continue; + } + if (argument == "--clamp") + { + clampRange = true; + continue; + } + if (argument is "--end" or "--end-line") + { + var acceptsEof = argument == "--end"; + prepared.Add(argument); + endAtEof = false; + if (i + 1 < args.Length) + { + var value = args[++i]; + endAtEof = acceptsEof + && string.Equals(value, "eof", StringComparison.OrdinalIgnoreCase); + prepared.Add(endAtEof ? eofSentinel : value); + } + continue; + } + + var equalsIndex = argument.IndexOf('='); + if (equalsIndex > 0 + && argument[..equalsIndex] is "--end" or "--end-line") + { + var option = argument[..equalsIndex]; + var value = argument[(equalsIndex + 1)..]; + endAtEof = option == "--end" + && string.Equals(value, "eof", StringComparison.OrdinalIgnoreCase); + prepared.Add(endAtEof + ? argument[..(equalsIndex + 1)] + eofSentinel + : argument); + continue; + } + + prepared.Add(argument); + } + + return (prepared.ToArray(), endAtEof, clampRange); + } + + private static int WriteExcerptOneBasedRangeError( + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + string rawValue) + => CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, + $"requested line {rawValue} is outside the valid range beginning at 1.", + CommandExitCodes.InvalidArgument, + "Use a line number of 1 or greater.", + GetUsageLineOrThrow("excerpt"), + CommandErrorCodes.LineOutOfRange, + category: "range", + command: "excerpt"); + + private static int WriteExcerptRangeOutsideFileError( + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + int requestedStartLine, + int requestedEndLine, + int totalLines, + string message) + { + var startBeyondEof = totalLines > 0 && requestedStartLine > totalLines; + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, + message, + CommandExitCodes.InvalidArgument, + totalLines == 0 + ? "The indexed file has no one-based line range to read." + : startBeyondEof + ? $"Use `--start {totalLines}` or earlier, or add `--clamp` to explicitly clamp the range to the indexed file." + : $"Use `--end eof` to read through line {totalLines}, or add `--clamp` to explicitly clamp numeric overshoot.", + GetUsageLineOrThrow("excerpt"), + CommandErrorCodes.LineOutOfRange, + category: "range", + command: "excerpt", + additionalJsonProperties: new JsonObject + { + ["requested_start_line"] = requestedStartLine, + ["requested_end_line"] = requestedEndLine, + ["total_lines"] = totalLines, + ["range_recovery"] = new JsonObject + { + ["strict_numeric_default"] = true, + ["end_at_eof_supported"] = totalLines > 0 && !startBeyondEof, + ["clamp_supported"] = true, + ["suggested_start_line"] = startBeyondEof ? totalLines : null, + ["suggested_end_line"] = totalLines > 0 && !startBeyondEof ? totalLines : null, + }, + }); + } + private static List BuildExcerptSemanticTokens( FileExcerptResult excerpt, DbReader reader) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index 1fa48379f..1895243ca 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -23,7 +23,15 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions 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")) + if (TryWriteNonPositiveCoordinateRangeError( + options, + jsonOptions, + includeHumanOutput: false, + "--line", + "--start", + "--start-line", + "--end", + "--end-line")) return CommandExitCodes.InvalidArgument; if (TryWriteParseError(options, "inspect", options.LanguageValidationError ? jsonOptions : null)) return CommandExitCodes.UsageError; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs index 78b26123c..45d9e8565 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs @@ -67,12 +67,13 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma return "Error: inspect graph pagination cursors can only be used with the inspect command."; } - private static bool TryWriteNonPositiveCoordinateJsonError( + private static bool TryWriteNonPositiveCoordinateRangeError( QueryCommandOptions options, JsonSerializerOptions jsonOptions, + bool includeHumanOutput, params string[] coordinateOptionNames) { - if (!options.Json || options.ParseError == null) + if ((!options.Json && !includeHumanOutput) || options.ParseError == null) return false; foreach (var optionName in coordinateOptionNames) @@ -95,13 +96,15 @@ private static bool TryWriteNonPositiveCoordinateJsonError( continue; CommandErrorWriter.WriteJsonOrHuman( - true, + options.Json, jsonOptions, $"requested line {rawValue} is outside the valid range beginning at 1.", CommandExitCodes.InvalidArgument, "Use a line number of 1 or greater.", + includeHumanOutput ? GetUsageLineOrThrow("excerpt") : null, errorCode: CommandErrorCodes.LineOutOfRange, - category: "range"); + category: "range", + command: includeHumanOutput ? "excerpt" : null); return true; } diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index ada1fecd2..612a56a0e 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -1071,19 +1071,23 @@ FROM chunks c before = 0; if (after < 0) after = 0; + var requestedStartLine = startLine; + var requestedEndLine = endLine; // `endLine` + `after` (and `startLine` - `before`) come from untrusted MCP callers in // some entry points and can overflow int when endLine is near `int.MaxValue`. Clamp via // long intermediates so the subsequent `Math.Max/Min` sees the real window (#1528). // 一部の MCP 経路では `endLine` + `after`(および `startLine` - `before`)が信頼できない // 入力で、`int.MaxValue` 近傍の endLine だと int 加算で overflow する。long 中間で実窓を // 確定させてから clamp する(#1528)。 - var requestedStart = (int)Math.Max(1L, (long)startLine - before); - var requestedEndCeiling = (int)Math.Min(int.MaxValue, (long)endLine + after); - if (!TryLoadIndexedFileLines(path, out var lang, out var totalLines, out var lineMap, requestedStart, requestedEndCeiling)) + var expandedStart = (int)Math.Max(1L, (long)startLine - before); + var expandedEndCeiling = (int)Math.Min(int.MaxValue, (long)endLine + after); + if (!TryLoadIndexedFileLines(path, out var lang, out var totalLines, out var lineMap, expandedStart, expandedEndCeiling)) + return null; + var expandedEnd = Math.Min(totalLines, expandedEndCeiling); + if (expandedStart > expandedEnd) return null; - var requestedEnd = Math.Min(totalLines, requestedEndCeiling); - var selectedLines = Enumerable.Range(requestedStart, requestedEnd - requestedStart + 1) + var selectedLines = Enumerable.Range(expandedStart, expandedEnd - expandedStart + 1) .Where(lineMap.ContainsKey) .ToList(); @@ -1127,10 +1131,11 @@ FROM chunks c Lang = lang, StartLine = selectedLines[0], EndLine = selectedLines[^1], - RequestedStartLine = requestedStart, - RequestedEndLine = requestedEndCeiling, + RequestedStartLine = requestedStartLine, + RequestedEndLine = requestedEndLine, EffectiveStartLine = selectedLines[0], EffectiveEndLine = selectedLines[^1], + TotalLines = totalLines, Content = string.Join("\n", excerptLines), ContentTruncated = contentTruncated, ContentTruncationReasons = contentTruncated ? ["line_width_cap"] : [], diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index ef1d5386f..5abeb7424 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -223,7 +223,7 @@ private static JsonArray CreateToolCatalog() ReadOnlyAnnotations()), CreateToolDefinition( "excerpt", - "Use this after `search`, `definition`, `references`, `outline`, or `map` identifies a file and line range. Prefer focused excerpts over whole-file reads; common next step is `outline` for neighboring structure. Reconstruct a file excerpt from indexed chunks for a given line range. Successful responses include `next_step_suggestion`; empty responses include `recovery_hint`. / `search` / `definition` / `references` / `outline` / `map` でファイルと行範囲を絞った後に使う。ファイル全体ではなく必要範囲の抜粋を優先し、次は周辺構造確認の `outline` を使う。指定行範囲について、インデックス済みチャンクからファイル抜粋を再構成。", + "Use this after `search`, `definition`, `references`, `outline`, or `map` identifies a file and line range. Prefer focused excerpts over whole-file reads; common next step is `outline` for neighboring structure. Reconstruct a file excerpt from indexed chunks for a given line range. Responses preserve requested and context-expanded effective ranges separately and report the indexed total line count. Successful responses include `next_step_suggestion`; empty responses include `recovery_hint`. / `search` / `definition` / `references` / `outline` / `map` でファイルと行範囲を絞った後に使う。ファイル全体ではなく必要範囲の抜粋を優先し、次は周辺構造確認の `outline` を使う。指定行範囲について、インデックス済みチャンクからファイル抜粋を再構成する。response は requested range と context 展開後の effective range を分けて保持し、インデックス済みの総行数も返す。", new JsonObject { ["type"] = "object", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs index 4a880cad0..75879606d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs @@ -124,10 +124,16 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) var excerpt = reader.GetExcerpt(path, startLine.Value, endLine, before, after, maxLineWidth, focusLine ?? startLine.Value, focusColumn, focusLength); if (excerpt == null) { + var indexedFile = reader.GetFileByPath(path); var emptyPayload = new JsonObject { ["path"] = path, - ["count"] = 0 + ["count"] = 0, + ["requestedStartLine"] = startLine.Value, + ["requestedEndLine"] = endLine, + ["effectiveStartLine"] = null, + ["effectiveEndLine"] = null, + ["totalLines"] = indexedFile?.Lines, }; AddRecoveryHint( emptyPayload, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 54447aecc..d9d44619f 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -370,6 +370,7 @@ public class FileExcerptResult public int RequestedEndLine { get; set; } public int EffectiveStartLine { get; set; } public int EffectiveEndLine { get; set; } + public int TotalLines { get; set; } public string Content { get; set; } = string.Empty; public bool ContentTruncated { get; set; } public List ContentTruncationReasons { get; set; } = []; diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index e866d751c..ca2f78f02 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -402,6 +402,8 @@ public void CanonicalValueRegistry_DrivesHelpValidationAndCompletions_Issue4861( Assert.Equal( ["call_site", "declaration", "identifier", "code", "comment", "string_literal", "regex_literal", "help_text", "schema_description", "unknown"], resultKinds); + Assert.Equal(["eof"], CliFlagSchema.GetCanonicalValuesForCommand("excerpt", "--end")); + Assert.Equal("", CliFlagSchema.GetValuePlaceholderForCommand("excerpt", "--end")); Assert.True(CliFlagSchema.TryNormalizeOptionValue("search", "--origin", "schema", out var normalizedOrigin)); Assert.Equal("schema_description", normalizedOrigin); Assert.True(CliFlagSchema.TryNormalizeOptionValue("search", "--origin", "schema-description", out normalizedOrigin)); @@ -429,10 +431,19 @@ public void CanonicalValueRegistry_DrivesHelpValidationAndCompletions_Issue4861( var bash = ConsoleCompletionRenderer.GetCompletionScript("bash"); Assert.Contains("audit) COMPREPLY=($(compgen -W \"text json count compact sarif issue-drafts\"", bash); Assert.Contains("--origin) COMPREPLY=($(compgen -W \"code comment string_literal regex_literal help_text schema_description unknown\"", bash); + Assert.Contains("--end) COMPREPLY=($(compgen -W \"eof\"", bash); + Assert.DoesNotContain("--end) COMPREPLY=($(compgen -W \"line eof\"", bash); var fish = ConsoleCompletionRenderer.GetCompletionScript("fish"); Assert.Contains("__fish_seen_subcommand_from audit' -l format -r -a 'text json count compact sarif issue-drafts'", fish); Assert.Contains("__fish_seen_subcommand_from search' -l result-kind -r -a 'call_site declaration identifier code comment string_literal regex_literal help_text schema_description unknown'", fish); + Assert.Contains("__fish_seen_subcommand_from excerpt' -l end -r -a 'eof'", fish); + + var zsh = ConsoleCompletionRenderer.GetCompletionScript("zsh"); + Assert.Contains("--end[Excerpt end line; eof reads through the indexed end of file]:value:(eof)", zsh); + + var powershell = ConsoleCompletionRenderer.GetCompletionScript("powershell"); + Assert.Contains("'--end' = @('eof')", powershell); } [Fact] diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index ce543dd15..5cbb8349e 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -420,7 +420,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("--optimize", output); Assert.Contains("--duration-format ", output); Assert.Contains("--ascii", output); - Assert.Contains("cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--redact-paths|--show-paths] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]", output); + Assert.Contains("cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--clamp] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--redact-paths|--show-paths] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]", output); Assert.Contains("cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--cursor ] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>] [--max-json-bytes ]", output); Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json[=ndjson|array]] [--compact] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--sort ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--group-partials] [--since ]", output); Assert.Contains("cdidx files [query||--query |-- ] [--db ] [--json[=ndjson|array]] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 931909069..183b5b984 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -1284,10 +1284,35 @@ public void GetExcerpt_ReconstructsRequestedLineRange() Assert.NotNull(excerpt); Assert.Equal(1, excerpt!.StartLine); Assert.Equal(2, excerpt.EndLine); + Assert.Equal(1, excerpt.RequestedStartLine); + Assert.Equal(2, excerpt.RequestedEndLine); + Assert.Equal(1, excerpt.EffectiveStartLine); + Assert.Equal(2, excerpt.EffectiveEndLine); + Assert.Equal(30, excerpt.TotalLines); Assert.Contains("def authenticate(user, password):", excerpt.Content); Assert.Contains("if user == 'admin':", excerpt.Content); } + [Fact] + public void GetExcerpt_PreservesRequestedRangeWhenContextExpandsEffectiveRange_Issue4877() + { + InsertIndexedFile( + "src/context.txt", + "text", + string.Join('\n', Enumerable.Range(1, 30).Select(line => $"line {line}"))); + + var excerpt = _reader.GetExcerpt("src/context.txt", 18, 22, before: 2, after: 2); + + Assert.NotNull(excerpt); + Assert.Equal(18, excerpt!.RequestedStartLine); + Assert.Equal(22, excerpt.RequestedEndLine); + Assert.Equal(16, excerpt.StartLine); + Assert.Equal(24, excerpt.EndLine); + Assert.Equal(16, excerpt.EffectiveStartLine); + Assert.Equal(24, excerpt.EffectiveEndLine); + Assert.Equal(30, excerpt.TotalLines); + } + [Fact] public void FindInFiles_ReturnsPathScopedLiteralMatchesWithContext() { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index dfb08855d..cc3c5af38 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -4545,6 +4545,73 @@ public void ToolsCall_Excerpt_ReturnsExcerpt() Assert.Contains("Excerpt returned", text); Assert.Equal("src/app.cs", response["result"]!["structuredContent"]!["path"]!.GetValue()); Assert.Contains("public class App", response["result"]!["structuredContent"]!["content"]!.GetValue()); + Assert.Equal(1, response["result"]!["structuredContent"]!["requestedStartLine"]!.GetValue()); + Assert.Equal(1, response["result"]!["structuredContent"]!["requestedEndLine"]!.GetValue()); + Assert.True(response["result"]!["structuredContent"]!["totalLines"]!.GetValue() >= 1); + } + + [Fact] + public void ToolsCall_Excerpt_SeparatesRequestedAndEffectiveContextRanges_Issue4877() + { + InsertIndexedFile( + "src/context-range.txt", + "text", + string.Join('\n', Enumerable.Range(1, 30).Select(line => line == 20 ? "日本語 Ω" : $"line {line}"))); + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"excerpt","arguments":{"path":"src/context-range.txt","startLine":18,"endLine":22,"before":2,"after":2}}}""")!; + + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + + Assert.Equal(18, structured["requestedStartLine"]!.GetValue()); + Assert.Equal(22, structured["requestedEndLine"]!.GetValue()); + Assert.Equal(16, structured["effectiveStartLine"]!.GetValue()); + Assert.Equal(24, structured["effectiveEndLine"]!.GetValue()); + Assert.Equal(30, structured["totalLines"]!.GetValue()); + Assert.Contains("日本語 Ω", structured["content"]!.GetValue()); + } + + [Theory] + [InlineData("src/empty-range.txt", "", 1, 1, 0)] + [InlineData("src/out-of-range.txt", "one\ntwo\nthree", 10, 12, 3)] + public void ToolsCall_Excerpt_EmptyResultsRetainRangeRecoveryMetadata_Issue4877( + string path, + string content, + int requestedStartLine, + int requestedEndLine, + int totalLines) + { + InsertIndexedFile(path, "text", content); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "excerpt", + ["arguments"] = new JsonObject + { + ["path"] = path, + ["startLine"] = requestedStartLine, + ["endLine"] = requestedEndLine, + }, + }, + }; + + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!.AsObject(); + + Assert.Equal(path, structured["path"]!.GetValue()); + Assert.Equal(0, structured["count"]!.GetValue()); + Assert.Equal(requestedStartLine, structured["requestedStartLine"]!.GetValue()); + Assert.Equal(requestedEndLine, structured["requestedEndLine"]!.GetValue()); + Assert.True(structured.ContainsKey("effectiveStartLine")); + Assert.True(structured.ContainsKey("effectiveEndLine")); + Assert.Null(structured["effectiveStartLine"]); + Assert.Null(structured["effectiveEndLine"]); + Assert.Equal(totalLines, structured["totalLines"]!.GetValue()); + Assert.NotNull(structured["recovery_hint"]); } [Fact] @@ -4725,6 +4792,9 @@ public void ToolsCall_Excerpt_HugeEndLineDoesNotOverflow() Assert.Equal("dist/data-endline-overflow.txt", structured["path"]!.GetValue()); Assert.Equal(1, structured["startLine"]!.GetValue()); Assert.Equal(3, structured["endLine"]!.GetValue()); + Assert.Equal(1, structured["requestedStartLine"]!.GetValue()); + Assert.Equal(int.MaxValue, structured["requestedEndLine"]!.GetValue()); + Assert.Equal(3, structured["totalLines"]!.GetValue()); Assert.Contains("line three", structured["content"]!.GetValue()); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 82ab971e1..fb8f4b257 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -973,8 +973,11 @@ public int Compute() Assert.Equal("src/Target.cs", sourceExcerpt.GetProperty("path").GetString()); Assert.Equal(4, sourceExcerpt.GetProperty("start_line").GetInt32()); Assert.Equal(6, sourceExcerpt.GetProperty("end_line").GetInt32()); - Assert.Equal(4, sourceExcerpt.GetProperty("requested_start_line").GetInt32()); - Assert.Equal(6, sourceExcerpt.GetProperty("requested_end_line").GetInt32()); + Assert.Equal(5, sourceExcerpt.GetProperty("requested_start_line").GetInt32()); + Assert.Equal(5, sourceExcerpt.GetProperty("requested_end_line").GetInt32()); + Assert.Equal(4, sourceExcerpt.GetProperty("effective_start_line").GetInt32()); + Assert.Equal(6, sourceExcerpt.GetProperty("effective_end_line").GetInt32()); + Assert.Equal(7, sourceExcerpt.GetProperty("total_lines").GetInt32()); Assert.Contains("return 42;", sourceExcerpt.GetProperty("content").GetString(), StringComparison.Ordinal); Assert.False(sourceExcerpt.GetProperty("content_truncated").GetBoolean()); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 51fe9625e..5e364c426 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -4598,6 +4598,183 @@ public void RunFindThenExcerpt_JsonKeepsMatchedTokenVisible() Assert.True(excerptJson.GetProperty("content_truncated").GetBoolean()); } + [Fact] + public void RunExcerpt_EofClampAndContextRangesReuseOneFixture_Issue4877() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_issue4877_excerpt_ranges"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile( + dbPath, + "docs/range.txt", + "text", + string.Join('\n', Enumerable.Range(1, 30).Select(line => line == 20 ? "日本語 Ω" : $"line {line}"))); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/one-line-newline.txt", "text", "終端\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/one-line-no-newline.txt", "text", "終端"); + + JsonElement RunSuccess(params string[] args) + { + var (exitCode, stdout, stderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt(args, _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + return document.RootElement.Clone(); + } + + var context = RunSuccess( + "docs/range.txt", "--db", dbPath, "--start", "18", "--end", "22", + "--before", "2", "--after", "2", "--json", "--no-semantic-tokens"); + Assert.Equal(18, context.GetProperty("requested_start_line").GetInt32()); + Assert.Equal(22, context.GetProperty("requested_end_line").GetInt32()); + Assert.Equal(16, context.GetProperty("effective_start_line").GetInt32()); + Assert.Equal(24, context.GetProperty("effective_end_line").GetInt32()); + Assert.Equal(30, context.GetProperty("total_lines").GetInt32()); + Assert.Contains("日本語 Ω", context.GetProperty("content").GetString()); + + var eof = RunSuccess( + "docs/range.txt", "--db", dbPath, "--start", "28", "--end", "eof", + "--json", "--no-semantic-tokens"); + Assert.Equal(28, eof.GetProperty("requested_start_line").GetInt32()); + Assert.Equal(30, eof.GetProperty("requested_end_line").GetInt32()); + Assert.Equal(30, eof.GetProperty("effective_end_line").GetInt32()); + Assert.Equal("eof", eof.GetProperty("requested_end_mode").GetString()); + Assert.False(eof.GetProperty("range_clamped").GetBoolean()); + + var (duplicateExitCode, duplicateStdout, duplicateStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + [ + "docs/range.txt", "--db", dbPath, "--start", "1", + "--end", "eof", "--end-line", "2", "--json", "--no-semantic-tokens", + ], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, duplicateExitCode); + Assert.Contains("--end specified more than once", duplicateStderr); + using (var duplicateDocument = JsonDocument.Parse(duplicateStdout)) + { + var duplicate = duplicateDocument.RootElement; + Assert.Equal(2, duplicate.GetProperty("requested_end_line").GetInt32()); + Assert.Equal(2, duplicate.GetProperty("effective_end_line").GetInt32()); + Assert.Equal("numeric", duplicate.GetProperty("requested_end_mode").GetString()); + } + + var clamped = RunSuccess( + "docs/range.txt", "--db", dbPath, "--start", "28", "--end", "999", + "--clamp", "--json", "--no-semantic-tokens"); + Assert.Equal(999, clamped.GetProperty("requested_end_line").GetInt32()); + Assert.Equal(30, clamped.GetProperty("effective_end_line").GetInt32()); + Assert.True(clamped.GetProperty("range_clamped").GetBoolean()); + + foreach (var path in new[] { "docs/one-line-newline.txt", "docs/one-line-no-newline.txt" }) + { + var oneLine = RunSuccess( + path, "--db", dbPath, "--start", "1", "--end", "eof", + "--json", "--no-semantic-tokens"); + Assert.Equal(1, oneLine.GetProperty("total_lines").GetInt32()); + Assert.Equal("終端", oneLine.GetProperty("content").GetString()); + } + } + + [Fact] + public void RunExcerpt_InvalidAndStrictRangesReturnRecoveryMetadata_Issue4877() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_issue4877_excerpt_errors"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/range.txt", "text", "one\ntwo\nthree"); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/empty.txt", "text", string.Empty); + + var (strictExitCode, strictStdout, strictStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + ["docs/range.txt", "--db", dbPath, "--start", "2", "--end", "99", "--json"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, strictExitCode); + Assert.Equal(string.Empty, strictStderr); + using (var strictDocument = JsonDocument.Parse(strictStdout)) + { + var error = strictDocument.RootElement; + Assert.Equal(CommandErrorCodes.LineOutOfRange, error.GetProperty("error_code").GetString()); + Assert.Equal(2, error.GetProperty("requested_start_line").GetInt32()); + Assert.Equal(99, error.GetProperty("requested_end_line").GetInt32()); + Assert.Equal(3, error.GetProperty("total_lines").GetInt32()); + var recovery = error.GetProperty("range_recovery"); + Assert.True(recovery.GetProperty("strict_numeric_default").GetBoolean()); + Assert.True(recovery.GetProperty("end_at_eof_supported").GetBoolean()); + Assert.True(recovery.GetProperty("clamp_supported").GetBoolean()); + Assert.Equal(3, recovery.GetProperty("suggested_end_line").GetInt32()); + } + + var (startOvershootExitCode, startOvershootStdout, startOvershootStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + ["docs/range.txt", "--db", dbPath, "--start", "99", "--end", "100", "--json"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, startOvershootExitCode); + Assert.Equal(string.Empty, startOvershootStderr); + Assert.DoesNotContain("--end eof", startOvershootStdout); + using (var startOvershootDocument = JsonDocument.Parse(startOvershootStdout)) + { + var recovery = startOvershootDocument.RootElement.GetProperty("range_recovery"); + Assert.False(recovery.GetProperty("end_at_eof_supported").GetBoolean()); + Assert.True(recovery.GetProperty("clamp_supported").GetBoolean()); + Assert.Equal(3, recovery.GetProperty("suggested_start_line").GetInt32()); + Assert.Equal(JsonValueKind.Null, recovery.GetProperty("suggested_end_line").ValueKind); + } + + var (inlineExitCode, inlineStdout, inlineStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + ["docs/range.txt:0", "--db", dbPath], + _jsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, inlineExitCode); + Assert.Equal(string.Empty, inlineStdout); + Assert.Contains(CommandErrorCodes.LineOutOfRange, inlineStderr); + Assert.Contains("requested line 0", inlineStderr); + Assert.DoesNotContain("requires --start", inlineStderr); + + var (lineZeroExitCode, lineZeroStdout, lineZeroStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + ["docs/range.txt", "--db", dbPath, "--line", "0"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, lineZeroExitCode); + Assert.Equal(string.Empty, lineZeroStdout); + Assert.Contains(CommandErrorCodes.LineOutOfRange, lineZeroStderr); + Assert.Contains("requested line 0", lineZeroStderr); + + var (explicitNegativeExitCode, explicitNegativeStdout, explicitNegativeStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + ["docs/range.txt", "--db", dbPath, "--start", "-1"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, explicitNegativeExitCode); + Assert.Equal(string.Empty, explicitNegativeStdout); + Assert.Contains(CommandErrorCodes.LineOutOfRange, explicitNegativeStderr); + Assert.Contains("requested line -1", explicitNegativeStderr); + + var (inlineNegativeExitCode, inlineNegativeStdout, inlineNegativeStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + ["docs/range.txt:-1-2", "--db", dbPath], + _jsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, inlineNegativeExitCode); + Assert.Equal(string.Empty, inlineNegativeStdout); + Assert.Contains(CommandErrorCodes.LineOutOfRange, inlineNegativeStderr); + Assert.Contains("requested line -1", inlineNegativeStderr); + + var (numericAliasExitCode, numericAliasStdout, numericAliasStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + ["docs/range.txt", "--db", dbPath, "--start", "1", "--end-line", "eof"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, numericAliasExitCode); + Assert.Equal(string.Empty, numericAliasStdout); + Assert.Contains("eof", numericAliasStderr); + Assert.Contains("--end-line", numericAliasStderr); + + var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole( + () => QueryCommandRunner.RunExcerpt( + ["docs/empty.txt", "--db", dbPath, "--start", "1", "--end", "eof", "--json"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, emptyExitCode); + Assert.Equal(string.Empty, emptyStderr); + using var emptyDocument = JsonDocument.Parse(emptyStdout); + Assert.Equal(0, emptyDocument.RootElement.GetProperty("total_lines").GetInt32()); + Assert.Equal(CommandErrorCodes.LineOutOfRange, emptyDocument.RootElement.GetProperty("error_code").GetString()); + } + diff --git a/tests/CodeIndex.Tests/golden/excerpt.json b/tests/CodeIndex.Tests/golden/excerpt.json index 216cc62fd..e7ce9868c 100644 --- a/tests/CodeIndex.Tests/golden/excerpt.json +++ b/tests/CodeIndex.Tests/golden/excerpt.json @@ -8,6 +8,7 @@ "requested_end_line": 6, "effective_start_line": 1, "effective_end_line": 6, + "total_lines": 6, "content": "namespace Demo;\n\npublic static class Lib\n{\n public static int Add(int a, int b) =\u003E a \u002B b;\n}", "content_truncated": false, "content_truncation_reasons": [], @@ -200,5 +201,7 @@ "modifiers": [] } ], + "requested_end_mode": "numeric", + "range_clamped": false, "semantic_tokens_hint": "Use --no-semantic-tokens to omit semantic_tokens for compact JSON." }