mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62971c9f8c | ||
|
|
99514ff8d6 | ||
|
|
2fb9d3e379 | ||
|
|
3195e353e2 | ||
|
|
549ec2a177 | ||
|
|
96db1c7749 | ||
|
|
5452cfce30 | ||
|
|
85a539b18b | ||
|
|
084c1fd2b2 | ||
|
|
2e444401f5 | ||
|
|
1b41e43bf3 | ||
|
|
13b1a22102 | ||
|
|
d0f649a9b8 | ||
|
|
5214ad2b66 | ||
|
|
80c3f03f62 | ||
|
|
61b04b46cd | ||
|
|
debe79e33e | ||
|
|
dfd68f08c9 | ||
|
|
b2c4101b5b | ||
|
|
53bc06df7f | ||
|
|
cc1661d295 | ||
|
|
b6489ee4bc | ||
|
|
5ed14457f1 | ||
|
|
1c26f8d105 | ||
|
|
7b8914017c | ||
|
|
5827290ece | ||
|
|
b0c1fc0480 | ||
|
|
a0b41a6eb8 | ||
|
|
c261fb8c76 | ||
|
|
ff37be1f17 | ||
|
|
36deb948c8 | ||
|
|
833e898d20 | ||
|
|
02d54b56bb | ||
|
|
e11f140997 | ||
|
|
aa7d884b0c | ||
|
|
0fe678cd60 |
+9
-9
@@ -211,7 +211,7 @@ func GenerateMarkdown() string {
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString("# MCP Filepuff API Reference\n\n")
|
sb.WriteString("# MCP Filepuff API Reference\n\n")
|
||||||
sb.WriteString(fmt.Sprintf("> Auto-generated on %s\n\n", time.Now().Format("2006-01-02")))
|
fmt.Fprintf(&sb, "> Auto-generated on %s\n\n", time.Now().Format("2006-01-02"))
|
||||||
sb.WriteString("This document provides detailed API documentation for all MCP tools available in filepuff.\n\n")
|
sb.WriteString("This document provides detailed API documentation for all MCP tools available in filepuff.\n\n")
|
||||||
|
|
||||||
// Table of Contents
|
// Table of Contents
|
||||||
@@ -228,9 +228,9 @@ func GenerateMarkdown() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, cat := range categoryOrder {
|
for _, cat := range categoryOrder {
|
||||||
sb.WriteString(fmt.Sprintf("### %s\n", cat))
|
fmt.Fprintf(&sb, "### %s\n", cat)
|
||||||
for _, tool := range categories[cat] {
|
for _, tool := range categories[cat] {
|
||||||
sb.WriteString(fmt.Sprintf("- [`%s`](#%s)\n", tool.Name, tool.Name))
|
fmt.Fprintf(&sb, "- [`%s`](#%s)\n", tool.Name, tool.Name)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -240,11 +240,11 @@ func GenerateMarkdown() string {
|
|||||||
sb.WriteString("## Tool Reference\n\n")
|
sb.WriteString("## Tool Reference\n\n")
|
||||||
|
|
||||||
for _, cat := range categoryOrder {
|
for _, cat := range categoryOrder {
|
||||||
sb.WriteString(fmt.Sprintf("### %s\n\n", cat))
|
fmt.Fprintf(&sb, "### %s\n\n", cat)
|
||||||
|
|
||||||
for _, tool := range categories[cat] {
|
for _, tool := range categories[cat] {
|
||||||
sb.WriteString(fmt.Sprintf("#### `%s`\n\n", tool.Name))
|
fmt.Fprintf(&sb, "#### `%s`\n\n", tool.Name)
|
||||||
sb.WriteString(fmt.Sprintf("%s\n\n", tool.Description))
|
fmt.Fprintf(&sb, "%s\n\n", tool.Description)
|
||||||
|
|
||||||
// Read-only indicator
|
// Read-only indicator
|
||||||
if tool.ReadOnly {
|
if tool.ReadOnly {
|
||||||
@@ -263,8 +263,8 @@ func GenerateMarkdown() string {
|
|||||||
if param.Required {
|
if param.Required {
|
||||||
required = "**Yes**"
|
required = "**Yes**"
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("| `%s` | `%s` | %s | %s |\n",
|
fmt.Fprintf(&sb, "| `%s` | `%s` | %s | %s |\n",
|
||||||
param.Name, param.Type, required, param.Description))
|
param.Name, param.Type, required, param.Description)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
} else {
|
} else {
|
||||||
@@ -285,7 +285,7 @@ func GenerateMarkdown() string {
|
|||||||
if len(tool.Notes) > 0 {
|
if len(tool.Notes) > 0 {
|
||||||
sb.WriteString("**Notes:**\n\n")
|
sb.WriteString("**Notes:**\n\n")
|
||||||
for _, note := range tool.Notes {
|
for _, note := range tool.Notes {
|
||||||
sb.WriteString(fmt.Sprintf("- %s\n", note))
|
fmt.Fprintf(&sb, "- %s\n", note)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0
|
github.com/cespare/xxhash/v2 v2.3.0
|
||||||
github.com/goccy/go-json v0.10.6
|
github.com/goccy/go-json v0.10.6
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/mark3labs/mcp-go v0.54.1
|
github.com/mark3labs/mcp-go v0.55.0
|
||||||
github.com/sergi/go-diff v1.4.0
|
github.com/sergi/go-diff v1.4.0
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -18,5 +18,5 @@ require (
|
|||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
|
github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
|
||||||
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
@@ -44,8 +44,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|||||||
@@ -84,14 +84,24 @@ func Load(workspaceRoot string) (*Config, error) {
|
|||||||
// Restore WorkspaceRoot — config file must not override path guards.
|
// Restore WorkspaceRoot — config file must not override path guards.
|
||||||
cfg.WorkspaceRoot = savedRoot
|
cfg.WorkspaceRoot = savedRoot
|
||||||
|
|
||||||
// Clamp size limits to prevent config file from requesting excessive memory.
|
// Clamp size/count limits so the config file cannot request excessive memory.
|
||||||
const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB
|
const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB
|
||||||
|
const maxAllowedSearchResults = 100000
|
||||||
if cfg.MaxFileSize > maxAllowedSize {
|
if cfg.MaxFileSize > maxAllowedSize {
|
||||||
cfg.MaxFileSize = maxAllowedSize
|
cfg.MaxFileSize = maxAllowedSize
|
||||||
}
|
}
|
||||||
if cfg.MaxParseSize > maxAllowedSize {
|
if cfg.MaxParseSize > maxAllowedSize {
|
||||||
cfg.MaxParseSize = maxAllowedSize
|
cfg.MaxParseSize = maxAllowedSize
|
||||||
}
|
}
|
||||||
|
if cfg.MaxEditSize > maxAllowedSize {
|
||||||
|
cfg.MaxEditSize = maxAllowedSize
|
||||||
|
}
|
||||||
|
if cfg.MaxSearchResults > maxAllowedSearchResults {
|
||||||
|
cfg.MaxSearchResults = maxAllowedSearchResults
|
||||||
|
}
|
||||||
|
if cfg.MaxSearchResults < 0 {
|
||||||
|
cfg.MaxSearchResults = 0 // negative would re-enable unbounded search
|
||||||
|
}
|
||||||
|
|
||||||
// Override from environment variables
|
// Override from environment variables
|
||||||
cfg.loadFromEnv()
|
cfg.loadFromEnv()
|
||||||
|
|||||||
@@ -183,6 +183,35 @@ func TestLoadWithConfigFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A config file requesting excessive size/count limits must be clamped so it
|
||||||
|
// cannot force the server into unbounded memory use.
|
||||||
|
func TestLoadClampsExcessiveLimits(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
||||||
|
configContent := `{
|
||||||
|
"max_file_size": 999999999999,
|
||||||
|
"max_parse_size": 999999999999,
|
||||||
|
"max_edit_size": 999999999999,
|
||||||
|
"max_search_results": 999999999
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize int64 = 100 * 1024 * 1024
|
||||||
|
if cfg.MaxFileSize > maxSize || cfg.MaxParseSize > maxSize || cfg.MaxEditSize > maxSize {
|
||||||
|
t.Errorf("size limits not clamped: file=%d parse=%d edit=%d", cfg.MaxFileSize, cfg.MaxParseSize, cfg.MaxEditSize)
|
||||||
|
}
|
||||||
|
if cfg.MaxSearchResults > 100000 {
|
||||||
|
t.Errorf("MaxSearchResults not clamped: %d", cfg.MaxSearchResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestValidate tests the Validate method with various inputs.
|
// TestValidate tests the Validate method with various inputs.
|
||||||
func TestValidate(t *testing.T) {
|
func TestValidate(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ type payload struct {
|
|||||||
// Encode creates an opaque cursor string from an offset and query hash.
|
// Encode creates an opaque cursor string from an offset and query hash.
|
||||||
func Encode(offset int, queryHash string) string {
|
func Encode(offset int, queryHash string) string {
|
||||||
p := payload{Offset: offset, QueryHash: queryHash}
|
p := payload{Offset: offset, QueryHash: queryHash}
|
||||||
|
// Marshalling a struct of two scalar fields (int, string) cannot fail, so the
|
||||||
|
// error is intentionally ignored.
|
||||||
b, _ := json.Marshal(p)
|
b, _ := json.Marshal(p)
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-25
@@ -270,7 +270,11 @@ func (e *Engine) validateBaseEdit(edit *ASTEdit) error {
|
|||||||
switch edit.Operation {
|
switch edit.Operation {
|
||||||
case EditReplace, EditInsertBefore, EditInsertAfter:
|
case EditReplace, EditInsertBefore, EditInsertAfter:
|
||||||
if edit.NewContent == "" {
|
if edit.NewContent == "" {
|
||||||
return errors.NewInvalidEditError(fmt.Sprintf("new_content is required for %s operation", edit.Operation))
|
msg := fmt.Sprintf("new_content is required for %s operation", edit.Operation)
|
||||||
|
if edit.Operation == EditReplace {
|
||||||
|
msg += " (to remove content instead, use operation=delete)"
|
||||||
|
}
|
||||||
|
return errors.NewInvalidEditError(msg)
|
||||||
}
|
}
|
||||||
case EditDelete:
|
case EditDelete:
|
||||||
// new_content not required
|
// new_content not required
|
||||||
@@ -638,16 +642,19 @@ func flattenDiffLines(diffs []diffmatchpatch.Diff) (lines []diffLine, maxOldN, m
|
|||||||
// Strip the terminator for display; also drop a trailing CR so CRLF files
|
// Strip the terminator for display; also drop a trailing CR so CRLF files
|
||||||
// do not leak raw carriage returns into the rendered diff.
|
// do not leak raw carriage returns into the rendered diff.
|
||||||
text := strings.TrimSuffix(strings.TrimSuffix(raw, "\n"), "\r")
|
text := strings.TrimSuffix(strings.TrimSuffix(raw, "\n"), "\r")
|
||||||
|
// Record both running line numbers on every line (not just the active
|
||||||
|
// side): the opposite-side counter is the position the change sits at,
|
||||||
|
// which hunkBounds needs to compute correct @@ header start lines.
|
||||||
switch d.Type {
|
switch d.Type {
|
||||||
case diffmatchpatch.DiffEqual:
|
case diffmatchpatch.DiffEqual:
|
||||||
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
|
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
|
||||||
oldLine++
|
oldLine++
|
||||||
newLine++
|
newLine++
|
||||||
case diffmatchpatch.DiffDelete:
|
case diffmatchpatch.DiffDelete:
|
||||||
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine})
|
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
|
||||||
oldLine++
|
oldLine++
|
||||||
case diffmatchpatch.DiffInsert:
|
case diffmatchpatch.DiffInsert:
|
||||||
lines = append(lines, diffLine{op: d.Type, text: text, newN: newLine})
|
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
|
||||||
newLine++
|
newLine++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -676,38 +683,31 @@ func diffHunkRanges(lines []diffLine) []indexRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hunkBounds computes the unified-diff hunk header line numbers and counts for
|
// hunkBounds computes the unified-diff hunk header line numbers and counts for
|
||||||
// lines[start:end+1]. newStart/oldStart for one-sided lines are approximate.
|
// lines[start:end+1]. Each line carries both running line numbers (see
|
||||||
|
// flattenDiffLines), so the hunk start is simply the first line's numbers. When a
|
||||||
|
// side has zero lines in the hunk (pure insertion or deletion), the header start
|
||||||
|
// for that side follows GNU's convention: the line number *before* the change,
|
||||||
|
// clamped to 0 at the start of the file (e.g. "@@ -0,0 +1,N @@").
|
||||||
func hunkBounds(lines []diffLine, start, end int) (oldStart, oldCount, newStart, newCount int) {
|
func hunkBounds(lines []diffLine, start, end int) (oldStart, oldCount, newStart, newCount int) {
|
||||||
|
oldStart = lines[start].oldN
|
||||||
|
newStart = lines[start].newN
|
||||||
for i := start; i <= end; i++ {
|
for i := start; i <= end; i++ {
|
||||||
l := lines[i]
|
switch lines[i].op {
|
||||||
switch l.op {
|
|
||||||
case diffmatchpatch.DiffEqual:
|
case diffmatchpatch.DiffEqual:
|
||||||
if oldCount == 0 {
|
|
||||||
oldStart = l.oldN
|
|
||||||
}
|
|
||||||
if newCount == 0 {
|
|
||||||
newStart = l.newN
|
|
||||||
}
|
|
||||||
oldCount++
|
oldCount++
|
||||||
newCount++
|
newCount++
|
||||||
case diffmatchpatch.DiffDelete:
|
case diffmatchpatch.DiffDelete:
|
||||||
if oldCount == 0 {
|
|
||||||
oldStart = l.oldN
|
|
||||||
}
|
|
||||||
if newCount == 0 {
|
|
||||||
newStart = l.oldN // approximate
|
|
||||||
}
|
|
||||||
oldCount++
|
oldCount++
|
||||||
case diffmatchpatch.DiffInsert:
|
case diffmatchpatch.DiffInsert:
|
||||||
if newCount == 0 {
|
|
||||||
newStart = l.newN
|
|
||||||
}
|
|
||||||
if oldCount == 0 {
|
|
||||||
oldStart = l.newN // approximate
|
|
||||||
}
|
|
||||||
newCount++
|
newCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if oldCount == 0 {
|
||||||
|
oldStart = max(0, oldStart-1)
|
||||||
|
}
|
||||||
|
if newCount == 0 {
|
||||||
|
newStart = max(0, newStart-1)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,7 +864,7 @@ func truncateString(s string, maxLen int) string {
|
|||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:maxLen-3] + "..."
|
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateLanguage checks if AST editing is supported for a file.
|
// ValidateLanguage checks if AST editing is supported for a file.
|
||||||
|
|||||||
@@ -101,6 +101,22 @@ func TestValidateEdit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A replace with empty new_content should point the caller at operation=delete.
|
||||||
|
func TestValidateReplaceEmptySuggestsDelete(t *testing.T) {
|
||||||
|
e := NewEngine(parser.NewRegistry())
|
||||||
|
err := e.validateASTEdit(&ASTEdit{
|
||||||
|
File: "test.go",
|
||||||
|
Operation: EditReplace,
|
||||||
|
Selector: ASTSelector{Kind: "function_declaration"},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for replace with empty new_content")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "operation=delete") {
|
||||||
|
t.Errorf("error should suggest operation=delete, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveSelector(t *testing.T) {
|
func TestResolveSelector(t *testing.T) {
|
||||||
registry := parser.NewRegistry()
|
registry := parser.NewRegistry()
|
||||||
defer registry.Close()
|
defer registry.Close()
|
||||||
@@ -353,6 +369,45 @@ func Hello() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An edit on a CRLF file must normalize inserted LF content to CRLF so the file
|
||||||
|
// keeps a single, consistent line-ending convention (a documented guarantee).
|
||||||
|
func TestApplyPreservesCRLF(t *testing.T) {
|
||||||
|
registry := parser.NewRegistry()
|
||||||
|
defer registry.Close()
|
||||||
|
e := NewEngine(registry)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "crlf.go")
|
||||||
|
content := "package main\r\n\r\nfunc Hello() {\r\n\tprintln(\"hi\")\r\n}\r\n"
|
||||||
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := e.Apply(context.Background(), &ASTEdit{
|
||||||
|
File: tmpFile,
|
||||||
|
Operation: EditReplace,
|
||||||
|
Selector: ASTSelector{Kind: "function_declaration"},
|
||||||
|
NewContent: "func Bye() {\n\tprintln(\"bye\")\n}", // LF newlines on input
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.ReadFile(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if !strings.Contains(s, "func Bye()") {
|
||||||
|
t.Fatalf("edit not applied:\n%q", s)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\n' && (i == 0 || s[i-1] != '\r') {
|
||||||
|
t.Fatalf("bare LF at offset %d — CRLF not preserved:\n%q", i, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateDiff(t *testing.T) {
|
func TestGenerateDiff(t *testing.T) {
|
||||||
original := "line1\nline2\nline3"
|
original := "line1\nline2\nline3"
|
||||||
modified := "line1\nmodified\nline3"
|
modified := "line1\nmodified\nline3"
|
||||||
@@ -411,6 +466,29 @@ func TestGenerateDiffLineLevelAccuracy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hunk @@ headers must be correct at file boundaries (previously approximate).
|
||||||
|
func TestGenerateDiffHunkHeaderBounds(t *testing.T) {
|
||||||
|
e := &Engine{dmp: diffmatchpatch.New()}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
original string
|
||||||
|
modified string
|
||||||
|
wantHdr string
|
||||||
|
}{
|
||||||
|
{name: "insert into empty file", original: "", modified: "X\nY\n", wantHdr: "@@ -0,0 +1,2 @@"},
|
||||||
|
{name: "delete entire file", original: "a\nb\n", modified: "", wantHdr: "@@ -1,2 +0,0 @@"},
|
||||||
|
{name: "mid-file change keeps 1-based start", original: "l1\nl2\nl3\nl4\nl5\n", modified: "l1\nl2\nCHANGED\nl4\nl5\n", wantHdr: "@@ -1,5 +1,5 @@"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
diff := e.generateDiff(tt.original, tt.modified, "f.txt")
|
||||||
|
if !strings.Contains(diff, tt.wantHdr) {
|
||||||
|
t.Errorf("expected hunk header %q, got:\n%s", tt.wantHdr, diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateDiffNoPhantomChanges(t *testing.T) {
|
func TestGenerateDiffNoPhantomChanges(t *testing.T) {
|
||||||
// Regression test: replacing a line range should not produce phantom
|
// Regression test: replacing a line range should not produce phantom
|
||||||
// +/- lines for unchanged code after the edit region.
|
// +/- lines for unchanged code after the edit region.
|
||||||
|
|||||||
+43
-3
@@ -22,6 +22,10 @@ const ProcessKillTimeout = 5 * time.Second
|
|||||||
// StderrBufferSize is the buffer size for draining stderr.
|
// StderrBufferSize is the buffer size for draining stderr.
|
||||||
const StderrBufferSize = 1024
|
const StderrBufferSize = 1024
|
||||||
|
|
||||||
|
// StderrCaptureMax is the maximum number of recent stderr bytes retained for
|
||||||
|
// diagnostics (e.g. to explain an init failure). Older bytes are dropped.
|
||||||
|
const StderrCaptureMax = 4096
|
||||||
|
|
||||||
// Client represents an LSP client connection.
|
// Client represents an LSP client connection.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
@@ -35,6 +39,8 @@ type Client struct {
|
|||||||
runningMu sync.RWMutex
|
runningMu sync.RWMutex
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
stderrMu sync.Mutex
|
||||||
|
stderrTail []byte // most recent stderr, capped at StderrCaptureMax (diagnostics)
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +127,12 @@ func NewClient(cmd *exec.Cmd) (*Client, error) {
|
|||||||
|
|
||||||
// drainStderr consumes stderr output to prevent the LSP server from blocking.
|
// drainStderr consumes stderr output to prevent the LSP server from blocking.
|
||||||
// LSP servers may write diagnostic messages to stderr which we discard.
|
// LSP servers may write diagnostic messages to stderr which we discard.
|
||||||
|
//
|
||||||
|
// Cancellation note: the goroutine parks in the blocking stderr.Read below, so
|
||||||
|
// closing c.done does not interrupt it directly — termination relies on the
|
||||||
|
// read returning an error when the pipe reaches EOF (the process is killed on
|
||||||
|
// Close). The c.done check is only a best-effort fast path for the case where
|
||||||
|
// done is already closed between reads.
|
||||||
func (c *Client) drainStderr() {
|
func (c *Client) drainStderr() {
|
||||||
buf := make([]byte, StderrBufferSize)
|
buf := make([]byte, StderrBufferSize)
|
||||||
for {
|
for {
|
||||||
@@ -129,14 +141,35 @@ func (c *Client) drainStderr() {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
// Read and discard stderr output
|
n, err := c.stderr.Read(buf)
|
||||||
_, err := c.stderr.Read(buf)
|
if n > 0 {
|
||||||
|
c.captureStderr(buf[:n])
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// captureStderr appends p to the retained stderr tail, keeping at most
|
||||||
|
// StderrCaptureMax bytes (dropping the oldest).
|
||||||
|
func (c *Client) captureStderr(p []byte) {
|
||||||
|
c.stderrMu.Lock()
|
||||||
|
defer c.stderrMu.Unlock()
|
||||||
|
c.stderrTail = append(c.stderrTail, p...)
|
||||||
|
if len(c.stderrTail) > StderrCaptureMax {
|
||||||
|
c.stderrTail = c.stderrTail[len(c.stderrTail)-StderrCaptureMax:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentStderr returns the retained tail of the server's stderr, trimmed. Useful
|
||||||
|
// for surfacing why an LSP server failed to start.
|
||||||
|
func (c *Client) RecentStderr() string {
|
||||||
|
c.stderrMu.Lock()
|
||||||
|
defer c.stderrMu.Unlock()
|
||||||
|
return strings.TrimSpace(string(c.stderrTail))
|
||||||
|
}
|
||||||
|
|
||||||
// Call sends a request and waits for a response.
|
// Call sends a request and waits for a response.
|
||||||
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
||||||
c.runningMu.RLock()
|
c.runningMu.RLock()
|
||||||
@@ -315,7 +348,14 @@ func (c *Client) readLoop() {
|
|||||||
if err := json.Unmarshal(body, &resp); err == nil && resp.ID != 0 {
|
if err := json.Unmarshal(body, &resp); err == nil && resp.ID != 0 {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if ch, ok := c.pending[resp.ID]; ok {
|
if ch, ok := c.pending[resp.ID]; ok {
|
||||||
ch <- &resp
|
// Non-blocking send: the pending channel is buffered(1) and read
|
||||||
|
// exactly once. A misbehaving server that sends a duplicate
|
||||||
|
// response for the same id must not block here while holding c.mu
|
||||||
|
// (which would deadlock all future dispatches).
|
||||||
|
select {
|
||||||
|
case ch <- &resp:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package lsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// drainStderr retains a bounded tail of server stderr for diagnostics; capture
|
||||||
|
// must keep recent bytes and never grow past StderrCaptureMax.
|
||||||
|
func TestClientStderrCapture(t *testing.T) {
|
||||||
|
c := &Client{}
|
||||||
|
|
||||||
|
if c.RecentStderr() != "" {
|
||||||
|
t.Errorf("expected empty stderr initially, got %q", c.RecentStderr())
|
||||||
|
}
|
||||||
|
|
||||||
|
c.captureStderr([]byte("gopls: command not found\n"))
|
||||||
|
if !strings.Contains(c.RecentStderr(), "command not found") {
|
||||||
|
t.Errorf("expected captured stderr, got %q", c.RecentStderr())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exceed the cap: only the most recent StderrCaptureMax bytes are kept.
|
||||||
|
big := strings.Repeat("x", StderrCaptureMax*2)
|
||||||
|
c.captureStderr([]byte(big))
|
||||||
|
if got := len(c.RecentStderr()); got > StderrCaptureMax {
|
||||||
|
t.Errorf("stderr tail not bounded: len=%d, want <= %d", got, StderrCaptureMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package lsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseDefinitionResult covers every LSP definition result shape: single
|
||||||
|
// Location, Location[], single LocationLink, LocationLink[], plus empty/error
|
||||||
|
// cases. The LocationLink shapes are the regression target — we advertise
|
||||||
|
// LinkSupport, so gopls/rust-analyzer return targetUri/targetRange, which a
|
||||||
|
// naive []Location unmarshal would silently turn into zero-value locations.
|
||||||
|
func TestParseDefinitionResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantURIs []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single Location",
|
||||||
|
raw: `{"uri":"file:///a.go","range":{"start":{"line":4,"character":2},"end":{"line":4,"character":8}}}`,
|
||||||
|
wantURIs: []string{"file:///a.go"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Location array",
|
||||||
|
raw: `[{"uri":"file:///a.go","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":3}}},{"uri":"file:///b.go","range":{"start":{"line":2,"character":0},"end":{"line":2,"character":3}}}]`,
|
||||||
|
wantURIs: []string{"file:///a.go", "file:///b.go"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single LocationLink",
|
||||||
|
raw: `{"targetUri":"file:///c.go","targetRange":{"start":{"line":9,"character":0},"end":{"line":12,"character":1}}}`,
|
||||||
|
wantURIs: []string{"file:///c.go"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LocationLink array",
|
||||||
|
raw: `[{"targetUri":"file:///d.go","targetRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}}}]`,
|
||||||
|
wantURIs: []string{"file:///d.go"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty array",
|
||||||
|
raw: `[]`,
|
||||||
|
wantURIs: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "garbage",
|
||||||
|
raw: `"not-a-location"`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
locs, err := parseDefinitionResult(json.RawMessage(tt.raw))
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got locations %+v", locs)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(locs) != len(tt.wantURIs) {
|
||||||
|
t.Fatalf("got %d locations, want %d (%+v)", len(locs), len(tt.wantURIs), locs)
|
||||||
|
}
|
||||||
|
for i, want := range tt.wantURIs {
|
||||||
|
if locs[i].URI != want {
|
||||||
|
t.Errorf("location[%d].URI = %q, want %q", i, locs[i].URI, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LocationLink ranges must be carried over, not zeroed.
|
||||||
|
if tt.name == "single LocationLink" && locs[0].Range.Start.Line != 9 {
|
||||||
|
t.Errorf("LocationLink targetRange not preserved: got start line %d, want 9", locs[0].Range.Start.Line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-9
@@ -212,10 +212,16 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
|||||||
// Initialize server
|
// Initialize server
|
||||||
if err := m.initializeServer(ctx, newSrv); err != nil {
|
if err := m.initializeServer(ctx, newSrv); err != nil {
|
||||||
newSrv.initErr = err
|
newSrv.initErr = err
|
||||||
return nil, errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
|
wrapped := errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
|
||||||
WithContext("language", string(lang)).
|
WithContext("language", string(lang)).
|
||||||
WithContext("command", config.Command[0]).
|
WithContext("command", config.Command[0]).
|
||||||
WithRemediation("Check LSP server logs for initialization errors")
|
WithRemediation("Check LSP server logs for initialization errors")
|
||||||
|
// Surface the server's own stderr (e.g. "command not found", missing
|
||||||
|
// module) so the failure is diagnosable instead of opaque.
|
||||||
|
if stderr := client.RecentStderr(); stderr != "" {
|
||||||
|
wrapped = wrapped.WithContext("stderr", stderr)
|
||||||
|
}
|
||||||
|
return nil, wrapped
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as successfully initialized to prevent cleanup
|
// Mark as successfully initialized to prevent cleanup
|
||||||
@@ -358,17 +364,55 @@ func (m *Manager) Definition(ctx context.Context, file string, line, col int) ([
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result can be Location, []Location, or []LocationLink
|
return parseDefinitionResult(resp.Result)
|
||||||
var locations []Location
|
}
|
||||||
if err := json.Unmarshal(resp.Result, &locations); err != nil {
|
|
||||||
// Try single location
|
// parseDefinitionResult decodes a textDocument/definition result. Per LSP the
|
||||||
var single Location
|
// result may be Location, Location[], LocationLink, or LocationLink[]. Because
|
||||||
if err := json.Unmarshal(resp.Result, &single); err == nil {
|
// we advertise LinkSupport (see initialize), servers such as gopls and
|
||||||
locations = []Location{single}
|
// rust-analyzer return the LocationLink shape (targetUri/targetRange). A naive
|
||||||
|
// unmarshal into []Location silently succeeds with zero-value URIs/ranges, so
|
||||||
|
// decode into a unified shape that captures both and normalize to Location.
|
||||||
|
func parseDefinitionResult(raw json.RawMessage) ([]Location, error) {
|
||||||
|
// locOrLink captures both the Location (uri/range) and LocationLink
|
||||||
|
// (targetUri/targetRange) field sets; whichever is populated wins.
|
||||||
|
type locOrLink struct {
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Range Range `json:"range"`
|
||||||
|
TargetURI string `json:"targetUri"`
|
||||||
|
TargetRange Range `json:"targetRange"`
|
||||||
|
}
|
||||||
|
toLocation := func(l locOrLink) (Location, bool) {
|
||||||
|
if l.URI != "" {
|
||||||
|
return Location{URI: l.URI, Range: l.Range}, true
|
||||||
}
|
}
|
||||||
|
if l.TargetURI != "" {
|
||||||
|
return Location{URI: l.TargetURI, Range: l.TargetRange}, true
|
||||||
|
}
|
||||||
|
return Location{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return locations, nil
|
// Array form (Location[] or LocationLink[]).
|
||||||
|
var arr []locOrLink
|
||||||
|
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||||
|
locations := make([]Location, 0, len(arr))
|
||||||
|
for _, l := range arr {
|
||||||
|
if loc, ok := toLocation(l); ok {
|
||||||
|
locations = append(locations, loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single object form (Location or LocationLink).
|
||||||
|
var single locOrLink
|
||||||
|
if err := json.Unmarshal(raw, &single); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse definition result: %w", err)
|
||||||
|
}
|
||||||
|
if loc, ok := toLocation(single); ok {
|
||||||
|
return []Location{loc}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// References finds all references to the symbol at the given position.
|
// References finds all references to the symbol at the given position.
|
||||||
|
|||||||
+22
-17
@@ -33,8 +33,13 @@ func (c *Counter) Inc() {
|
|||||||
c.value.Add(1)
|
c.value.Add(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds the given value to the counter.
|
// Add adds the given value to the counter. Counters are monotonic, so a
|
||||||
|
// negative delta (which would corrupt rate()-style calculations downstream) is
|
||||||
|
// ignored rather than applied.
|
||||||
func (c *Counter) Add(delta int64) {
|
func (c *Counter) Add(delta int64) {
|
||||||
|
if delta < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
c.value.Add(delta)
|
c.value.Add(delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,27 +283,27 @@ func (r *Registry) Expose() string {
|
|||||||
// Export counters
|
// Export counters
|
||||||
for _, c := range r.counters {
|
for _, c := range r.counters {
|
||||||
if c.help != "" {
|
if c.help != "" {
|
||||||
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", c.name, c.help))
|
fmt.Fprintf(&sb, "# HELP %s %s\n", c.name, c.help)
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("# TYPE %s counter\n", c.name))
|
fmt.Fprintf(&sb, "# TYPE %s counter\n", c.name)
|
||||||
sb.WriteString(fmt.Sprintf("%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load()))
|
fmt.Fprintf(&sb, "%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export gauges
|
// Export gauges
|
||||||
for _, g := range r.gauges {
|
for _, g := range r.gauges {
|
||||||
if g.help != "" {
|
if g.help != "" {
|
||||||
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", g.name, g.help))
|
fmt.Fprintf(&sb, "# HELP %s %s\n", g.name, g.help)
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("# TYPE %s gauge\n", g.name))
|
fmt.Fprintf(&sb, "# TYPE %s gauge\n", g.name)
|
||||||
sb.WriteString(fmt.Sprintf("%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load()))
|
fmt.Fprintf(&sb, "%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export histograms
|
// Export histograms
|
||||||
for _, h := range r.histograms {
|
for _, h := range r.histograms {
|
||||||
if h.help != "" {
|
if h.help != "" {
|
||||||
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", h.name, h.help))
|
fmt.Fprintf(&sb, "# HELP %s %s\n", h.name, h.help)
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("# TYPE %s histogram\n", h.name))
|
fmt.Fprintf(&sb, "# TYPE %s histogram\n", h.name)
|
||||||
|
|
||||||
// Cumulative bucket counts
|
// Cumulative bucket counts
|
||||||
var cumulative int64
|
var cumulative int64
|
||||||
@@ -306,26 +311,26 @@ func (r *Registry) Expose() string {
|
|||||||
cumulative += h.counts[i].Load()
|
cumulative += h.counts[i].Load()
|
||||||
labelStr := formatLabels(h.labels)
|
labelStr := formatLabels(h.labels)
|
||||||
if labelStr == "" {
|
if labelStr == "" {
|
||||||
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative))
|
fmt.Fprintf(&sb, "%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative)
|
||||||
} else {
|
} else {
|
||||||
// Insert le label into existing labels
|
// Insert le label into existing labels
|
||||||
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
|
fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
|
||||||
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative))
|
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// +Inf bucket
|
// +Inf bucket
|
||||||
cumulative += h.counts[len(h.buckets)].Load()
|
cumulative += h.counts[len(h.buckets)].Load()
|
||||||
labelStr := formatLabels(h.labels)
|
labelStr := formatLabels(h.labels)
|
||||||
if labelStr == "" {
|
if labelStr == "" {
|
||||||
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative))
|
fmt.Fprintf(&sb, "%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative)
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
|
fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
|
||||||
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative))
|
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum and count
|
// Sum and count
|
||||||
sb.WriteString(fmt.Sprintf("%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum()))
|
fmt.Fprintf(&sb, "%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum())
|
||||||
sb.WriteString(fmt.Sprintf("%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load()))
|
fmt.Fprintf(&sb, "%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ func TestCounter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Counters are monotonic: a negative Add must be ignored, not applied.
|
||||||
|
func TestCounterRejectsNegativeAdd(t *testing.T) {
|
||||||
|
c := NewCounter("mono_counter", "test", nil)
|
||||||
|
c.Add(5)
|
||||||
|
c.Add(-3)
|
||||||
|
if got := c.Value(); got != 5 {
|
||||||
|
t.Errorf("negative Add should be ignored: Value() = %d, want 5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCounterConcurrency(t *testing.T) {
|
func TestCounterConcurrency(t *testing.T) {
|
||||||
c := NewCounter("concurrent_counter", "test", nil)
|
c := NewCounter("concurrent_counter", "test", nil)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package parser
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
@@ -530,11 +531,16 @@ func FormatDocComment(doc *DocComment) string {
|
|||||||
written[t] = true
|
written[t] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for tagName, val := range doc.Tags {
|
remaining := make([]string, 0, len(doc.Tags))
|
||||||
|
for tagName := range doc.Tags {
|
||||||
if !written[tagName] {
|
if !written[tagName] {
|
||||||
sb.WriteString("@" + tagName + " " + val + "\n")
|
remaining = append(remaining, tagName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sort.Strings(remaining) // deterministic output (map order is random)
|
||||||
|
for _, tagName := range remaining {
|
||||||
|
sb.WriteString("@" + tagName + " " + doc.Tags[tagName] + "\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(sb.String())
|
return strings.TrimSpace(sb.String())
|
||||||
|
|||||||
@@ -48,10 +48,13 @@ type Registry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CachedTree stores a parsed tree with its metadata.
|
// CachedTree stores a parsed tree with its metadata.
|
||||||
// Content is not stored to reduce memory usage.
|
// Content is not stored to reduce memory usage. Syntax errors are a pure
|
||||||
|
// function of the tree (extractErrors ignores content), so they are cached
|
||||||
|
// alongside it to avoid re-walking the whole tree on every cache hit.
|
||||||
type CachedTree struct {
|
type CachedTree struct {
|
||||||
Tree *sitter.Tree
|
Tree *sitter.Tree
|
||||||
Language protocol.Language
|
Language protocol.Language
|
||||||
|
Errors []SyntaxError
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseResult contains the result of parsing a file.
|
// ParseResult contains the result of parsing a file.
|
||||||
@@ -202,11 +205,10 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
|||||||
hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content))
|
hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content))
|
||||||
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
|
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
|
||||||
r.cacheHits.Add(1)
|
r.cacheHits.Add(1)
|
||||||
errors := extractErrors(cached.Tree.RootNode(), content)
|
|
||||||
return &ParseResult{
|
return &ParseResult{
|
||||||
Tree: cached.Tree,
|
Tree: cached.Tree,
|
||||||
Language: lang,
|
Language: lang,
|
||||||
Errors: errors,
|
Errors: cached.Errors,
|
||||||
Content: content,
|
Content: content,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -242,10 +244,12 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
|||||||
// Extract syntax errors
|
// Extract syntax errors
|
||||||
errors := extractErrors(tree.RootNode(), content)
|
errors := extractErrors(tree.RootNode(), content)
|
||||||
|
|
||||||
// Cache result (LRU cache handles eviction automatically)
|
// Cache result (LRU cache handles eviction automatically).
|
||||||
|
// Errors are cached so cache hits skip a full tree walk.
|
||||||
r.cache.Add(hash, &CachedTree{
|
r.cache.Add(hash, &CachedTree{
|
||||||
Tree: tree,
|
Tree: tree,
|
||||||
Language: lang,
|
Language: lang,
|
||||||
|
Errors: errors,
|
||||||
})
|
})
|
||||||
|
|
||||||
return &ParseResult{
|
return &ParseResult{
|
||||||
|
|||||||
@@ -172,6 +172,43 @@ func TestParseWithSyntaxErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseCachedErrorsMatch verifies that the syntax errors returned on a
|
||||||
|
// cache hit are identical to those computed on the initial parse. Errors are
|
||||||
|
// cached alongside the tree (they are a pure function of the tree), so the hot
|
||||||
|
// cache-hit path must not lose or alter them.
|
||||||
|
func TestParseCachedErrorsMatch(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
content := []byte("package main\n\nfunc main( {}\n") // missing closing paren
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, err := r.Parse(ctx, "test.go", content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first parse failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(first.Errors) == 0 {
|
||||||
|
t.Fatal("expected syntax errors on first parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := r.Parse(ctx, "test.go", content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cached parse failed: %v", err)
|
||||||
|
}
|
||||||
|
if second.Tree != first.Tree {
|
||||||
|
t.Fatal("expected cache hit (same tree pointer)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(second.Errors) != len(first.Errors) {
|
||||||
|
t.Fatalf("cached errors count = %d, want %d", len(second.Errors), len(first.Errors))
|
||||||
|
}
|
||||||
|
for i := range first.Errors {
|
||||||
|
if second.Errors[i] != first.Errors[i] {
|
||||||
|
t.Errorf("cached error[%d] = %+v, want %+v", i, second.Errors[i], first.Errors[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsBinary(t *testing.T) {
|
func TestIsBinary(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+39
-11
@@ -36,22 +36,23 @@ func SkeletonFile(ctx context.Context, reg *Registry, filename string, content [
|
|||||||
default:
|
default:
|
||||||
// TODO: skeleton for c, cpp, elixir, html, vue — fall back to symbols_only
|
// TODO: skeleton for c, cpp, elixir, html, vue — fall back to symbols_only
|
||||||
syms := ExtractSymbols(result.Tree, content, lang, filename)
|
syms := ExtractSymbols(result.Tree, content, lang, filename)
|
||||||
return renderSymbolsOnly(syms, filename, lang, content), false, nil
|
return renderSymbolsOnly(syms, lang), false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderSymbolsOnly renders a simple symbol list (fallback for unsupported languages).
|
// renderSymbolsOnly renders a compact symbol list (fallback for unsupported
|
||||||
func renderSymbolsOnly(syms []protocol.Symbol, _ string, lang protocol.Language, content []byte) string {
|
// languages). It deliberately does not fabricate "<source line> { ... }" output:
|
||||||
lines := strings.Split(string(content), "\n")
|
// the raw start line plus a guessed brace is wrong for brace-less languages (e.g.
|
||||||
|
// Elixir's "def f do", HTML) and only wastes tokens. The output is not valid
|
||||||
|
// source, so it drops "// " comment framing and uses a dense "kind name :line"
|
||||||
|
// form to minimise tokens. The symbol list (kind, name, line, doc) is accurate.
|
||||||
|
func renderSymbolsOnly(syms []protocol.Symbol, lang protocol.Language) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(fmt.Sprintf("// skeleton unavailable for %s — symbol list only\n", lang))
|
fmt.Fprintf(&sb, "symbols only (no skeleton for %s):\n", lang)
|
||||||
for _, s := range syms {
|
for _, s := range syms {
|
||||||
sb.WriteString(fmt.Sprintf("// %s %s (line %d)\n", s.Kind, s.Name, s.Location.Line))
|
fmt.Fprintf(&sb, "%s %s :%d\n", s.Kind, s.Name, s.Location.Line)
|
||||||
if s.Doc != "" {
|
if s.Doc != "" {
|
||||||
sb.WriteString(fmt.Sprintf("// doc: %s\n", s.Doc))
|
fmt.Fprintf(&sb, " doc: %s\n", s.Doc)
|
||||||
}
|
|
||||||
if s.Location.Line >= 1 && s.Location.Line <= len(lines) {
|
|
||||||
sb.WriteString(lines[s.Location.Line-1] + " { ... }\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -216,7 +217,34 @@ func skeletonPythonNode(node *sitter.Node, content []byte, sb *strings.Builder,
|
|||||||
skeletonPythonNode(child, content, sb, indent)
|
skeletonPythonNode(child, content, sb, indent)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
case "function_definition", "decorated_definition":
|
case "decorated_definition":
|
||||||
|
// Emit the decorator line(s), then render the wrapped definition so its
|
||||||
|
// signature (def/class) is preserved — taking lines[0] of the whole node
|
||||||
|
// would keep only the first decorator and drop the signature entirely.
|
||||||
|
var def *sitter.Node
|
||||||
|
for i := 0; i < int(node.NamedChildCount()); i++ {
|
||||||
|
if child := node.NamedChild(i); child != nil {
|
||||||
|
if t := child.Type(); t == "function_definition" || t == "class_definition" {
|
||||||
|
def = child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def != nil {
|
||||||
|
decText := strings.TrimRight(string(content[node.StartByte():def.StartByte()]), "\n")
|
||||||
|
for _, dl := range strings.Split(decText, "\n") {
|
||||||
|
if strings.TrimSpace(dl) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(indent)
|
||||||
|
sb.WriteString(dl)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
skeletonPythonNode(def, content, sb, indent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fallthrough
|
||||||
|
case "function_definition":
|
||||||
nodeText := string(content[node.StartByte():node.EndByte()])
|
nodeText := string(content[node.StartByte():node.EndByte()])
|
||||||
lines := strings.SplitN(nodeText, "\n", 2)
|
lines := strings.SplitN(nodeText, "\n", 2)
|
||||||
sb.WriteString(indent)
|
sb.WriteString(indent)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A decorated Python function must keep BOTH its decorator(s) and its def
|
||||||
|
// signature in the skeleton — previously only the first decorator survived and
|
||||||
|
// the signature was dropped.
|
||||||
|
func TestSkeletonPythonDecoratedFunction(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
src := "@app.route(\"/\")\n" +
|
||||||
|
"def home():\n" +
|
||||||
|
" return \"hi\"\n"
|
||||||
|
|
||||||
|
out, _, err := SkeletonFile(context.Background(), r, "app.py", []byte(src))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SkeletonFile error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "@app.route(\"/\")") {
|
||||||
|
t.Errorf("skeleton lost decorator, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "def home():") {
|
||||||
|
t.Errorf("skeleton lost def signature, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "return \"hi\"") {
|
||||||
|
t.Errorf("skeleton should elide the body, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain (undecorated) Python function still renders its signature + body stub.
|
||||||
|
func TestSkeletonPythonPlainFunction(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
src := "def add(a, b):\n return a + b\n"
|
||||||
|
out, _, err := SkeletonFile(context.Background(), r, "m.py", []byte(src))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SkeletonFile error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "def add(a, b):") {
|
||||||
|
t.Errorf("skeleton lost signature, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
-15
@@ -59,20 +59,60 @@ func StripContent(content string, flags []StripFlag, lang protocol.Language) Str
|
|||||||
return StripResult{Content: content, Stripped: stripped}
|
return StripResult{Content: content, Stripped: stripped}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripLicense removes a leading block comment that looks like a license header.
|
// containsLicenseKeyword reports whether lowercased text looks like a license
|
||||||
// A comment qualifies if it contains "copyright", "license", or "spdx-license-identifier" (case-insensitive).
|
// header (contains "copyright", "license", or an SPDX identifier).
|
||||||
|
func containsLicenseKeyword(lower string) bool {
|
||||||
|
return strings.Contains(lower, "copyright") ||
|
||||||
|
strings.Contains(lower, "license") ||
|
||||||
|
strings.Contains(lower, "spdx-license-identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
// peelLicensePrefix splits off a leading run of lines that must be preserved
|
||||||
|
// above a license header: a shebang (#!) on the first line and/or Go
|
||||||
|
// build-constraint lines (//go:build, // +build). Returns the preserved prefix
|
||||||
|
// (with terminators) and the remaining content. With no such lines, prefix is "".
|
||||||
|
func peelLicensePrefix(content string) (prefix, rest string) {
|
||||||
|
lines := strings.SplitAfter(content, "\n")
|
||||||
|
n := 0
|
||||||
|
for n < len(lines) {
|
||||||
|
t := strings.TrimSpace(lines[n])
|
||||||
|
isShebang := n == 0 && strings.HasPrefix(t, "#!")
|
||||||
|
isBuild := strings.HasPrefix(t, "//go:build") || strings.HasPrefix(t, "// +build")
|
||||||
|
if !isShebang && !isBuild {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "", content
|
||||||
|
}
|
||||||
|
return strings.Join(lines[:n], ""), strings.Join(lines[n:], "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripLicense removes a leading license header comment. A comment qualifies if
|
||||||
|
// it contains a license keyword (see containsLicenseKeyword). A leading shebang
|
||||||
|
// or Go build-constraint block is preserved above the license rather than
|
||||||
|
// consumed or blocking detection.
|
||||||
func stripLicense(content string) (string, bool) {
|
func stripLicense(content string) (string, bool) {
|
||||||
trimmed := strings.TrimLeft(content, " \t\n\r")
|
prefix, rest := peelLicensePrefix(content)
|
||||||
|
next, removed := stripLeadingLicenseComment(rest)
|
||||||
|
if !removed {
|
||||||
|
return content, false
|
||||||
|
}
|
||||||
|
return prefix + next, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripLeadingLicenseComment removes a license header at the top of s, handling
|
||||||
|
// C-style /* */ blocks, contiguous // line comments, and contiguous # comments.
|
||||||
|
func stripLeadingLicenseComment(s string) (string, bool) {
|
||||||
|
trimmed := strings.TrimLeft(s, " \t\n\r")
|
||||||
|
|
||||||
// C-style block comment at top
|
// C-style block comment at top
|
||||||
if strings.HasPrefix(trimmed, "/*") {
|
if strings.HasPrefix(trimmed, "/*") {
|
||||||
end := strings.Index(trimmed, "*/")
|
end := strings.Index(trimmed, "*/")
|
||||||
if end >= 0 {
|
if end >= 0 {
|
||||||
candidate := trimmed[:end+2]
|
candidate := trimmed[:end+2]
|
||||||
lower := strings.ToLower(candidate)
|
if containsLicenseKeyword(strings.ToLower(candidate)) {
|
||||||
if strings.Contains(lower, "copyright") ||
|
|
||||||
strings.Contains(lower, "license") ||
|
|
||||||
strings.Contains(lower, "spdx-license-identifier") {
|
|
||||||
rest := trimmed[end+2:]
|
rest := trimmed[end+2:]
|
||||||
// Consume trailing newline(s)
|
// Consume trailing newline(s)
|
||||||
rest = strings.TrimLeft(rest, "\r\n")
|
rest = strings.TrimLeft(rest, "\r\n")
|
||||||
@@ -81,6 +121,24 @@ func stripLicense(content string) (string, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "//" line-comment header (Go/C/TS). Only contiguous "//" lines belong to
|
||||||
|
// the header; a non-comment line ends it and is preserved.
|
||||||
|
if strings.HasPrefix(trimmed, "//") {
|
||||||
|
lines := strings.Split(trimmed, "\n")
|
||||||
|
var commentLines, rest []string
|
||||||
|
for i, l := range lines {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(l), "//") {
|
||||||
|
commentLines = append(commentLines, l)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = lines[i:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
|
||||||
|
return strings.Join(rest, "\n"), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Python/hash-style leading comment block. Only contiguous "#" lines belong to the
|
// Python/hash-style leading comment block. Only contiguous "#" lines belong to the
|
||||||
// header; a blank line ends it and is preserved as a separator (rather than being
|
// header; a blank line ends it and is preserved as a separator (rather than being
|
||||||
// greedily swallowed and collapsed away).
|
// greedily swallowed and collapsed away).
|
||||||
@@ -95,15 +153,12 @@ func stripLicense(content string) (string, bool) {
|
|||||||
rest = lines[i:]
|
rest = lines[i:]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(strings.Join(commentLines, "\n"))
|
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
|
||||||
if strings.Contains(lower, "copyright") ||
|
|
||||||
strings.Contains(lower, "license") ||
|
|
||||||
strings.Contains(lower, "spdx-license-identifier") {
|
|
||||||
return strings.Join(rest, "\n"), true
|
return strings.Join(rest, "\n"), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return content, false
|
return s, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripImports removes top-of-file import blocks, language-specific.
|
// stripImports removes top-of-file import blocks, language-specific.
|
||||||
@@ -147,7 +202,10 @@ func stripGoImports(content string) (string, bool) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(trimLine, `import "`) || strings.HasPrefix(trimLine, "import `") {
|
// Single import spec in any form: import "path", import `path`,
|
||||||
|
// import alias "path", import _ "path", import . "path".
|
||||||
|
if strings.HasPrefix(trimLine, "import ") &&
|
||||||
|
(strings.Contains(trimLine, `"`) || strings.Contains(trimLine, "`")) {
|
||||||
removed = true
|
removed = true
|
||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
@@ -161,15 +219,45 @@ func stripGoImports(content string) (string, bool) {
|
|||||||
return strings.Join(out, "\n"), true
|
return strings.Join(out, "\n"), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripTSImports removes TypeScript/JavaScript "import ... from ..." and "require(...)" lines.
|
// tsImportNeedsContinuation reports whether an import line (known to start an
|
||||||
|
// import) continues onto following lines. A multi-line named import opens a
|
||||||
|
// brace and only reaches its module specifier ("from '...'") on a later line.
|
||||||
|
func tsImportNeedsContinuation(line string) bool {
|
||||||
|
if strings.Contains(line, "from ") || strings.HasSuffix(line, ";") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Side-effect import (import '...' / import "...") is single-line.
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(line, "import"))
|
||||||
|
if strings.HasPrefix(rest, "'") || strings.HasPrefix(rest, `"`) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripTSImports removes TypeScript/JavaScript "import ... from ..." (including
|
||||||
|
// multi-line named imports) and destructured require() lines.
|
||||||
func stripTSImports(content string) (string, bool) {
|
func stripTSImports(content string) (string, bool) {
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var out []string
|
var out []string
|
||||||
removed := false
|
removed := false
|
||||||
|
inMulti := false
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
trimLine := strings.TrimSpace(l)
|
trimLine := strings.TrimSpace(l)
|
||||||
if strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(") {
|
if inMulti {
|
||||||
removed = true
|
removed = true
|
||||||
|
// The module-specifier line ("from '...'") or a statement terminator ends it.
|
||||||
|
if strings.Contains(trimLine, "from ") || strings.HasSuffix(trimLine, ";") {
|
||||||
|
inMulti = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isImport := strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "import{")
|
||||||
|
isRequire := strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(")
|
||||||
|
if isImport || isRequire {
|
||||||
|
removed = true
|
||||||
|
if isImport && tsImportNeedsContinuation(trimLine) {
|
||||||
|
inMulti = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, l)
|
out = append(out, l)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package parser
|
package parser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
@@ -45,3 +46,55 @@ func TestStripLicensePythonPreservesSeparatorBlank(t *testing.T) {
|
|||||||
t.Fatalf("python license strip must keep the blank separator.\nwant: %q\ngot: %q", want, got.Content)
|
t.Fatalf("python license strip must keep the blank separator.\nwant: %q\ngot: %q", want, got.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A multi-line TS named import must be removed in full, not just its first line.
|
||||||
|
func TestStripTSImportsMultiLine(t *testing.T) {
|
||||||
|
src := "import {\n a,\n b,\n} from 'x';\nconst y = 1;\n"
|
||||||
|
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
|
||||||
|
want := "const y = 1;\n"
|
||||||
|
if got.Content != want {
|
||||||
|
t.Fatalf("multi-line import must be fully removed.\nwant: %q\ngot: %q", want, got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A side-effect import without a semicolon must not consume following code.
|
||||||
|
func TestStripTSImportsSideEffectNoRunaway(t *testing.T) {
|
||||||
|
src := "import './setup'\nconst y = 1\n"
|
||||||
|
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
|
||||||
|
want := "const y = 1\n"
|
||||||
|
if got.Content != want {
|
||||||
|
t.Fatalf("side-effect import must not swallow code.\nwant: %q\ngot: %q", want, got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank/underscore/dot/aliased single Go imports must all be stripped.
|
||||||
|
func TestStripGoImportsSingleSpecForms(t *testing.T) {
|
||||||
|
src := "package main\n\nimport _ \"embed\"\nimport . \"math\"\nimport m \"strings\"\n\nfunc main() {}\n"
|
||||||
|
got := StripContent(src, []StripFlag{StripImports}, protocol.LangGo)
|
||||||
|
for _, frag := range []string{`"embed"`, `"math"`, `"strings"`} {
|
||||||
|
if containsStr(got.Content, frag) {
|
||||||
|
t.Fatalf("single import %s not stripped, got:\n%s", frag, got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !containsStr(got.Content, "func main") {
|
||||||
|
t.Fatalf("code removed, got:\n%s", got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Go // line-comment license after build constraints: the license is removed
|
||||||
|
// while the build constraint is preserved above it.
|
||||||
|
func TestStripLicenseGoLineCommentAfterBuildTag(t *testing.T) {
|
||||||
|
src := "//go:build linux\n\n// Copyright 2024 Acme\n// SPDX-License-Identifier: MIT\n\npackage main\n"
|
||||||
|
got := StripContent(src, []StripFlag{StripLicense}, protocol.LangGo)
|
||||||
|
if containsStr(got.Content, "Copyright") {
|
||||||
|
t.Fatalf("line-comment license not stripped, got:\n%q", got.Content)
|
||||||
|
}
|
||||||
|
if !containsStr(got.Content, "//go:build linux") {
|
||||||
|
t.Fatalf("build constraint must be preserved, got:\n%q", got.Content)
|
||||||
|
}
|
||||||
|
if !containsStr(got.Content, "package main") {
|
||||||
|
t.Fatalf("code removed, got:\n%q", got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(s, sub string) bool { return strings.Contains(s, sub) }
|
||||||
|
|||||||
+195
-51
@@ -1,6 +1,8 @@
|
|||||||
package parser
|
package parser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
sitter "github.com/smacker/go-tree-sitter"
|
sitter "github.com/smacker/go-tree-sitter"
|
||||||
)
|
)
|
||||||
@@ -35,6 +37,28 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
|
|||||||
}
|
}
|
||||||
|
|
||||||
// extractGoSymbols extracts symbols from Go code.
|
// extractGoSymbols extracts symbols from Go code.
|
||||||
|
// goBodyTypes are the node types whose descendants are function-local (not
|
||||||
|
// package-level) declarations in Go.
|
||||||
|
var goBodyTypes = map[string]bool{"block": true, "func_literal": true}
|
||||||
|
|
||||||
|
// jsBodyTypes are the node types whose descendants are function-local (not
|
||||||
|
// module/class-member) declarations in JS/TS.
|
||||||
|
var jsBodyTypes = map[string]bool{
|
||||||
|
"statement_block": true, "function_declaration": true, "method_definition": true,
|
||||||
|
"arrow_function": true, "function_expression": true, "generator_function_declaration": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNestedInBody reports whether n has an ancestor in bodyTypes, i.e. it is a
|
||||||
|
// function-local declaration rather than a top-level/module one.
|
||||||
|
func isNestedInBody(n *sitter.Node, bodyTypes map[string]bool) bool {
|
||||||
|
for p := n.Parent(); p != nil; p = p.Parent() {
|
||||||
|
if bodyTypes[p.Type()] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func extractGoSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
|
func extractGoSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
|
||||||
var symbols []protocol.Symbol
|
var symbols []protocol.Symbol
|
||||||
|
|
||||||
@@ -49,6 +73,10 @@ func extractGoSymbols(root *sitter.Node, content []byte, filename string) []prot
|
|||||||
case "type_declaration":
|
case "type_declaration":
|
||||||
symbol = extractGoType(n, content, filename)
|
symbol = extractGoType(n, content, filename)
|
||||||
case "const_declaration", "var_declaration":
|
case "const_declaration", "var_declaration":
|
||||||
|
// Skip function-local var/const; only package-level ones are symbols.
|
||||||
|
if isNestedInBody(n, goBodyTypes) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
syms := extractGoVarConst(n, content, filename)
|
syms := extractGoVarConst(n, content, filename)
|
||||||
symbols = append(symbols, syms...)
|
symbols = append(symbols, syms...)
|
||||||
return true
|
return true
|
||||||
@@ -152,12 +180,20 @@ func extractGoVarConst(n *sitter.Node, content []byte, filename string) []protoc
|
|||||||
|
|
||||||
WalkTree(n, func(node *sitter.Node) bool {
|
WalkTree(n, func(node *sitter.Node) bool {
|
||||||
if node.Type() == "const_spec" || node.Type() == "var_spec" {
|
if node.Type() == "const_spec" || node.Type() == "var_spec" {
|
||||||
nameNode := node.ChildByFieldName("name")
|
// A spec may declare multiple names (e.g. var a, b = 1, 2);
|
||||||
if nameNode != nil {
|
// ChildByFieldName returns only the first, so iterate all "name" fields.
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
if node.FieldNameForChild(i) != "name" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameNode := node.Child(i)
|
||||||
|
if nameNode == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
symbols = append(symbols, protocol.Symbol{
|
symbols = append(symbols, protocol.Symbol{
|
||||||
Name: GetNodeText(nameNode, content),
|
Name: GetNodeText(nameNode, content),
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Location: NodeLocation(node, filename),
|
Location: NodeLocation(nameNode, filename),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +218,11 @@ func extractJSSymbols(root *sitter.Node, content []byte, filename string) []prot
|
|||||||
case "method_definition":
|
case "method_definition":
|
||||||
symbol = extractJSMethod(n, content, filename)
|
symbol = extractJSMethod(n, content, filename)
|
||||||
case "lexical_declaration", "variable_declaration":
|
case "lexical_declaration", "variable_declaration":
|
||||||
|
// Skip function-local let/const/var; only module/top-level ones
|
||||||
|
// (including `export const`) are symbols.
|
||||||
|
if isNestedInBody(n, jsBodyTypes) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
syms := extractJSVariable(n, content, filename)
|
syms := extractJSVariable(n, content, filename)
|
||||||
symbols = append(symbols, syms...)
|
symbols = append(symbols, syms...)
|
||||||
return true
|
return true
|
||||||
@@ -386,29 +427,65 @@ func extractCSymbols(root *sitter.Node, content []byte, filename string) []proto
|
|||||||
return symbols
|
return symbols
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cDeclaratorName descends the declarator field-chain (pointer/reference/array/
|
||||||
|
// function declarators) to the innermost name node and returns its text. Unlike a
|
||||||
|
// first-identifier scan, this returns the function/method NAME (not a parameter)
|
||||||
|
// and handles C++ method names (field_identifier), out-of-line definitions
|
||||||
|
// (qualified_identifier like User::foo), destructors and operators.
|
||||||
|
func cDeclaratorName(declarator *sitter.Node, content []byte) string {
|
||||||
|
node := declarator
|
||||||
|
for node != nil {
|
||||||
|
switch node.Type() {
|
||||||
|
case "identifier", "field_identifier", "qualified_identifier",
|
||||||
|
"destructor_name", "operator_name":
|
||||||
|
return GetNodeText(node, content)
|
||||||
|
}
|
||||||
|
next := node.ChildByFieldName("declarator")
|
||||||
|
if next == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
node = next
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// enclosingCppClassName returns the name of the nearest class/struct ancestor of
|
||||||
|
// n (for qualifying in-class method names), or "" if n is not inside one.
|
||||||
|
func enclosingCppClassName(n *sitter.Node, content []byte) string {
|
||||||
|
for p := n.Parent(); p != nil; p = p.Parent() {
|
||||||
|
if p.Type() == "class_specifier" || p.Type() == "struct_specifier" {
|
||||||
|
if name := p.ChildByFieldName("name"); name != nil {
|
||||||
|
return GetNodeText(name, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func extractCFunction(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
func extractCFunction(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||||
declarator := n.ChildByFieldName("declarator")
|
declarator := n.ChildByFieldName("declarator")
|
||||||
if declarator == nil {
|
if declarator == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the function name within the declarator
|
name := cDeclaratorName(declarator, content)
|
||||||
var name string
|
|
||||||
WalkTree(declarator, func(node *sitter.Node) bool {
|
|
||||||
if node.Type() == "identifier" {
|
|
||||||
name = GetNodeText(node, content)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-class definitions are methods; qualify the name with the class. Out-of-line
|
||||||
|
// definitions already carry a qualified name (Class::method).
|
||||||
|
kind := protocol.SymbolFunction
|
||||||
|
if cls := enclosingCppClassName(n, content); cls != "" {
|
||||||
|
kind = protocol.SymbolMethod
|
||||||
|
name = cls + "::" + name
|
||||||
|
} else if strings.Contains(name, "::") {
|
||||||
|
kind = protocol.SymbolMethod
|
||||||
|
}
|
||||||
|
|
||||||
return &protocol.Symbol{
|
return &protocol.Symbol{
|
||||||
Name: name,
|
Name: name,
|
||||||
Kind: protocol.SymbolFunction,
|
Kind: kind,
|
||||||
Location: NodeLocation(n, filename),
|
Location: NodeLocation(n, filename),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,22 +522,22 @@ func extractCFunctionDecl(n *sitter.Node, content []byte, filename string) *prot
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var name string
|
name := cDeclaratorName(declarator, content)
|
||||||
WalkTree(declarator, func(node *sitter.Node) bool {
|
|
||||||
if node.Type() == "identifier" {
|
|
||||||
name = GetNodeText(node, content)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kind := protocol.SymbolFunction
|
||||||
|
if cls := enclosingCppClassName(n, content); cls != "" {
|
||||||
|
kind = protocol.SymbolMethod
|
||||||
|
name = cls + "::" + name
|
||||||
|
} else if strings.Contains(name, "::") {
|
||||||
|
kind = protocol.SymbolMethod
|
||||||
|
}
|
||||||
|
|
||||||
return &protocol.Symbol{
|
return &protocol.Symbol{
|
||||||
Name: name,
|
Name: name,
|
||||||
Kind: protocol.SymbolFunction,
|
Kind: kind,
|
||||||
Location: NodeLocation(n, filename),
|
Location: NodeLocation(n, filename),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -673,17 +750,52 @@ func extractElixirMacro(n *sitter.Node, content []byte, filename string) *protoc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractElixirStruct extracts a struct definition.
|
// extractElixirStruct extracts a struct definition. A defstruct has no name of
|
||||||
|
// its own — it defines the struct of its enclosing module — so name it after
|
||||||
|
// that module (%ModuleName{}) rather than the useless literal "defstruct".
|
||||||
func extractElixirStruct(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
func extractElixirStruct(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||||
// defstruct is typically inside a module, the struct name is the module name
|
name := "defstruct"
|
||||||
// We just mark this as a struct symbol
|
if mod := enclosingElixirModuleName(n, content); mod != "" {
|
||||||
|
name = "%" + mod + "{}"
|
||||||
|
}
|
||||||
return &protocol.Symbol{
|
return &protocol.Symbol{
|
||||||
Name: "defstruct",
|
Name: name,
|
||||||
Kind: protocol.SymbolStruct,
|
Kind: protocol.SymbolStruct,
|
||||||
Location: NodeLocation(n, filename),
|
Location: NodeLocation(n, filename),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enclosingElixirModuleName walks ancestors of n to the nearest defmodule call
|
||||||
|
// and returns its module alias (e.g. "MyApp.User"), or "" if none is found.
|
||||||
|
func enclosingElixirModuleName(n *sitter.Node, content []byte) string {
|
||||||
|
for p := n.Parent(); p != nil; p = p.Parent() {
|
||||||
|
if p.Type() != "call" || p.NamedChildCount() < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := p.NamedChild(0)
|
||||||
|
if target == nil || GetNodeText(target, content) != "defmodule" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args := p.ChildByFieldName("arguments")
|
||||||
|
if args == nil && p.NamedChildCount() >= 2 {
|
||||||
|
args = p.NamedChild(1)
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var moduleName string
|
||||||
|
WalkTree(args, func(node *sitter.Node) bool {
|
||||||
|
if node.Type() == "alias" {
|
||||||
|
moduleName = GetNodeText(node, content)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return moduleName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// extractElixirProtocol extracts a protocol definition.
|
// extractElixirProtocol extracts a protocol definition.
|
||||||
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||||
// defprotocol ProtocolName do ... end
|
// defprotocol ProtocolName do ... end
|
||||||
@@ -910,17 +1022,37 @@ func kindMatchesNode(kind protocol.SymbolKind, nodeType string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindSymbolRange finds a named symbol in the AST and returns its line range (1-indexed, inclusive).
|
// SymbolCandidate is one symbol matching a name (and optional kind) lookup,
|
||||||
// symbolKind filters by symbol kind (e.g. "function", "struct"); empty string matches any kind.
|
// with its declaration node type and 1-indexed inclusive line range. NameLine
|
||||||
// Returns (0, 0, false) if the symbol is not found.
|
// and NameColumn point at the name identifier itself (1-indexed), which is the
|
||||||
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
|
// position LSP queries (hover/definition/references) expect.
|
||||||
|
type SymbolCandidate struct {
|
||||||
|
NodeType string
|
||||||
|
StartLine int
|
||||||
|
EndLine int
|
||||||
|
NameLine int
|
||||||
|
NameColumn int
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbolDeclNodeTypes is the set of declaration node types FindSymbolCandidates
|
||||||
|
// treats as nameable symbols across supported languages.
|
||||||
|
var symbolDeclNodeTypes = map[string]bool{
|
||||||
|
"function_declaration": true, "method_declaration": true, "type_declaration": true,
|
||||||
|
"function_definition": true, "class_definition": true, "class_declaration": true,
|
||||||
|
"interface_declaration": true, "type_alias_declaration": true,
|
||||||
|
"function_item": true, "struct_item": true, "enum_item": true, "trait_item": true,
|
||||||
|
"type_item": true, "const_item": true, "mod_item": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSymbolCandidates returns every declaration whose name equals symbolName and
|
||||||
|
// whose kind matches symbolKind (empty matches any). Multiple results indicate an
|
||||||
|
// ambiguous lookup (e.g. two methods named String on different receivers).
|
||||||
|
func FindSymbolCandidates(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) []SymbolCandidate {
|
||||||
if tree == nil || symbolName == "" {
|
if tree == nil || symbolName == "" {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
var candidates []SymbolCandidate
|
||||||
WalkTree(tree.RootNode(), func(n *sitter.Node) bool {
|
WalkTree(tree.RootNode(), func(n *sitter.Node) bool {
|
||||||
if found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
nameNode := n.ChildByFieldName("name")
|
nameNode := n.ChildByFieldName("name")
|
||||||
if nameNode == nil {
|
if nameNode == nil {
|
||||||
return true
|
return true
|
||||||
@@ -928,22 +1060,34 @@ func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName str
|
|||||||
if GetNodeText(nameNode, content) != symbolName {
|
if GetNodeText(nameNode, content) != symbolName {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch n.Type() {
|
if !symbolDeclNodeTypes[n.Type()] {
|
||||||
case "function_declaration", "method_declaration", "type_declaration",
|
return true
|
||||||
"function_definition", "class_definition", "class_declaration",
|
|
||||||
"interface_declaration", "type_alias_declaration",
|
|
||||||
"function_item", "struct_item", "enum_item", "trait_item",
|
|
||||||
"type_item", "const_item", "mod_item":
|
|
||||||
if !kindMatchesNode(symbolKind, n.Type()) {
|
|
||||||
return true // kind mismatch, keep searching
|
|
||||||
}
|
|
||||||
r := NodeRange(n, filename)
|
|
||||||
startLine = r.Start.Line
|
|
||||||
endLine = r.End.Line
|
|
||||||
found = true
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
if !kindMatchesNode(symbolKind, n.Type()) {
|
||||||
|
return true // kind mismatch, keep searching
|
||||||
|
}
|
||||||
|
r := NodeRange(n, filename)
|
||||||
|
nameLoc := NodeLocation(nameNode, filename)
|
||||||
|
candidates = append(candidates, SymbolCandidate{
|
||||||
|
NodeType: n.Type(),
|
||||||
|
StartLine: r.Start.Line,
|
||||||
|
EndLine: r.End.Line,
|
||||||
|
NameLine: nameLoc.Line,
|
||||||
|
NameColumn: nameLoc.Column,
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSymbolRange finds a named symbol in the AST and returns its line range (1-indexed, inclusive).
|
||||||
|
// symbolKind filters by symbol kind (e.g. "function", "struct"); empty string matches any kind.
|
||||||
|
// Returns (0, 0, false) if the symbol is not found. On ambiguity it returns the first match;
|
||||||
|
// callers that need to detect ambiguity should use FindSymbolCandidates.
|
||||||
|
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
|
||||||
|
candidates := FindSymbolCandidates(tree, content, filename, symbolName, symbolKind)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return candidates[0].StartLine, candidates[0].EndLine, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,64 @@ import (
|
|||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestFindSymbolCandidates(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
// Two methods named String on different receivers, plus a unique func.
|
||||||
|
content := `package main
|
||||||
|
|
||||||
|
type A struct{}
|
||||||
|
|
||||||
|
func (a A) String() string { return "a" }
|
||||||
|
|
||||||
|
type B struct{}
|
||||||
|
|
||||||
|
func (b B) String() string { return "b" }
|
||||||
|
|
||||||
|
func Unique() {}
|
||||||
|
`
|
||||||
|
ctx := context.Background()
|
||||||
|
result, err := r.Parse(ctx, "test.go", []byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("ambiguous name returns all candidates", func(t *testing.T) {
|
||||||
|
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "String", "")
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d candidates for String, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].StartLine >= got[1].StartLine {
|
||||||
|
t.Errorf("candidates not in source order: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unique name returns one candidate", func(t *testing.T) {
|
||||||
|
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "Unique", "")
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d candidates for Unique, want 1: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing name returns none", func(t *testing.T) {
|
||||||
|
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "Missing", "")
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("got %d candidates for Missing, want 0", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("FindSymbolRange returns first match", func(t *testing.T) {
|
||||||
|
start, end, found := FindSymbolRange(result.Tree, []byte(content), "test.go", "String", "")
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected String to be found")
|
||||||
|
}
|
||||||
|
if start <= 0 || end < start {
|
||||||
|
t.Errorf("invalid range: start=%d end=%d", start, end)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractGoSymbols(t *testing.T) {
|
func TestExtractGoSymbols(t *testing.T) {
|
||||||
r := NewRegistry()
|
r := NewRegistry()
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
@@ -65,6 +123,149 @@ var globalVar = "test"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grouped var/const specs declare multiple names on one spec (var a, b = 1, 2);
|
||||||
|
// every name must surface as its own symbol, not just the first.
|
||||||
|
// C++ in-class methods must be extracted as methods qualified by their class
|
||||||
|
// (Class::method), not misnamed after a parameter or dropped entirely.
|
||||||
|
func TestExtractCppMethods(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
defer r.Close()
|
||||||
|
content := "class User {\npublic:\n void setName(const char* n) { name_ = n; }\n int id() const { return id_; }\nprivate:\n const char* name_;\n int id_;\n};\n\nvoid freeFunc() {}\n"
|
||||||
|
result, err := r.Parse(context.Background(), "u.cpp", []byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
syms := ExtractSymbols(result.Tree, []byte(content), protocol.LangCpp, "u.cpp")
|
||||||
|
|
||||||
|
kindByName := make(map[string]protocol.SymbolKind, len(syms))
|
||||||
|
for _, s := range syms {
|
||||||
|
kindByName[s.Name] = s.Kind
|
||||||
|
}
|
||||||
|
if kindByName["User::setName"] != protocol.SymbolMethod {
|
||||||
|
t.Errorf("setName: want method User::setName, got %v", kindByName)
|
||||||
|
}
|
||||||
|
if kindByName["User::id"] != protocol.SymbolMethod {
|
||||||
|
t.Errorf("id: want method User::id, got %v", kindByName)
|
||||||
|
}
|
||||||
|
if kindByName["freeFunc"] != protocol.SymbolFunction {
|
||||||
|
t.Errorf("freeFunc: want function, got %v", kindByName)
|
||||||
|
}
|
||||||
|
if _, leaked := kindByName["n"]; leaked {
|
||||||
|
t.Errorf("parameter 'n' leaked as a symbol: %v", kindByName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function-local var/const declarations must NOT appear as top-level symbols
|
||||||
|
// (they flood the list and aren't package/module symbols).
|
||||||
|
func TestExtractSymbolsExcludesLocals(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
defer r.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("go", func(t *testing.T) {
|
||||||
|
content := "package main\n\nvar TopLevel = 1\n\nfunc f() {\n\tvar localVar = 2\n\tconst localConst = 3\n\t_ = localVar\n\t_ = localConst\n}\n"
|
||||||
|
result, err := r.Parse(ctx, "m.go", []byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
names := symbolNameSet(ExtractSymbols(result.Tree, []byte(content), protocol.LangGo, "m.go"))
|
||||||
|
if !names["TopLevel"] {
|
||||||
|
t.Errorf("expected TopLevel symbol, got %v", names)
|
||||||
|
}
|
||||||
|
if names["localVar"] || names["localConst"] {
|
||||||
|
t.Errorf("function-local var/const leaked into symbols: %v", names)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("js exported and module consts kept, locals dropped", func(t *testing.T) {
|
||||||
|
content := "export const Exported = 1;\nconst ModuleConst = 2;\nfunction f() {\n const localOne = 3;\n return localOne;\n}\n"
|
||||||
|
result, err := r.Parse(ctx, "m.js", []byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
names := symbolNameSet(ExtractSymbols(result.Tree, []byte(content), protocol.LangJavaScript, "m.js"))
|
||||||
|
if !names["Exported"] || !names["ModuleConst"] {
|
||||||
|
t.Errorf("expected Exported and ModuleConst symbols, got %v", names)
|
||||||
|
}
|
||||||
|
if names["localOne"] {
|
||||||
|
t.Errorf("function-local const leaked into symbols: %v", names)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func symbolNameSet(syms []protocol.Symbol) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(syms))
|
||||||
|
for _, s := range syms {
|
||||||
|
m[s.Name] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Elixir defstruct must be named after its enclosing module (%Mod{}),
|
||||||
|
// not the useless literal "defstruct".
|
||||||
|
func TestExtractElixirStructNamedByModule(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
content := "defmodule MyApp.User do\n defstruct [:name, :email]\nend\n"
|
||||||
|
result, err := r.Parse(context.Background(), "user.ex", []byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangElixir, "user.ex")
|
||||||
|
var structName string
|
||||||
|
found := false
|
||||||
|
for _, s := range symbols {
|
||||||
|
if s.Kind == protocol.SymbolStruct {
|
||||||
|
structName = s.Name
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("no struct symbol extracted, got: %+v", symbols)
|
||||||
|
}
|
||||||
|
if structName != "%MyApp.User{}" {
|
||||||
|
t.Errorf("struct name = %q, want %q", structName, "%MyApp.User{}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractGoVarConstMultiName(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
content := `package main
|
||||||
|
|
||||||
|
var a, b = 1, 2
|
||||||
|
|
||||||
|
const (
|
||||||
|
C, D = 3, 4
|
||||||
|
)
|
||||||
|
`
|
||||||
|
ctx := context.Background()
|
||||||
|
result, err := r.Parse(ctx, "test.go", []byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangGo, "test.go")
|
||||||
|
want := map[string]protocol.SymbolKind{
|
||||||
|
"a": protocol.SymbolVariable,
|
||||||
|
"b": protocol.SymbolVariable,
|
||||||
|
"C": protocol.SymbolConstant,
|
||||||
|
"D": protocol.SymbolConstant,
|
||||||
|
}
|
||||||
|
got := make(map[string]protocol.SymbolKind)
|
||||||
|
for _, sym := range symbols {
|
||||||
|
got[sym.Name] = sym.Kind
|
||||||
|
}
|
||||||
|
for name, kind := range want {
|
||||||
|
if got[name] != kind {
|
||||||
|
t.Errorf("symbol %q: got kind %q, want %q (symbols: %+v)", name, got[name], kind, symbols)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractJSSymbols(t *testing.T) {
|
func TestExtractJSSymbols(t *testing.T) {
|
||||||
r := NewRegistry()
|
r := NewRegistry()
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
|
|||||||
@@ -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) {
|
func TestFormatResultsEmpty(t *testing.T) {
|
||||||
out := FormatResultsWithOptions(nil, 0, "verbose", 0)
|
out := FormatResultsWithOptions(nil, 0, "verbose", 0)
|
||||||
if out != "No matches found." {
|
if out != "No matches found." {
|
||||||
@@ -159,6 +186,9 @@ func TestFirstLineOf(t *testing.T) {
|
|||||||
{"line1\nline2", 20, "line1"},
|
{"line1\nline2", 20, "line1"},
|
||||||
{"\n\nfoo", 20, "foo"},
|
{"\n\nfoo", 20, "foo"},
|
||||||
{"abcdefghij", 5, "abcd…"},
|
{"abcdefghij", 5, "abcd…"},
|
||||||
|
// Multibyte: "ααα" is 6 bytes (each α = 2). maxLen-1 = 3 lands inside the
|
||||||
|
// 2nd α; must back off to a rune boundary, never split into invalid UTF-8.
|
||||||
|
{"ααα", 4, "α…"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got := firstLineOf(c.input, c.maxLen)
|
got := firstLineOf(c.input, c.maxLen)
|
||||||
|
|||||||
+31
-11
@@ -460,7 +460,24 @@ func FormatResults(results []MatchResult, maxResults int) string {
|
|||||||
// format: "verbose" (default) | "compact" | "location"
|
// format: "verbose" (default) | "compact" | "location"
|
||||||
// offset: skip this many results before rendering (used for cursor pagination).
|
// 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).
|
// 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 {
|
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 {
|
if len(results) == 0 {
|
||||||
return "No matches found."
|
return "No matches found."
|
||||||
}
|
}
|
||||||
@@ -484,9 +501,8 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
|
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
|
||||||
wantVerbose := len(verbose) > 0 && verbose[0]
|
if verbose {
|
||||||
if wantVerbose {
|
fmt.Fprintf(&sb, "Found %d match(es):\n", renderCount)
|
||||||
sb.WriteString(fmt.Sprintf("Found %d match(es):\n", renderCount))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
@@ -498,13 +514,13 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
|||||||
nodeType = r.Node.Type()
|
nodeType = r.Node.Type()
|
||||||
}
|
}
|
||||||
firstLine := firstLineOf(r.Text, 80)
|
firstLine := firstLineOf(r.Text, 80)
|
||||||
sb.WriteString(fmt.Sprintf("%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine))
|
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "location":
|
case "location":
|
||||||
for i := 0; i < renderCount; i++ {
|
for i := 0; i < renderCount; i++ {
|
||||||
r := results[i]
|
r := results[i]
|
||||||
sb.WriteString(fmt.Sprintf("%s:%d\n", r.File, r.Location.Line))
|
fmt.Fprintf(&sb, "%s:%d\n", r.File, r.Location.Line)
|
||||||
}
|
}
|
||||||
|
|
||||||
default: // "verbose"
|
default: // "verbose"
|
||||||
@@ -514,7 +530,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
|||||||
if r.Node != nil {
|
if r.Node != nil {
|
||||||
nodeType = r.Node.Type()
|
nodeType = r.Node.Type()
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
|
fmt.Fprintf(&sb, "**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType)
|
||||||
|
|
||||||
// Truncate very long text
|
// Truncate very long text
|
||||||
text := r.Text
|
text := r.Text
|
||||||
@@ -538,7 +554,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
|||||||
if len(capText) > 50 {
|
if len(capText) > 50 {
|
||||||
capText = capText[:50] + "..."
|
capText = capText[:50] + "..."
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
|
fmt.Fprintf(&sb, "$%s=%s", name, capText)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -547,9 +563,13 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
if remaining > 0 {
|
if remaining > 0 {
|
||||||
// Caller must embed the cursor token; we just append the remaining count hint.
|
if cursorLine != "" {
|
||||||
// The actual [cursor: ...] line is written by the handler after calling MakeCursor.
|
// Caller supplied a fully-formed "[cursor: ..., remaining: N]" footer.
|
||||||
sb.WriteString(fmt.Sprintf("[remaining: %d]\n", remaining))
|
sb.WriteString(strings.TrimRight(cursorLine, "\n"))
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&sb, "[remaining: %d]\n", remaining)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -563,7 +583,7 @@ func firstLineOf(s string, maxLen int) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(line) > maxLen {
|
if len(line) > maxLen {
|
||||||
return line[:maxLen-1] + "…"
|
return line[:util.RuneBoundary(line, maxLen-1)] + "…"
|
||||||
}
|
}
|
||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
json "github.com/goccy/go-json"
|
json "github.com/goccy/go-json"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||||
|
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
)
|
)
|
||||||
@@ -389,13 +390,15 @@ func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatO
|
|||||||
if opts.Verbose {
|
if opts.Verbose {
|
||||||
totalMatches := len(results.Results)
|
totalMatches := len(results.Results)
|
||||||
fileCount := len(fileResults)
|
fileCount := len(fileResults)
|
||||||
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
|
fmt.Fprintf(&sb, "Found %d matches in %d files", totalMatches, fileCount)
|
||||||
if results.Truncated {
|
if results.Truncated {
|
||||||
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
|
fmt.Fprintf(&sb, " (truncated, total: %d)", results.Total)
|
||||||
}
|
}
|
||||||
sb.WriteString(":\n\n")
|
sb.WriteString(":\n\n")
|
||||||
} else if results.Truncated {
|
} else if results.Truncated && opts.CursorLine == "" {
|
||||||
sb.WriteString(fmt.Sprintf("(truncated, showing subset of %d total matches)\n\n", results.Total))
|
// When a cursor footer is present it already reports the remaining count,
|
||||||
|
// so the prose truncation line would be redundant.
|
||||||
|
fmt.Fprintf(&sb, "(truncated, showing subset of %d total matches)\n\n", results.Total)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write results grouped by file
|
// Write results grouped by file
|
||||||
@@ -408,7 +411,7 @@ func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
|
fmt.Fprintf(&sb, "**%s**\n", relPath)
|
||||||
|
|
||||||
if opts.Cluster {
|
if opts.Cluster {
|
||||||
writeClusteredResults(&sb, fileResults[file])
|
writeClusteredResults(&sb, fileResults[file])
|
||||||
@@ -486,10 +489,11 @@ func writeClusteredResults(sb *strings.Builder, results []Result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateLine truncates a line if it exceeds maxLen.
|
// truncateLine truncates a line if it exceeds maxLen bytes, backing off to a
|
||||||
|
// rune boundary so a multibyte character is never split into invalid UTF-8.
|
||||||
func truncateLine(s string, maxLen int) string {
|
func truncateLine(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:maxLen-3] + "..."
|
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,27 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// truncateLine must back off to a rune boundary so multibyte characters are
|
||||||
|
// never split into invalid UTF-8.
|
||||||
|
func TestTruncateLineRuneSafe(t *testing.T) {
|
||||||
|
s := strings.Repeat("é", 200) // 2 bytes each → 400 bytes
|
||||||
|
out := truncateLine(s, 50)
|
||||||
|
if !utf8.ValidString(out) {
|
||||||
|
t.Errorf("truncated output is not valid UTF-8: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(out, "...") {
|
||||||
|
t.Errorf("expected ellipsis suffix, got %q", out)
|
||||||
|
}
|
||||||
|
if got := truncateLine("hi", 50); got != "hi" {
|
||||||
|
t.Errorf("short string should be unchanged, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
cfg := config.Default()
|
cfg := config.Default()
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
|||||||
@@ -55,16 +55,18 @@ func Epsilon() int { return 2 }
|
|||||||
|
|
||||||
// ---- Feature 1: ast_query format flag ----
|
// ---- Feature 1: ast_query format flag ----
|
||||||
|
|
||||||
func TestASTQueryFormatVerboseDefault(t *testing.T) {
|
func TestASTQueryFormatCompactDefault(t *testing.T) {
|
||||||
srv, tmpDir := newFeaturesServer(t)
|
srv, tmpDir := newFeaturesServer(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := mcp.CallToolRequest{}
|
// Default (no format arg) is compact: one line per match, no code blocks.
|
||||||
req.Params.Arguments = map[string]interface{}{
|
args := map[string]interface{}{
|
||||||
"pattern": "func $NAME() string",
|
"pattern": "func $NAME() string",
|
||||||
"language": "go",
|
"language": "go",
|
||||||
"paths": []interface{}{tmpDir},
|
"paths": []interface{}{tmpDir},
|
||||||
}
|
}
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = args
|
||||||
res, err := srv.handleASTQuery(ctx, req)
|
res, err := srv.handleASTQuery(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -72,10 +74,19 @@ func TestASTQueryFormatVerboseDefault(t *testing.T) {
|
|||||||
if res == nil || len(res.Content) == 0 {
|
if res == nil || len(res.Content) == 0 {
|
||||||
t.Fatal("nil/empty result")
|
t.Fatal("nil/empty result")
|
||||||
}
|
}
|
||||||
text := res.Content[0].(mcp.TextContent).Text
|
if text := res.Content[0].(mcp.TextContent).Text; strings.Contains(text, "```") {
|
||||||
// verbose mode has code blocks
|
t.Errorf("compact default should NOT have code blocks, got:\n%s", text)
|
||||||
if !strings.Contains(text, "```") {
|
}
|
||||||
t.Errorf("verbose mode should have code blocks, got:\n%s", text)
|
|
||||||
|
// Verbose remains available on explicit request.
|
||||||
|
args["format"] = "verbose"
|
||||||
|
req.Params.Arguments = args
|
||||||
|
res, err = srv.handleASTQuery(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if text := res.Content[0].(mcp.TextContent).Text; !strings.Contains(text, "```") {
|
||||||
|
t.Errorf("format=verbose should have code blocks, got:\n%s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +150,69 @@ func TestASTQueryFormatLocation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestASTQuerySkipsDependencyDirs verifies ast_query excludes node_modules/vendor
|
||||||
|
// by default (they flood results with third-party code) but still searches them
|
||||||
|
// when the caller targets one explicitly via paths (the walk root is never skipped).
|
||||||
|
func TestASTQuerySkipsDependencyDirs(t *testing.T) {
|
||||||
|
srv, tmpDir := newFeaturesServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
deps := map[string]string{
|
||||||
|
"node_modules": "NodeModulesDep",
|
||||||
|
"vendor": "VendorDep",
|
||||||
|
}
|
||||||
|
for dir, fn := range deps {
|
||||||
|
full := filepath.Join(tmpDir, dir)
|
||||||
|
if err := os.MkdirAll(full, 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := "package dep\n\nfunc " + fn + "() string { return \"\" }\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(full, "dep.go"), []byte(src), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: workspace-root query must skip the dependency dirs.
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]interface{}{
|
||||||
|
"pattern": "func $NAME() string",
|
||||||
|
"language": "go",
|
||||||
|
"paths": []interface{}{tmpDir},
|
||||||
|
"format": "location",
|
||||||
|
}
|
||||||
|
res, err := srv.handleASTQuery(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("default query error: %v", err)
|
||||||
|
}
|
||||||
|
// location format prints file:line, so assert on paths.
|
||||||
|
text := res.Content[0].(mcp.TextContent).Text
|
||||||
|
if !strings.Contains(text, "a.go") {
|
||||||
|
t.Errorf("default query should find top-level a.go, got:\n%s", text)
|
||||||
|
}
|
||||||
|
for dir := range deps {
|
||||||
|
if strings.Contains(text, dir+string(filepath.Separator)) {
|
||||||
|
t.Errorf("default query must skip %s, got:\n%s", dir, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit target: querying inside node_modules must still find its code.
|
||||||
|
req2 := mcp.CallToolRequest{}
|
||||||
|
req2.Params.Arguments = map[string]interface{}{
|
||||||
|
"pattern": "func $NAME() string",
|
||||||
|
"language": "go",
|
||||||
|
"paths": []interface{}{filepath.Join(tmpDir, "node_modules")},
|
||||||
|
"format": "location",
|
||||||
|
}
|
||||||
|
res2, err := srv.handleASTQuery(ctx, req2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicit-target query error: %v", err)
|
||||||
|
}
|
||||||
|
text2 := res2.Content[0].(mcp.TextContent).Text
|
||||||
|
if !strings.Contains(text2, "node_modules"+string(filepath.Separator)+"dep.go") {
|
||||||
|
t.Errorf("explicit node_modules target must find node_modules/dep.go, got:\n%s", text2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Feature 3 (ast_query): pagination cursor ----
|
// ---- Feature 3 (ast_query): pagination cursor ----
|
||||||
|
|
||||||
func TestASTQueryPaginationCursor(t *testing.T) {
|
func TestASTQueryPaginationCursor(t *testing.T) {
|
||||||
@@ -281,6 +355,39 @@ func TestFileSearchCursorStaleHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFileSearchFirstPageCursor is a strict regression guard: when the first
|
||||||
|
// page is capped by max_results but more matches exist, the response MUST carry
|
||||||
|
// a cursor so the caller can page. Previously offset==0 emitted "(truncated...)"
|
||||||
|
// with no cursor, silently breaking pagination on the common path.
|
||||||
|
func TestFileSearchFirstPageCursor(t *testing.T) {
|
||||||
|
srv, tmpDir := newFeaturesServer(t)
|
||||||
|
if srv.searcher == nil {
|
||||||
|
t.Skip("rg not available")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Corpus has 5 lines containing "func"; cap at 1 forces truncation on page 1.
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]interface{}{
|
||||||
|
"pattern": "func",
|
||||||
|
"paths": []interface{}{tmpDir},
|
||||||
|
"max_results": float64(1),
|
||||||
|
"context_lines": float64(0),
|
||||||
|
}
|
||||||
|
res, err := srv.handleFileSearch(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("file_search error: %v", err)
|
||||||
|
}
|
||||||
|
text := res.Content[0].(mcp.TextContent).Text
|
||||||
|
if !strings.Contains(text, "[cursor:") {
|
||||||
|
t.Errorf("first page truncated at max_results=1 with 5 matches must emit a cursor, got:\n%s", text)
|
||||||
|
}
|
||||||
|
// The dead-end prose must not be used when a cursor is available.
|
||||||
|
if strings.Contains(text, "(truncated, showing subset") {
|
||||||
|
t.Errorf("dead-end truncation prose should be replaced by a cursor footer, got:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileSearchPaginationCursor(t *testing.T) {
|
func TestFileSearchPaginationCursor(t *testing.T) {
|
||||||
srv, tmpDir := newFeaturesServer(t)
|
srv, tmpDir := newFeaturesServer(t)
|
||||||
if srv.searcher == nil {
|
if srv.searcher == nil {
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryPa
|
|||||||
if prefsFormat != "" {
|
if prefsFormat != "" {
|
||||||
p.format = prefsFormat
|
p.format = prefsFormat
|
||||||
} else {
|
} else {
|
||||||
p.format = "verbose"
|
// Default to compact (one line per match): far fewer tokens than
|
||||||
|
// verbose (full code + captures) on multi-match queries. Callers can
|
||||||
|
// opt into verbose per-call or via the default_format session pref.
|
||||||
|
p.format = "compact"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +131,10 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
if strings.HasPrefix(info.Name(), ".") {
|
// Skip hidden and dependency dirs (third-party code that would
|
||||||
|
// flood results), but never the walk root itself — the caller
|
||||||
|
// explicitly targeted it, e.g. paths:["vendor/foo"].
|
||||||
|
if path != searchPath && (strings.HasPrefix(info.Name(), ".") || isDependencyDir(info.Name())) {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -137,11 +143,13 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
// Skip oversized files before reading them off disk.
|
||||||
if err != nil {
|
if info.Size() > s.cfg.MaxFileSize {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if int64(len(content)) > s.cfg.MaxFileSize {
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +175,19 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
|||||||
return allResults
|
return allResults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDependencyDir reports whether name is a well-known third-party dependency
|
||||||
|
// directory that ast_query skips by default. These hold vendored/installed code,
|
||||||
|
// not the caller's source, and parsing them wastes time and floods results with
|
||||||
|
// irrelevant matches. Target one explicitly via paths to query inside it.
|
||||||
|
func isDependencyDir(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "node_modules", "vendor", "bower_components":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// hasAnySuffix reports whether path ends with any of the given suffixes.
|
// hasAnySuffix reports whether path ends with any of the given suffixes.
|
||||||
func hasAnySuffix(path string, suffixes []string) bool {
|
func hasAnySuffix(path string, suffixes []string) bool {
|
||||||
for _, ext := range suffixes {
|
for _, ext := range suffixes {
|
||||||
@@ -222,16 +243,9 @@ func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest
|
|||||||
allResults := s.runASTQueryWalk(ctx, p, exts)
|
allResults := s.runASTQueryWalk(ctx, p, exts)
|
||||||
cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash)
|
cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash)
|
||||||
|
|
||||||
output := query.FormatResultsWithOptions(allResults, p.maxResults, p.format, p.offset, p.verbose)
|
// The formatter emits cursorFooter in place of its "[remaining: N]" hint when
|
||||||
if cursorFooter != "" {
|
// results are truncated; no fragile placeholder string-replacement needed.
|
||||||
// Replace the [remaining: N] placeholder emitted by FormatResultsWithOptions
|
output := query.FormatResultsWithCursor(allResults, p.maxResults, p.format, p.offset, p.verbose, cursorFooter)
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(output), nil
|
return mcp.NewToolResultText(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,12 +274,12 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("**%s** (%d lines, %s)\n\n", relPath, len(splitLines(string(content))), lang))
|
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", relPath, countLines(content), lang)
|
||||||
sb.WriteString("Symbols:\n")
|
sb.WriteString("Symbols:\n")
|
||||||
|
|
||||||
for _, sym := range symbols {
|
for _, sym := range symbols {
|
||||||
kindStr := symbolKindIcon(sym.Kind)
|
kindStr := symbolKindIcon(sym.Kind)
|
||||||
sb.WriteString(fmt.Sprintf(" %s %s L%d\n", kindStr, sym.Name, sym.Location.Line))
|
fmt.Fprintf(&sb, " %s %s L%d\n", kindStr, sym.Name, sym.Location.Line)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,10 +16,19 @@ import (
|
|||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/search"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/search"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||||
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/fuzzy"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Symbol-suggestion tuning for not-found symbol_name reads.
|
||||||
|
const (
|
||||||
|
// maxSymbolSuggestionDistance is the max fuzzy edit distance for a suggestion.
|
||||||
|
maxSymbolSuggestionDistance = 3
|
||||||
|
// maxSymbolSuggestions caps how many "did you mean" names are listed.
|
||||||
|
maxSymbolSuggestions = 3
|
||||||
|
)
|
||||||
|
|
||||||
// handleFileSearch handles the file_search tool.
|
// handleFileSearch handles the file_search tool.
|
||||||
func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -51,7 +61,10 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
|||||||
prefsMaxResults = prefs.DefaultMaxResults
|
prefsMaxResults = prefs.DefaultMaxResults
|
||||||
prefsCluster = prefs.DefaultCluster
|
prefsCluster = prefs.DefaultCluster
|
||||||
}
|
}
|
||||||
maxResults := effectiveInt(request, "max_results", prefsMaxResults, 0)
|
// Default to the configured cap (not 0/unbounded) so a broad pattern can't
|
||||||
|
// flood memory and the context window; an explicit max_results still wins,
|
||||||
|
// and the cursor footer lets the caller page through the rest.
|
||||||
|
maxResults := effectiveInt(request, "max_results", prefsMaxResults, s.cfg.MaxSearchResults)
|
||||||
cluster := effectiveBool(request, "cluster", prefsCluster, false)
|
cluster := effectiveBool(request, "cluster", prefsCluster, false)
|
||||||
|
|
||||||
cursorStr := request.GetString("cursor", "")
|
cursorStr := request.GetString("cursor", "")
|
||||||
@@ -87,13 +100,14 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := &search.Request{
|
req := &search.Request{
|
||||||
Pattern: pattern,
|
Pattern: pattern,
|
||||||
Paths: paths,
|
Paths: paths,
|
||||||
FileTypes: fileTypes,
|
FileTypes: fileTypes,
|
||||||
IgnoreCase: ignoreCase,
|
IgnoreCase: ignoreCase,
|
||||||
Regex: regex,
|
Regex: regex,
|
||||||
ContextLines: contextLines,
|
ContextLines: contextLines,
|
||||||
MaxResults: rgMaxResults,
|
MaxResults: rgMaxResults,
|
||||||
|
IncludeHidden: request.GetBool("include_hidden", false),
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := s.searcher.Search(ctx, req)
|
results, err := s.searcher.Search(ctx, req)
|
||||||
@@ -113,13 +127,23 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
|||||||
|
|
||||||
// Apply in-process max_results cap and compute cursor footer.
|
// Apply in-process max_results cap and compute cursor footer.
|
||||||
var cursorLine string
|
var cursorLine string
|
||||||
if maxResults > 0 && len(results.Results) > maxResults {
|
switch {
|
||||||
|
case maxResults > 0 && len(results.Results) > maxResults:
|
||||||
|
// Cursor pages (offset>0) fetch all results, so cap here and emit a cursor.
|
||||||
remaining := len(results.Results) - maxResults
|
remaining := len(results.Results) - maxResults
|
||||||
results.Results = results.Results[:maxResults]
|
results.Results = results.Results[:maxResults]
|
||||||
results.Truncated = true
|
results.Truncated = true
|
||||||
nextOffset := offset + maxResults
|
nextOffset := offset + maxResults
|
||||||
nextCursor := cursor.Encode(nextOffset, queryHash)
|
nextCursor := cursor.Encode(nextOffset, queryHash)
|
||||||
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
|
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
|
||||||
|
case offset == 0 && maxResults > 0 && results.Truncated:
|
||||||
|
// First page: ripgrep capped us at exactly maxResults in parseOutput, but
|
||||||
|
// more matches exist. Emit a cursor so the caller can page instead of being
|
||||||
|
// told "truncated" with no way to continue. remaining derives from rg's
|
||||||
|
// total match count (a submatch count — an upper-bound proxy for rows).
|
||||||
|
nextCursor := cursor.Encode(maxResults, queryHash)
|
||||||
|
remaining := max(results.Total-maxResults, 1)
|
||||||
|
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("search completed",
|
s.logger.Info("search completed",
|
||||||
@@ -184,7 +208,7 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
|
|||||||
}
|
}
|
||||||
result, err := s.readOneFile(ctx, request, p)
|
result, err := s.readOneFile(ctx, request, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.WriteString(fmt.Sprintf("--- %s ---\n[error: %s]\n", p, errors.SanitizeError(err)))
|
fmt.Fprintf(&output, "--- %s ---\n[error: %s]\n", p, errors.SanitizeError(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Extract etag from result footer for dedup check.
|
// Extract etag from result footer for dedup check.
|
||||||
@@ -192,12 +216,12 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
|
|||||||
if etag != "" {
|
if etag != "" {
|
||||||
if firstPath, seen := seenEtag[etag]; seen {
|
if firstPath, seen := seenEtag[etag]; seen {
|
||||||
// Duplicate content: emit pointer instead of full content.
|
// Duplicate content: emit pointer instead of full content.
|
||||||
output.WriteString(fmt.Sprintf("--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag))
|
fmt.Fprintf(&output, "--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenEtag[etag] = p
|
seenEtag[etag] = p
|
||||||
}
|
}
|
||||||
output.WriteString(fmt.Sprintf("--- %s ---\n%s", p, result))
|
fmt.Fprintf(&output, "--- %s ---\n%s", p, result)
|
||||||
}
|
}
|
||||||
return mcp.NewToolResultText(output.String()), nil
|
return mcp.NewToolResultText(output.String()), nil
|
||||||
}
|
}
|
||||||
@@ -439,12 +463,19 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
|
|||||||
// Symbol-based line range: find the symbol and use its exact bounds.
|
// Symbol-based line range: find the symbol and use its exact bounds.
|
||||||
if symbolName != "" {
|
if symbolName != "" {
|
||||||
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
|
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
|
||||||
start, end, found := s.resolveSymbolLines(ctx, path, content, symbolName, symbolKind)
|
candidates := s.resolveSymbolCandidates(ctx, path, content, symbolName, symbolKind)
|
||||||
if !found {
|
switch len(candidates) {
|
||||||
return "", fmt.Errorf("symbol %q not found in %s", symbolName, path)
|
case 0:
|
||||||
|
return "", fmt.Errorf("symbol %q not found in %s%s", symbolName, path, s.nearestSymbolSuggestion(ctx, path, content, symbolName))
|
||||||
|
case 1:
|
||||||
|
lineStart = candidates[0].StartLine
|
||||||
|
lineEnd = candidates[0].EndLine
|
||||||
|
default:
|
||||||
|
// Ambiguous: returning the first match risks reading the wrong
|
||||||
|
// symbol. List candidates so the caller can disambiguate.
|
||||||
|
return "", fmt.Errorf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or read a specific one with line_start/line_end",
|
||||||
|
symbolName, path, len(candidates), formatSymbolCandidates(candidates))
|
||||||
}
|
}
|
||||||
lineStart = start
|
|
||||||
lineEnd = end
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp to valid range.
|
// Clamp to valid range.
|
||||||
@@ -499,14 +530,60 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
|
|||||||
return output.String(), nil
|
return output.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSymbolLines parses the AST and returns the line range of the named symbol.
|
// resolveSymbolCandidates parses the AST and returns every symbol matching the
|
||||||
// symbolKind optionally filters by kind (empty = any).
|
// name (and optional kind). Zero results means not found; more than one means an
|
||||||
func (s *Server) resolveSymbolLines(ctx context.Context, path string, content []byte, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
|
// ambiguous lookup. Returns nil on parse failure (treated as not found).
|
||||||
|
func (s *Server) resolveSymbolCandidates(ctx context.Context, path string, content []byte, symbolName string, symbolKind protocol.SymbolKind) []parser.SymbolCandidate {
|
||||||
result, err := s.parser.Parse(ctx, path, content)
|
result, err := s.parser.Parse(ctx, path, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
return parser.FindSymbolRange(result.Tree, content, path, symbolName, symbolKind)
|
return parser.FindSymbolCandidates(result.Tree, content, path, symbolName, symbolKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nearestSymbolSuggestion returns a " — did you mean: a, b, c?" hint listing the
|
||||||
|
// closest symbol names in the file to name, or "" if none are reasonably close.
|
||||||
|
// The re-parse is cheap: the tree is served from the parser cache.
|
||||||
|
func (s *Server) nearestSymbolSuggestion(ctx context.Context, path string, content []byte, name string) string {
|
||||||
|
result, err := s.parser.Parse(ctx, path, content)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
syms := parser.ExtractSymbols(result.Tree, content, result.Language, path)
|
||||||
|
if len(syms) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(syms))
|
||||||
|
seen := make(map[string]bool, len(syms))
|
||||||
|
for _, sym := range syms {
|
||||||
|
if !seen[sym.Name] {
|
||||||
|
seen[sym.Name] = true
|
||||||
|
names = append(names, sym.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches := fuzzy.New(maxSymbolSuggestionDistance).Match(name, names)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
top := matches
|
||||||
|
if len(top) > maxSymbolSuggestions {
|
||||||
|
top = top[:maxSymbolSuggestions]
|
||||||
|
}
|
||||||
|
suggestions := make([]string, len(top))
|
||||||
|
for i, m := range top {
|
||||||
|
suggestions[i] = m.Text
|
||||||
|
}
|
||||||
|
return " — did you mean: " + strings.Join(suggestions, ", ") + "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSymbolCandidates renders ambiguous symbol matches as a compact,
|
||||||
|
// comma-separated list, e.g. "method_declaration L10-24, method_declaration L42-58".
|
||||||
|
func formatSymbolCandidates(candidates []parser.SymbolCandidate) string {
|
||||||
|
parts := make([]string, len(candidates))
|
||||||
|
for i, c := range candidates {
|
||||||
|
parts[i] = fmt.Sprintf("%s L%d-%d", c.NodeType, c.StartLine, c.EndLine)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeLines writes the selected line range into output, applying all formatting options.
|
// writeLines writes the selected line range into output, applying all formatting options.
|
||||||
@@ -571,6 +648,21 @@ func extractEtag(result string) string {
|
|||||||
// splitLines splits a string into lines.
|
// splitLines splits a string into lines.
|
||||||
// For large files (> 1MB), uses bufio.Scanner which is more memory efficient.
|
// For large files (> 1MB), uses bufio.Scanner which is more memory efficient.
|
||||||
// For smaller files, uses simple string split which is faster.
|
// For smaller files, uses simple string split which is faster.
|
||||||
|
// countLines returns the number of source lines in content. Unlike
|
||||||
|
// len(splitLines(...)), it does not over-count newline-terminated files
|
||||||
|
// (a trailing "\n" does not introduce a phantom final line) and avoids
|
||||||
|
// materializing the line slice.
|
||||||
|
func countLines(content []byte) int {
|
||||||
|
if len(content) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n := bytes.Count(content, []byte{'\n'})
|
||||||
|
if content[len(content)-1] != '\n' {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func splitLines(s string) []string {
|
func splitLines(s string) []string {
|
||||||
const largeSizeThreshold = 1024 * 1024 // 1MB
|
const largeSizeThreshold = 1024 * 1024 // 1MB
|
||||||
|
|
||||||
|
|||||||
@@ -266,6 +266,28 @@ struct Foo {
|
|||||||
|
|
||||||
// ---- Feature 2: strip flag ----
|
// ---- Feature 2: strip flag ----
|
||||||
|
|
||||||
|
// A misspelled symbol_name should fail with a "did you mean" hint listing the
|
||||||
|
// closest real symbols, so the model can self-correct.
|
||||||
|
func TestReadSymbolNotFoundSuggestsNearest(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
srv := newTestServer(t, tmpDir)
|
||||||
|
|
||||||
|
goSrc := "package main\n\nfunc Hello() {}\n\nfunc Goodbye() {}\n"
|
||||||
|
f := writeFile(t, tmpDir, "main.go", goSrc)
|
||||||
|
|
||||||
|
out := callRead(t, srv, map[string]interface{}{
|
||||||
|
"path": f,
|
||||||
|
"symbol_name": "Helo", // typo for Hello
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(out, "not found") {
|
||||||
|
t.Errorf("expected not-found error, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "did you mean") || !strings.Contains(out, "Hello") {
|
||||||
|
t.Errorf("expected suggestion of Hello, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStripImportsGo(t *testing.T) {
|
func TestStripImportsGo(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
srv := newTestServer(t, tmpDir)
|
srv := newTestServer(t, tmpDir)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||||
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,20 +27,30 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
|
|||||||
return mcp.NewToolResultError("file is required"), nil
|
return mcp.NewToolResultError("file is required"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
line := request.GetInt("line", 0)
|
|
||||||
if line <= 0 {
|
|
||||||
return mcp.NewToolResultError("line must be positive"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
col := request.GetInt("column", 0)
|
|
||||||
if col <= 0 {
|
|
||||||
return mcp.NewToolResultError("column must be positive"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.cfg.IsPathAllowed(file) {
|
if !s.cfg.IsPathAllowed(file) {
|
||||||
return mcp.NewToolResultError("file is outside workspace root"), nil
|
return mcp.NewToolResultError("file is outside workspace root"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Position can be given directly (line+column) or resolved from a symbol name.
|
||||||
|
var line, col int
|
||||||
|
if symbolName := request.GetString("symbol_name", ""); symbolName != "" {
|
||||||
|
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
|
||||||
|
resolvedLine, resolvedCol, errResult := s.resolveLSPSymbolPosition(ctx, file, symbolName, symbolKind)
|
||||||
|
if errResult != nil {
|
||||||
|
return errResult, nil
|
||||||
|
}
|
||||||
|
line, col = resolvedLine, resolvedCol
|
||||||
|
} else {
|
||||||
|
line = request.GetInt("line", 0)
|
||||||
|
if line <= 0 {
|
||||||
|
return mcp.NewToolResultError("line must be positive (or provide symbol_name)"), nil
|
||||||
|
}
|
||||||
|
col = request.GetInt("column", 0)
|
||||||
|
if col <= 0 {
|
||||||
|
return mcp.NewToolResultError("column must be positive (or provide symbol_name)"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
verbose := request.GetBool("verbose", false)
|
verbose := request.GetBool("verbose", false)
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
@@ -76,6 +87,31 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLSPSymbolPosition resolves a symbol name to the 1-indexed line:column of
|
||||||
|
// its name identifier, for use as an LSP position. Returns an error result when the
|
||||||
|
// file can't be read/parsed, the symbol is not found (with a "did you mean" hint),
|
||||||
|
// or the name is ambiguous (listing candidates).
|
||||||
|
func (s *Server) resolveLSPSymbolPosition(ctx context.Context, file, symbolName string, symbolKind protocol.SymbolKind) (line, col int, errResult *mcp.CallToolResult) {
|
||||||
|
content, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err)))
|
||||||
|
}
|
||||||
|
result, err := s.parser.Parse(ctx, file, content)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err)))
|
||||||
|
}
|
||||||
|
candidates := parser.FindSymbolCandidates(result.Tree, content, file, symbolName, symbolKind)
|
||||||
|
switch len(candidates) {
|
||||||
|
case 0:
|
||||||
|
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q not found in %s%s", symbolName, file, s.nearestSymbolSuggestion(ctx, file, content, symbolName)))
|
||||||
|
case 1:
|
||||||
|
return candidates[0].NameLine, candidates[0].NameColumn, nil
|
||||||
|
default:
|
||||||
|
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or pass line+column",
|
||||||
|
symbolName, file, len(candidates), formatSymbolCandidates(candidates)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// lspHover performs hover (symbol info) for the given position.
|
// lspHover performs hover (symbol info) for the given position.
|
||||||
func (s *Server) lspHover(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
|
func (s *Server) lspHover(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
|
||||||
hover, err := s.lspManager.Hover(ctx, file, line, col)
|
hover, err := s.lspManager.Hover(ctx, file, line, col)
|
||||||
@@ -115,10 +151,14 @@ func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line,
|
|||||||
|
|
||||||
var output strings.Builder
|
var output strings.Builder
|
||||||
if verbose {
|
if verbose {
|
||||||
output.WriteString("**Symbol Information** (AST fallback)\n\n")
|
output.WriteString("**Symbol Information** (AST fallback — LSP unavailable)\n\n")
|
||||||
|
} else {
|
||||||
|
// Always signal the degraded result so the model knows this is an AST
|
||||||
|
// guess, not real LSP type info, and can decide whether to retry.
|
||||||
|
output.WriteString("[AST fallback — LSP unavailable]\n")
|
||||||
}
|
}
|
||||||
output.WriteString(fmt.Sprintf("Node type: `%s`\n", node.Type()))
|
fmt.Fprintf(&output, "Node type: `%s`\n", node.Type())
|
||||||
output.WriteString(fmt.Sprintf("Text: `%s`\n", parser.GetNodeText(node, content)))
|
fmt.Fprintf(&output, "Text: `%s`\n", parser.GetNodeText(node, content))
|
||||||
|
|
||||||
return mcp.NewToolResultText(output.String()), nil
|
return mcp.NewToolResultText(output.String()), nil
|
||||||
}
|
}
|
||||||
@@ -136,12 +176,12 @@ func (s *Server) lspDefinition(ctx context.Context, file string, line, col int,
|
|||||||
|
|
||||||
var output strings.Builder
|
var output strings.Builder
|
||||||
if verbose {
|
if verbose {
|
||||||
output.WriteString(fmt.Sprintf("Found %d definition(s):\n\n", len(locations)))
|
fmt.Fprintf(&output, "Found %d definition(s):\n\n", len(locations))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, loc := range locations {
|
for _, loc := range locations {
|
||||||
filePath := lsp.URIToFile(loc.URI)
|
filePath := lsp.URIToFile(loc.URI)
|
||||||
output.WriteString(fmt.Sprintf("**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1))
|
fmt.Fprintf(&output, "**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
||||||
|
|
||||||
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
||||||
if preview != "" {
|
if preview != "" {
|
||||||
@@ -187,7 +227,7 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
|
|||||||
func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string {
|
func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string {
|
||||||
var output strings.Builder
|
var output strings.Builder
|
||||||
if verbose {
|
if verbose {
|
||||||
output.WriteString(fmt.Sprintf("Found %d reference(s):\n\n", total))
|
fmt.Fprintf(&output, "Found %d reference(s):\n\n", total)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, filePath := range fileOrder {
|
for _, filePath := range fileOrder {
|
||||||
@@ -195,9 +235,9 @@ func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string,
|
|||||||
if compact {
|
if compact {
|
||||||
output.WriteString(formatReferencesCompact(filePath, locs))
|
output.WriteString(formatReferencesCompact(filePath, locs))
|
||||||
} else {
|
} else {
|
||||||
output.WriteString(fmt.Sprintf("**%s** (%d)\n", filePath, len(locs)))
|
fmt.Fprintf(&output, "**%s** (%d)\n", filePath, len(locs))
|
||||||
for _, loc := range locs {
|
for _, loc := range locs {
|
||||||
output.WriteString(fmt.Sprintf(" L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1))
|
fmt.Fprintf(&output, " L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
||||||
}
|
}
|
||||||
output.WriteString("\n")
|
output.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -273,7 +313,7 @@ func (s *Server) readFilePreview(file string, line, contextLines int) string {
|
|||||||
if i+1 == line {
|
if i+1 == line {
|
||||||
prefix = "> "
|
prefix = "> "
|
||||||
}
|
}
|
||||||
preview.WriteString(fmt.Sprintf("%s%4d: %s\n", prefix, i+1, lineText))
|
fmt.Fprintf(&preview, "%s%4d: %s\n", prefix, i+1, lineText)
|
||||||
}
|
}
|
||||||
|
|
||||||
return preview.String()
|
return preview.String()
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errorText extracts the text payload from an error CallToolResult.
|
||||||
|
func errorText(res *mcp.CallToolResult) string {
|
||||||
|
if res == nil || len(res.Content) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if tc, ok := res.Content[0].(mcp.TextContent); ok {
|
||||||
|
return tc.Text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// makeLocation builds an lsp.Location with a file:// URI.
|
// makeLocation builds an lsp.Location with a file:// URI.
|
||||||
func makeLocation(file string, line, col int) lsp.Location {
|
func makeLocation(file string, line, col int) lsp.Location {
|
||||||
return lsp.Location{
|
return lsp.Location{
|
||||||
@@ -179,3 +192,53 @@ func TestFormatReferencesVerbosePreservesLPrefix(t *testing.T) {
|
|||||||
t.Errorf("verbose output must contain L3:4, got:\n%s", out)
|
t.Errorf("verbose output must contain L3:4, got:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLSPSymbolPosition underpins lsp_query's symbol_name affordance: it maps
|
||||||
|
// a symbol name to the line:column of its name identifier, or returns an error
|
||||||
|
// result for not-found / ambiguous names. It does not require a running LSP.
|
||||||
|
func TestResolveLSPSymbolPosition(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
srv := newTestServer(t, tmpDir)
|
||||||
|
|
||||||
|
goSrc := "package main\n" + // 1
|
||||||
|
"\n" + // 2
|
||||||
|
"type A struct{}\n" + // 3
|
||||||
|
"func (a A) Run() {}\n" + // 4
|
||||||
|
"\n" + // 5
|
||||||
|
"type B struct{}\n" + // 6
|
||||||
|
"func (b B) Run() {}\n" + // 7
|
||||||
|
"\n" + // 8
|
||||||
|
"func Unique() {}\n" // 9
|
||||||
|
f := writeFile(t, tmpDir, "main.go", goSrc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("unique symbol resolves to its name position", func(t *testing.T) {
|
||||||
|
line, col, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Unique", "")
|
||||||
|
if errRes != nil {
|
||||||
|
t.Fatalf("unexpected error result for Unique: %s", errorText(errRes))
|
||||||
|
}
|
||||||
|
if line != 9 || col != 6 {
|
||||||
|
t.Errorf("Unique position = %d:%d, want 9:6", line, col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ambiguous symbol errors", func(t *testing.T) {
|
||||||
|
_, _, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Run", "")
|
||||||
|
if errRes == nil {
|
||||||
|
t.Fatal("expected ambiguity error for Run")
|
||||||
|
}
|
||||||
|
if !strings.Contains(errorText(errRes), "ambiguous") {
|
||||||
|
t.Errorf("expected 'ambiguous' in error, got: %s", errorText(errRes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing symbol errors", func(t *testing.T) {
|
||||||
|
_, _, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Nope", "")
|
||||||
|
if errRes == nil {
|
||||||
|
t.Fatal("expected not-found error for Nope")
|
||||||
|
}
|
||||||
|
if !strings.Contains(errorText(errRes), "not found") {
|
||||||
|
t.Errorf("expected 'not found' in error, got: %s", errorText(errRes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
|
|||||||
"| Flag | Effect |\n" +
|
"| Flag | Effect |\n" +
|
||||||
"|------|--------|\n" +
|
"|------|--------|\n" +
|
||||||
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\n" +
|
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\n" +
|
||||||
"| `format` | `verbose` (default, full code+captures) \\| `compact` (one line per match) \\| `location` (file:line only) |\n" +
|
"| `format` | `compact` (default, one line per match) \\| `verbose` (full code+captures) \\| `location` (file:line only) |\n" +
|
||||||
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
|
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
|
||||||
"| `max_results` | Page size (default: 100). |\n" +
|
"| `max_results` | Page size (default: 100). |\n" +
|
||||||
"| `name_exact` | Exact symbol name to match. |\n" +
|
"| `name_exact` | Exact symbol name to match. |\n" +
|
||||||
@@ -88,9 +88,22 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
|
|||||||
"| `$NAME` | Matches a single node, captures as `$NAME` |\n" +
|
"| `$NAME` | Matches a single node, captures as `$NAME` |\n" +
|
||||||
"| `$$$ARGS` | Matches zero or more nodes (variadic capture) |\n" +
|
"| `$$$ARGS` | Matches zero or more nodes (variadic capture) |\n" +
|
||||||
"| `$_` | Wildcard — matches any single node, no capture |\n\n" +
|
"| `$_` | Wildcard — matches any single node, no capture |\n\n" +
|
||||||
|
"## Matching semantics (important)\n\n" +
|
||||||
|
"Matching is keyword- and kind-based, not full structural matching. The leading keyword " +
|
||||||
|
"(`func`/`function`/`class`/`struct`/`interface`) plus placeholders select the node KIND; " +
|
||||||
|
"argument shapes, return types, and the literal bound to `$NAME` are NOT enforced as filters. " +
|
||||||
|
"So `func $NAME($$$ARGS) error` matches every function/method, not only those returning `error`. " +
|
||||||
|
"Captured `$VAR` values appear in output but do not constrain which nodes match.\n\n" +
|
||||||
|
"For precise selection, combine the pattern with `name_exact`, `name_matches`, and/or `kind_in` — " +
|
||||||
|
"these ARE applied as filters.\n\n" +
|
||||||
|
"## Scope\n\n" +
|
||||||
|
"The walk skips hidden directories (dot-prefixed) and dependency directories " +
|
||||||
|
"(`node_modules`, `vendor`, `bower_components`) — these hold third-party code that would " +
|
||||||
|
"flood results. To query inside one, name it explicitly in `paths` (e.g. `\"paths\": [\"vendor/foo\"]`); " +
|
||||||
|
"the targeted root is never skipped. Note: unlike file_search, ast_query does not consult .gitignore.\n\n" +
|
||||||
"## Examples\n\n" +
|
"## Examples\n\n" +
|
||||||
"```json\n" +
|
"```json\n" +
|
||||||
"// All Go functions returning error\n" +
|
"// All Go functions/methods (the 'error' return is NOT enforced — add name_/kind_in to narrow)\n" +
|
||||||
`{"pattern": "func $NAME($$$ARGS) error", "language": "go"}` + "\n\n" +
|
`{"pattern": "func $NAME($$$ARGS) error", "language": "go"}` + "\n\n" +
|
||||||
"// Python classes\n" +
|
"// Python classes\n" +
|
||||||
`{"pattern": "class $NAME: $$$BODY", "language": "python"}` + "\n\n" +
|
`{"pattern": "class $NAME: $$$BODY", "language": "python"}` + "\n\n" +
|
||||||
@@ -114,10 +127,16 @@ const helpLSPQuery = "# lsp_query — flags and examples\n\n" +
|
|||||||
"- `compact=true` — collapse to one line per file\n" +
|
"- `compact=true` — collapse to one line per file\n" +
|
||||||
"- `verbose=true` — add `Found N reference(s):` header\n\n" +
|
"- `verbose=true` — add `Found N reference(s):` header\n\n" +
|
||||||
"Note: `include_declaration` and `compact` are errors when used with actions other than `references`.\n\n" +
|
"Note: `include_declaration` and `compact` are errors when used with actions other than `references`.\n\n" +
|
||||||
|
"## Position: line+column or symbol_name\n\n" +
|
||||||
|
"Give the position directly with `line`+`column`, or pass `symbol_name` (optionally `symbol_kind`) to " +
|
||||||
|
"resolve it from the AST — handy when you know the name but not the coordinates. Ambiguous or unknown " +
|
||||||
|
"names return an error (with candidates / a 'did you mean' hint).\n\n" +
|
||||||
"## Examples\n\n" +
|
"## Examples\n\n" +
|
||||||
"```json\n" +
|
"```json\n" +
|
||||||
"// Hover — type/doc at position\n" +
|
"// Hover — type/doc at position\n" +
|
||||||
`{"action": "hover", "file": "server.go", "line": 45, "column": 6}` + "\n\n" +
|
`{"action": "hover", "file": "server.go", "line": 45, "column": 6}` + "\n\n" +
|
||||||
|
"// Definition by symbol name — no line/column needed\n" +
|
||||||
|
`{"action": "definition", "file": "server.go", "symbol_name": "NewServer"}` + "\n\n" +
|
||||||
"// Definition — where is this symbol defined?\n" +
|
"// Definition — where is this symbol defined?\n" +
|
||||||
`{"action": "definition", "file": "handler.go", "line": 23, "column": 10}` + "\n\n" +
|
`{"action": "definition", "file": "handler.go", "line": 23, "column": 10}` + "\n\n" +
|
||||||
"// References — all usages\n" +
|
"// References — all usages\n" +
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ func (s *Server) registerTools() {
|
|||||||
mcp.WithBoolean("ignore_case",
|
mcp.WithBoolean("ignore_case",
|
||||||
mcp.Description("Case insensitive search"),
|
mcp.Description("Case insensitive search"),
|
||||||
),
|
),
|
||||||
|
mcp.WithBoolean("include_hidden",
|
||||||
|
mcp.Description("Search hidden files and directories (dotfiles like .github/). Default: false."),
|
||||||
|
),
|
||||||
mcp.WithBoolean("regex",
|
mcp.WithBoolean("regex",
|
||||||
mcp.Description("Treat pattern as regex (default: true)"),
|
mcp.Description("Treat pattern as regex (default: true)"),
|
||||||
),
|
),
|
||||||
@@ -222,6 +225,8 @@ func (s *Server) registerTools() {
|
|||||||
s.mcp.AddTool(
|
s.mcp.AddTool(
|
||||||
mcp.NewTool("ast_query",
|
mcp.NewTool("ast_query",
|
||||||
mcp.WithDescription("Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types. "+
|
mcp.WithDescription("Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types. "+
|
||||||
|
"Matching is keyword/kind-based, not fully structural: argument and return-type constraints in the pattern are not enforced — use name_exact/name_matches/kind_in to filter precisely. "+
|
||||||
|
"Hidden dirs and dependency dirs (node_modules, vendor, bower_components) are skipped unless targeted explicitly via paths. "+
|
||||||
"See resource filepuff://help/ast_query for flags and examples."),
|
"See resource filepuff://help/ast_query for flags and examples."),
|
||||||
mcp.WithReadOnlyHintAnnotation(true),
|
mcp.WithReadOnlyHintAnnotation(true),
|
||||||
mcp.WithString("pattern",
|
mcp.WithString("pattern",
|
||||||
@@ -250,7 +255,7 @@ func (s *Server) registerTools() {
|
|||||||
mcp.Description("Maximum number of results to return (default: 100, page size for pagination)"),
|
mcp.Description("Maximum number of results to return (default: 100, page size for pagination)"),
|
||||||
),
|
),
|
||||||
mcp.WithString("format",
|
mcp.WithString("format",
|
||||||
mcp.Description("Output format: \"verbose\" (default, full code+captures), \"compact\" (one line per match), \"location\" (file:line only)"),
|
mcp.Description("Output format: \"compact\" (default, one line per match), \"verbose\" (full code+captures), \"location\" (file:line only)"),
|
||||||
),
|
),
|
||||||
mcp.WithString("cursor",
|
mcp.WithString("cursor",
|
||||||
mcp.Description("Pagination cursor from a previous truncated response. Pass back to fetch the next page."),
|
mcp.Description("Pagination cursor from a previous truncated response. Pass back to fetch the next page."),
|
||||||
@@ -266,7 +271,8 @@ func (s *Server) registerTools() {
|
|||||||
if s.lspManager != nil {
|
if s.lspManager != nil {
|
||||||
s.mcp.AddTool(
|
s.mcp.AddTool(
|
||||||
mcp.NewTool("lsp_query",
|
mcp.NewTool("lsp_query",
|
||||||
mcp.WithDescription("Query LSP for symbol info, definition, or references at a specific file position. "+
|
mcp.WithDescription("Query LSP for symbol info, definition, or references at a file position. "+
|
||||||
|
"Give the position as line+column, or pass symbol_name to resolve it via AST (no line/column needed). "+
|
||||||
"See resource filepuff://help/lsp_query for flags and examples."),
|
"See resource filepuff://help/lsp_query for flags and examples."),
|
||||||
mcp.WithReadOnlyHintAnnotation(true),
|
mcp.WithReadOnlyHintAnnotation(true),
|
||||||
mcp.WithString("action",
|
mcp.WithString("action",
|
||||||
@@ -277,13 +283,17 @@ func (s *Server) registerTools() {
|
|||||||
mcp.Required(),
|
mcp.Required(),
|
||||||
mcp.Description("Path to the file"),
|
mcp.Description("Path to the file"),
|
||||||
),
|
),
|
||||||
|
mcp.WithString("symbol_name",
|
||||||
|
mcp.Description("Resolve the position from a named symbol (function, struct, class, etc.) instead of line+column. Errors if the name is ambiguous or not found."),
|
||||||
|
),
|
||||||
|
mcp.WithString("symbol_kind",
|
||||||
|
mcp.Description("Disambiguate symbol_name by kind when multiple symbols share the name. Accepted: function, method, struct, class, interface, type, enum, trait, constant, module."),
|
||||||
|
),
|
||||||
mcp.WithNumber("line",
|
mcp.WithNumber("line",
|
||||||
mcp.Required(),
|
mcp.Description("Line number (1-indexed). Required unless symbol_name is given."),
|
||||||
mcp.Description("Line number (1-indexed)"),
|
|
||||||
),
|
),
|
||||||
mcp.WithNumber("column",
|
mcp.WithNumber("column",
|
||||||
mcp.Required(),
|
mcp.Description("Column number (1-indexed). Required unless symbol_name is given."),
|
||||||
mcp.Description("Column number (1-indexed)"),
|
|
||||||
),
|
),
|
||||||
mcp.WithBoolean("include_declaration",
|
mcp.WithBoolean("include_declaration",
|
||||||
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
|
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
|
||||||
|
|||||||
@@ -415,6 +415,32 @@ func TestSplitLinesLargeFileNoTrailingNewline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCountLines verifies countLines reports source line counts without the
|
||||||
|
// phantom trailing line that len(splitLines(...)) produces for newline-terminated
|
||||||
|
// files.
|
||||||
|
func TestCountLines(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "empty", content: "", want: 0},
|
||||||
|
{name: "single line no newline", content: "a", want: 1},
|
||||||
|
{name: "single line trailing newline", content: "a\n", want: 1},
|
||||||
|
{name: "two lines trailing newline", content: "a\nb\n", want: 2},
|
||||||
|
{name: "two lines no trailing newline", content: "a\nb", want: 2},
|
||||||
|
{name: "blank lines", content: "\n\n\n", want: 3},
|
||||||
|
{name: "crlf trailing", content: "a\r\nb\r\n", want: 2},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := countLines([]byte(tt.content)); got != tt.want {
|
||||||
|
t.Errorf("countLines(%q) = %d, want %d", tt.content, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSplitLinesLongLine verifies the scanner gracefully handles very long lines
|
// TestSplitLinesLongLine verifies the scanner gracefully handles very long lines
|
||||||
// (up to the 1MB buffer limit set in C-05 fix).
|
// (up to the 1MB buffer limit set in C-05 fix).
|
||||||
func TestSplitLinesLongLine(t *testing.T) {
|
func TestSplitLinesLongLine(t *testing.T) {
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func TestSessionPrefsASTQueryMaxResults(t *testing.T) {
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("package main\n\n")
|
sb.WriteString("package main\n\n")
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
sb.WriteString(fmt.Sprintf("func Fn%c() {}\n\n", rune('A'+i)))
|
fmt.Fprintf(&sb, "func Fn%c() {}\n\n", rune('A'+i))
|
||||||
}
|
}
|
||||||
testFile := filepath.Join(tmpDir, "many.go")
|
testFile := filepath.Join(tmpDir, "many.go")
|
||||||
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
|
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
|
||||||
@@ -281,7 +281,7 @@ func TestSessionPrefsMaxResultsExplicitOverride(t *testing.T) {
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("package main\n\n")
|
sb.WriteString("package main\n\n")
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
sb.WriteString(fmt.Sprintf("func Fn%c() {}\n\n", rune('A'+i)))
|
fmt.Fprintf(&sb, "func Fn%c() {}\n\n", rune('A'+i))
|
||||||
}
|
}
|
||||||
testFile := filepath.Join(tmpDir, "many.go")
|
testFile := filepath.Join(tmpDir, "many.go")
|
||||||
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
|
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "unicode/utf8"
|
||||||
|
|
||||||
|
// RuneBoundary returns the largest index j <= i such that s[:j] is valid UTF-8
|
||||||
|
// (j falls on a rune start, never inside a multibyte sequence). i is clamped to
|
||||||
|
// [0, len(s)]. Callers slicing s[:i] for truncation should slice s[:RuneBoundary(s, i)]
|
||||||
|
// instead so a multibyte character is never split into an invalid encoding.
|
||||||
|
func RuneBoundary(s string, i int) int {
|
||||||
|
if i <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if i >= len(s) {
|
||||||
|
return len(s)
|
||||||
|
}
|
||||||
|
for i > 0 && !utf8.RuneStart(s[i]) {
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuneBoundary(t *testing.T) {
|
||||||
|
// "café" — 'é' is 2 bytes (0xC3 0xA9), occupying indices 3,4. len == 5.
|
||||||
|
const cafe = "café"
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
s string
|
||||||
|
i int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"negative clamps to zero", "abc", -1, 0},
|
||||||
|
{"zero", "abc", 0, 0},
|
||||||
|
{"beyond length clamps to len", "abc", 99, 3},
|
||||||
|
{"ascii exact boundary", "abcdef", 4, 4},
|
||||||
|
{"inside multibyte backs off", cafe, 4, 3}, // index 4 is the 2nd byte of 'é'
|
||||||
|
{"on multibyte start stays", cafe, 3, 3}, // index 3 is the 1st byte of 'é'
|
||||||
|
{"end of multibyte string", cafe, 5, 5}, // len == 5, clamped path
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := RuneBoundary(c.s, c.i)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("RuneBoundary(%q, %d) = %d, want %d", c.s, c.i, got, c.want)
|
||||||
|
}
|
||||||
|
// Invariant: the prefix is always valid UTF-8.
|
||||||
|
if !utf8.ValidString(c.s[:got]) {
|
||||||
|
t.Fatalf("RuneBoundary(%q, %d) sliced invalid UTF-8: %q", c.s, c.i, c.s[:got])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,24 +77,24 @@ func (e *StructuredError) Error() string {
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Error code and message
|
// Error code and message
|
||||||
sb.WriteString(fmt.Sprintf("[%d] %s", e.Code, e.Message))
|
fmt.Fprintf(&sb, "[%d] %s", e.Code, e.Message)
|
||||||
|
|
||||||
// Context if available
|
// Context if available
|
||||||
if len(e.Context) > 0 {
|
if len(e.Context) > 0 {
|
||||||
sb.WriteString("\nContext:")
|
sb.WriteString("\nContext:")
|
||||||
for k, v := range e.Context {
|
for k, v := range e.Context {
|
||||||
sb.WriteString(fmt.Sprintf("\n %s: %v", k, v))
|
fmt.Fprintf(&sb, "\n %s: %v", k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remediation if available
|
// Remediation if available
|
||||||
if e.Remediation != "" {
|
if e.Remediation != "" {
|
||||||
sb.WriteString(fmt.Sprintf("\nHow to fix: %s", e.Remediation))
|
fmt.Fprintf(&sb, "\nHow to fix: %s", e.Remediation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Underlying cause if available
|
// Underlying cause if available
|
||||||
if e.Cause != nil {
|
if e.Cause != nil {
|
||||||
sb.WriteString(fmt.Sprintf("\nCaused by: %v", e.Cause))
|
fmt.Fprintf(&sb, "\nCaused by: %v", e.Cause)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -215,7 +215,7 @@ func captureStack(skip int) string {
|
|||||||
for {
|
for {
|
||||||
frame, more := frames.Next()
|
frame, more := frames.Next()
|
||||||
if !strings.Contains(frame.File, "runtime/") {
|
if !strings.Contains(frame.File, "runtime/") {
|
||||||
sb.WriteString(fmt.Sprintf("\n %s:%d %s", frame.File, frame.Line, frame.Function))
|
fmt.Fprintf(&sb, "\n %s:%d %s", frame.File, frame.Line, frame.Function)
|
||||||
}
|
}
|
||||||
if !more {
|
if !more {
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user