diff --git a/internal/query/format_test.go b/internal/query/format_test.go index 5d3288b..a334aa9 100644 --- a/internal/query/format_test.go +++ b/internal/query/format_test.go @@ -128,6 +128,33 @@ func TestFormatResultsPaginationCursor(t *testing.T) { } } +// FormatResultsWithCursor must emit the supplied cursor footer verbatim in place +// of the bare "[remaining: N]" hint when results are truncated. +func TestFormatResultsWithCursor(t *testing.T) { + results := makeResults(5) + cursor := "[cursor: abc123, remaining: 3]" + + out := FormatResultsWithCursor(results, 2, "compact", 0, false, cursor) + if !strings.Contains(out, cursor) { + t.Errorf("expected cursor footer %q, got:\n%s", cursor, out) + } + if strings.Contains(out, "[remaining: 3]\n") { + t.Errorf("bare remaining hint should be replaced by cursor footer, got:\n%s", out) + } + + // Empty cursorLine falls back to the bare hint (parity with FormatResultsWithOptions). + fallback := FormatResultsWithCursor(results, 2, "compact", 0, false, "") + if !strings.Contains(fallback, "[remaining: 3]") { + t.Errorf("empty cursorLine should fall back to [remaining: 3], got:\n%s", fallback) + } + + // No truncation → no footer at all. + none := FormatResultsWithCursor(makeResults(2), 0, "compact", 0, false, cursor) + if strings.Contains(none, cursor) || strings.Contains(none, "[remaining:") { + t.Errorf("no truncation should emit no footer, got:\n%s", none) + } +} + func TestFormatResultsEmpty(t *testing.T) { out := FormatResultsWithOptions(nil, 0, "verbose", 0) if out != "No matches found." { diff --git a/internal/query/query.go b/internal/query/query.go index af6d023..f72883a 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -460,7 +460,24 @@ func FormatResults(results []MatchResult, maxResults int) string { // format: "verbose" (default) | "compact" | "location" // offset: skip this many results before rendering (used for cursor pagination). // verbose: opt-in variadic — pass true to restore "Found N match(es):" preamble (v1 behaviour). +// When results are truncated it emits a bare "[remaining: N]" hint; callers that +// have a pagination cursor should use FormatResultsWithCursor to emit the full line. func FormatResultsWithOptions(results []MatchResult, maxResults int, format string, offset int, verbose ...bool) string { + return formatResults(results, maxResults, format, offset, len(verbose) > 0 && verbose[0], "") +} + +// FormatResultsWithCursor is like FormatResultsWithOptions but, when results are +// truncated, appends cursorLine verbatim instead of the bare "[remaining: N]" +// hint. This lets the caller pass a fully-formed "[cursor: ..., remaining: N]" +// footer without string-replacing a placeholder. If cursorLine is empty it +// behaves like FormatResultsWithOptions. +func FormatResultsWithCursor(results []MatchResult, maxResults int, format string, offset int, verbose bool, cursorLine string) string { + return formatResults(results, maxResults, format, offset, verbose, cursorLine) +} + +// formatResults is the shared implementation. cursorLine, when non-empty, is +// emitted in place of the "[remaining: N]" hint on truncation. +func formatResults(results []MatchResult, maxResults int, format string, offset int, verbose bool, cursorLine string) string { if len(results) == 0 { return "No matches found." } @@ -484,8 +501,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri var sb strings.Builder // Emit preamble only when verbose=true is explicitly passed (opt-in, default off). - wantVerbose := len(verbose) > 0 && verbose[0] - if wantVerbose { + if verbose { fmt.Fprintf(&sb, "Found %d match(es):\n", renderCount) } @@ -547,9 +563,13 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri } if remaining > 0 { - // Caller must embed the cursor token; we just append the remaining count hint. - // The actual [cursor: ...] line is written by the handler after calling MakeCursor. - fmt.Fprintf(&sb, "[remaining: %d]\n", remaining) + if cursorLine != "" { + // Caller supplied a fully-formed "[cursor: ..., remaining: N]" footer. + sb.WriteString(strings.TrimRight(cursorLine, "\n")) + sb.WriteByte('\n') + } else { + fmt.Fprintf(&sb, "[remaining: %d]\n", remaining) + } } return sb.String() diff --git a/internal/server/handlers_ast.go b/internal/server/handlers_ast.go index e1b1d85..ebc91a7 100644 --- a/internal/server/handlers_ast.go +++ b/internal/server/handlers_ast.go @@ -224,16 +224,9 @@ func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest allResults := s.runASTQueryWalk(ctx, p, exts) cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash) - output := query.FormatResultsWithOptions(allResults, p.maxResults, p.format, p.offset, p.verbose) - if cursorFooter != "" { - // Replace the [remaining: N] placeholder emitted by FormatResultsWithOptions - // with the full [cursor: ..., remaining: N] line. - output = strings.ReplaceAll(output, fmt.Sprintf("[remaining: %d]\n", len(allResults)-p.offset-p.maxResults), cursorFooter+"\n") - // Fallback: if placeholder wasn't present (e.g. format=location), append footer. - if !strings.Contains(output, cursorFooter) { - output = strings.TrimRight(output, "\n") + "\n" + cursorFooter + "\n" - } - } + // The formatter emits cursorFooter in place of its "[remaining: N]" hint when + // results are truncated; no fragile placeholder string-replacement needed. + output := query.FormatResultsWithCursor(allResults, p.maxResults, p.format, p.offset, p.verbose, cursorFooter) return mcp.NewToolResultText(output), nil }