mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-19 05:57:38 +00:00
V2/token optimization (#11)
* v2.0: token-optimization overhaul Additive (backward-compatible flags): - file_read: skeleton mode, strip (imports/license/block_comments), compact_line_numbers, 8-char etag with prefix-match compat - ast_query: format=verbose|compact|location, pagination cursor - file_search: cluster mode, pagination cursor - lsp_query (references): compact output Breaking (v2): - Preambles removed; opt-in verbose=true restores - edit_apply: response=count|diff|none, default count - ping tool removed - symbol_at/find_definition/find_references merged into lsp_query - Tool descriptions trimmed -83%, help moved to filepuff://help/<tool> - Batch file_read dedups by etag Protocol: - ResourceLink returned for file_read >64 KiB (force_inline override) - OnAfterInitialize hook reads capabilities.experimental.filepuff for session defaults (default_format, default_max_results, default_cluster, compact_refs, line_numbers, resource_link_threshold) * fix: drop --max-total-count from ripgrep args The flag does not exist in stable ripgrep (confirmed up to 15.1.0 -- "unrecognized flag --max-total-count, similar flags that are available: --max-count"). Every file_search call failed on hosts with stock rg. --max-count is per-file, not a drop-in replacement, so rely on the in-process truncation in parseOutput that was already the documented safety net.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func newTestSearcher(t *testing.T) *Searcher {
|
||||
t.Helper()
|
||||
cfg := &config.Config{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
// Create a Searcher directly without requiring rg
|
||||
return &Searcher{cfg: cfg, logger: logger, rgPath: "rg"}
|
||||
}
|
||||
|
||||
func makeSearchResults(lines []int) *SearchResults {
|
||||
results := make([]Result, len(lines))
|
||||
for i, l := range lines {
|
||||
results[i] = Result{
|
||||
File: "/workspace/foo.go",
|
||||
Line: l,
|
||||
MatchText: "match at line " + itoa2(l),
|
||||
}
|
||||
}
|
||||
return &SearchResults{Results: results}
|
||||
}
|
||||
|
||||
// itoa2 simple int to string for test
|
||||
func itoa2(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := []byte{}
|
||||
for n > 0 {
|
||||
digits = append([]byte{byte('0' + n%10)}, digits...)
|
||||
n /= 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
// TestFormatResultsVerboseBackwardCompat verifies that Verbose=true restores the v1 preamble.
|
||||
func TestFormatResultsVerboseBackwardCompat(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
sr := makeSearchResults([]int{1, 5, 10})
|
||||
// With Verbose=true, the preamble is emitted (v1 behaviour).
|
||||
out := s.FormatResultsWithOptions(sr, FormatOptions{Verbose: true})
|
||||
if !strings.Contains(out, "Found 3 matches in 1 files") {
|
||||
t.Errorf("expected header with Verbose=true, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "L1│") {
|
||||
t.Errorf("expected L1 match line, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatResultsDefaultNoPreamble verifies the v2 default has no preamble.
|
||||
func TestFormatResultsDefaultNoPreamble(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
sr := makeSearchResults([]int{1, 5, 10})
|
||||
out := s.FormatResults(sr)
|
||||
if strings.Contains(out, "Found ") {
|
||||
t.Errorf("v2 default should NOT emit preamble, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "L1│") {
|
||||
t.Errorf("expected L1 match line, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsClusterSingleLine(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
// Non-consecutive lines → each should appear separately
|
||||
sr := makeSearchResults([]int{1, 5, 10})
|
||||
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
|
||||
if !strings.Contains(out, "L1│") {
|
||||
t.Errorf("expected L1 cluster, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "L5│") {
|
||||
t.Errorf("expected L5 cluster, got:\n%s", out)
|
||||
}
|
||||
// Should NOT have " │" context lines in cluster mode
|
||||
if strings.Contains(out, " │") {
|
||||
t.Errorf("cluster mode should not have context-line decoration, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsClusterConsecutive(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
// Lines 3,4,5 are consecutive → should be clustered as L3-5
|
||||
sr := makeSearchResults([]int{3, 4, 5, 10})
|
||||
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
|
||||
if !strings.Contains(out, "L3-5│") {
|
||||
t.Errorf("expected L3-5 cluster, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "L10│") {
|
||||
t.Errorf("expected L10 separate cluster, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsClusterAdjacentMerge(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
// Lines 7 and 8 differ by 1 → merge
|
||||
sr := makeSearchResults([]int{7, 8})
|
||||
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
|
||||
if !strings.Contains(out, "L7-8│") {
|
||||
t.Errorf("expected L7-8 cluster, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsCursorLine(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
sr := makeSearchResults([]int{1})
|
||||
cursorText := "[cursor: abc123, remaining: 5]"
|
||||
out := s.FormatResultsWithOptions(sr, FormatOptions{CursorLine: cursorText})
|
||||
if !strings.Contains(out, cursorText) {
|
||||
t.Errorf("expected cursor footer in output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsNoMatchesVerbose(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
sr := &SearchResults{Results: nil}
|
||||
out := s.FormatResults(sr)
|
||||
if out != "No matches found." {
|
||||
t.Errorf("expected 'No matches found.', got: %s", out)
|
||||
}
|
||||
}
|
||||
+97
-26
@@ -219,11 +219,9 @@ func (s *Searcher) buildArgs(req *Request) []string {
|
||||
args = append(args, "--no-ignore")
|
||||
}
|
||||
|
||||
// Global result cap — --max-total-count stops rg early across all files.
|
||||
// Requires ripgrep >= 13.0. In-process truncation in parseOutput is kept as a safety net.
|
||||
if req.MaxResults > 0 {
|
||||
args = append(args, fmt.Sprintf("--max-total-count=%d", req.MaxResults))
|
||||
}
|
||||
// Result cap enforced in-process by parseOutput. rg has no cross-file
|
||||
// total-count flag in stable releases, so we don't pass one; --max-count is
|
||||
// per-file and would miss results unevenly.
|
||||
|
||||
// Add pattern
|
||||
args = append(args, "--", req.Pattern)
|
||||
@@ -356,8 +354,21 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// FormatResults formats search results for display.
|
||||
// FormatOptions controls how search results are rendered.
|
||||
type FormatOptions struct {
|
||||
Cluster bool // coalesce consecutive matches into line-range blocks
|
||||
CursorLine string // if non-empty, appended as a footer line
|
||||
Verbose bool // if true, emit "Found N matches in M files:" preamble (opt-in)
|
||||
}
|
||||
|
||||
// FormatResults formats search results for display (backward-compat wrapper).
|
||||
func (s *Searcher) FormatResults(results *SearchResults) string {
|
||||
return s.FormatResultsWithOptions(results, FormatOptions{})
|
||||
}
|
||||
|
||||
// FormatResultsWithOptions formats search results with configurable output.
|
||||
// By default the "Found N matches in M files:" preamble is omitted; set opts.Verbose=true to restore it.
|
||||
func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatOptions) string {
|
||||
if len(results.Results) == 0 {
|
||||
return "No matches found."
|
||||
}
|
||||
@@ -374,14 +385,18 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
|
||||
fileResults[r.File] = append(fileResults[r.File], r)
|
||||
}
|
||||
|
||||
// Write summary
|
||||
totalMatches := len(results.Results)
|
||||
fileCount := len(fileResults)
|
||||
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
|
||||
if results.Truncated {
|
||||
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
|
||||
// Write preamble only when Verbose is requested.
|
||||
if opts.Verbose {
|
||||
totalMatches := len(results.Results)
|
||||
fileCount := len(fileResults)
|
||||
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
|
||||
if results.Truncated {
|
||||
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
|
||||
}
|
||||
sb.WriteString(":\n\n")
|
||||
} else if results.Truncated {
|
||||
sb.WriteString(fmt.Sprintf("(truncated, showing subset of %d total matches)\n\n", results.Total))
|
||||
}
|
||||
sb.WriteString(":\n\n")
|
||||
|
||||
// Write results grouped by file
|
||||
for _, file := range fileOrder {
|
||||
@@ -395,26 +410,82 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
|
||||
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
|
||||
|
||||
for _, r := range fileResults[file] {
|
||||
// Write context before
|
||||
for _, ctx := range r.Context.Before {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
|
||||
}
|
||||
|
||||
// Write match line
|
||||
sb.WriteString(fmt.Sprintf("L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200)))
|
||||
|
||||
// Write context after
|
||||
for _, ctx := range r.Context.After {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
|
||||
}
|
||||
if opts.Cluster {
|
||||
writeClusteredResults(&sb, fileResults[file])
|
||||
} else {
|
||||
writeVerboseResults(&sb, fileResults[file])
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if opts.CursorLine != "" {
|
||||
sb.WriteString(opts.CursorLine)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// writeVerboseResults writes results in the standard verbose format.
|
||||
func writeVerboseResults(sb *strings.Builder, results []Result) {
|
||||
for _, r := range results {
|
||||
// Write context before
|
||||
for _, ctx := range r.Context.Before {
|
||||
fmt.Fprintf(sb, " │ %s\n", truncateLine(ctx, 200))
|
||||
}
|
||||
// Write match line
|
||||
fmt.Fprintf(sb, "L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200))
|
||||
// Write context after
|
||||
for _, ctx := range r.Context.After {
|
||||
fmt.Fprintf(sb, " │ %s\n", truncateLine(ctx, 200))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeClusteredResults coalesces consecutive or adjacent match lines into
|
||||
// a single "L12-14│ <first-match-text>" entry. Context lines are dropped
|
||||
// in cluster mode to maximise information density.
|
||||
func writeClusteredResults(sb *strings.Builder, results []Result) {
|
||||
if len(results) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
type clusterEntry struct {
|
||||
startLine int
|
||||
endLine int
|
||||
firstText string
|
||||
}
|
||||
|
||||
var clusters []clusterEntry
|
||||
cur := clusterEntry{
|
||||
startLine: results[0].Line,
|
||||
endLine: results[0].Line,
|
||||
firstText: results[0].MatchText,
|
||||
}
|
||||
|
||||
for _, r := range results[1:] {
|
||||
// Merge if adjacent (within 1 line gap)
|
||||
if r.Line <= cur.endLine+1 {
|
||||
if r.Line > cur.endLine {
|
||||
cur.endLine = r.Line
|
||||
}
|
||||
} else {
|
||||
clusters = append(clusters, cur)
|
||||
cur = clusterEntry{startLine: r.Line, endLine: r.Line, firstText: r.MatchText}
|
||||
}
|
||||
}
|
||||
clusters = append(clusters, cur)
|
||||
|
||||
for _, c := range clusters {
|
||||
text := truncateLine(c.firstText, 200)
|
||||
if c.startLine == c.endLine {
|
||||
fmt.Fprintf(sb, "L%d│ %s\n", c.startLine, text)
|
||||
} else {
|
||||
fmt.Fprintf(sb, "L%d-%d│ %s\n", c.startLine, c.endLine, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// truncateLine truncates a line if it exceeds maxLen.
|
||||
func truncateLine(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
|
||||
@@ -94,8 +94,8 @@ func TestBuildArgs(t *testing.T) {
|
||||
MaxResults: 10,
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--max-total-count=10", "--", "test", "."},
|
||||
notExpected: []string{"--ignore-case", "--fixed-strings"},
|
||||
expected: []string{"--json", "--", "test", "."},
|
||||
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=10", "--max-count=10"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user