mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6e0c5247c1 | ||
|
|
7774dc29d1 | ||
|
|
9af2801b1b |
@@ -22,6 +22,7 @@ jobs:
|
||||
rolling-release-tag: "v1"
|
||||
platforms: >-
|
||||
[
|
||||
{"os":"macos-15-intel","goos":"darwin","goarch":"amd64","platform":"darwin_amd64"},
|
||||
{"os":"macos-latest","goos":"darwin","goarch":"arm64","platform":"darwin_arm64"},
|
||||
{"os":"ubuntu-latest","goos":"linux","goarch":"amd64","platform":"linux_amd64"},
|
||||
{"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64","platform":"linux_arm64"},
|
||||
|
||||
+14
-8
@@ -5,9 +5,12 @@ version: 2
|
||||
|
||||
project_name: mcp-filepuff
|
||||
|
||||
# Split mode by full target (goos+goarch). Each matrix runner only builds the
|
||||
# artifact matching its own architecture, avoiding CGO cross-compile failures
|
||||
# (ARM asm on x86 runners and vice versa).
|
||||
# Split CGO builds by full target (GOOS+GOARCH), not just GOOS.
|
||||
# Each release matrix runner builds natively for exactly one target via the
|
||||
# GGOOS/GGOARCH filters set in workflow-prepare.sh. Without `by: target` the
|
||||
# split partitions by GOOS only, so a single linux runner would try to build
|
||||
# both amd64 and arm64 — and native gcc cannot assemble the other arch's CGO
|
||||
# runtime (gcc_arm64.S errors), breaking the release.
|
||||
partial:
|
||||
by: target
|
||||
|
||||
@@ -37,22 +40,25 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
# Ship raw binaries — downloaders curl the asset and run it directly
|
||||
# (Windows gets .exe appended automatically). No tarball indirection.
|
||||
formats:
|
||||
- binary
|
||||
- tar.gz
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- .Version }}_
|
||||
{{- .Os }}_
|
||||
{{- .Arch }}
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
@@ -58,21 +58,25 @@ Claude Code configuration (`.claude/settings.json`):
|
||||
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
|
||||
|
||||
```bash
|
||||
# macOS (Apple Silicon)
|
||||
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_arm64
|
||||
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/
|
||||
# macOS (ARM64)
|
||||
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_arm64.tar.gz | tar xz
|
||||
mv mcp-filepuff ~/.local/bin/
|
||||
|
||||
# macOS (AMD64)
|
||||
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_amd64.tar.gz | tar xz
|
||||
mv mcp-filepuff ~/.local/bin/
|
||||
|
||||
# Linux (ARM64)
|
||||
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_arm64
|
||||
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/
|
||||
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_arm64.tar.gz | tar xz
|
||||
mv mcp-filepuff ~/.local/bin/
|
||||
|
||||
# Linux (AMD64)
|
||||
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_amd64
|
||||
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/
|
||||
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_amd64.tar.gz | tar xz
|
||||
mv mcp-filepuff ~/.local/bin/
|
||||
|
||||
# Windows (PowerShell)
|
||||
Invoke-WebRequest -Uri "https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_windows_amd64.exe" -OutFile mcp-filepuff.exe
|
||||
Move-Item mcp-filepuff.exe $env:USERPROFILE\.local\bin\
|
||||
Invoke-WebRequest -Uri "https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_windows_amd64.zip" -OutFile mcp-filepuff.zip
|
||||
Expand-Archive mcp-filepuff.zip -DestinationPath $env:USERPROFILE\.local\bin
|
||||
```
|
||||
|
||||
Replace `<version>` with the actual version (e.g., `v1.0.0`).
|
||||
@@ -844,19 +848,6 @@ Use the `ping` tool to verify the server is running correctly:
|
||||
|
||||
Expected response: `"pong"`
|
||||
|
||||
## Telemetry
|
||||
|
||||
On startup this MCP server sends a single anonymous adoption ping — project
|
||||
name, version, timestamp; no identifiers, no tool call data, no file
|
||||
contents. Fire-and-forget with a 2-second timeout; cannot block startup
|
||||
or panic.
|
||||
|
||||
See **[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**
|
||||
for the exact wire format, source, and full opt-out documentation.
|
||||
|
||||
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
|
||||
or `MCP_FILEPUFF_DISABLE_TELEMETRY=1`.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
+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")
|
||||
}
|
||||
|
||||
@@ -9,16 +9,9 @@ import (
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/server"
|
||||
telemetry "github.com/lukaszraczylo/oss-telemetry"
|
||||
)
|
||||
|
||||
// version is the build version. Set via goreleaser ldflags
|
||||
// (-X main.version={{.Version}}).
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
telemetry.Send("mcp-filepuff", version)
|
||||
|
||||
// Parse command line flags
|
||||
var (
|
||||
workspaceRoot = flag.String("workspace", "", "Workspace root directory (default: current directory)")
|
||||
|
||||
@@ -6,8 +6,7 @@ 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/lukaszraczylo/oss-telemetry v0.2.1
|
||||
github.com/mark3labs/mcp-go v0.54.0
|
||||
github.com/mark3labs/mcp-go v0.55.0
|
||||
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
|
||||
@@ -19,5 +18,5 @@ require (
|
||||
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.37.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
)
|
||||
|
||||
@@ -24,10 +24,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.1 h1:6ULyfzXplpdmIY/i01OPM1jeod9+L1RAhI0jtbVnJI0=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.1/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA=
|
||||
github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
|
||||
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -46,8 +44,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -84,14 +84,24 @@ func Load(workspaceRoot string) (*Config, error) {
|
||||
// Restore WorkspaceRoot — config file must not override path guards.
|
||||
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)
|
||||
}
|
||||
|
||||
+188
-213
@@ -270,7 +270,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
|
||||
@@ -481,98 +485,104 @@ func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte
|
||||
return true
|
||||
}
|
||||
|
||||
// applyEdit applies the edit operation to the content.
|
||||
// AST mode uses exact byte positions — new_content is inserted verbatim without auto-indentation.
|
||||
// applyEdit applies an AST-mode edit. new_content is inserted verbatim — the caller
|
||||
// controls indentation — and only line endings are normalized to the file's convention.
|
||||
func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]byte, error) {
|
||||
startByte := node.StartByte()
|
||||
endByte := node.EndByte()
|
||||
eol := detectLineEnding(content)
|
||||
newContent := normalizeLineEndings(edit.NewContent, eol)
|
||||
return spliceContent(edit.Operation, content, int(node.StartByte()), int(node.EndByte()), newContent, eol)
|
||||
}
|
||||
|
||||
newContent := edit.NewContent
|
||||
// detectLineEnding reports the file's dominant line-ending convention: "\r\n" when any
|
||||
// CRLF terminator is present, otherwise "\n".
|
||||
func detectLineEnding(content []byte) string {
|
||||
if bytes.Contains(content, []byte("\r\n")) {
|
||||
return "\r\n"
|
||||
}
|
||||
return "\n"
|
||||
}
|
||||
|
||||
// normalizeLineEndings rewrites every line ending in s to eol. It first collapses CRLF to
|
||||
// LF, then expands to the target, so mixed input becomes uniform and new_content can never
|
||||
// introduce a line ending foreign to the file being edited.
|
||||
func normalizeLineEndings(s, eol string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
if eol != "\n" {
|
||||
s = strings.ReplaceAll(s, "\n", eol)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func endsWithNewline(s string) bool { return strings.HasSuffix(s, "\n") }
|
||||
|
||||
func startsWithNewline(s string) bool {
|
||||
return s != "" && (s[0] == '\n' || s[0] == '\r')
|
||||
}
|
||||
|
||||
// spliceContent applies an edit operation by splicing newContent into content over the
|
||||
// byte range [start, end). It is shared by AST and text modes — once auto-indentation is
|
||||
// removed the two are identical. Restored terminators and separators use eol so the
|
||||
// file's line-ending convention is preserved.
|
||||
func spliceContent(op EditOperation, content []byte, start, end int, newContent, eol string) ([]byte, error) {
|
||||
// A line-based selection on a CRLF file can land `end` between the \r (treated as
|
||||
// line content) and the \n of a terminator. Pull it back so the full \r\n stays
|
||||
// intact in the tail and is never split into a bare LF.
|
||||
if end > start && end < len(content) && content[end-1] == '\r' && content[end] == '\n' {
|
||||
end--
|
||||
}
|
||||
|
||||
var result []byte
|
||||
|
||||
switch edit.Operation {
|
||||
switch op {
|
||||
case EditReplace:
|
||||
result = append(result, content[:startByte]...)
|
||||
result = append(result, []byte(newContent)...)
|
||||
// Preserve trailing newline: if selection ended with \n but replacement doesn't,
|
||||
// re-add it to prevent line merging
|
||||
if endByte > startByte && content[endByte-1] == '\n' && !strings.HasSuffix(newContent, "\n") {
|
||||
result = append(result, '\n')
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, newContent...)
|
||||
// Restore a line terminator if the replaced range ended with one but the
|
||||
// replacement does not, to prevent merging with the following line.
|
||||
if end > start && content[end-1] == '\n' && !endsWithNewline(newContent) {
|
||||
result = append(result, eol...)
|
||||
}
|
||||
result = append(result, content[endByte:]...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
case EditInsertBefore:
|
||||
insertion := newContent
|
||||
if !strings.HasSuffix(insertion, "\n") {
|
||||
insertion += "\n"
|
||||
if !endsWithNewline(insertion) {
|
||||
insertion += eol
|
||||
}
|
||||
result = append(result, content[:startByte]...)
|
||||
result = append(result, []byte(insertion)...)
|
||||
result = append(result, content[startByte:]...)
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, insertion...)
|
||||
result = append(result, content[start:]...)
|
||||
|
||||
case EditInsertAfter:
|
||||
insertion := newContent
|
||||
// Ensure separation from preceding content
|
||||
if endByte > 0 && content[endByte-1] != '\n' && !strings.HasPrefix(insertion, "\n") {
|
||||
insertion = "\n" + insertion
|
||||
// Separate from preceding content.
|
||||
if end > 0 && content[end-1] != '\n' && !startsWithNewline(insertion) {
|
||||
insertion = eol + insertion
|
||||
}
|
||||
// Ensure separation from following content
|
||||
if !strings.HasSuffix(insertion, "\n") && endByte < uint32(len(content)) && content[endByte] != '\n' {
|
||||
insertion += "\n"
|
||||
// Separate from following content.
|
||||
if !endsWithNewline(insertion) && end < len(content) && content[end] != '\n' {
|
||||
insertion += eol
|
||||
}
|
||||
result = append(result, content[:endByte]...)
|
||||
result = append(result, []byte(insertion)...)
|
||||
result = append(result, content[endByte:]...)
|
||||
result = append(result, content[:end]...)
|
||||
result = append(result, insertion...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
case EditDelete:
|
||||
result = append(result, content[:startByte]...)
|
||||
result = append(result, content[endByte:]...)
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
default:
|
||||
return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", edit.Operation))
|
||||
return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", op))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// detectIndentation detects the indentation at a given byte position.
|
||||
func detectIndentation(content []byte, bytePos int) string {
|
||||
// Find the start of the line
|
||||
lineStart := bytePos
|
||||
for lineStart > 0 && content[lineStart-1] != '\n' {
|
||||
lineStart--
|
||||
}
|
||||
|
||||
// Extract leading whitespace
|
||||
var indent strings.Builder
|
||||
for i := lineStart; i < bytePos && i < len(content); i++ {
|
||||
c := content[i]
|
||||
if c == ' ' || c == '\t' {
|
||||
indent.WriteByte(c)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return indent.String()
|
||||
}
|
||||
|
||||
// indentContent applies indentation to multi-line content.
|
||||
func indentContent(content string, indent string) string {
|
||||
if indent == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
if i > 0 && line != "" {
|
||||
lines[i] = indent + line
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
// noNewlineMarker is the git-style annotation emitted after a diff line whose source
|
||||
// version has no trailing newline.
|
||||
const noNewlineMarker = "\\ No newline at end of file\n"
|
||||
|
||||
// diffLine represents a single line in the diff with its type and content.
|
||||
type diffLine struct {
|
||||
@@ -582,137 +592,148 @@ type diffLine struct {
|
||||
newN int // 1-based line number in modified (0 if delete)
|
||||
}
|
||||
|
||||
// indexRange is an inclusive [start, end] range of diffLine indices forming one hunk.
|
||||
type indexRange struct{ start, end int }
|
||||
|
||||
// generateDiff creates a unified diff between original and modified content.
|
||||
// Uses line-level Myers diff algorithm and outputs a proper unified diff
|
||||
// with context lines (3 before/after each change, merging close hunks).
|
||||
// Uses a line-level Myers diff and outputs a unified diff with 3 lines of context
|
||||
// before/after each change, merging close hunks.
|
||||
func (e *Engine) generateDiff(original, modified, filename string) string {
|
||||
dmp := e.dmp
|
||||
|
||||
// Use line-level diffing: encode each line as a single character,
|
||||
// diff the encoded strings, then decode back to real lines.
|
||||
// Line-level diffing: encode each line as a single rune, diff the encoded strings,
|
||||
// then decode back to real lines.
|
||||
chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified)
|
||||
diffs := dmp.DiffMain(chars1, chars2, false)
|
||||
diffs = dmp.DiffCharsToLines(diffs, lineArray)
|
||||
|
||||
// Cleanup for readability
|
||||
diffs := dmp.DiffCharsToLines(dmp.DiffMain(chars1, chars2, false), lineArray)
|
||||
diffs = dmp.DiffCleanupSemantic(diffs)
|
||||
|
||||
// Flatten diffs into individual lines with line numbers
|
||||
var lines []diffLine
|
||||
oldLine := 1
|
||||
newLine := 1
|
||||
// Track whether each version lacks a final newline, so the diff is annotated
|
||||
// git-style ("\ No newline at end of file") instead of implying a phantom one.
|
||||
origNoEOL := len(original) > 0 && !strings.HasSuffix(original, "\n")
|
||||
modNoEOL := len(modified) > 0 && !strings.HasSuffix(modified, "\n")
|
||||
|
||||
lines, maxOldN, maxNewN := flattenDiffLines(diffs)
|
||||
|
||||
ranges := diffHunkRanges(lines)
|
||||
if len(ranges) == 0 {
|
||||
return "" // no changes
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "--- %s\n", filename)
|
||||
fmt.Fprintf(&buf, "+++ %s\n", filename)
|
||||
for _, r := range ranges {
|
||||
oldStart, oldCount, newStart, newCount := hunkBounds(lines, r.start, r.end)
|
||||
fmt.Fprintf(&buf, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)
|
||||
writeDiffBody(&buf, lines, r.start, r.end, origNoEOL, modNoEOL, maxOldN, maxNewN)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// flattenDiffLines expands diff segments into per-line records with 1-based line numbers,
|
||||
// returning the lines plus the final line number of each version (for no-newline marking).
|
||||
func flattenDiffLines(diffs []diffmatchpatch.Diff) (lines []diffLine, maxOldN, maxNewN int) {
|
||||
oldLine, newLine := 1, 1
|
||||
for _, d := range diffs {
|
||||
rawLines := strings.SplitAfter(d.Text, "\n")
|
||||
for _, raw := range rawLines {
|
||||
for _, raw := range strings.SplitAfter(d.Text, "\n") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSuffix(raw, "\n")
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines, oldLine - 1, newLine - 1
|
||||
}
|
||||
|
||||
// Identify indices of changed lines
|
||||
// diffHunkRanges returns the inclusive index ranges to emit: each changed line padded by
|
||||
// 3 lines of context, with overlapping/adjacent ranges merged.
|
||||
func diffHunkRanges(lines []diffLine) []indexRange {
|
||||
const contextSize = 3
|
||||
var changedIndices []int
|
||||
for i, l := range lines {
|
||||
if l.op != diffmatchpatch.DiffEqual {
|
||||
changedIndices = append(changedIndices, i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(changedIndices) == 0 {
|
||||
return "" // no changes
|
||||
}
|
||||
|
||||
// Build inclusion ranges: for each changed line, include contextSize lines before/after.
|
||||
// Merge overlapping or adjacent ranges (gap <= 2*contextSize = 6 context lines).
|
||||
type indexRange struct{ start, end int } // inclusive
|
||||
var ranges []indexRange
|
||||
for _, ci := range changedIndices {
|
||||
rStart := ci - contextSize
|
||||
if rStart < 0 {
|
||||
rStart = 0
|
||||
}
|
||||
rEnd := ci + contextSize
|
||||
if rEnd >= len(lines) {
|
||||
rEnd = len(lines) - 1
|
||||
for i, l := range lines {
|
||||
if l.op == diffmatchpatch.DiffEqual {
|
||||
continue
|
||||
}
|
||||
rStart := max(i-contextSize, 0)
|
||||
rEnd := min(i+contextSize, len(lines)-1)
|
||||
if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 {
|
||||
// Merge with previous range
|
||||
ranges[len(ranges)-1].end = rEnd
|
||||
ranges[len(ranges)-1].end = rEnd // merge with previous
|
||||
} else {
|
||||
ranges = append(ranges, indexRange{rStart, rEnd})
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// Emit unified diff
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(fmt.Sprintf("--- %s\n", filename))
|
||||
buf.WriteString(fmt.Sprintf("+++ %s\n", filename))
|
||||
|
||||
for _, r := range ranges {
|
||||
// Determine hunk header line numbers
|
||||
var oldStart, oldCount, newStart, newCount int
|
||||
for i := r.start; i <= r.end; i++ {
|
||||
l := lines[i]
|
||||
switch l.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 {
|
||||
// Set newStart from context or next available
|
||||
newStart = l.oldN // approximate
|
||||
}
|
||||
oldCount++
|
||||
case diffmatchpatch.DiffInsert:
|
||||
if newCount == 0 {
|
||||
newStart = l.newN
|
||||
}
|
||||
if oldCount == 0 {
|
||||
oldStart = l.newN // approximate
|
||||
}
|
||||
newCount++
|
||||
}
|
||||
// hunkBounds computes the unified-diff hunk header line numbers and counts for
|
||||
// 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++ {
|
||||
switch lines[i].op {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
oldCount++
|
||||
newCount++
|
||||
case diffmatchpatch.DiffDelete:
|
||||
oldCount++
|
||||
case diffmatchpatch.DiffInsert:
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
if oldCount == 0 {
|
||||
oldStart = max(0, oldStart-1)
|
||||
}
|
||||
if newCount == 0 {
|
||||
newStart = max(0, newStart-1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
buf.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount))
|
||||
|
||||
for i := r.start; i <= r.end; i++ {
|
||||
l := lines[i]
|
||||
switch l.op {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
buf.WriteString(fmt.Sprintf(" %s\n", l.text))
|
||||
case diffmatchpatch.DiffDelete:
|
||||
buf.WriteString(fmt.Sprintf("-%s\n", l.text))
|
||||
case diffmatchpatch.DiffInsert:
|
||||
buf.WriteString(fmt.Sprintf("+%s\n", l.text))
|
||||
// writeDiffBody writes the space/-/+ body lines for one hunk, appending the git-style
|
||||
// no-newline marker after the final line of any version that lacks a trailing newline.
|
||||
func writeDiffBody(buf *bytes.Buffer, lines []diffLine, start, end int, origNoEOL, modNoEOL bool, maxOldN, maxNewN int) {
|
||||
for i := start; i <= end; i++ {
|
||||
l := lines[i]
|
||||
switch l.op {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
fmt.Fprintf(buf, " %s\n", l.text)
|
||||
if (origNoEOL && l.oldN == maxOldN) || (modNoEOL && l.newN == maxNewN) {
|
||||
buf.WriteString(noNewlineMarker)
|
||||
}
|
||||
case diffmatchpatch.DiffDelete:
|
||||
fmt.Fprintf(buf, "-%s\n", l.text)
|
||||
if origNoEOL && l.oldN == maxOldN {
|
||||
buf.WriteString(noNewlineMarker)
|
||||
}
|
||||
case diffmatchpatch.DiffInsert:
|
||||
fmt.Fprintf(buf, "+%s\n", l.text)
|
||||
if modNoEOL && l.newN == maxNewN {
|
||||
buf.WriteString(noNewlineMarker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// resolveTextSelector finds the byte range for a text-based selection.
|
||||
@@ -831,57 +852,11 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// applyTextEditOperation applies a text edit operation.
|
||||
// applyTextEditOperation applies a text-mode edit. Like AST mode, new_content is inserted
|
||||
// verbatim (no auto-indentation) with its line endings normalized to the file's convention.
|
||||
func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, end int, newContent string) ([]byte, error) {
|
||||
// Detect indentation at the selection point
|
||||
indentation := detectIndentation(content, start)
|
||||
indentedContent := indentContent(newContent, indentation)
|
||||
|
||||
var result []byte
|
||||
|
||||
switch op {
|
||||
case EditReplace:
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, []byte(indentedContent)...)
|
||||
// Preserve trailing newline: if selection ended with \n but replacement doesn't,
|
||||
// re-add it to prevent line merging
|
||||
if end > start && content[end-1] == '\n' && !strings.HasSuffix(indentedContent, "\n") {
|
||||
result = append(result, '\n')
|
||||
}
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
case EditInsertBefore:
|
||||
insertion := indentedContent
|
||||
if !strings.HasSuffix(insertion, "\n") {
|
||||
insertion += "\n"
|
||||
}
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, []byte(insertion)...)
|
||||
result = append(result, content[start:]...)
|
||||
|
||||
case EditInsertAfter:
|
||||
insertion := indentedContent
|
||||
// Ensure separation from preceding content
|
||||
if end > 0 && content[end-1] != '\n' && !strings.HasPrefix(insertion, "\n") {
|
||||
insertion = "\n" + insertion
|
||||
}
|
||||
// Ensure separation from following content
|
||||
if !strings.HasSuffix(insertion, "\n") && end < len(content) && content[end] != '\n' {
|
||||
insertion += "\n"
|
||||
}
|
||||
result = append(result, content[:end]...)
|
||||
result = append(result, []byte(insertion)...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
case EditDelete:
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
default:
|
||||
return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", op))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
eol := detectLineEnding(content)
|
||||
return spliceContent(op, content, start, end, normalizeLineEndings(newContent, eol), eol)
|
||||
}
|
||||
|
||||
// truncateString truncates a string to maxLen with ellipsis.
|
||||
@@ -889,7 +864,7 @@ func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
// ValidateLanguage checks if AST editing is supported for a file.
|
||||
|
||||
+73
-32
@@ -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,40 +369,42 @@ func Hello() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectIndentation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
pos int
|
||||
}{
|
||||
{
|
||||
name: "no indent",
|
||||
content: "func main() {}",
|
||||
pos: 0,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "tab indent",
|
||||
content: "func main() {\n\tprintln(\"hello\")\n}",
|
||||
pos: 15,
|
||||
want: "\t",
|
||||
},
|
||||
{
|
||||
name: "space indent",
|
||||
content: "func main() {\n println(\"hello\")\n}",
|
||||
pos: 18,
|
||||
want: " ",
|
||||
},
|
||||
// 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)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectIndentation([]byte(tt.content), tt.pos)
|
||||
if got != tt.want {
|
||||
t.Errorf("detectIndentation() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +466,29 @@ func TestGenerateDiffLineLevelAccuracy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hunk @@ headers must be correct at file boundaries (previously approximate).
|
||||
func TestGenerateDiffHunkHeaderBounds(t *testing.T) {
|
||||
e := &Engine{dmp: diffmatchpatch.New()}
|
||||
tests := []struct {
|
||||
name string
|
||||
original string
|
||||
modified string
|
||||
wantHdr string
|
||||
}{
|
||||
{name: "insert into empty file", original: "", modified: "X\nY\n", wantHdr: "@@ -0,0 +1,2 @@"},
|
||||
{name: "delete entire file", original: "a\nb\n", modified: "", wantHdr: "@@ -1,2 +0,0 @@"},
|
||||
{name: "mid-file change keeps 1-based start", original: "l1\nl2\nl3\nl4\nl5\n", modified: "l1\nl2\nCHANGED\nl4\nl5\n", wantHdr: "@@ -1,5 +1,5 @@"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
diff := e.generateDiff(tt.original, tt.modified, "f.txt")
|
||||
if !strings.Contains(diff, tt.wantHdr) {
|
||||
t.Errorf("expected hunk header %q, got:\n%s", tt.wantHdr, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiffNoPhantomChanges(t *testing.T) {
|
||||
// Regression test: replacing a line range should not produce phantom
|
||||
// +/- lines for unchanged code after the edit region.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package edit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||
)
|
||||
|
||||
// mkEngine builds an Engine with a registry that is closed at test end.
|
||||
func mkEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
reg := parser.NewRegistry()
|
||||
t.Cleanup(reg.Close)
|
||||
return NewEngine(reg)
|
||||
}
|
||||
|
||||
// applyEditFile writes content to a temp file, applies the edit, and returns the on-disk bytes.
|
||||
func applyEditFile(t *testing.T, name, content string, e *ASTEdit) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp: %v", err)
|
||||
}
|
||||
e.File = p
|
||||
res, err := mkEngine(t).Apply(context.Background(), e)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("apply unsuccessful: %s", res.Error)
|
||||
}
|
||||
got, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("readback: %v", err)
|
||||
}
|
||||
return string(got)
|
||||
}
|
||||
|
||||
// assertAllCRLF fails if s contains any bare LF (not preceded by CR) or a doubled CR.
|
||||
func assertAllCRLF(t *testing.T, s string) {
|
||||
t.Helper()
|
||||
if strings.Count(s, "\n") != strings.Count(s, "\r\n") {
|
||||
t.Fatalf("file contains bare LF (mixed line endings): %q", s)
|
||||
}
|
||||
if strings.Contains(s, "\r\r") {
|
||||
t.Fatalf("file contains doubled CR: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cluster C: text-mode edits insert new_content verbatim (no auto-indentation) ----
|
||||
|
||||
func TestTextEditVerbatimNoAutoIndent(t *testing.T) {
|
||||
// selector_text matches a token that sits at one tab of indentation, so the old
|
||||
// code detected "\t" and re-indented continuation lines of new_content. Because
|
||||
// new_content is itself already indented, that produced a DOUBLE tab on line 2.
|
||||
src := "func f() {\n\tOLD\n}\n"
|
||||
got := applyEditFile(t, "f.txt", src, &ASTEdit{
|
||||
Operation: EditReplace,
|
||||
NewContent: "A\n\tB", // line 2 already carries its own tab
|
||||
Selector: ASTSelector{Text: "OLD"},
|
||||
})
|
||||
want := "func f() {\n\tA\n\tB\n}\n"
|
||||
if got != want {
|
||||
t.Fatalf("text edit must insert new_content verbatim (no auto-indent).\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cluster B: edits preserve the file's original line-ending convention ----
|
||||
|
||||
func TestASTEditPreservesCRLF(t *testing.T) {
|
||||
src := "package main\r\n\r\nfunc demo() {\r\n\tprintln(\"old\")\r\n}\r\n"
|
||||
got := applyEditFile(t, "demo.go", src, &ASTEdit{
|
||||
Operation: EditReplace,
|
||||
NewContent: "func demo() {\n\treturn\n}",
|
||||
Selector: ASTSelector{Kind: "function_declaration", Name: "demo"},
|
||||
})
|
||||
assertAllCRLF(t, got)
|
||||
if !strings.Contains(got, "func demo() {\r\n\treturn\r\n}") {
|
||||
t.Fatalf("replacement not normalized to CRLF: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextEditReplacePreservesCRLF(t *testing.T) {
|
||||
src := "alpha\r\nbravo\r\ncharlie\r\n"
|
||||
got := applyEditFile(t, "f.txt", src, &ASTEdit{
|
||||
Operation: EditReplace,
|
||||
NewContent: "BRAVO1\nBRAVO2",
|
||||
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
|
||||
})
|
||||
assertAllCRLF(t, got)
|
||||
want := "alpha\r\nBRAVO1\r\nBRAVO2\r\ncharlie\r\n"
|
||||
if got != want {
|
||||
t.Fatalf("CRLF text replace.\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextEditInsertAfterPreservesCRLF(t *testing.T) {
|
||||
src := "alpha\r\nbravo\r\n"
|
||||
got := applyEditFile(t, "f.txt", src, &ASTEdit{
|
||||
Operation: EditInsertAfter,
|
||||
NewContent: "INSERTED",
|
||||
Selector: ASTSelector{AtLine: 1, LineEnd: 1},
|
||||
})
|
||||
assertAllCRLF(t, got)
|
||||
want := "alpha\r\nINSERTED\r\nbravo\r\n"
|
||||
if got != want {
|
||||
t.Fatalf("CRLF insert_after.\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLFFileStaysLFWhenNewContentHasCRLF(t *testing.T) {
|
||||
src := "alpha\nbravo\ncharlie\n"
|
||||
got := applyEditFile(t, "f.txt", src, &ASTEdit{
|
||||
Operation: EditReplace,
|
||||
NewContent: "B1\r\nB2", // stray CRLF in new_content must be normalized to the LF file
|
||||
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
|
||||
})
|
||||
if strings.Contains(got, "\r") {
|
||||
t.Fatalf("LF file must not gain CR: %q", got)
|
||||
}
|
||||
want := "alpha\nB1\nB2\ncharlie\n"
|
||||
if got != want {
|
||||
t.Fatalf("LF normalization.\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cluster D: diff rendering ----
|
||||
|
||||
func TestDiffMarksNoNewlineAtEOF(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
if err := os.WriteFile(p, []byte("alpha\nbravo"), 0o600); err != nil { // no trailing newline
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
res, err := mkEngine(t).Preview(context.Background(), &ASTEdit{
|
||||
File: p,
|
||||
Operation: EditReplace,
|
||||
NewContent: "BRAVO",
|
||||
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
|
||||
})
|
||||
if err != nil || !res.Success {
|
||||
t.Fatalf("preview failed: %v %s", err, res.Error)
|
||||
}
|
||||
if !strings.Contains(res.Diff, "No newline at end of file") {
|
||||
t.Fatalf("diff should mark missing final newline, got:\n%s", res.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffHasNoRawCarriageReturn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
if err := os.WriteFile(p, []byte("alpha\r\nbravo\r\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
res, err := mkEngine(t).Preview(context.Background(), &ASTEdit{
|
||||
File: p,
|
||||
Operation: EditReplace,
|
||||
NewContent: "BRAVO",
|
||||
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
|
||||
})
|
||||
if err != nil || !res.Success {
|
||||
t.Fatalf("preview failed: %v %s", err, res.Error)
|
||||
}
|
||||
if strings.Contains(res.Diff, "\r") {
|
||||
t.Fatalf("diff display must not contain raw CR, got:\n%q", res.Diff)
|
||||
}
|
||||
}
|
||||
+43
-3
@@ -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()
|
||||
@@ -315,7 +348,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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// drainStderr retains a bounded tail of server stderr for diagnostics; capture
|
||||
// must keep recent bytes and never grow past StderrCaptureMax.
|
||||
func TestClientStderrCapture(t *testing.T) {
|
||||
c := &Client{}
|
||||
|
||||
if c.RecentStderr() != "" {
|
||||
t.Errorf("expected empty stderr initially, got %q", c.RecentStderr())
|
||||
}
|
||||
|
||||
c.captureStderr([]byte("gopls: command not found\n"))
|
||||
if !strings.Contains(c.RecentStderr(), "command not found") {
|
||||
t.Errorf("expected captured stderr, got %q", c.RecentStderr())
|
||||
}
|
||||
|
||||
// Exceed the cap: only the most recent StderrCaptureMax bytes are kept.
|
||||
big := strings.Repeat("x", StderrCaptureMax*2)
|
||||
c.captureStderr([]byte(big))
|
||||
if got := len(c.RecentStderr()); got > StderrCaptureMax {
|
||||
t.Errorf("stderr tail not bounded: len=%d, want <= %d", got, StderrCaptureMax)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseDefinitionResult covers every LSP definition result shape: single
|
||||
// Location, Location[], single LocationLink, LocationLink[], plus empty/error
|
||||
// cases. The LocationLink shapes are the regression target — we advertise
|
||||
// LinkSupport, so gopls/rust-analyzer return targetUri/targetRange, which a
|
||||
// naive []Location unmarshal would silently turn into zero-value locations.
|
||||
func TestParseDefinitionResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantURIs []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single Location",
|
||||
raw: `{"uri":"file:///a.go","range":{"start":{"line":4,"character":2},"end":{"line":4,"character":8}}}`,
|
||||
wantURIs: []string{"file:///a.go"},
|
||||
},
|
||||
{
|
||||
name: "Location array",
|
||||
raw: `[{"uri":"file:///a.go","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":3}}},{"uri":"file:///b.go","range":{"start":{"line":2,"character":0},"end":{"line":2,"character":3}}}]`,
|
||||
wantURIs: []string{"file:///a.go", "file:///b.go"},
|
||||
},
|
||||
{
|
||||
name: "single LocationLink",
|
||||
raw: `{"targetUri":"file:///c.go","targetRange":{"start":{"line":9,"character":0},"end":{"line":12,"character":1}}}`,
|
||||
wantURIs: []string{"file:///c.go"},
|
||||
},
|
||||
{
|
||||
name: "LocationLink array",
|
||||
raw: `[{"targetUri":"file:///d.go","targetRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}}}]`,
|
||||
wantURIs: []string{"file:///d.go"},
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
raw: `[]`,
|
||||
wantURIs: []string{},
|
||||
},
|
||||
{
|
||||
name: "garbage",
|
||||
raw: `"not-a-location"`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
locs, err := parseDefinitionResult(json.RawMessage(tt.raw))
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got locations %+v", locs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(locs) != len(tt.wantURIs) {
|
||||
t.Fatalf("got %d locations, want %d (%+v)", len(locs), len(tt.wantURIs), locs)
|
||||
}
|
||||
for i, want := range tt.wantURIs {
|
||||
if locs[i].URI != want {
|
||||
t.Errorf("location[%d].URI = %q, want %q", i, locs[i].URI, want)
|
||||
}
|
||||
}
|
||||
// LocationLink ranges must be carried over, not zeroed.
|
||||
if tt.name == "single LocationLink" && locs[0].Range.Start.Line != 9 {
|
||||
t.Errorf("LocationLink targetRange not preserved: got start line %d, want 9", locs[0].Range.Start.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+53
-9
@@ -212,10 +212,16 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
// Initialize server
|
||||
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,17 +364,55 @@ 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
|
||||
}
|
||||
|
||||
return locations, nil
|
||||
// Array form (Location[] or LocationLink[]).
|
||||
var arr []locOrLink
|
||||
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||
locations := make([]Location, 0, len(arr))
|
||||
for _, l := range arr {
|
||||
if loc, ok := toLocation(l); ok {
|
||||
locations = append(locations, loc)
|
||||
}
|
||||
}
|
||||
return locations, nil
|
||||
}
|
||||
|
||||
// Single object form (Location or LocationLink).
|
||||
var single locOrLink
|
||||
if err := json.Unmarshal(raw, &single); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse definition result: %w", err)
|
||||
}
|
||||
if loc, ok := toLocation(single); ok {
|
||||
return []Location{loc}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// References finds all references to the symbol at the given position.
|
||||
|
||||
+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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+181
-45
@@ -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,30 +121,44 @@ func stripLicense(content string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Python/hash-style leading comment block
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
// "//" 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 []string
|
||||
var rest []string
|
||||
inComment := true
|
||||
var commentLines, rest []string
|
||||
for i, l := range lines {
|
||||
if inComment && (strings.HasPrefix(l, "#") || strings.TrimSpace(l) == "") {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "//") {
|
||||
commentLines = append(commentLines, l)
|
||||
} else {
|
||||
rest = lines[i:]
|
||||
break
|
||||
continue
|
||||
}
|
||||
rest = lines[i:]
|
||||
break
|
||||
}
|
||||
block := strings.Join(commentLines, "\n")
|
||||
lower := strings.ToLower(block)
|
||||
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
|
||||
// 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).
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
var commentLines, rest []string
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "#") {
|
||||
commentLines = append(commentLines, l)
|
||||
continue
|
||||
}
|
||||
rest = lines[i:]
|
||||
break
|
||||
}
|
||||
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
|
||||
return strings.Join(rest, "\n"), true
|
||||
}
|
||||
}
|
||||
|
||||
return s, false
|
||||
}
|
||||
|
||||
// stripImports removes top-of-file import blocks, language-specific.
|
||||
@@ -148,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
|
||||
@@ -162,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)
|
||||
@@ -240,60 +327,109 @@ func stripBlockComments(content string, lang protocol.Language) (string, bool) {
|
||||
return stripCStyleBlockComments(content)
|
||||
}
|
||||
|
||||
// stripCStyleBlockComments removes /* ... */ from content.
|
||||
// trimTrailingLineWhitespace drops trailing spaces/tabs from out (back to, but not past,
|
||||
// the previous newline). Used when a standalone comment line is removed so its leading
|
||||
// indentation does not linger as a whitespace-only line.
|
||||
func trimTrailingLineWhitespace(out []byte) []byte {
|
||||
for len(out) > 0 && (out[len(out)-1] == ' ' || out[len(out)-1] == '\t') {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skipLineTail advances i over trailing spaces/tabs and a CR, then a single LF — i.e. the
|
||||
// remainder of a line after a standalone comment's closer, including its \n or \r\n
|
||||
// terminator. Returns the new index.
|
||||
func skipLineTail(content string, i int) int {
|
||||
for i < len(content) && (content[i] == ' ' || content[i] == '\t' || content[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if i < len(content) && content[i] == '\n' {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// stripCStyleBlockComments removes /* ... */ comments. A comment that occupies a whole
|
||||
// line (only whitespace before it) is removed together with that line's indentation and
|
||||
// terminator; an inline comment (code precedes it) is removed in place, leaving the
|
||||
// surrounding line — and crucially its terminator — intact so lines are never merged.
|
||||
func stripCStyleBlockComments(content string) (string, bool) {
|
||||
removed := false
|
||||
var sb strings.Builder
|
||||
out := make([]byte, 0, len(content))
|
||||
lineHasNonSpace := false
|
||||
i := 0
|
||||
for i < len(content) {
|
||||
if i+1 < len(content) && content[i] == '/' && content[i+1] == '*' {
|
||||
// find closing */
|
||||
end := strings.Index(content[i+2:], "*/")
|
||||
if end >= 0 {
|
||||
if end := strings.Index(content[i+2:], "*/"); end >= 0 {
|
||||
removed = true
|
||||
// advance past */
|
||||
i = i + 2 + end + 2
|
||||
// consume trailing newline
|
||||
if i < len(content) && content[i] == '\n' {
|
||||
i++
|
||||
standalone := !lineHasNonSpace
|
||||
i = i + 2 + end + 2 // advance past closing */
|
||||
if standalone {
|
||||
out = trimTrailingLineWhitespace(out)
|
||||
i = skipLineTail(content, i)
|
||||
lineHasNonSpace = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.WriteByte(content[i])
|
||||
c := content[i]
|
||||
switch c {
|
||||
case '\n':
|
||||
lineHasNonSpace = false
|
||||
case ' ', '\t', '\r':
|
||||
// whitespace: does not mark the line as having content
|
||||
default:
|
||||
lineHasNonSpace = true
|
||||
}
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
if !removed {
|
||||
return content, false
|
||||
}
|
||||
return sb.String(), true
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// stripPythonDocstrings removes triple-quoted strings (""" and ”').
|
||||
// stripPythonDocstrings removes triple-quoted strings (""" and ”'). As with block
|
||||
// comments, a standalone docstring line is removed along with its indentation and
|
||||
// terminator, while an inline triple-quoted string leaves its line's terminator intact.
|
||||
func stripPythonDocstrings(content string) (string, bool) {
|
||||
removed := false
|
||||
var sb strings.Builder
|
||||
out := make([]byte, 0, len(content))
|
||||
lineHasNonSpace := false
|
||||
i := 0
|
||||
for i < len(content) {
|
||||
if i+2 < len(content) {
|
||||
triple := content[i : i+3]
|
||||
if triple == `"""` || triple == `'''` {
|
||||
end := strings.Index(content[i+3:], triple)
|
||||
if end >= 0 {
|
||||
if end := strings.Index(content[i+3:], triple); end >= 0 {
|
||||
removed = true
|
||||
standalone := !lineHasNonSpace
|
||||
i = i + 3 + end + 3
|
||||
if i < len(content) && content[i] == '\n' {
|
||||
i++
|
||||
if standalone {
|
||||
out = trimTrailingLineWhitespace(out)
|
||||
i = skipLineTail(content, i)
|
||||
lineHasNonSpace = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteByte(content[i])
|
||||
c := content[i]
|
||||
switch c {
|
||||
case '\n':
|
||||
lineHasNonSpace = false
|
||||
case ' ', '\t', '\r':
|
||||
// whitespace: does not mark the line as having content
|
||||
default:
|
||||
lineHasNonSpace = true
|
||||
}
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
if !removed {
|
||||
return content, false
|
||||
}
|
||||
return sb.String(), true
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
// An inline block comment (code before it on the same line) must not cause the following
|
||||
// line to be merged onto it — the line's terminator must survive.
|
||||
func TestStripBlockCommentInlineNoLineMerge(t *testing.T) {
|
||||
got := StripContent("a := 1 /* note */\nb := 2\n", []StripFlag{StripBlockComments}, protocol.LangGo)
|
||||
want := "a := 1 \nb := 2\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("inline block comment must not merge lines.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A standalone block-comment line (only whitespace before it) is removed in full,
|
||||
// including its indentation and terminator — no stray blank/whitespace line left behind.
|
||||
func TestStripBlockCommentStandaloneRemovesLine(t *testing.T) {
|
||||
got := StripContent("x\n\t/* c */\ny\n", []StripFlag{StripBlockComments}, protocol.LangGo)
|
||||
want := "x\ny\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("standalone block comment line must be removed cleanly.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// On a CRLF file, removing a standalone block-comment line must consume the full \r\n
|
||||
// terminator rather than leaving a stray blank (bare-CR) line.
|
||||
func TestStripBlockCommentCRLFNoStrayBlank(t *testing.T) {
|
||||
got := StripContent("code\r\n/* c */\r\nmore\r\n", []StripFlag{StripBlockComments}, protocol.LangGo)
|
||||
want := "code\r\nmore\r\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("CRLF standalone block comment must not leave a stray blank line.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Stripping a hash-style license header must not greedily swallow the blank separator
|
||||
// line that follows it.
|
||||
func TestStripLicensePythonPreservesSeparatorBlank(t *testing.T) {
|
||||
got := StripContent("# Copyright 2024\n# License MIT\n\ncode\n", []StripFlag{StripLicense}, protocol.LangPython)
|
||||
want := "\ncode\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("python license strip must keep the blank separator.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-line TS named import must be removed in full, not just its first line.
|
||||
func TestStripTSImportsMultiLine(t *testing.T) {
|
||||
src := "import {\n a,\n b,\n} from 'x';\nconst y = 1;\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
|
||||
want := "const y = 1;\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("multi-line import must be fully removed.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A side-effect import without a semicolon must not consume following code.
|
||||
func TestStripTSImportsSideEffectNoRunaway(t *testing.T) {
|
||||
src := "import './setup'\nconst y = 1\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
|
||||
want := "const y = 1\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("side-effect import must not swallow code.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Blank/underscore/dot/aliased single Go imports must all be stripped.
|
||||
func TestStripGoImportsSingleSpecForms(t *testing.T) {
|
||||
src := "package main\n\nimport _ \"embed\"\nimport . \"math\"\nimport m \"strings\"\n\nfunc main() {}\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangGo)
|
||||
for _, frag := range []string{`"embed"`, `"math"`, `"strings"`} {
|
||||
if containsStr(got.Content, frag) {
|
||||
t.Fatalf("single import %s not stripped, got:\n%s", frag, got.Content)
|
||||
}
|
||||
}
|
||||
if !containsStr(got.Content, "func main") {
|
||||
t.Fatalf("code removed, got:\n%s", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A Go // line-comment license after build constraints: the license is removed
|
||||
// while the build constraint is preserved above it.
|
||||
func TestStripLicenseGoLineCommentAfterBuildTag(t *testing.T) {
|
||||
src := "//go:build linux\n\n// Copyright 2024 Acme\n// SPDX-License-Identifier: MIT\n\npackage main\n"
|
||||
got := StripContent(src, []StripFlag{StripLicense}, protocol.LangGo)
|
||||
if containsStr(got.Content, "Copyright") {
|
||||
t.Fatalf("line-comment license not stripped, got:\n%q", got.Content)
|
||||
}
|
||||
if !containsStr(got.Content, "//go:build linux") {
|
||||
t.Fatalf("build constraint must be preserved, got:\n%q", got.Content)
|
||||
}
|
||||
if !containsStr(got.Content, "package main") {
|
||||
t.Fatalf("code removed, got:\n%q", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool { return strings.Contains(s, sub) }
|
||||
+195
-51
@@ -1,6 +1,8 @@
|
||||
package parser
|
||||
|
||||
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 !kindMatchesNode(symbolKind, n.Type()) {
|
||||
return true // kind mismatch, keep searching
|
||||
}
|
||||
r := NodeRange(n, filename)
|
||||
startLine = r.Start.Line
|
||||
endLine = r.End.Line
|
||||
found = true
|
||||
return false
|
||||
if !symbolDeclNodeTypes[n.Type()] {
|
||||
return true
|
||||
}
|
||||
if !kindMatchesNode(symbolKind, n.Type()) {
|
||||
return true // kind mismatch, keep searching
|
||||
}
|
||||
r := NodeRange(n, filename)
|
||||
nameLoc := NodeLocation(nameNode, filename)
|
||||
candidates = append(candidates, SymbolCandidate{
|
||||
NodeType: n.Type(),
|
||||
StartLine: r.Start.Line,
|
||||
EndLine: r.End.Line,
|
||||
NameLine: nameLoc.Line,
|
||||
NameColumn: nameLoc.Column,
|
||||
})
|
||||
return true
|
||||
})
|
||||
return
|
||||
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()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -389,13 +390,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 +411,7 @@ func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatO
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
|
||||
fmt.Fprintf(&sb, "**%s**\n", relPath)
|
||||
|
||||
if opts.Cluster {
|
||||
writeClusteredResults(&sb, fileResults[file])
|
||||
@@ -486,10 +489,11 @@ func writeClusteredResults(sb *strings.Builder, results []Result) {
|
||||
}
|
||||
}
|
||||
|
||||
// truncateLine truncates a line if it exceeds maxLen.
|
||||
// truncateLine truncates a line if it exceeds maxLen bytes, backing off to a
|
||||
// rune boundary so a multibyte character is never split into invalid UTF-8.
|
||||
func truncateLine(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
@@ -8,10 +8,27 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
)
|
||||
|
||||
// truncateLine must back off to a rune boundary so multibyte characters are
|
||||
// never split into invalid UTF-8.
|
||||
func TestTruncateLineRuneSafe(t *testing.T) {
|
||||
s := strings.Repeat("é", 200) // 2 bytes each → 400 bytes
|
||||
out := truncateLine(s, 50)
|
||||
if !utf8.ValidString(out) {
|
||||
t.Errorf("truncated output is not valid UTF-8: %q", out)
|
||||
}
|
||||
if !strings.HasSuffix(out, "...") {
|
||||
t.Errorf("expected ellipsis suffix, got %q", out)
|
||||
}
|
||||
if got := truncateLine("hi", 50); got != "hi" {
|
||||
t.Errorf("short string should be unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
@@ -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,101 @@ func TestASTQueryFormatLocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestASTQuerySkipsDependencyDirs verifies ast_query excludes node_modules/vendor
|
||||
// by default (they flood results with third-party code) but still searches them
|
||||
// when the caller targets one explicitly via paths (the walk root is never skipped).
|
||||
func TestASTQuerySkipsDependencyDirs(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deps := map[string]string{
|
||||
"node_modules": "NodeModulesDep",
|
||||
"vendor": "VendorDep",
|
||||
}
|
||||
for dir, fn := range deps {
|
||||
full := filepath.Join(tmpDir, dir)
|
||||
if err := os.MkdirAll(full, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := "package dep\n\nfunc " + fn + "() string { return \"\" }\n"
|
||||
if err := os.WriteFile(filepath.Join(full, "dep.go"), []byte(src), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default: workspace-root query must skip the dependency dirs.
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() string",
|
||||
"language": "go",
|
||||
"paths": []interface{}{tmpDir},
|
||||
"format": "location",
|
||||
}
|
||||
res, err := srv.handleASTQuery(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("default query error: %v", err)
|
||||
}
|
||||
// location format prints file:line, so assert on paths.
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "a.go") {
|
||||
t.Errorf("default query should find top-level a.go, got:\n%s", text)
|
||||
}
|
||||
for dir := range deps {
|
||||
if strings.Contains(text, dir+string(filepath.Separator)) {
|
||||
t.Errorf("default query must skip %s, got:\n%s", dir, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit target: querying inside node_modules must still find its code.
|
||||
req2 := mcp.CallToolRequest{}
|
||||
req2.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() string",
|
||||
"language": "go",
|
||||
"paths": []interface{}{filepath.Join(tmpDir, "node_modules")},
|
||||
"format": "location",
|
||||
}
|
||||
res2, err := srv.handleASTQuery(ctx, req2)
|
||||
if err != nil {
|
||||
t.Fatalf("explicit-target query error: %v", err)
|
||||
}
|
||||
text2 := res2.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text2, "node_modules"+string(filepath.Separator)+"dep.go") {
|
||||
t.Errorf("explicit node_modules target must find node_modules/dep.go, got:\n%s", text2)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Feature 3 (ast_query): pagination cursor ----
|
||||
|
||||
// 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 +385,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
|
||||
}
|
||||
|
||||
@@ -260,12 +271,12 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("**%s** (%d lines, %s)\n\n", relPath, len(splitLines(string(content))), lang))
|
||||
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", relPath, countLines(content), lang)
|
||||
sb.WriteString("Symbols:\n")
|
||||
|
||||
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()
|
||||
|
||||
@@ -11,16 +11,6 @@ import (
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// unescapeNewlines converts literal \n, \t, \" sequences to actual characters.
|
||||
// This handles cases where MCP clients send double-escaped JSON strings.
|
||||
func unescapeNewlines(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\\t", "\t")
|
||||
s = strings.ReplaceAll(s, "\\\"", "\"")
|
||||
s = strings.ReplaceAll(s, "\\\\", "\\")
|
||||
return s
|
||||
}
|
||||
|
||||
// handleEditApply handles the edit_apply tool.
|
||||
func (s *Server) handleEditApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return s.handleEdit(ctx, request)
|
||||
@@ -51,8 +41,12 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*
|
||||
return mcp.NewToolResultError("file is outside workspace root"), nil
|
||||
}
|
||||
|
||||
// new_content arrives already fully decoded by the JSON-RPC layer (mcp-go):
|
||||
// JSON escapes such as \n, \t, \\, \" have been resolved to their real bytes.
|
||||
// It must therefore be used verbatim — any further unescaping would corrupt
|
||||
// legitimate backslash sequences in source code (string literals, regexes,
|
||||
// Windows paths). See TestEditApplyPreservesBackslashSequences*.
|
||||
newContent := request.GetString("new_content", "")
|
||||
newContent = unescapeNewlines(newContent)
|
||||
|
||||
selectorName := request.GetString("selector_name", "")
|
||||
|
||||
@@ -98,37 +92,41 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// countDiffLines counts added (+) and removed (-) lines in a unified diff string.
|
||||
// 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
|
||||
// with + or - is counted correctly rather than mistaken for a header. The git-style
|
||||
// "\ No newline at end of file" marker is ignored.
|
||||
func countDiffLines(diff string) (added, removed int) {
|
||||
inHunk := false
|
||||
for _, line := range strings.Split(diff, "\n") {
|
||||
if len(line) == 0 {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if !inHunk || line == "" {
|
||||
continue
|
||||
}
|
||||
switch line[0] {
|
||||
case '+':
|
||||
if !strings.HasPrefix(line, "+++") {
|
||||
added++
|
||||
}
|
||||
added++
|
||||
case '-':
|
||||
if !strings.HasPrefix(line, "---") {
|
||||
removed++
|
||||
}
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
@@ -1,47 +1,130 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
func TestUnescapeNewlines(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no escapes",
|
||||
input: "func test() {\n\treturn true;\n}",
|
||||
expected: "func test() {\n\treturn true;\n}",
|
||||
},
|
||||
{
|
||||
name: "literal backslash n",
|
||||
input: "func test() {\\n\\treturn true;\\n}",
|
||||
expected: "func test() {\n\treturn true;\n}",
|
||||
},
|
||||
{
|
||||
name: "literal backslash t",
|
||||
input: "func test() {\\n\\treturn true;\\n}",
|
||||
expected: "func test() {\n\treturn true;\n}",
|
||||
},
|
||||
{
|
||||
name: "literal quotes",
|
||||
input: `func test() {\n\treturn \"true\";\n}`,
|
||||
expected: "func test() {\n\treturn \"true\";\n}",
|
||||
},
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
{
|
||||
name: "mixed content",
|
||||
input: "line1\\nline2\\tindented\\nline3",
|
||||
expected: "line1\nline2\tindented\nline3",
|
||||
},
|
||||
// callEdit invokes handleEditApply with the given args and returns the result.
|
||||
func callEdit(t *testing.T, srv *Server, args map[string]any) *mcp.CallToolResult {
|
||||
t.Helper()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = args
|
||||
res, err := srv.handleEditApply(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleEditApply error: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("handleEditApply returned nil result")
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// TestEditApplyPreservesBackslashSequencesText is the primary regression guard for the
|
||||
// double-unescape bug: new_content arrives from the JSON-RPC layer already fully decoded,
|
||||
// so the handler must write it to disk verbatim. Literal backslash sequences (\n, \t, \",
|
||||
// \\) that legitimately appear in source code must survive byte-for-byte.
|
||||
func TestEditApplyPreservesBackslashSequencesText(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
f := writeFile(t, tmpDir, "note.txt", "PLACEHOLDER\n")
|
||||
|
||||
// Exactly the bytes a client intends after JSON decoding: backslash sequences are
|
||||
// real source text here, NOT escapes to be interpreted.
|
||||
newContent := `printf("a\nb\tc"); re = \d+; path = "C:\\tmp"; q = \"`
|
||||
|
||||
res := callEdit(t, srv, map[string]any{
|
||||
"file": f,
|
||||
"operation": "replace",
|
||||
"selector_text": "PLACEHOLDER",
|
||||
"new_content": newContent,
|
||||
"response": "none",
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("edit returned error: %+v", res.Content)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := unescapeNewlines(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("unescapeNewlines(%q) = %q, expected %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
got, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
want := newContent + "\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("new_content not written verbatim (double-unescape regression).\nwant: %q\ngot: %q", want, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
diff := "--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n context\n+++plusprefix\n+normal add\n---minusprefix\n-normal del\n"
|
||||
added, removed := countDiffLines(diff)
|
||||
if added != 2 || removed != 2 {
|
||||
t.Fatalf("hunk-aware diff count wrong: added=%d removed=%d (want 2 and 2)", added, removed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditApplyPreservesBackslashSequencesCode covers the AST/code path: the same verbatim
|
||||
// guarantee must hold for syntactically-validated code files.
|
||||
func TestEditApplyPreservesBackslashSequencesCode(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
src := "package main\n\nfunc demo() {\n\tprintln(\"old\")\n}\n"
|
||||
f := writeFile(t, tmpDir, "demo.go", src)
|
||||
|
||||
// Real tab indentation + literal backslash sequences inside string/raw-string literals.
|
||||
newBody := "func demo() {\n\tprintln(\"a\\nb\\tc\")\n\t_ = `\\d+`\n\t_ = \"C:\\\\tmp\"\n}"
|
||||
|
||||
res := callEdit(t, srv, map[string]any{
|
||||
"file": f,
|
||||
"operation": "replace",
|
||||
"selector_kind": "function_declaration",
|
||||
"selector_name": "demo",
|
||||
"new_content": newBody,
|
||||
"response": "none",
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("edit returned error: %+v", res.Content)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(got), newBody) {
|
||||
t.Fatalf("backslash sequences corrupted in code edit.\nwant substring:\n%q\ngot file:\n%q", newBody, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "")
|
||||
@@ -87,13 +100,14 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
||||
}
|
||||
|
||||
req := &search.Request{
|
||||
Pattern: pattern,
|
||||
Paths: paths,
|
||||
FileTypes: fileTypes,
|
||||
IgnoreCase: ignoreCase,
|
||||
Regex: regex,
|
||||
ContextLines: contextLines,
|
||||
MaxResults: rgMaxResults,
|
||||
Pattern: pattern,
|
||||
Paths: paths,
|
||||
FileTypes: fileTypes,
|
||||
IgnoreCase: ignoreCase,
|
||||
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
|
||||
}
|
||||
@@ -439,12 +463,19 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p
|
||||
// Symbol-based line range: find the symbol and use its exact bounds.
|
||||
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 +530,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 +648,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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -76,6 +87,31 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLSPSymbolPosition resolves a symbol name to the 1-indexed line:column of
|
||||
// its name identifier, for use as an LSP position. Returns an error result when the
|
||||
// file can't be read/parsed, the symbol is not found (with a "did you mean" hint),
|
||||
// or the name is ambiguous (listing candidates).
|
||||
func (s *Server) resolveLSPSymbolPosition(ctx context.Context, file, symbolName string, symbolKind protocol.SymbolKind) (line, col int, errResult *mcp.CallToolResult) {
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err)))
|
||||
}
|
||||
result, err := s.parser.Parse(ctx, file, content)
|
||||
if err != nil {
|
||||
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err)))
|
||||
}
|
||||
candidates := parser.FindSymbolCandidates(result.Tree, content, file, symbolName, symbolKind)
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q not found in %s%s", symbolName, file, s.nearestSymbolSuggestion(ctx, file, content, symbolName)))
|
||||
case 1:
|
||||
return candidates[0].NameLine, candidates[0].NameColumn, nil
|
||||
default:
|
||||
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or pass line+column",
|
||||
symbolName, file, len(candidates), formatSymbolCandidates(candidates)))
|
||||
}
|
||||
}
|
||||
|
||||
// lspHover performs hover (symbol info) for the given position.
|
||||
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 +151,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 +176,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", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
||||
|
||||
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
||||
if preview != "" {
|
||||
@@ -187,7 +227,7 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
|
||||
func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string {
|
||||
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 +235,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 +313,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" +
|
||||
@@ -137,6 +156,8 @@ const helpEditApply = "# edit_apply — flags and examples\n\n" +
|
||||
"| `none` | Empty response (silent success) |\n\n" +
|
||||
"`compact_response=true` is a deprecated alias for `response=\"count\"` kept for pre-v2 compatibility.\n\n" +
|
||||
"For code files (Go, TypeScript, JavaScript, Python, C, C++, Rust) syntax is validated before writing — the edit is rejected if it would produce invalid syntax.\n\n" +
|
||||
"## new_content handling\n\n" +
|
||||
"`new_content` is inserted **verbatim**. Send it as normal JSON — real newlines (or JSON `\\n`), with quotes JSON-escaped as usual. The server does not re-interpret escape sequences, so literal backslash sequences in source (`\\n`, `\\t`, `\\\\`, regexes, Windows paths) are written exactly as given. Indentation is not auto-applied — include the leading whitespace you want. Line endings are normalized to the file's existing convention (LF or CRLF).\n\n" +
|
||||
"## Selector types\n\n" +
|
||||
"### AST-mode selectors (code files)\n" +
|
||||
"- `selector_kind` — AST node type (e.g. `function_declaration`, `class_declaration`)\n" +
|
||||
|
||||
@@ -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",
|
||||
@@ -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,13 +283,17 @@ 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)."),
|
||||
@@ -313,7 +323,7 @@ func (s *Server) registerTools() {
|
||||
mcp.Description("Edit operation: replace, insert_before, insert_after, delete"),
|
||||
),
|
||||
mcp.WithString("new_content",
|
||||
mcp.Description("New content (required for replace/insert operations)"),
|
||||
mcp.Description("New content (required for replace/insert operations). Inserted verbatim: send it as normal JSON (real newlines, quotes JSON-escaped as usual). The server does NOT re-interpret escape sequences, so backslash sequences in source code (e.g. \\n, \\t, \\\\ inside string literals, regexes, or Windows paths) are preserved exactly. Indentation is not auto-applied — include the leading whitespace you want. Line endings are normalized to the file's existing convention (LF or CRLF)."),
|
||||
),
|
||||
// AST-mode selectors (for code files)
|
||||
mcp.WithString("selector_kind",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-23
@@ -59,12 +59,10 @@ detect_platform() {
|
||||
;;
|
||||
esac
|
||||
|
||||
# Intel Macs are no longer receiving macOS updates; we only ship Apple
|
||||
# Silicon builds. Users on older hardware can build from source.
|
||||
if [ "$os" = "darwin" ] && [ "$arch" = "amd64" ]; then
|
||||
print_error "macOS Intel (x86_64) is no longer supported."
|
||||
print_error "Apple Silicon binaries are available; for Intel, build from source:"
|
||||
print_error " https://github.com/lukaszraczylo/filepuff-mcp#build-from-source"
|
||||
# Warn about unsupported combinations
|
||||
if [ "$os" = "linux" ] && [ "$arch" = "arm64" ]; then
|
||||
print_error "Linux ARM64 builds are not currently available due to CGO cross-compilation complexity"
|
||||
print_error "Please build from source: https://github.com/lukaszraczylo/filepuff-mcp#build-from-source"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -75,7 +73,7 @@ check_dependencies() {
|
||||
local missing_deps=()
|
||||
|
||||
# Check for required commands
|
||||
for cmd in curl; do
|
||||
for cmd in curl tar; do
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
missing_deps+=("$cmd")
|
||||
fi
|
||||
@@ -212,15 +210,14 @@ download_and_install() {
|
||||
|
||||
print_info "Downloading mcp-filepuff ${version} for ${platform}..."
|
||||
|
||||
# Releases ship raw binaries named mcp-filepuff_<version>_<os>_<arch>
|
||||
# (with .exe on Windows — not handled here).
|
||||
local asset_name="mcp-filepuff_${version#v}_${platform}"
|
||||
local asset_url="https://github.com/${REPO}/releases/download/${version}/${asset_name}"
|
||||
# Construct download URLs
|
||||
local archive_name="mcp-filepuff_${version#v}_${platform}.tar.gz"
|
||||
local archive_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}"
|
||||
local checksum_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt"
|
||||
|
||||
# Download binary
|
||||
if ! curl -sSfL "$asset_url" -o "$tmpdir/$asset_name"; then
|
||||
print_error "Failed to download $asset_url"
|
||||
# Download archive
|
||||
if ! curl -sSfL "$archive_url" -o "$tmpdir/$archive_name"; then
|
||||
print_error "Failed to download $archive_url"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -230,24 +227,26 @@ download_and_install() {
|
||||
else
|
||||
print_info "Verifying checksum..."
|
||||
cd "$tmpdir"
|
||||
|
||||
|
||||
# Extract expected checksum for our archive
|
||||
local expected_checksum
|
||||
expected_checksum=$(grep "$asset_name" checksums.txt | awk '{print $1}')
|
||||
|
||||
expected_checksum=$(grep "$archive_name" checksums.txt | awk '{print $1}')
|
||||
|
||||
if [ -z "$expected_checksum" ]; then
|
||||
print_warn "Checksum not found for $asset_name, skipping verification"
|
||||
print_warn "Checksum not found for $archive_name, skipping verification"
|
||||
else
|
||||
# Calculate actual checksum (use shasum on macOS, sha256sum on Linux)
|
||||
local actual_checksum
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
actual_checksum=$(sha256sum "$asset_name" | awk '{print $1}')
|
||||
actual_checksum=$(sha256sum "$archive_name" | awk '{print $1}')
|
||||
elif command -v shasum &> /dev/null; then
|
||||
actual_checksum=$(shasum -a 256 "$asset_name" | awk '{print $1}')
|
||||
actual_checksum=$(shasum -a 256 "$archive_name" | awk '{print $1}')
|
||||
else
|
||||
print_warn "No checksum utility found, skipping verification"
|
||||
cd - > /dev/null
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
if [ "$expected_checksum" = "$actual_checksum" ]; then
|
||||
print_info "Checksum verification passed"
|
||||
else
|
||||
@@ -260,6 +259,10 @@ download_and_install() {
|
||||
cd - > /dev/null
|
||||
fi
|
||||
|
||||
# Extract archive
|
||||
print_info "Extracting archive..."
|
||||
tar -xzf "$tmpdir/$archive_name" -C "$tmpdir"
|
||||
|
||||
# Ensure install directory exists
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
print_info "Creating installation directory: $INSTALL_DIR"
|
||||
@@ -269,11 +272,11 @@ download_and_install() {
|
||||
# Install binary
|
||||
print_info "Installing to $INSTALL_DIR/$BINARY_NAME..."
|
||||
if [ -w "$INSTALL_DIR" ]; then
|
||||
cp "$tmpdir/$asset_name" "$INSTALL_DIR/$BINARY_NAME"
|
||||
cp "$tmpdir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
|
||||
chmod +x "$INSTALL_DIR/$BINARY_NAME"
|
||||
else
|
||||
print_warn "No write permission to $INSTALL_DIR, trying with sudo..."
|
||||
sudo cp "$tmpdir/$asset_name" "$INSTALL_DIR/$BINARY_NAME"
|
||||
sudo cp "$tmpdir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
|
||||
sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
|
||||
fi
|
||||
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Invoked by lukaszraczylo/shared-actions go-release-cgo.yaml before GoReleaser,
|
||||
# with TARGET_GOOS/TARGET_GOARCH set from the build matrix entry.
|
||||
#
|
||||
# GoReleaser Pro `release --split` partitions builds across matrix runners.
|
||||
# Its default partition is by GOOS only, so every linux runner would build
|
||||
# BOTH linux/amd64 and linux/arm64. With CGO_ENABLED=1 (go-tree-sitter needs
|
||||
# CGO) the native gcc on one runner cannot assemble the other arch's CGO
|
||||
# runtime (`gcc_arm64.S: no such instruction`), failing the release.
|
||||
#
|
||||
# Exporting GGOOS/GGOARCH restricts each runner to its own target. These are
|
||||
# filter-only vars (unlike GOOS/GOARCH, which bleed into before-hooks such as
|
||||
# `go run`/`go generate`) and require `partial.by: target` in .goreleaser.yaml.
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${GITHUB_ENV:-}" ] && [ -n "${TARGET_GOOS:-}" ] && [ -n "${TARGET_GOARCH:-}" ]; then
|
||||
echo "GGOOS=${TARGET_GOOS}" >>"${GITHUB_ENV}"
|
||||
echo "GGOARCH=${TARGET_GOARCH}" >>"${GITHUB_ENV}"
|
||||
echo "Pinned GoReleaser split target: GGOOS=${TARGET_GOOS} GGOARCH=${TARGET_GOARCH}"
|
||||
fi
|
||||
Reference in New Issue
Block a user