mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9abab7a550 | ||
|
|
658c895318 | ||
|
|
d24c8218fd | ||
|
|
f4c34c41e4 | ||
|
|
749076518d | ||
|
|
664b7dac45 | ||
|
|
d0c07f0157 | ||
|
|
a841210092 | ||
|
|
98a499ab20 | ||
|
|
8d940022b7 | ||
|
|
99de8a93b9 | ||
|
|
a11623cb0d | ||
|
|
5f0bb90e28 | ||
|
|
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 | ||
|
|
70484d20c3 |
+9
-9
@@ -211,7 +211,7 @@ func GenerateMarkdown() string {
|
||||
var sb strings.Builder
|
||||
|
||||
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")
|
||||
|
||||
// Table of Contents
|
||||
@@ -228,9 +228,9 @@ func GenerateMarkdown() string {
|
||||
}
|
||||
|
||||
for _, cat := range categoryOrder {
|
||||
sb.WriteString(fmt.Sprintf("### %s\n", cat))
|
||||
fmt.Fprintf(&sb, "### %s\n", 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")
|
||||
}
|
||||
@@ -240,11 +240,11 @@ func GenerateMarkdown() string {
|
||||
sb.WriteString("## Tool Reference\n\n")
|
||||
|
||||
for _, cat := range categoryOrder {
|
||||
sb.WriteString(fmt.Sprintf("### %s\n\n", cat))
|
||||
fmt.Fprintf(&sb, "### %s\n\n", cat)
|
||||
|
||||
for _, tool := range categories[cat] {
|
||||
sb.WriteString(fmt.Sprintf("#### `%s`\n\n", tool.Name))
|
||||
sb.WriteString(fmt.Sprintf("%s\n\n", tool.Description))
|
||||
fmt.Fprintf(&sb, "#### `%s`\n\n", tool.Name)
|
||||
fmt.Fprintf(&sb, "%s\n\n", tool.Description)
|
||||
|
||||
// Read-only indicator
|
||||
if tool.ReadOnly {
|
||||
@@ -263,8 +263,8 @@ func GenerateMarkdown() string {
|
||||
if param.Required {
|
||||
required = "**Yes**"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("| `%s` | `%s` | %s | %s |\n",
|
||||
param.Name, param.Type, required, param.Description))
|
||||
fmt.Fprintf(&sb, "| `%s` | `%s` | %s | %s |\n",
|
||||
param.Name, param.Type, required, param.Description)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
@@ -285,7 +285,7 @@ func GenerateMarkdown() string {
|
||||
if len(tool.Notes) > 0 {
|
||||
sb.WriteString("**Notes:**\n\n")
|
||||
for _, note := range tool.Notes {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", note))
|
||||
fmt.Fprintf(&sb, "- %s\n", note)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -6,15 +6,17 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/goccy/go-json v0.10.6
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/mark3labs/mcp-go v0.48.0
|
||||
github.com/mark3labs/mcp-go v0.55.1
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
)
|
||||
|
||||
@@ -3,14 +3,16 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -22,12 +24,14 @@ 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw=
|
||||
github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
|
||||
github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA=
|
||||
github.com/mark3labs/mcp-go v0.55.1/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
||||
@@ -36,13 +40,16 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
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 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -84,14 +84,24 @@ func Load(workspaceRoot string) (*Config, error) {
|
||||
// Restore WorkspaceRoot — config file must not override path guards.
|
||||
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 maxAllowedSearchResults = 100000
|
||||
if cfg.MaxFileSize > maxAllowedSize {
|
||||
cfg.MaxFileSize = maxAllowedSize
|
||||
}
|
||||
if 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
|
||||
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.
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -26,6 +26,8 @@ type payload struct {
|
||||
// Encode creates an opaque cursor string from an offset and query hash.
|
||||
func Encode(offset int, queryHash string) string {
|
||||
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)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
+57
-53
@@ -63,17 +63,46 @@ type Engine struct {
|
||||
registry *parser.Registry
|
||||
dmp *diffmatchpatch.DiffMatchPatch
|
||||
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
|
||||
// pathAllowed re-checks that a target path is admissible (within the
|
||||
// workspace root) immediately before a write. Injected to avoid coupling
|
||||
// edit to config; nil disables the check (used in unit tests).
|
||||
pathAllowed func(string) bool
|
||||
}
|
||||
|
||||
// NewEngine creates a new edit engine.
|
||||
// NewEngine creates a new edit engine with no write-time path validation.
|
||||
func NewEngine(registry *parser.Registry) *Engine {
|
||||
return NewEngineWithValidator(registry, nil)
|
||||
}
|
||||
|
||||
// NewEngineWithValidator creates an edit engine that re-validates each target
|
||||
// path with pathAllowed immediately before writing it, closing most of the
|
||||
// time-of-check/time-of-use window between the handler's admission check and the
|
||||
// actual write (a symlink swapped in afterward is rejected).
|
||||
func NewEngineWithValidator(registry *parser.Registry, pathAllowed func(string) bool) *Engine {
|
||||
return &Engine{
|
||||
registry: registry,
|
||||
dmp: diffmatchpatch.New(),
|
||||
fileLocks: sync.Map{},
|
||||
pathAllowed: pathAllowed,
|
||||
}
|
||||
}
|
||||
|
||||
// writeValidatedFile writes content to path, preserving the file's existing
|
||||
// permission bits, after re-checking that path is still admissible. The re-check
|
||||
// resolves symlinks at write time, so a symlink swapped in after the caller's
|
||||
// original validation cannot redirect the write outside the workspace root.
|
||||
func (e *Engine) writeValidatedFile(path string, content []byte) error {
|
||||
if e.pathAllowed != nil && !e.pathAllowed(path) {
|
||||
return errors.New(errors.ErrPathNotAllowed, "path is outside the workspace root").
|
||||
WithContext("path", path)
|
||||
}
|
||||
perm := os.FileMode(0o600) // default fallback when the file can't be stat'd
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
perm = info.Mode().Perm()
|
||||
}
|
||||
return os.WriteFile(path, content, perm)
|
||||
}
|
||||
|
||||
// lockFile acquires a lock for the specified file and returns an unlock function.
|
||||
// This prevents concurrent edits to the same file which could cause corruption.
|
||||
func (e *Engine) lockFile(filePath string) func() {
|
||||
@@ -179,14 +208,7 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
|
||||
|
||||
// Apply changes if requested
|
||||
if apply {
|
||||
// Preserve original file permissions
|
||||
fileInfo, err := os.Stat(edit.File)
|
||||
perm := os.FileMode(0o600) // default fallback
|
||||
if err == nil {
|
||||
perm = fileInfo.Mode().Perm()
|
||||
}
|
||||
|
||||
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
|
||||
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
|
||||
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||
return &EditResult{
|
||||
Success: false,
|
||||
@@ -236,14 +258,7 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
|
||||
|
||||
// Apply changes if requested
|
||||
if apply {
|
||||
// Preserve original file permissions
|
||||
fileInfo, err := os.Stat(edit.File)
|
||||
perm := os.FileMode(0o600) // default fallback
|
||||
if err == nil {
|
||||
perm = fileInfo.Mode().Perm()
|
||||
}
|
||||
|
||||
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
|
||||
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
|
||||
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||
return &EditResult{
|
||||
Success: false,
|
||||
@@ -270,7 +285,11 @@ func (e *Engine) validateBaseEdit(edit *ASTEdit) error {
|
||||
switch edit.Operation {
|
||||
case EditReplace, EditInsertBefore, EditInsertAfter:
|
||||
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:
|
||||
// new_content not required
|
||||
@@ -638,16 +657,19 @@ func flattenDiffLines(diffs []diffmatchpatch.Diff) (lines []diffLine, maxOldN, m
|
||||
// Strip the terminator for display; also drop a trailing CR so CRLF files
|
||||
// do not leak raw carriage returns into the rendered diff.
|
||||
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 {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
|
||||
oldLine++
|
||||
newLine++
|
||||
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++
|
||||
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++
|
||||
}
|
||||
}
|
||||
@@ -676,38 +698,31 @@ func diffHunkRanges(lines []diffLine) []indexRange {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
oldStart = lines[start].oldN
|
||||
newStart = lines[start].newN
|
||||
for i := start; i <= end; i++ {
|
||||
l := lines[i]
|
||||
switch l.op {
|
||||
switch lines[i].op {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
if oldCount == 0 {
|
||||
oldStart = l.oldN
|
||||
}
|
||||
if newCount == 0 {
|
||||
newStart = l.newN
|
||||
}
|
||||
oldCount++
|
||||
newCount++
|
||||
case diffmatchpatch.DiffDelete:
|
||||
if oldCount == 0 {
|
||||
oldStart = l.oldN
|
||||
}
|
||||
if newCount == 0 {
|
||||
newStart = l.oldN // approximate
|
||||
}
|
||||
oldCount++
|
||||
case diffmatchpatch.DiffInsert:
|
||||
if newCount == 0 {
|
||||
newStart = l.newN
|
||||
}
|
||||
if oldCount == 0 {
|
||||
oldStart = l.newN // approximate
|
||||
}
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
if oldCount == 0 {
|
||||
oldStart = max(0, oldStart-1)
|
||||
}
|
||||
if newCount == 0 {
|
||||
newStart = max(0, newStart-1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -864,16 +879,5 @@ func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// ValidateLanguage checks if AST editing is supported for a file.
|
||||
// Returns nil for supported languages, error for unsupported.
|
||||
// Note: Text-based editing is always available regardless of this check.
|
||||
func ValidateLanguage(filename string) error {
|
||||
lang := protocol.DetectLanguage(filename)
|
||||
if lang == protocol.LangUnknown {
|
||||
return fmt.Errorf("unsupported file type for AST editing: %s (text-based editing is available)", filename)
|
||||
}
|
||||
return nil
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
@@ -353,6 +369,112 @@ func Hello() {
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRevalidatesPathBeforeWrite verifies the write-time admission re-check:
|
||||
// an Engine whose validator rejects the target must refuse the write and leave
|
||||
// the file untouched (the TOCTOU guard against a symlink swapped in after the
|
||||
// handler's original IsPathAllowed check).
|
||||
func TestApplyRevalidatesPathBeforeWrite(t *testing.T) {
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.go")
|
||||
original := "package main\n\nfunc Hello() {}\n"
|
||||
if err := os.WriteFile(tmpFile, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Validator denies the path (as IsPathAllowed would for a path that now
|
||||
// resolves outside the workspace root).
|
||||
e := NewEngineWithValidator(registry, func(string) bool { return false })
|
||||
|
||||
result, err := e.Apply(context.Background(), &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditReplace,
|
||||
Selector: ASTSelector{Kind: "function_declaration"},
|
||||
NewContent: "func Evil() {}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply returned transport error: %v", err)
|
||||
}
|
||||
if result.Success || result.Applied {
|
||||
t.Errorf("rejected-path edit must not apply (Success=%v Applied=%v)", result.Success, result.Applied)
|
||||
}
|
||||
if got, _ := os.ReadFile(tmpFile); string(got) != original {
|
||||
t.Errorf("file must be unchanged when path validation fails; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPreservesFileMode verifies an edit keeps the file's existing
|
||||
// permission bits rather than resetting them.
|
||||
func TestApplyPreservesFileMode(t *testing.T) {
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
e := NewEngine(registry)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "script.go")
|
||||
if err := os.WriteFile(tmpFile, []byte("package main\n\nfunc Hello() {}\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := e.Apply(context.Background(), &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditReplace,
|
||||
Selector: ASTSelector{Kind: "function_declaration"},
|
||||
NewContent: "func Hi() {}",
|
||||
})
|
||||
if err != nil || !result.Success {
|
||||
t.Fatalf("apply failed: err=%v result=%+v", err, result)
|
||||
}
|
||||
info, err := os.Stat(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o755 {
|
||||
t.Errorf("file mode not preserved: got %o, want 0755", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
original := "line1\nline2\nline3"
|
||||
modified := "line1\nmodified\nline3"
|
||||
@@ -411,6 +533,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) {
|
||||
// Regression test: replacing a line range should not produce phantom
|
||||
// +/- lines for unchanged code after the edit region.
|
||||
@@ -1673,3 +1818,80 @@ func TestRegressionLineMergeDelete(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindExactTextIndexAndOverlap pins selector_index resolution for literal
|
||||
// text matches, including overlapping matches and the out-of-range guard — the
|
||||
// range these return is the span that gets mutated, so wrong indices = wrong-
|
||||
// location writes.
|
||||
func TestFindExactTextIndexAndOverlap(t *testing.T) {
|
||||
e := NewEngine(parser.NewRegistry())
|
||||
content := []byte("aaaa") // "aa" matches at 0,1,2 (overlapping)
|
||||
|
||||
for i, want := range []struct{ start, end int }{{0, 2}, {1, 3}, {2, 4}} {
|
||||
start, end, err := e.findExactText(content, "aa", i)
|
||||
if err != nil {
|
||||
t.Fatalf("index %d: unexpected error: %v", i, err)
|
||||
}
|
||||
if start != want.start || end != want.end {
|
||||
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, err := e.findExactText(content, "aa", 3); err == nil {
|
||||
t.Error("index 3 (only 3 matches) must be out-of-range error")
|
||||
}
|
||||
if _, _, err := e.findExactText(content, "zz", 0); err == nil {
|
||||
t.Error("missing text must error")
|
||||
}
|
||||
if _, _, err := e.findExactText(content, "", 0); err == nil {
|
||||
t.Error("empty text selector must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindRegexPatternIndexAndError pins selector_index and error paths for regex
|
||||
// selection (non-overlapping matches via FindAllIndex).
|
||||
func TestFindRegexPatternIndexAndError(t *testing.T) {
|
||||
e := NewEngine(parser.NewRegistry())
|
||||
content := []byte("a1b2c3") // [0-9] matches at 1,3,5
|
||||
|
||||
for i, want := range []struct{ start, end int }{{1, 2}, {3, 4}, {5, 6}} {
|
||||
start, end, err := e.findRegexPattern(content, "[0-9]", i)
|
||||
if err != nil {
|
||||
t.Fatalf("index %d: unexpected error: %v", i, err)
|
||||
}
|
||||
if start != want.start || end != want.end {
|
||||
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, err := e.findRegexPattern(content, "[0-9]", 3); err == nil {
|
||||
t.Error("index 3 (only 3 matches) must be out-of-range error")
|
||||
}
|
||||
if _, _, err := e.findRegexPattern(content, "zzz", 0); err == nil {
|
||||
t.Error("non-matching pattern must error")
|
||||
}
|
||||
if _, _, err := e.findRegexPattern(content, "[unclosed", 0); err == nil {
|
||||
t.Error("invalid regex must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpliceContentCRLFNoBareLF verifies the CRLF pullback: when an edit range
|
||||
// ends between the \r and \n of a terminator, the \r\n must stay intact in the
|
||||
// tail rather than being split into a bare LF (which would corrupt the file's
|
||||
// line-ending convention).
|
||||
func TestSpliceContentCRLFNoBareLF(t *testing.T) {
|
||||
// Replace [0,4) which ends on the \n of "foo\r\n" (index 4, preceded by \r@3).
|
||||
got, err := spliceContent(EditReplace, []byte("foo\r\nbar\r\n"), 0, 4, "X", "\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("splice: %v", err)
|
||||
}
|
||||
if string(got) != "X\r\nbar\r\n" {
|
||||
t.Fatalf("pullback failed: got %q, want %q", got, "X\r\nbar\r\n")
|
||||
}
|
||||
// No \n may appear without a preceding \r.
|
||||
for i, b := range got {
|
||||
if b == '\n' && (i == 0 || got[i-1] != '\r') {
|
||||
t.Errorf("bare LF at index %d in %q", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
-4
@@ -22,6 +22,10 @@ const ProcessKillTimeout = 5 * time.Second
|
||||
// StderrBufferSize is the buffer size for draining stderr.
|
||||
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.
|
||||
type Client struct {
|
||||
stdin io.WriteCloser
|
||||
@@ -35,6 +39,8 @@ type Client struct {
|
||||
runningMu sync.RWMutex
|
||||
stopOnce sync.Once
|
||||
mu sync.Mutex
|
||||
stderrMu sync.Mutex
|
||||
stderrTail []byte // most recent stderr, capped at StderrCaptureMax (diagnostics)
|
||||
running bool
|
||||
}
|
||||
|
||||
@@ -121,6 +127,12 @@ func NewClient(cmd *exec.Cmd) (*Client, error) {
|
||||
|
||||
// drainStderr consumes stderr output to prevent the LSP server from blocking.
|
||||
// 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() {
|
||||
buf := make([]byte, StderrBufferSize)
|
||||
for {
|
||||
@@ -129,14 +141,35 @@ func (c *Client) drainStderr() {
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Read and discard stderr output
|
||||
_, err := c.stderr.Read(buf)
|
||||
n, err := c.stderr.Read(buf)
|
||||
if n > 0 {
|
||||
c.captureStderr(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
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.
|
||||
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
||||
c.runningMu.RLock()
|
||||
@@ -271,7 +304,12 @@ func (c *Client) send(msg interface{}) error {
|
||||
// On exit (for any reason), it drains all pending Call waiters with a
|
||||
// synthetic error so that goroutines blocked in Call are unblocked.
|
||||
func (c *Client) readLoop() {
|
||||
// On exit (EOF/error from a crashed or closed server) mark the client not
|
||||
// running so subsequent Calls fail fast — otherwise they pass the running
|
||||
// guard, send to a dead stdin, and block until their own timeout. Then
|
||||
// unblock any already-in-flight waiters. LIFO: setRunning(false) runs first.
|
||||
defer c.drainPending()
|
||||
defer c.setRunning(false)
|
||||
|
||||
reader := bufio.NewReader(c.stdout)
|
||||
|
||||
@@ -315,7 +353,14 @@ func (c *Client) readLoop() {
|
||||
if err := json.Unmarshal(body, &resp); err == nil && resp.ID != 0 {
|
||||
c.mu.Lock()
|
||||
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()
|
||||
continue
|
||||
@@ -340,13 +385,19 @@ func (c *Client) drainPending() {
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for id, ch := range c.pending {
|
||||
ch <- &Response{
|
||||
// Non-blocking, matching the dispatch path: the channel is buffered(1)
|
||||
// and read once, but a duplicate-id race could have already filled it —
|
||||
// never block here while holding c.mu.
|
||||
select {
|
||||
case ch <- &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &ResponseError{
|
||||
Code: -32603, // InternalError
|
||||
Message: "LSP client readLoop terminated",
|
||||
},
|
||||
}:
|
||||
default:
|
||||
}
|
||||
delete(c.pending, id)
|
||||
}
|
||||
@@ -358,3 +409,10 @@ func (c *Client) IsRunning() bool {
|
||||
defer c.runningMu.RUnlock()
|
||||
return c.running
|
||||
}
|
||||
|
||||
// setRunning sets the running flag under its lock.
|
||||
func (c *Client) setRunning(v bool) {
|
||||
c.runningMu.Lock()
|
||||
c.running = v
|
||||
c.runningMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestClientMarksNotRunningOnProcessExit verifies crash detection: when the
|
||||
// underlying server process exits (stdout EOF), readLoop must flip the client to
|
||||
// not-running so later Calls fail fast instead of blocking until their timeout.
|
||||
func TestClientMarksNotRunningOnProcessExit(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0") // exits immediately, closing stdout
|
||||
c, err := NewClient(cmd)
|
||||
if err != nil {
|
||||
t.Skipf("cannot start helper process: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsRunning() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if c.IsRunning() {
|
||||
t.Fatal("client should be marked not running after its process exits")
|
||||
}
|
||||
|
||||
// A Call on the dead client must fail fast (the running guard), not hang.
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, callErr := c.Call(context.Background(), "test/method", nil)
|
||||
done <- callErr
|
||||
}()
|
||||
select {
|
||||
case callErr := <-done:
|
||||
if callErr == nil {
|
||||
t.Error("Call on a dead client should return an error")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Call on a dead client hung instead of failing fast")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+67
-11
@@ -128,7 +128,11 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
srv, exists := m.servers[lang]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists && srv.ready {
|
||||
// A cached server is only reusable if it is both initialized and still
|
||||
// running — a crashed language server stays in the map with ready==true, so
|
||||
// without the liveness check every later call would be routed to a dead
|
||||
// process and block until timeout.
|
||||
if exists && srv.ready && srv.client.IsRunning() {
|
||||
// Update lastUsed with server's own lock to avoid race condition
|
||||
srv.mu.Lock()
|
||||
srv.lastUsed = time.Now()
|
||||
@@ -141,13 +145,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if srv, ok := m.servers[lang]; ok && srv.ready {
|
||||
if srv, ok := m.servers[lang]; ok && srv.ready && srv.client.IsRunning() {
|
||||
srv.mu.Lock()
|
||||
srv.lastUsed = time.Now()
|
||||
srv.mu.Unlock()
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Evict a stale entry (crashed or never-ready) before spawning a replacement.
|
||||
// Its process has already exited, so close it asynchronously to avoid holding
|
||||
// m.mu on the (fast) cmd.Wait().
|
||||
if stale, ok := m.servers[lang]; ok {
|
||||
delete(m.servers, lang)
|
||||
go func() { _ = stale.client.Close() }()
|
||||
}
|
||||
|
||||
// Check if server config exists
|
||||
config, ok := DefaultServerConfigs[lang]
|
||||
if !ok {
|
||||
@@ -212,10 +224,16 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
// Initialize server
|
||||
if err := m.initializeServer(ctx, newSrv); err != nil {
|
||||
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("command", config.Command[0]).
|
||||
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
|
||||
@@ -358,19 +376,57 @@ func (m *Manager) Definition(ctx context.Context, file string, line, col int) ([
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Result can be Location, []Location, or []LocationLink
|
||||
var locations []Location
|
||||
if err := json.Unmarshal(resp.Result, &locations); err != nil {
|
||||
// Try single location
|
||||
var single Location
|
||||
if err := json.Unmarshal(resp.Result, &single); err == nil {
|
||||
locations = []Location{single}
|
||||
}
|
||||
return parseDefinitionResult(resp.Result)
|
||||
}
|
||||
|
||||
// parseDefinitionResult decodes a textDocument/definition result. Per LSP the
|
||||
// result may be Location, Location[], LocationLink, or LocationLink[]. Because
|
||||
// we advertise LinkSupport (see initialize), servers such as gopls and
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *Manager) References(ctx context.Context, file string, line, col int, includeDeclaration bool) ([]Location, error) {
|
||||
lang := protocol.DetectLanguage(file)
|
||||
|
||||
+22
-17
@@ -33,8 +33,13 @@ func (c *Counter) Inc() {
|
||||
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) {
|
||||
if delta < 0 {
|
||||
return
|
||||
}
|
||||
c.value.Add(delta)
|
||||
}
|
||||
|
||||
@@ -278,27 +283,27 @@ func (r *Registry) Expose() string {
|
||||
// Export counters
|
||||
for _, c := range r.counters {
|
||||
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))
|
||||
sb.WriteString(fmt.Sprintf("%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load()))
|
||||
fmt.Fprintf(&sb, "# TYPE %s counter\n", c.name)
|
||||
fmt.Fprintf(&sb, "%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load())
|
||||
}
|
||||
|
||||
// Export gauges
|
||||
for _, g := range r.gauges {
|
||||
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))
|
||||
sb.WriteString(fmt.Sprintf("%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load()))
|
||||
fmt.Fprintf(&sb, "# TYPE %s gauge\n", g.name)
|
||||
fmt.Fprintf(&sb, "%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load())
|
||||
}
|
||||
|
||||
// Export histograms
|
||||
for _, h := range r.histograms {
|
||||
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
|
||||
var cumulative int64
|
||||
@@ -306,26 +311,26 @@ func (r *Registry) Expose() string {
|
||||
cumulative += h.counts[i].Load()
|
||||
labelStr := formatLabels(h.labels)
|
||||
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 {
|
||||
// Insert le label into existing labels
|
||||
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
|
||||
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative))
|
||||
fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
|
||||
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative)
|
||||
}
|
||||
}
|
||||
// +Inf bucket
|
||||
cumulative += h.counts[len(h.buckets)].Load()
|
||||
labelStr := formatLabels(h.labels)
|
||||
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 {
|
||||
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
|
||||
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative))
|
||||
fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
|
||||
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative)
|
||||
}
|
||||
|
||||
// Sum and count
|
||||
sb.WriteString(fmt.Sprintf("%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_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum())
|
||||
fmt.Fprintf(&sb, "%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load())
|
||||
}
|
||||
|
||||
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) {
|
||||
c := NewCounter("concurrent_counter", "test", nil)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -16,6 +16,14 @@ func FindNodeAtPosition(tree *sitter.Tree, line, col int) *sitter.Node {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Positions are 1-indexed; reject non-positive values so the uint32
|
||||
// conversion below can't underflow to a huge Row/Column (line/col == 0
|
||||
// would wrap to 4294967295). Reachable from the LSP-down AST fallback with
|
||||
// caller-supplied coordinates.
|
||||
if line < 1 || col < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to 0-indexed
|
||||
point := sitter.Point{
|
||||
Row: uint32(line - 1), // #nosec G115 - line numbers are bounded by file size
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindNodeAtPosition(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
content := []byte("package main\n\nfunc Hello() string { return \"hi\" }\n")
|
||||
res, err := r.Parse(context.Background(), "test.go", content)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
t.Run("nil tree", func(t *testing.T) {
|
||||
if FindNodeAtPosition(nil, 3, 6) != nil {
|
||||
t.Error("nil tree must return nil")
|
||||
}
|
||||
})
|
||||
|
||||
// Guard against the uint32 underflow: non-positive coordinates must return
|
||||
// nil, never wrap to a huge Row/Column.
|
||||
t.Run("non-positive coordinates", func(t *testing.T) {
|
||||
for _, c := range []struct{ line, col int }{{0, 0}, {0, 5}, {3, 0}, {-1, 4}} {
|
||||
if got := FindNodeAtPosition(res.Tree, c.line, c.col); got != nil {
|
||||
t.Errorf("FindNodeAtPosition(tree, %d, %d) = %v, want nil", c.line, c.col, got.Type())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of range line returns nil", func(t *testing.T) {
|
||||
if got := FindNodeAtPosition(res.Tree, 99999, 1); got != nil {
|
||||
t.Errorf("far-out-of-range line should return nil, got %v", got.Type())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid position returns a node", func(t *testing.T) {
|
||||
// Line 3, column 6 lands inside "Hello".
|
||||
got := FindNodeAtPosition(res.Tree, 3, 6)
|
||||
if got == nil {
|
||||
t.Fatal("expected a node at a valid in-range position, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package parser
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
@@ -530,11 +531,16 @@ func FormatDocComment(doc *DocComment) string {
|
||||
written[t] = true
|
||||
}
|
||||
|
||||
for tagName, val := range doc.Tags {
|
||||
remaining := make([]string, 0, len(doc.Tags))
|
||||
for tagName := range doc.Tags {
|
||||
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())
|
||||
|
||||
@@ -48,10 +48,13 @@ type Registry struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Tree *sitter.Tree
|
||||
Language protocol.Language
|
||||
Errors []SyntaxError
|
||||
}
|
||||
|
||||
// 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))
|
||||
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
|
||||
r.cacheHits.Add(1)
|
||||
errors := extractErrors(cached.Tree.RootNode(), content)
|
||||
return &ParseResult{
|
||||
Tree: cached.Tree,
|
||||
Language: lang,
|
||||
Errors: errors,
|
||||
Errors: cached.Errors,
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
@@ -242,10 +244,12 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
||||
// Extract syntax errors
|
||||
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{
|
||||
Tree: tree,
|
||||
Language: lang,
|
||||
Errors: errors,
|
||||
})
|
||||
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+39
-11
@@ -36,22 +36,23 @@ func SkeletonFile(ctx context.Context, reg *Registry, filename string, content [
|
||||
default:
|
||||
// TODO: skeleton for c, cpp, elixir, html, vue — fall back to symbols_only
|
||||
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).
|
||||
func renderSymbolsOnly(syms []protocol.Symbol, _ string, lang protocol.Language, content []byte) string {
|
||||
lines := strings.Split(string(content), "\n")
|
||||
// renderSymbolsOnly renders a compact symbol list (fallback for unsupported
|
||||
// languages). It deliberately does not fabricate "<source line> { ... }" output:
|
||||
// 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
|
||||
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 {
|
||||
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 != "" {
|
||||
sb.WriteString(fmt.Sprintf("// doc: %s\n", s.Doc))
|
||||
}
|
||||
if s.Location.Line >= 1 && s.Location.Line <= len(lines) {
|
||||
sb.WriteString(lines[s.Location.Line-1] + " { ... }\n")
|
||||
fmt.Fprintf(&sb, " doc: %s\n", s.Doc)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
@@ -216,7 +217,34 @@ func skeletonPythonNode(node *sitter.Node, content []byte, sb *strings.Builder,
|
||||
skeletonPythonNode(child, content, sb, indent)
|
||||
}
|
||||
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()])
|
||||
lines := strings.SplitN(nodeText, "\n", 2)
|
||||
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}
|
||||
}
|
||||
|
||||
// stripLicense removes a leading block comment that looks like a license header.
|
||||
// A comment qualifies if it contains "copyright", "license", or "spdx-license-identifier" (case-insensitive).
|
||||
// containsLicenseKeyword reports whether lowercased text looks like a license
|
||||
// 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) {
|
||||
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
|
||||
if strings.HasPrefix(trimmed, "/*") {
|
||||
end := strings.Index(trimmed, "*/")
|
||||
if end >= 0 {
|
||||
candidate := trimmed[:end+2]
|
||||
lower := strings.ToLower(candidate)
|
||||
if strings.Contains(lower, "copyright") ||
|
||||
strings.Contains(lower, "license") ||
|
||||
strings.Contains(lower, "spdx-license-identifier") {
|
||||
if containsLicenseKeyword(strings.ToLower(candidate)) {
|
||||
rest := trimmed[end+2:]
|
||||
// Consume trailing newline(s)
|
||||
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
|
||||
// header; a blank line ends it and is preserved as a separator (rather than being
|
||||
// greedily swallowed and collapsed away).
|
||||
@@ -95,15 +153,12 @@ func stripLicense(content string) (string, bool) {
|
||||
rest = lines[i:]
|
||||
break
|
||||
}
|
||||
lower := strings.ToLower(strings.Join(commentLines, "\n"))
|
||||
if strings.Contains(lower, "copyright") ||
|
||||
strings.Contains(lower, "license") ||
|
||||
strings.Contains(lower, "spdx-license-identifier") {
|
||||
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
|
||||
return strings.Join(rest, "\n"), true
|
||||
}
|
||||
}
|
||||
|
||||
return content, false
|
||||
return s, false
|
||||
}
|
||||
|
||||
// stripImports removes top-of-file import blocks, language-specific.
|
||||
@@ -147,7 +202,10 @@ func stripGoImports(content string) (string, bool) {
|
||||
}
|
||||
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
|
||||
i++
|
||||
continue
|
||||
@@ -161,15 +219,45 @@ func stripGoImports(content string) (string, bool) {
|
||||
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) {
|
||||
lines := strings.Split(content, "\n")
|
||||
var out []string
|
||||
removed := false
|
||||
inMulti := false
|
||||
for _, l := range lines {
|
||||
trimLine := strings.TrimSpace(l)
|
||||
if strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(") {
|
||||
if inMulti {
|
||||
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
|
||||
}
|
||||
out = append(out, l)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) }
|
||||
|
||||
+192
-48
@@ -1,6 +1,8 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
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.
|
||||
// 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 {
|
||||
var symbols []protocol.Symbol
|
||||
|
||||
@@ -49,6 +73,10 @@ func extractGoSymbols(root *sitter.Node, content []byte, filename string) []prot
|
||||
case "type_declaration":
|
||||
symbol = extractGoType(n, content, filename)
|
||||
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)
|
||||
symbols = append(symbols, syms...)
|
||||
return true
|
||||
@@ -152,12 +180,20 @@ func extractGoVarConst(n *sitter.Node, content []byte, filename string) []protoc
|
||||
|
||||
WalkTree(n, func(node *sitter.Node) bool {
|
||||
if node.Type() == "const_spec" || node.Type() == "var_spec" {
|
||||
nameNode := node.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
// A spec may declare multiple names (e.g. var a, b = 1, 2);
|
||||
// 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{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
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":
|
||||
symbol = extractJSMethod(n, content, filename)
|
||||
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)
|
||||
symbols = append(symbols, syms...)
|
||||
return true
|
||||
@@ -386,29 +427,65 @@ func extractCSymbols(root *sitter.Node, content []byte, filename string) []proto
|
||||
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 {
|
||||
declarator := n.ChildByFieldName("declarator")
|
||||
if declarator == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the function name within the declarator
|
||||
var name string
|
||||
WalkTree(declarator, func(node *sitter.Node) bool {
|
||||
if node.Type() == "identifier" {
|
||||
name = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
name := cDeclaratorName(declarator, content)
|
||||
if name == "" {
|
||||
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{
|
||||
Name: name,
|
||||
Kind: protocol.SymbolFunction,
|
||||
Kind: kind,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
@@ -445,22 +522,22 @@ func extractCFunctionDecl(n *sitter.Node, content []byte, filename string) *prot
|
||||
return nil
|
||||
}
|
||||
|
||||
var name string
|
||||
WalkTree(declarator, func(node *sitter.Node) bool {
|
||||
if node.Type() == "identifier" {
|
||||
name = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
name := cDeclaratorName(declarator, content)
|
||||
if name == "" {
|
||||
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{
|
||||
Name: name,
|
||||
Kind: protocol.SymbolFunction,
|
||||
Kind: kind,
|
||||
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 {
|
||||
// defstruct is typically inside a module, the struct name is the module name
|
||||
// We just mark this as a struct symbol
|
||||
name := "defstruct"
|
||||
if mod := enclosingElixirModuleName(n, content); mod != "" {
|
||||
name = "%" + mod + "{}"
|
||||
}
|
||||
return &protocol.Symbol{
|
||||
Name: "defstruct",
|
||||
Name: name,
|
||||
Kind: protocol.SymbolStruct,
|
||||
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.
|
||||
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// defprotocol ProtocolName do ... end
|
||||
@@ -910,17 +1022,37 @@ func kindMatchesNode(kind protocol.SymbolKind, nodeType string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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.
|
||||
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
|
||||
// SymbolCandidate is one symbol matching a name (and optional kind) lookup,
|
||||
// with its declaration node type and 1-indexed inclusive line range. NameLine
|
||||
// and NameColumn point at the name identifier itself (1-indexed), which is the
|
||||
// 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 == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
var candidates []SymbolCandidate
|
||||
WalkTree(tree.RootNode(), func(n *sitter.Node) bool {
|
||||
if found {
|
||||
return false
|
||||
}
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode == nil {
|
||||
return true
|
||||
@@ -928,22 +1060,34 @@ func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName str
|
||||
if GetNodeText(nameNode, content) != symbolName {
|
||||
return true
|
||||
}
|
||||
switch n.Type() {
|
||||
case "function_declaration", "method_declaration", "type_declaration",
|
||||
"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 !symbolDeclNodeTypes[n.Type()] {
|
||||
return true
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
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"
|
||||
)
|
||||
|
||||
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) {
|
||||
r := NewRegistry()
|
||||
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) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
@@ -4,70 +4,45 @@ package parser
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
)
|
||||
|
||||
// YAMLNode wraps yaml.Node to provide tree-sitter-like interface
|
||||
type YAMLNode struct {
|
||||
*yaml.Node
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// JSONNode represents a JSON AST node
|
||||
type JSONNode struct {
|
||||
Value any
|
||||
Type string
|
||||
Children []*JSONNode
|
||||
Line int
|
||||
Column int
|
||||
}
|
||||
|
||||
// ParseYAML parses YAML content and returns a tree-sitter-compatible result
|
||||
// ParseYAML parses YAML content and returns a tree-sitter-compatible result.
|
||||
// YAML does not use tree-sitter, so the returned ParseResult.Tree is nil.
|
||||
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||
// Check file size
|
||||
if len(content) > maxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
return nil, errors.NewParseError("yaml", filename, err)
|
||||
}
|
||||
|
||||
// Extract syntax errors from YAML parse
|
||||
syntaxErrors := extractYAMLErrors()
|
||||
|
||||
// Create a pseudo tree-sitter tree for compatibility
|
||||
// We'll use nil for the tree since YAML doesn't use tree-sitter
|
||||
return &ParseResult{
|
||||
Tree: nil, // YAML uses yaml.Node instead
|
||||
Tree: nil, // YAML uses yaml.Node instead of tree-sitter
|
||||
Language: protocol.LangYAML,
|
||||
Errors: syntaxErrors,
|
||||
Errors: extractYAMLErrors(),
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseJSON parses JSON content and returns a tree-sitter-compatible result
|
||||
// ParseJSON parses JSON content and returns a tree-sitter-compatible result.
|
||||
// JSON does not use tree-sitter, so the returned ParseResult.Tree is nil.
|
||||
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||
// Check file size
|
||||
if len(content) > maxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||
}
|
||||
|
||||
// Parse JSON to validate syntax
|
||||
var jsonData any
|
||||
if err := json.Unmarshal(content, &jsonData); err != nil {
|
||||
return nil, errors.NewParseError("json", filename, err)
|
||||
}
|
||||
|
||||
// JSON parsing succeeded, no syntax errors
|
||||
return &ParseResult{
|
||||
Tree: nil, // JSON uses native Go structures
|
||||
Language: protocol.LangJSON,
|
||||
@@ -76,120 +51,8 @@ func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byt
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractYAMLErrors extracts errors from YAML nodes
|
||||
// extractYAMLErrors returns YAML syntax errors. yaml.Unmarshal already reports
|
||||
// parse errors, so reaching here means the document was syntactically valid.
|
||||
func extractYAMLErrors() []SyntaxError {
|
||||
// YAML parser already validates during unmarshal
|
||||
// If we got here, there are no syntax errors
|
||||
// However, we could add semantic validation here in the future
|
||||
return []SyntaxError{}
|
||||
}
|
||||
|
||||
// WalkYAML walks a YAML AST and calls fn for each node
|
||||
func WalkYAML(node *yaml.Node, fn func(*yaml.Node) bool) {
|
||||
if node == nil || !fn(node) {
|
||||
return
|
||||
}
|
||||
|
||||
for _, child := range node.Content {
|
||||
WalkYAML(child, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// GetYAMLNodeText returns the text representation of a YAML node
|
||||
func GetYAMLNodeText(node *yaml.Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode:
|
||||
if len(node.Content) > 0 {
|
||||
return GetYAMLNodeText(node.Content[0])
|
||||
}
|
||||
return ""
|
||||
case yaml.MappingNode:
|
||||
return node.Value
|
||||
case yaml.SequenceNode:
|
||||
return node.Value
|
||||
case yaml.ScalarNode:
|
||||
return node.Value
|
||||
case yaml.AliasNode:
|
||||
return node.Value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetYAMLNodeLocation returns the location of a YAML node
|
||||
func GetYAMLNodeLocation(node *yaml.Node) protocol.Location {
|
||||
if node == nil {
|
||||
return protocol.Location{Line: 1, Column: 1}
|
||||
}
|
||||
|
||||
return protocol.Location{
|
||||
Line: node.Line,
|
||||
Column: node.Column,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryYAML performs a simple query on YAML content
|
||||
// Example: "$.metadata.name" to find the name field in metadata
|
||||
func QueryYAML(content []byte, query string) ([]*yaml.Node, error) {
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
||||
}
|
||||
|
||||
// Simple path-based query implementation
|
||||
// This is a basic implementation - can be extended with more sophisticated queries
|
||||
var results []*yaml.Node
|
||||
|
||||
WalkYAML(&root, func(node *yaml.Node) bool {
|
||||
if node.Value == query || node.Tag == query {
|
||||
results = append(results, node)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// QueryJSON performs a simple query on JSON content
|
||||
func QueryJSON(content []byte, query string) ([]any, error) {
|
||||
var data any
|
||||
if err := json.Unmarshal(content, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// Basic implementation - can be extended with JSONPath support
|
||||
var results []any
|
||||
|
||||
// For now, just validate that it's valid JSON
|
||||
results = append(results, data)
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ValidateYAML validates YAML content without parsing to full AST
|
||||
func ValidateYAML(content []byte) error {
|
||||
var node yaml.Node
|
||||
if err := yaml.Unmarshal(content, &node); err != nil {
|
||||
return fmt.Errorf("YAML validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJSON validates JSON content
|
||||
func ValidateJSON(content []byte) error {
|
||||
var data any
|
||||
if err := json.Unmarshal(content, &data); err != nil {
|
||||
return fmt.Errorf("JSON validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToSitterTree is a placeholder that returns nil for YAML/JSON
|
||||
// These formats don't use tree-sitter, but we keep this for interface compatibility
|
||||
func (yn *YAMLNode) ToSitterTree() *sitter.Tree {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
@@ -202,82 +200,3 @@ version: 1.0.0`)
|
||||
t.Errorf("expected YAML language for .yml extension, got %s", ymlResult.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkYAML(t *testing.T) {
|
||||
content := []byte(`metadata:
|
||||
name: test
|
||||
labels:
|
||||
app: myapp
|
||||
env: prod`)
|
||||
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
t.Fatalf("failed to parse YAML: %v", err)
|
||||
}
|
||||
|
||||
nodeCount := 0
|
||||
WalkYAML(&root, func(node *yaml.Node) bool {
|
||||
nodeCount++
|
||||
return true
|
||||
})
|
||||
|
||||
if nodeCount == 0 {
|
||||
t.Error("expected to visit nodes, but count is 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateYAML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid YAML",
|
||||
content: []byte("name: test\nvalue: 100"),
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
content: []byte("name: test\n bad:\n[unclosed"),
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateYAML(tt.content)
|
||||
if (err != nil) != tt.shouldError {
|
||||
t.Errorf("ValidateYAML() error = %v, shouldError = %v", err, tt.shouldError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid JSON",
|
||||
content: []byte(`{"name": "test", "value": 100}`),
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
content: []byte(`{"name": "test", "value": 100`),
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateJSON(tt.content)
|
||||
if (err != nil) != tt.shouldError {
|
||||
t.Errorf("ValidateJSON() error = %v, shouldError = %v", err, tt.shouldError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,33 @@ func TestFormatResultsPaginationCursor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// FormatResultsWithCursor must emit the supplied cursor footer verbatim in place
|
||||
// of the bare "[remaining: N]" hint when results are truncated.
|
||||
func TestFormatResultsWithCursor(t *testing.T) {
|
||||
results := makeResults(5)
|
||||
cursor := "[cursor: abc123, remaining: 3]"
|
||||
|
||||
out := FormatResultsWithCursor(results, 2, "compact", 0, false, cursor)
|
||||
if !strings.Contains(out, cursor) {
|
||||
t.Errorf("expected cursor footer %q, got:\n%s", cursor, out)
|
||||
}
|
||||
if strings.Contains(out, "[remaining: 3]\n") {
|
||||
t.Errorf("bare remaining hint should be replaced by cursor footer, got:\n%s", out)
|
||||
}
|
||||
|
||||
// Empty cursorLine falls back to the bare hint (parity with FormatResultsWithOptions).
|
||||
fallback := FormatResultsWithCursor(results, 2, "compact", 0, false, "")
|
||||
if !strings.Contains(fallback, "[remaining: 3]") {
|
||||
t.Errorf("empty cursorLine should fall back to [remaining: 3], got:\n%s", fallback)
|
||||
}
|
||||
|
||||
// No truncation → no footer at all.
|
||||
none := FormatResultsWithCursor(makeResults(2), 0, "compact", 0, false, cursor)
|
||||
if strings.Contains(none, cursor) || strings.Contains(none, "[remaining:") {
|
||||
t.Errorf("no truncation should emit no footer, got:\n%s", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResultsEmpty(t *testing.T) {
|
||||
out := FormatResultsWithOptions(nil, 0, "verbose", 0)
|
||||
if out != "No matches found." {
|
||||
@@ -159,6 +186,9 @@ func TestFirstLineOf(t *testing.T) {
|
||||
{"line1\nline2", 20, "line1"},
|
||||
{"\n\nfoo", 20, "foo"},
|
||||
{"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 {
|
||||
got := firstLineOf(c.input, c.maxLen)
|
||||
|
||||
+31
-11
@@ -460,7 +460,24 @@ func FormatResults(results []MatchResult, maxResults int) string {
|
||||
// format: "verbose" (default) | "compact" | "location"
|
||||
// offset: skip this many results before rendering (used for cursor pagination).
|
||||
// verbose: opt-in variadic — pass true to restore "Found N match(es):" preamble (v1 behaviour).
|
||||
// When results are truncated it emits a bare "[remaining: N]" hint; callers that
|
||||
// have a pagination cursor should use FormatResultsWithCursor to emit the full line.
|
||||
func FormatResultsWithOptions(results []MatchResult, maxResults int, format string, offset int, verbose ...bool) string {
|
||||
return formatResults(results, maxResults, format, offset, len(verbose) > 0 && verbose[0], "")
|
||||
}
|
||||
|
||||
// FormatResultsWithCursor is like FormatResultsWithOptions but, when results are
|
||||
// truncated, appends cursorLine verbatim instead of the bare "[remaining: N]"
|
||||
// hint. This lets the caller pass a fully-formed "[cursor: ..., remaining: N]"
|
||||
// footer without string-replacing a placeholder. If cursorLine is empty it
|
||||
// behaves like FormatResultsWithOptions.
|
||||
func FormatResultsWithCursor(results []MatchResult, maxResults int, format string, offset int, verbose bool, cursorLine string) string {
|
||||
return formatResults(results, maxResults, format, offset, verbose, cursorLine)
|
||||
}
|
||||
|
||||
// formatResults is the shared implementation. cursorLine, when non-empty, is
|
||||
// emitted in place of the "[remaining: N]" hint on truncation.
|
||||
func formatResults(results []MatchResult, maxResults int, format string, offset int, verbose bool, cursorLine string) string {
|
||||
if len(results) == 0 {
|
||||
return "No matches found."
|
||||
}
|
||||
@@ -484,9 +501,8 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
||||
var sb strings.Builder
|
||||
|
||||
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
|
||||
wantVerbose := len(verbose) > 0 && verbose[0]
|
||||
if wantVerbose {
|
||||
sb.WriteString(fmt.Sprintf("Found %d match(es):\n", renderCount))
|
||||
if verbose {
|
||||
fmt.Fprintf(&sb, "Found %d match(es):\n", renderCount)
|
||||
}
|
||||
|
||||
switch format {
|
||||
@@ -498,13 +514,13 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
||||
nodeType = r.Node.Type()
|
||||
}
|
||||
firstLine := firstLineOf(r.Text, 80)
|
||||
sb.WriteString(fmt.Sprintf("%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine))
|
||||
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
|
||||
}
|
||||
|
||||
case "location":
|
||||
for i := 0; i < renderCount; i++ {
|
||||
r := results[i]
|
||||
sb.WriteString(fmt.Sprintf("%s:%d\n", r.File, r.Location.Line))
|
||||
fmt.Fprintf(&sb, "%s:%d\n", r.File, r.Location.Line)
|
||||
}
|
||||
|
||||
default: // "verbose"
|
||||
@@ -514,7 +530,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
||||
if r.Node != nil {
|
||||
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
|
||||
text := r.Text
|
||||
@@ -538,7 +554,7 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
||||
if len(capText) > 50 {
|
||||
capText = capText[:50] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
|
||||
fmt.Fprintf(&sb, "$%s=%s", name, capText)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
@@ -547,9 +563,13 @@ func FormatResultsWithOptions(results []MatchResult, maxResults int, format stri
|
||||
}
|
||||
|
||||
if remaining > 0 {
|
||||
// Caller must embed the cursor token; we just append the remaining count hint.
|
||||
// The actual [cursor: ...] line is written by the handler after calling MakeCursor.
|
||||
sb.WriteString(fmt.Sprintf("[remaining: %d]\n", remaining))
|
||||
if cursorLine != "" {
|
||||
// Caller supplied a fully-formed "[cursor: ..., remaining: N]" footer.
|
||||
sb.WriteString(strings.TrimRight(cursorLine, "\n"))
|
||||
sb.WriteByte('\n')
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "[remaining: %d]\n", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -563,7 +583,7 @@ func firstLineOf(s string, maxLen int) string {
|
||||
continue
|
||||
}
|
||||
if len(line) > maxLen {
|
||||
return line[:maxLen-1] + "…"
|
||||
return line[:util.RuneBoundary(line, maxLen-1)] + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"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/protocol"
|
||||
)
|
||||
@@ -257,6 +258,17 @@ func (s *Searcher) validatePaths(paths []string) error {
|
||||
WithRemediation("Provide paths within the workspace root")
|
||||
}
|
||||
|
||||
// resultPathAllowed reports whether a ripgrep result path is within the
|
||||
// workspace root. ripgrep runs with Dir=WorkspaceRoot and emits paths relative
|
||||
// to it, so a relative path is joined to the root before the check; IsPathAllowed
|
||||
// then resolves symlinks, rejecting any path that --follow led outside the root.
|
||||
func (s *Searcher) resultPathAllowed(p string) bool {
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(s.cfg.WorkspaceRoot, p)
|
||||
}
|
||||
return s.cfg.IsPathAllowed(p)
|
||||
}
|
||||
|
||||
// parseOutput parses ripgrep JSON output.
|
||||
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
|
||||
results := &SearchResults{
|
||||
@@ -287,6 +299,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
||||
continue
|
||||
}
|
||||
|
||||
// Defense in depth: never surface a file outside the workspace root.
|
||||
// ripgrep with --follow (default on) can traverse a symlink whose
|
||||
// target lives outside the root; dropping those matches keeps search
|
||||
// from leaking external file content.
|
||||
if !s.resultPathAllowed(match.Path.Text) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check max results
|
||||
if maxResults > 0 && len(results.Results) >= maxResults {
|
||||
results.Truncated = true
|
||||
@@ -389,13 +409,15 @@ func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatO
|
||||
if opts.Verbose {
|
||||
totalMatches := len(results.Results)
|
||||
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 {
|
||||
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
|
||||
fmt.Fprintf(&sb, " (truncated, total: %d)", results.Total)
|
||||
}
|
||||
sb.WriteString(":\n\n")
|
||||
} else if results.Truncated {
|
||||
sb.WriteString(fmt.Sprintf("(truncated, showing subset of %d total matches)\n\n", results.Total))
|
||||
} else if results.Truncated && opts.CursorLine == "" {
|
||||
// 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
|
||||
@@ -408,7 +430,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 {
|
||||
writeClusteredResults(&sb, fileResults[file])
|
||||
@@ -486,10 +508,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 {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
@@ -8,10 +8,72 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
)
|
||||
|
||||
// parseOutput must not attach one file's pending before-context to a match in a
|
||||
// different file (cross-file leak guard in the JSON stream parser).
|
||||
func TestParseOutputCrossFileContextNotLeaked(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
var buf bytes.Buffer
|
||||
// fileA emits a before-context with no following match in fileA; then fileB
|
||||
// has a match. fileA's pending context must NOT attach to fileB's match.
|
||||
buf.WriteString(`{"type":"context","data":{"path":{"text":"fileA.go"},"lines":{"text":"orphan a ctx\n"},"line_number":1}}` + "\n")
|
||||
buf.WriteString(`{"type":"match","data":{"path":{"text":"fileB.go"},"lines":{"text":"hit B\n"},"line_number":5,"submatches":[{"match":{"text":"hit"},"start":0,"end":3}]}}` + "\n")
|
||||
|
||||
res, err := s.parseOutput(&buf, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parseOutput: %v", err)
|
||||
}
|
||||
if len(res.Results) != 1 {
|
||||
t.Fatalf("want 1 result, got %d", len(res.Results))
|
||||
}
|
||||
if res.Results[0].File != "fileB.go" {
|
||||
t.Errorf("result file = %q, want fileB.go", res.Results[0].File)
|
||||
}
|
||||
if len(res.Results[0].Context.Before) != 0 {
|
||||
t.Errorf("fileA context leaked into fileB match: %v", res.Results[0].Context.Before)
|
||||
}
|
||||
}
|
||||
|
||||
// parseOutput must attach a same-file before-context to the following match.
|
||||
func TestParseOutputSameFileBeforeContext(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(`{"type":"context","data":{"path":{"text":"f.go"},"lines":{"text":"before line\n"},"line_number":4}}` + "\n")
|
||||
buf.WriteString(`{"type":"match","data":{"path":{"text":"f.go"},"lines":{"text":"the match\n"},"line_number":5,"submatches":[{"match":{"text":"match"},"start":4,"end":9}]}}` + "\n")
|
||||
|
||||
res, err := s.parseOutput(&buf, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parseOutput: %v", err)
|
||||
}
|
||||
if len(res.Results) != 1 {
|
||||
t.Fatalf("want 1 result, got %d", len(res.Results))
|
||||
}
|
||||
got := res.Results[0].Context.Before
|
||||
if len(got) != 1 || got[0] != "before line" {
|
||||
t.Errorf("same-file before-context not attached: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
cfg := config.Default()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
@@ -340,3 +402,45 @@ func TestSearchEmptyPattern(t *testing.T) {
|
||||
t.Error("expected error for empty pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchSymlinkEscapeNotLeaked is a security regression guard: with
|
||||
// FollowSymlinks on (the default), ripgrep can traverse a symlink whose target
|
||||
// is outside the workspace root. Such matches must never be returned — search
|
||||
// must not leak the content of files outside the workspace.
|
||||
func TestSearchSymlinkEscapeNotLeaked(t *testing.T) {
|
||||
const secret = "SUPERSECRETLEAKCANARY"
|
||||
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte(secret+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
// A regular in-workspace file that does NOT contain the secret.
|
||||
if err := os.WriteFile(filepath.Join(workspace, "ok.txt"), []byte("nothing here\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A symlink inside the workspace pointing at the outside directory.
|
||||
if err := os.Symlink(outside, filepath.Join(workspace, "escape")); err != nil {
|
||||
t.Skipf("symlinks unsupported: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.WorkspaceRoot = workspace
|
||||
if !cfg.FollowSymlinks {
|
||||
t.Fatal("precondition: FollowSymlinks should default true so rg --follow is exercised")
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
s, err := New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Skip("ripgrep not installed")
|
||||
}
|
||||
|
||||
results, err := s.Search(context.Background(), &Request{Pattern: secret})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
for _, r := range results.Results {
|
||||
t.Errorf("symlink escape leaked an out-of-workspace match: file=%q text=%q", r.File, r.MatchText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,16 +55,18 @@ func Epsilon() int { return 2 }
|
||||
|
||||
// ---- Feature 1: ast_query format flag ----
|
||||
|
||||
func TestASTQueryFormatVerboseDefault(t *testing.T) {
|
||||
func TestASTQueryFormatCompactDefault(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
// Default (no format arg) is compact: one line per match, no code blocks.
|
||||
args := map[string]interface{}{
|
||||
"pattern": "func $NAME() string",
|
||||
"language": "go",
|
||||
"paths": []interface{}{tmpDir},
|
||||
}
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = args
|
||||
res, err := srv.handleASTQuery(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -72,10 +74,19 @@ func TestASTQueryFormatVerboseDefault(t *testing.T) {
|
||||
if res == nil || len(res.Content) == 0 {
|
||||
t.Fatal("nil/empty result")
|
||||
}
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
// verbose mode has code blocks
|
||||
if !strings.Contains(text, "```") {
|
||||
t.Errorf("verbose mode should have code blocks, got:\n%s", text)
|
||||
if text := res.Content[0].(mcp.TextContent).Text; strings.Contains(text, "```") {
|
||||
t.Errorf("compact default should NOT 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,8 +150,115 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelPath verifies workspace-relative display paths (token saving) with a
|
||||
// safe fallback to the original path for anything outside the root.
|
||||
func TestRelPath(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
if got := srv.relPath(filepath.Join(tmpDir, "pkg", "a.go")); got != filepath.Join("pkg", "a.go") {
|
||||
t.Errorf("in-workspace path should be relativized, got %q", got)
|
||||
}
|
||||
// Outside the workspace root → returned unchanged (never a ../ escape string).
|
||||
outside := "/definitely/outside/x.go"
|
||||
if got := srv.relPath(outside); got != outside {
|
||||
t.Errorf("out-of-workspace path should be returned as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Feature 3 (ast_query): pagination cursor ----
|
||||
|
||||
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
|
||||
// cursor with an accurate remaining count when more matches exist. Previously
|
||||
// the page-1 walk was capped at max_results, so the total was truncated and the
|
||||
// cursor fired nondeterministically (and with a wrong remaining count).
|
||||
func TestASTQueryFirstPageCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Fixture has 5 functions (Alpha/Beta/Gamma/Delta/Epsilon); cap at 2.
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() $RET",
|
||||
"language": "go",
|
||||
"paths": []interface{}{tmpDir},
|
||||
"max_results": float64(2),
|
||||
}
|
||||
res, err := srv.handleASTQuery(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("page1 error: %v", err)
|
||||
}
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "[cursor:") {
|
||||
t.Fatalf("page1 with 5 matches capped at 2 must emit a cursor, got:\n%s", text)
|
||||
}
|
||||
// 5 total - 2 shown = 3 remaining.
|
||||
if !strings.Contains(text, "remaining: 3") {
|
||||
t.Errorf("expected accurate 'remaining: 3', got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASTQueryPaginationCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
@@ -281,6 +399,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) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
if srv.searcher == nil {
|
||||
|
||||
@@ -67,7 +67,10 @@ func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryPa
|
||||
if prefsFormat != "" {
|
||||
p.format = prefsFormat
|
||||
} 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,12 +109,13 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
},
|
||||
}
|
||||
|
||||
// Collect limit for early-exit: when paginating we need all results first.
|
||||
collectLimit := p.maxResults
|
||||
if p.offset > 0 {
|
||||
collectLimit = 0
|
||||
}
|
||||
|
||||
// A full walk is required on every page: the cursor's "remaining" count is
|
||||
// derived from the total match count (see buildASTCursorFooter), so the page-1
|
||||
// response can only emit a correct cursor if it has seen every match. Capping
|
||||
// page 1 at max_results made the cursor fire nondeterministically (only when
|
||||
// the last parsed file overshot the cap) with a wrong remaining count. Parse
|
||||
// results are LRU-cached, so re-walking on later pages is mostly file I/O, and
|
||||
// matches below max_results already visit the whole tree regardless.
|
||||
var allResults []query.MatchResult
|
||||
for _, searchPath := range p.paths {
|
||||
if !s.cfg.IsPathAllowed(searchPath) {
|
||||
@@ -128,7 +132,10 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
return nil
|
||||
}
|
||||
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 nil
|
||||
@@ -137,11 +144,13 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Skip oversized files before reading them off disk.
|
||||
if info.Size() > s.cfg.MaxFileSize {
|
||||
return nil
|
||||
}
|
||||
if int64(len(content)) > s.cfg.MaxFileSize {
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -154,10 +163,6 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
return nil
|
||||
}
|
||||
allResults = append(allResults, matches...)
|
||||
|
||||
if collectLimit > 0 && len(allResults) >= collectLimit {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
@@ -167,6 +172,19 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
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.
|
||||
func hasAnySuffix(path string, suffixes []string) bool {
|
||||
for _, ext := range suffixes {
|
||||
@@ -222,16 +240,9 @@ func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest
|
||||
allResults := s.runASTQueryWalk(ctx, p, exts)
|
||||
cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash)
|
||||
|
||||
output := query.FormatResultsWithOptions(allResults, p.maxResults, p.format, p.offset, p.verbose)
|
||||
if cursorFooter != "" {
|
||||
// Replace the [remaining: N] placeholder emitted by FormatResultsWithOptions
|
||||
// with the full [cursor: ..., remaining: N] line.
|
||||
output = strings.ReplaceAll(output, fmt.Sprintf("[remaining: %d]\n", len(allResults)-p.offset-p.maxResults), cursorFooter+"\n")
|
||||
// Fallback: if placeholder wasn't present (e.g. format=location), append footer.
|
||||
if !strings.Contains(output, cursorFooter) {
|
||||
output = strings.TrimRight(output, "\n") + "\n" + cursorFooter + "\n"
|
||||
}
|
||||
}
|
||||
// The formatter emits cursorFooter in place of its "[remaining: N]" hint when
|
||||
// results are truncated; no fragile placeholder string-replacement needed.
|
||||
output := query.FormatResultsWithCursor(allResults, p.maxResults, p.format, p.offset, p.verbose, cursorFooter)
|
||||
return mcp.NewToolResultText(output), nil
|
||||
}
|
||||
|
||||
@@ -252,25 +263,29 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Get relative path
|
||||
relPath := path
|
||||
if absPath, err := filepath.Abs(path); err == nil {
|
||||
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
relPath = rel
|
||||
}
|
||||
}
|
||||
|
||||
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", s.relPath(path), countLines(content), lang)
|
||||
sb.WriteString("Symbols:\n")
|
||||
|
||||
for _, sym := range symbols {
|
||||
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()
|
||||
}
|
||||
|
||||
// relPath renders path relative to the workspace root for display, saving the
|
||||
// tokens an absolute path would cost. Falls back to the original path when it
|
||||
// can't be relativized (outside the root, or on error).
|
||||
func (s *Server) relPath(path string) string {
|
||||
if absPath, err := filepath.Abs(path); err == nil {
|
||||
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// symbolKindIcon returns an icon/prefix for a symbol kind.
|
||||
func symbolKindIcon(kind protocol.SymbolKind) string {
|
||||
switch kind {
|
||||
|
||||
@@ -92,22 +92,21 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*
|
||||
return mcp.NewToolResultText(fmt.Sprintf("+%d -%d", added, removed)), nil
|
||||
|
||||
case "diff":
|
||||
var output strings.Builder
|
||||
output.WriteString("Diff:\n```diff\n")
|
||||
output.WriteString(result.Diff)
|
||||
output.WriteString("```\n")
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
|
||||
|
||||
default:
|
||||
// Fallback: treat unknown values as "diff" for safety.
|
||||
var output strings.Builder
|
||||
output.WriteString("Diff:\n```diff\n")
|
||||
output.WriteString(result.Diff)
|
||||
output.WriteString("```\n")
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// fencedDiff wraps a unified diff in a ```diff code fence. No "Diff:" label is
|
||||
// added — the fence and the diff's own ---/+++ headers already identify it, so
|
||||
// the label would only cost tokens.
|
||||
func fencedDiff(diff string) string {
|
||||
return "```diff\n" + diff + "```\n"
|
||||
}
|
||||
|
||||
// countDiffLines counts added (+) and removed (-) content lines in a unified diff.
|
||||
// Only lines inside hunks are counted (everything after the first "@@" header), so the
|
||||
// "---"/"+++" file headers are skipped structurally — and content whose own text starts
|
||||
|
||||
@@ -59,6 +59,33 @@ func TestEditApplyPreservesBackslashSequencesText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditApplyDiffResponseFencedNoLabel verifies response=diff emits the unified
|
||||
// diff in a ```diff fence with no redundant "Diff:" label (the fence and the diff's
|
||||
// own headers already identify it; the label would only cost tokens).
|
||||
func TestEditApplyDiffResponseFencedNoLabel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
f := writeFile(t, tmpDir, "note.txt", "before\n")
|
||||
|
||||
res := callEdit(t, srv, map[string]any{
|
||||
"file": f,
|
||||
"operation": "replace",
|
||||
"selector_text": "before",
|
||||
"new_content": "after",
|
||||
"response": "diff",
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("edit returned error: %+v", res.Content)
|
||||
}
|
||||
out := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.HasPrefix(out, "```diff\n") {
|
||||
t.Errorf("diff response should start with the ```diff fence, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "Diff:") {
|
||||
t.Errorf("diff response should not carry a redundant 'Diff:' label, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountDiffLinesHunkAware verifies the line counter skips file headers structurally and
|
||||
// counts content lines whose own text begins with + or - (previously dropped as "+++"/"---").
|
||||
func TestCountDiffLinesHunkAware(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -15,10 +16,19 @@ import (
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/search"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/fuzzy"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
"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.
|
||||
func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
start := time.Now()
|
||||
@@ -51,7 +61,10 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
||||
prefsMaxResults = prefs.DefaultMaxResults
|
||||
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)
|
||||
|
||||
cursorStr := request.GetString("cursor", "")
|
||||
@@ -94,6 +107,7 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
||||
Regex: regex,
|
||||
ContextLines: contextLines,
|
||||
MaxResults: rgMaxResults,
|
||||
IncludeHidden: request.GetBool("include_hidden", false),
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
results.Results = results.Results[:maxResults]
|
||||
results.Truncated = true
|
||||
nextOffset := offset + maxResults
|
||||
nextCursor := cursor.Encode(nextOffset, queryHash)
|
||||
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",
|
||||
@@ -184,7 +208,7 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
|
||||
}
|
||||
result, err := s.readOneFile(ctx, request, p)
|
||||
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
|
||||
}
|
||||
// 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 firstPath, seen := seenEtag[etag]; seen {
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -304,7 +328,11 @@ func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOp
|
||||
opts := lineNumberOpts{
|
||||
noLineNumbers: request.GetBool("no_line_numbers", false),
|
||||
lineInterval: request.GetInt("line_number_interval", 1),
|
||||
compactLineNums: request.GetBool("compact_line_numbers", false),
|
||||
// Compact prefix ("12│content") is the default — it drops the 4-wide
|
||||
// padding and trailing space of the legacy " 12│ " form, saving ~3
|
||||
// chars/line with no information loss. Pass compact_line_numbers=false
|
||||
// (or mode/line_numbers="full") to restore the padded alignment.
|
||||
compactLineNums: request.GetBool("compact_line_numbers", true),
|
||||
}
|
||||
|
||||
// Apply session line_numbers pref when no explicit per-call override was supplied.
|
||||
@@ -439,12 +467,19 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
|
||||
// Symbol-based line range: find the symbol and use its exact bounds.
|
||||
if symbolName != "" {
|
||||
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
|
||||
start, end, found := s.resolveSymbolLines(ctx, path, content, symbolName, symbolKind)
|
||||
if !found {
|
||||
return "", fmt.Errorf("symbol %q not found in %s", symbolName, path)
|
||||
candidates := s.resolveSymbolCandidates(ctx, path, content, symbolName, symbolKind)
|
||||
switch len(candidates) {
|
||||
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.
|
||||
@@ -499,14 +534,60 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
// resolveSymbolLines parses the AST and returns the line range of the named symbol.
|
||||
// symbolKind optionally filters by kind (empty = any).
|
||||
func (s *Server) resolveSymbolLines(ctx context.Context, path string, content []byte, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
|
||||
// resolveSymbolCandidates parses the AST and returns every symbol matching the
|
||||
// name (and optional kind). Zero results means not found; more than one means an
|
||||
// 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)
|
||||
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.
|
||||
@@ -571,6 +652,21 @@ func extractEtag(result string) string {
|
||||
// splitLines splits a string into lines.
|
||||
// For large files (> 1MB), uses bufio.Scanner which is more memory efficient.
|
||||
// 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 {
|
||||
const largeSizeThreshold = 1024 * 1024 // 1MB
|
||||
|
||||
|
||||
@@ -266,6 +266,28 @@ struct Foo {
|
||||
|
||||
// ---- 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) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
@@ -567,19 +589,23 @@ func TestCompactLineNumbers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactLineNumbersOffByDefault(t *testing.T) {
|
||||
func TestCompactLineNumbersDefaultAndOptOut(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
content := "line one\nline two\n"
|
||||
f := writeFile(t, tmpDir, "test.txt", content)
|
||||
|
||||
// Default: no compact_line_numbers
|
||||
out := callRead(t, srv, map[string]interface{}{"path": f})
|
||||
// Default is now the compact prefix (token-saving): "1│line one".
|
||||
def := callRead(t, srv, map[string]interface{}{"path": f})
|
||||
if !strings.Contains(def, "1│line one") || strings.Contains(def, " 1│ line one") {
|
||||
t.Errorf("default should use compact prefix, got:\n%s", def)
|
||||
}
|
||||
|
||||
// Should have padded format
|
||||
if !strings.Contains(out, " 1│ line one") {
|
||||
t.Errorf("default should have padded format, got:\n%s", out)
|
||||
// Explicit opt-out restores the padded alignment.
|
||||
padded := callRead(t, srv, map[string]interface{}{"path": f, "compact_line_numbers": false})
|
||||
if !strings.Contains(padded, " 1│ line one") {
|
||||
t.Errorf("compact_line_numbers=false should restore padded format, got:\n%s", padded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,9 +683,9 @@ func Hello() {
|
||||
if !strings.Contains(out, `println("hello")`) {
|
||||
t.Errorf("full mode missing body, got:\n%s", out)
|
||||
}
|
||||
// Padded line numbers
|
||||
// Default line numbers use the compact prefix.
|
||||
if !strings.Contains(out, "1│package main") {
|
||||
t.Errorf("default should have padded line numbers, got:\n%s", out)
|
||||
t.Errorf("default should have compact line numbers, got:\n%s", out)
|
||||
}
|
||||
// etag present
|
||||
if !strings.Contains(out, "[etag:") {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
"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
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
|
||||
switch action {
|
||||
@@ -63,12 +74,14 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
|
||||
|
||||
case "references":
|
||||
includeDecl := request.GetBool("include_declaration", true)
|
||||
// compact: explicit call-time > session compact_refs pref > false
|
||||
// compact: explicit call-time > session compact_refs pref > true.
|
||||
// Compact (one line per file) is the default — it preserves every
|
||||
// line:col pair while cutting ~one line per reference for the LLM consumer.
|
||||
var prefsCompact *bool
|
||||
if sp := s.sessionPrefs.Load(); sp != nil {
|
||||
prefsCompact = sp.CompactRefs
|
||||
}
|
||||
compact := effectiveBool(request, "compact", prefsCompact, false)
|
||||
compact := effectiveBool(request, "compact", prefsCompact, true)
|
||||
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
|
||||
|
||||
default:
|
||||
@@ -76,6 +89,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.
|
||||
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)
|
||||
@@ -115,10 +153,14 @@ func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line,
|
||||
|
||||
var output strings.Builder
|
||||
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()))
|
||||
output.WriteString(fmt.Sprintf("Text: `%s`\n", parser.GetNodeText(node, content)))
|
||||
fmt.Fprintf(&output, "Node type: `%s`\n", node.Type())
|
||||
fmt.Fprintf(&output, "Text: `%s`\n", parser.GetNodeText(node, content))
|
||||
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
}
|
||||
@@ -136,12 +178,12 @@ func (s *Server) lspDefinition(ctx context.Context, file string, line, col int,
|
||||
|
||||
var output strings.Builder
|
||||
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 {
|
||||
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", s.relPath(filePath), loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
||||
|
||||
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
||||
if preview != "" {
|
||||
@@ -170,7 +212,9 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
|
||||
fileGroups := make(map[string][]lsp.Location)
|
||||
fileOrder := make([]string, 0)
|
||||
for _, loc := range locations {
|
||||
filePath := lsp.URIToFile(loc.URI)
|
||||
// Relativize for display; references are not re-read so the absolute
|
||||
// path isn't needed downstream.
|
||||
filePath := s.relPath(lsp.URIToFile(loc.URI))
|
||||
if _, seen := fileGroups[filePath]; !seen {
|
||||
fileOrder = append(fileOrder, filePath)
|
||||
}
|
||||
@@ -187,7 +231,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 {
|
||||
var output strings.Builder
|
||||
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 {
|
||||
@@ -195,9 +239,9 @@ func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string,
|
||||
if compact {
|
||||
output.WriteString(formatReferencesCompact(filePath, locs))
|
||||
} else {
|
||||
output.WriteString(fmt.Sprintf("**%s** (%d)\n", filePath, len(locs)))
|
||||
fmt.Fprintf(&output, "**%s** (%d)\n", filePath, len(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")
|
||||
}
|
||||
@@ -273,7 +317,7 @@ func (s *Server) readFilePreview(file string, line, contextLines int) string {
|
||||
if i+1 == line {
|
||||
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()
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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.
|
||||
func makeLocation(file string, line, col int) 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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" +
|
||||
"|------|--------|\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" +
|
||||
"| `max_results` | Page size (default: 100). |\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" +
|
||||
"| `$$$ARGS` | Matches zero or more nodes (variadic capture) |\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" +
|
||||
"```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" +
|
||||
"// Python classes\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" +
|
||||
"- `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" +
|
||||
"## 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" +
|
||||
"```json\n" +
|
||||
"// Hover — type/doc at position\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" +
|
||||
`{"action": "definition", "file": "handler.go", "line": 23, "column": 10}` + "\n\n" +
|
||||
"// References — all usages\n" +
|
||||
|
||||
+20
-10
@@ -54,7 +54,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
|
||||
logger: logger,
|
||||
parser: parserRegistry,
|
||||
matcher: query.NewMatcher(parserRegistry),
|
||||
editor: edit.NewEngine(parserRegistry),
|
||||
editor: edit.NewEngineWithValidator(parserRegistry, cfg.IsPathAllowed),
|
||||
readSem: make(chan struct{}, MaxConcurrentReads),
|
||||
querySem: make(chan struct{}, MaxConcurrentQueries),
|
||||
}
|
||||
@@ -129,6 +129,9 @@ func (s *Server) registerTools() {
|
||||
mcp.WithBoolean("ignore_case",
|
||||
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.Description("Treat pattern as regex (default: true)"),
|
||||
),
|
||||
@@ -190,7 +193,7 @@ func (s *Server) registerTools() {
|
||||
mcp.Description("Output mode: 'full' (default, full file), 'skeleton' (signatures + { ... } stubs, bodies elided), 'symbols_only' (symbol list only, alias for symbols_only=true)."),
|
||||
),
|
||||
mcp.WithArray("strip",
|
||||
mcp.Description("Strip content classes before line-numbering. Values: 'imports' (remove import blocks), 'license' (remove leading license comment), 'block_comments' (remove /* */ and Python triple-quoted strings). Emits [stripped: ...] footer."),
|
||||
mcp.Description("Strip content classes before line-numbering. Values: 'imports' (remove import blocks), 'license' (remove leading license comment), 'block_comments' (remove /* */ and Python triple-quoted strings). Emits [stripped: ...] footer. Note: line numbers are renumbered from 1 over the stripped content, so they no longer match the file's original line numbers."),
|
||||
mcp.WithStringItems(),
|
||||
),
|
||||
mcp.WithNumber("max_lines",
|
||||
@@ -203,7 +206,7 @@ func (s *Server) registerTools() {
|
||||
mcp.Description("Print line numbers only every N lines (default: 1 = every line). E.g. 10 = anchor every 10th line plus first/last. 0 = no line numbers."),
|
||||
),
|
||||
mcp.WithBoolean("compact_line_numbers",
|
||||
mcp.Description("Use compact line prefix '12\u2502' instead of ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default off."),
|
||||
mcp.Description("Use compact line prefix '12\u2502' instead of padded ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default true; set false to restore padded alignment."),
|
||||
),
|
||||
mcp.WithBoolean("collapse_blank_lines",
|
||||
mcp.Description("Collapse runs of consecutive blank lines to a single blank line. Useful for token savings on heavily-spaced code."),
|
||||
@@ -222,6 +225,8 @@ func (s *Server) registerTools() {
|
||||
s.mcp.AddTool(
|
||||
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. "+
|
||||
"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."),
|
||||
mcp.WithReadOnlyHintAnnotation(true),
|
||||
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.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.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 {
|
||||
s.mcp.AddTool(
|
||||
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."),
|
||||
mcp.WithReadOnlyHintAnnotation(true),
|
||||
mcp.WithString("action",
|
||||
@@ -277,19 +283,23 @@ func (s *Server) registerTools() {
|
||||
mcp.Required(),
|
||||
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.Required(),
|
||||
mcp.Description("Line number (1-indexed)"),
|
||||
mcp.Description("Line number (1-indexed). Required unless symbol_name is given."),
|
||||
),
|
||||
mcp.WithNumber("column",
|
||||
mcp.Required(),
|
||||
mcp.Description("Column number (1-indexed)"),
|
||||
mcp.Description("Column number (1-indexed). Required unless symbol_name is given."),
|
||||
),
|
||||
mcp.WithBoolean("include_declaration",
|
||||
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
|
||||
),
|
||||
mcp.WithBoolean("compact",
|
||||
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: false."),
|
||||
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: true; set false for one line per reference."),
|
||||
),
|
||||
mcp.WithBoolean("verbose",
|
||||
mcp.Description("Emit count/header preamble. Applies to all actions. Default: false."),
|
||||
|
||||
@@ -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
|
||||
// (up to the 1MB buffer limit set in C-05 fix).
|
||||
func TestSplitLinesLongLine(t *testing.T) {
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestSessionPrefsFileReadLineNumbersNone(t *testing.T) {
|
||||
t.Fatalf("handleFileRead: %v", err)
|
||||
}
|
||||
text := result.Content[0].(mcp.TextContent).Text
|
||||
// Standard line-number format is " 1│ "; with no_line_numbers it's absent.
|
||||
// line_numbers=none suppresses the prefix entirely (no "│" gutter at all).
|
||||
if strings.Contains(text, " 1│") {
|
||||
t.Errorf("session line_numbers=none: expected no line-number prefix, got:\n%s", text)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSessionPrefsFileReadLineNumbersCompact(t *testing.T) {
|
||||
req.Params.Arguments = map[string]interface{}{"path": testFile}
|
||||
result, _ := srv.handleFileRead(ctx, req)
|
||||
text := result.Content[0].(mcp.TextContent).Text
|
||||
// Standard padded prefix is " 1│ "; compact is "1│".
|
||||
// Padded prefix is " 1│ "; compact is "1│".
|
||||
if strings.Contains(text, " 1\u2502") {
|
||||
t.Errorf("session line_numbers=compact should use compact prefix, got:\n%s", text)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestSessionPrefsASTQueryMaxResults(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("package main\n\n")
|
||||
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")
|
||||
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
|
||||
@@ -281,7 +281,7 @@ func TestSessionPrefsMaxResultsExplicitOverride(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("package main\n\n")
|
||||
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")
|
||||
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
|
||||
|
||||
// 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
|
||||
if len(e.Context) > 0 {
|
||||
sb.WriteString("\nContext:")
|
||||
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
|
||||
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
|
||||
if e.Cause != nil {
|
||||
sb.WriteString(fmt.Sprintf("\nCaused by: %v", e.Cause))
|
||||
fmt.Fprintf(&sb, "\nCaused by: %v", e.Cause)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -215,7 +215,7 @@ func captureStack(skip int) string {
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
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 {
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user