mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +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,186 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
// makeResults builds N dummy MatchResults.
|
||||
func makeResults(n int) []MatchResult {
|
||||
out := make([]MatchResult, n)
|
||||
for i := range out {
|
||||
out[i] = MatchResult{
|
||||
File: "file.go",
|
||||
Location: protocol.Location{
|
||||
Line: i + 1,
|
||||
Column: 1,
|
||||
},
|
||||
Text: "func Foo() {}\nline2",
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestFormatResultsVerboseDefault verifies verbose format includes code blocks (no preamble by default).
|
||||
func TestFormatResultsVerboseDefault(t *testing.T) {
|
||||
results := makeResults(2)
|
||||
out := FormatResultsWithOptions(results, 0, "verbose", 0)
|
||||
// v2 default: no preamble
|
||||
if strings.Contains(out, "Found ") {
|
||||
t.Errorf("v2 default should NOT emit preamble, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "```") {
|
||||
t.Error("verbose mode should include code blocks")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatResultsVerbosePreamble verifies verbose=true restores the preamble.
|
||||
func TestFormatResultsVerbosePreamble(t *testing.T) {
|
||||
results := makeResults(2)
|
||||
out := FormatResultsWithOptions(results, 0, "verbose", 0, true)
|
||||
if !strings.Contains(out, "Found 2 match(es):") {
|
||||
t.Errorf("expected preamble with verbose=true, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsCompact(t *testing.T) {
|
||||
results := makeResults(3)
|
||||
out := FormatResultsWithOptions(results, 0, "compact", 0)
|
||||
// v2 default: no preamble in compact mode
|
||||
// Should NOT have code blocks
|
||||
if strings.Contains(out, "```") {
|
||||
t.Error("compact mode should not have code blocks")
|
||||
}
|
||||
// Should have one line per match (beyond header)
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
// First two lines are "Found..." and blank, then 3 match lines
|
||||
matchLines := 0
|
||||
for _, l := range lines {
|
||||
if strings.Contains(l, "file.go:") {
|
||||
matchLines++
|
||||
}
|
||||
}
|
||||
if matchLines != 3 {
|
||||
t.Errorf("expected 3 match lines in compact mode, got %d\nOutput:\n%s", matchLines, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsLocation(t *testing.T) {
|
||||
results := makeResults(3)
|
||||
out := FormatResultsWithOptions(results, 0, "location", 0)
|
||||
if strings.Contains(out, "```") {
|
||||
t.Error("location mode should not have code blocks")
|
||||
}
|
||||
// Should be file:line only
|
||||
for i := 1; i <= 3; i++ {
|
||||
expected := "file.go:" + itoa(i)
|
||||
if !strings.Contains(out, expected) {
|
||||
t.Errorf("location output missing %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsMaxResults(t *testing.T) {
|
||||
results := makeResults(5)
|
||||
out := FormatResultsWithOptions(results, 3, "verbose", 0)
|
||||
// v2 default: no preamble — check that exactly 3 code blocks are present
|
||||
codeBlockCount := strings.Count(out, "```")
|
||||
if codeBlockCount != 6 { // 3 opening + 3 closing = 6
|
||||
t.Errorf("expected 3 matches (6 backtick markers), got %d in:\n%s", codeBlockCount, out)
|
||||
}
|
||||
if !strings.Contains(out, "[remaining: 2]") {
|
||||
t.Errorf("expected [remaining: 2] footer, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsOffset(t *testing.T) {
|
||||
results := makeResults(5)
|
||||
// Skip first 2, show all remaining
|
||||
out := FormatResultsWithOptions(results, 0, "verbose", 2)
|
||||
// offset=2 from 5 results → 3 results; check 3 code blocks
|
||||
codeBlockCount := strings.Count(out, "```")
|
||||
if codeBlockCount != 6 {
|
||||
t.Errorf("expected 3 matches (6 backtick markers) after offset=2, got %d in:\n%s", codeBlockCount, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsOffsetBeyondEnd(t *testing.T) {
|
||||
results := makeResults(3)
|
||||
out := FormatResultsWithOptions(results, 0, "verbose", 10)
|
||||
if out != "No matches found." {
|
||||
t.Errorf("expected 'No matches found.' for offset beyond end, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsPaginationCursor(t *testing.T) {
|
||||
// Offset=2, maxResults=2, 5 total → show items 3&4, remaining=1
|
||||
results := makeResults(5)
|
||||
out := FormatResultsWithOptions(results, 2, "verbose", 2)
|
||||
// offset=2, maxResults=2 → items 3&4; check 2 code blocks
|
||||
codeBlockCount := strings.Count(out, "```")
|
||||
if codeBlockCount != 4 {
|
||||
t.Errorf("expected 2 matches (4 backtick markers), got %d in:\n%s", codeBlockCount, out)
|
||||
}
|
||||
if !strings.Contains(out, "[remaining: 1]") {
|
||||
t.Errorf("expected [remaining: 1], got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsEmpty(t *testing.T) {
|
||||
out := FormatResultsWithOptions(nil, 0, "verbose", 0)
|
||||
if out != "No matches found." {
|
||||
t.Errorf("expected 'No matches found.', got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsBackwardCompat(t *testing.T) {
|
||||
// FormatResults wrapper should produce same output as FormatResultsWithOptions with verbose=false (default).
|
||||
results := makeResults(2)
|
||||
a := FormatResults(results, 0)
|
||||
b := FormatResultsWithOptions(results, 0, "verbose", 0)
|
||||
if a != b {
|
||||
t.Error("FormatResults and FormatResultsWithOptions(verbose,0) should be identical")
|
||||
}
|
||||
// Both should have no preamble.
|
||||
if strings.Contains(a, "Found ") {
|
||||
t.Error("FormatResults should not emit preamble by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstLineOf(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"hello world", 20, "hello world"},
|
||||
{"line1\nline2", 20, "line1"},
|
||||
{"\n\nfoo", 20, "foo"},
|
||||
{"abcdefghij", 5, "abcd…"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := firstLineOf(c.input, c.maxLen)
|
||||
if got != c.want {
|
||||
t.Errorf("firstLineOf(%q, %d) = %q, want %q", c.input, c.maxLen, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n < 10 {
|
||||
return string(rune('0' + n))
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(
|
||||
func() string {
|
||||
buf := make([]byte, 20)
|
||||
pos := 20
|
||||
for n > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}(), ""), "")
|
||||
}
|
||||
+99
-40
@@ -451,62 +451,121 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
|
||||
return true
|
||||
}
|
||||
|
||||
// FormatResults formats match results for display.
|
||||
// FormatResults formats match results for display (backward-compat wrapper, verbose mode).
|
||||
func FormatResults(results []MatchResult, maxResults int) string {
|
||||
return FormatResultsWithOptions(results, maxResults, "verbose", 0)
|
||||
}
|
||||
|
||||
// FormatResultsWithOptions formats match results with configurable output format.
|
||||
// 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).
|
||||
func FormatResultsWithOptions(results []MatchResult, maxResults int, format string, offset int, verbose ...bool) string {
|
||||
if len(results) == 0 {
|
||||
return "No matches found."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d match(es):\n\n", len(results)))
|
||||
|
||||
displayCount := len(results)
|
||||
truncated := false
|
||||
if maxResults > 0 && displayCount > maxResults {
|
||||
displayCount = maxResults
|
||||
truncated = true
|
||||
// Apply offset (pagination skip).
|
||||
if offset > 0 {
|
||||
if offset >= len(results) {
|
||||
return "No matches found."
|
||||
}
|
||||
results = results[offset:]
|
||||
}
|
||||
|
||||
for i := 0; i < displayCount; i++ {
|
||||
r := results[i]
|
||||
nodeType := "unknown"
|
||||
if r.Node != nil {
|
||||
nodeType = r.Node.Type()
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
|
||||
// Determine how many to render and whether more remain.
|
||||
renderCount := len(results)
|
||||
remaining := 0
|
||||
if maxResults > 0 && renderCount > maxResults {
|
||||
remaining = renderCount - maxResults
|
||||
renderCount = maxResults
|
||||
}
|
||||
|
||||
// Truncate very long text
|
||||
text := r.Text
|
||||
if len(text) > 500 {
|
||||
text = text[:500] + "..."
|
||||
}
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString(text)
|
||||
sb.WriteString("\n```\n")
|
||||
var sb strings.Builder
|
||||
|
||||
// Show captures
|
||||
if len(r.Captures) > 0 {
|
||||
sb.WriteString("Captures: ")
|
||||
first := true
|
||||
for name, cap := range r.Captures {
|
||||
if !first {
|
||||
sb.WriteString(", ")
|
||||
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
|
||||
wantVerbose := len(verbose) > 0 && verbose[0]
|
||||
if wantVerbose {
|
||||
sb.WriteString(fmt.Sprintf("Found %d match(es):\n", renderCount))
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "compact":
|
||||
for i := 0; i < renderCount; i++ {
|
||||
r := results[i]
|
||||
nodeType := "unknown"
|
||||
if r.Node != nil {
|
||||
nodeType = r.Node.Type()
|
||||
}
|
||||
firstLine := firstLineOf(r.Text, 80)
|
||||
sb.WriteString(fmt.Sprintf("%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine))
|
||||
}
|
||||
|
||||
case "location":
|
||||
for i := 0; i < renderCount; i++ {
|
||||
r := results[i]
|
||||
sb.WriteString(fmt.Sprintf("%s:%d\n", r.File, r.Location.Line))
|
||||
}
|
||||
|
||||
default: // "verbose"
|
||||
for i := 0; i < renderCount; i++ {
|
||||
r := results[i]
|
||||
nodeType := "unknown"
|
||||
if r.Node != nil {
|
||||
nodeType = r.Node.Type()
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
|
||||
|
||||
// Truncate very long text
|
||||
text := r.Text
|
||||
if len(text) > 500 {
|
||||
text = text[:500] + "..."
|
||||
}
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString(text)
|
||||
sb.WriteString("\n```\n")
|
||||
|
||||
// Show captures
|
||||
if len(r.Captures) > 0 {
|
||||
sb.WriteString("Captures: ")
|
||||
first := true
|
||||
for name, cap := range r.Captures {
|
||||
if !first {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
first = false
|
||||
capText := cap.Text
|
||||
if len(capText) > 50 {
|
||||
capText = capText[:50] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
|
||||
}
|
||||
first = false
|
||||
capText := cap.Text
|
||||
if len(capText) > 50 {
|
||||
capText = capText[:50] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if truncated {
|
||||
sb.WriteString(fmt.Sprintf("... and %d more matches (truncated)\n", len(results)-maxResults))
|
||||
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.
|
||||
sb.WriteString(fmt.Sprintf("[remaining: %d]\n", remaining))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// firstLineOf returns the first non-empty line of s, trimmed and capped at maxLen chars.
|
||||
func firstLineOf(s string, maxLen int) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if len(line) > maxLen {
|
||||
return line[:maxLen-1] + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user