mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d84c1253b | ||
|
|
b8d868115c | ||
|
|
ac1b81b70e | ||
|
|
f216380d37 | ||
|
|
3be03aef7b | ||
|
|
5800c689e9 | ||
|
|
81f146e78d | ||
|
|
9db035888e |
@@ -16,9 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
||||
with:
|
||||
go-version: "1.24"
|
||||
docker-enabled: false
|
||||
rolling-release-tag: "v1"
|
||||
secrets: inherit
|
||||
|
||||
+9
-27
@@ -15,7 +15,7 @@ builds:
|
||||
main: ./cmd/mcp-filepuff
|
||||
binary: mcp-filepuff
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
- CGO_ENABLED=1
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
@@ -30,6 +30,13 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
@@ -82,24 +89,6 @@ release:
|
||||
curl -sSL https://raw.githubusercontent.com/lukaszraczylo/filepuff-mcp/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
dockers_v2:
|
||||
- images:
|
||||
- "ghcr.io/lukaszraczylo/filepuff-mcp"
|
||||
tags:
|
||||
- "{{ .Version }}"
|
||||
- "latest"
|
||||
- "v1"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/filepuff-mcp"
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
@@ -112,11 +101,4 @@ signs:
|
||||
artifacts: checksum
|
||||
output: true
|
||||
|
||||
docker_signs:
|
||||
- cmd: cosign
|
||||
artifacts: manifests
|
||||
output: true
|
||||
args:
|
||||
- sign
|
||||
- "${artifact}@${digest}"
|
||||
- "--yes"
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lukasz Raczylo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -14,33 +14,49 @@ A Go-based MCP (Model Context Protocol) server for Claude Code providing intelli
|
||||
|
||||
## Installation
|
||||
|
||||
### Binary Releases (Recommended)
|
||||
### Quick Install (Recommended)
|
||||
|
||||
Install the latest version with a single command:
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/lukaszraczylo/filepuff-mcp/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Automatically detect your platform (OS and architecture)
|
||||
- Download the latest release
|
||||
- Verify checksums
|
||||
- Install to `~/.local/bin` (or `/usr/local/bin` if needed)
|
||||
- Make the binary executable
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
|
||||
|
||||
```bash
|
||||
# macOS (ARM64)
|
||||
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-darwin-arm64.tar.gz | tar xz
|
||||
sudo mv mcp-filepuff /usr/local/bin/
|
||||
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-darwin-amd64.tar.gz | tar xz
|
||||
sudo mv mcp-filepuff /usr/local/bin/
|
||||
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 -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-linux-arm64.tar.gz | tar xz
|
||||
sudo mv mcp-filepuff /usr/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 -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-linux-amd64.tar.gz | tar xz
|
||||
sudo mv mcp-filepuff /usr/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-windows-amd64.zip" -OutFile mcp-filepuff.zip
|
||||
Expand-Archive mcp-filepuff.zip -DestinationPath .
|
||||
Move-Item mcp-filepuff.exe C:\Windows\System32\
|
||||
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`).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) installed and in PATH
|
||||
@@ -409,6 +425,7 @@ Apply an edit to a file. Uses AST-aware editing for code files with syntax valid
|
||||
| HTML | .html, .htm | Yes | Yes | - | Yes |
|
||||
| Vue | .vue | Yes | Yes* | - | Yes |
|
||||
| React | .jsx, .tsx | Yes | Yes | typescript-language-server | Yes |
|
||||
| Elixir | .ex, .exs | Yes | Yes | elixir-ls | Yes |
|
||||
|
||||
\* Vue uses HTML parser for template sections
|
||||
|
||||
|
||||
+944
-824
File diff suppressed because it is too large
Load Diff
@@ -352,6 +352,12 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
|
||||
return nil, errors.NewNodeNotFoundError(selectorDesc)
|
||||
}
|
||||
|
||||
// When using AtLine without a specific Kind, prefer the smallest (most specific) node.
|
||||
// This prevents matching large parent nodes like source_file when we want a specific declaration.
|
||||
if sel.AtLine > 0 && sel.Kind == "" {
|
||||
matches = sortBySpecificity(matches)
|
||||
}
|
||||
|
||||
// Use index to select specific match
|
||||
index := sel.Index
|
||||
if index < 0 || index >= len(matches) {
|
||||
@@ -361,6 +367,76 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
|
||||
return matches[index], nil
|
||||
}
|
||||
|
||||
// sortBySpecificity sorts nodes so that the most useful nodes come first.
|
||||
// Prefers: 1) Named nodes (declarations/statements) over anonymous tokens
|
||||
// 2) Smaller nodes over larger ones (more specific)
|
||||
func sortBySpecificity(nodes []*sitter.Node) []*sitter.Node {
|
||||
if len(nodes) <= 1 {
|
||||
return nodes
|
||||
}
|
||||
|
||||
// Sort by specificity: named nodes first, then by size (smallest first)
|
||||
result := make([]*sitter.Node, len(nodes))
|
||||
copy(result, nodes)
|
||||
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
if shouldPrefer(result[j], result[i]) {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// shouldPrefer returns true if node a should come before node b.
|
||||
func shouldPrefer(a, b *sitter.Node) bool {
|
||||
// Prefer named nodes over anonymous tokens
|
||||
aIsNamed := a.IsNamed()
|
||||
bIsNamed := b.IsNamed()
|
||||
if aIsNamed && !bIsNamed {
|
||||
return true
|
||||
}
|
||||
if !aIsNamed && bIsNamed {
|
||||
return false
|
||||
}
|
||||
|
||||
// Both named or both anonymous: prefer smaller meaningful nodes
|
||||
// But filter out very small nodes (likely just identifiers/literals)
|
||||
aSize := a.EndByte() - a.StartByte()
|
||||
bSize := b.EndByte() - b.StartByte()
|
||||
|
||||
// If both are named, prefer "declaration" or "statement" types
|
||||
aIsDecl := isDeclarationLike(a.Type())
|
||||
bIsDecl := isDeclarationLike(b.Type())
|
||||
if aIsDecl && !bIsDecl {
|
||||
return true
|
||||
}
|
||||
if !aIsDecl && bIsDecl {
|
||||
return false
|
||||
}
|
||||
|
||||
// Same category: prefer smaller
|
||||
return aSize < bSize
|
||||
}
|
||||
|
||||
// isDeclarationLike returns true for node types that represent declarations or statements.
|
||||
func isDeclarationLike(nodeType string) bool {
|
||||
// Common declaration/statement patterns across languages
|
||||
declarationPatterns := []string{
|
||||
"declaration", "definition", "statement", "spec", "clause",
|
||||
"function", "method", "class", "struct", "interface", "type",
|
||||
"import", "package", "module", "const", "var", "let",
|
||||
}
|
||||
for _, pattern := range declarationPatterns {
|
||||
if strings.Contains(nodeType, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesSelector checks if a node matches the selector criteria.
|
||||
func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte) bool {
|
||||
// Check kind
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
)
|
||||
|
||||
func TestValidateEdit(t *testing.T) {
|
||||
@@ -754,6 +755,500 @@ func TestValidateTextEdit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSelectorAtLineSpecificity(t *testing.T) {
|
||||
// This test verifies that when using AtLine selector without Kind,
|
||||
// the smallest (most specific) node is selected, not the largest parent.
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
e := NewEngine(registry)
|
||||
|
||||
// Create a Go file with nested structures
|
||||
content := []byte(`package main
|
||||
|
||||
const (
|
||||
// First const group
|
||||
FOO = "foo"
|
||||
BAR = "bar"
|
||||
)
|
||||
|
||||
const (
|
||||
// Second const group
|
||||
BAZ = "baz"
|
||||
QUX = "qux"
|
||||
)
|
||||
|
||||
func main() {
|
||||
println(FOO)
|
||||
}
|
||||
`)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := registry.Parse(ctx, "test.go", content)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Selector at line 5 (FOO = "foo") should match the specific const_spec,
|
||||
// not the entire const_declaration or source_file
|
||||
node, err := e.resolveSelector(ASTSelector{AtLine: 5}, result.Tree, content)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
|
||||
// The node should be small - just the "FOO = \"foo\"" part
|
||||
nodeText := string(content[node.StartByte():node.EndByte()])
|
||||
if strings.Contains(nodeText, "BAR") {
|
||||
t.Errorf("selected node is too large (contains BAR): %q", nodeText)
|
||||
}
|
||||
if strings.Contains(nodeText, "package") {
|
||||
t.Errorf("selected node is the entire file: %q", truncateString(nodeText, 50))
|
||||
}
|
||||
|
||||
t.Logf("Selected node type: %s, text: %q", node.Type(), truncateString(nodeText, 100))
|
||||
}
|
||||
|
||||
// ==================== Regression tests for file corruption bug ====================
|
||||
// These tests verify that the fix for the AtLine selector specificity issue
|
||||
// prevents file corruption when inserting content.
|
||||
|
||||
func TestRegressionInsertAfterAtLine(t *testing.T) {
|
||||
// Regression test: Insert after a specific const should not corrupt the file
|
||||
// by inserting at the wrong location (e.g., at start of file or wrong block)
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
e := NewEngine(registry)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "queries.go")
|
||||
|
||||
// Simulate a typical Go queries file
|
||||
content := `package org
|
||||
|
||||
const (
|
||||
// queryGetOrg retrieves an organization by ID
|
||||
queryGetOrg = ` + "`" + `
|
||||
SELECT id, name FROM orgs WHERE id = $1
|
||||
` + "`" + `
|
||||
|
||||
// queryListOrgs lists all organizations
|
||||
queryListOrgs = ` + "`" + `
|
||||
SELECT id, name FROM orgs
|
||||
` + "`" + `
|
||||
)
|
||||
|
||||
const (
|
||||
// queryCreateOrg creates a new organization
|
||||
queryCreateOrg = ` + "`" + `
|
||||
INSERT INTO orgs (name) VALUES ($1)
|
||||
` + "`" + `
|
||||
)
|
||||
`
|
||||
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert after the first const block (line ~14) - should NOT corrupt file
|
||||
edit := &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditInsertAfter,
|
||||
Selector: ASTSelector{
|
||||
Kind: "const_declaration",
|
||||
AtLine: 5, // Line within the first const block
|
||||
},
|
||||
NewContent: `const (
|
||||
// queryGetGitHub retrieves GitHub settings
|
||||
queryGetGitHub = ` + "`" + `SELECT * FROM github` + "`" + `
|
||||
)`,
|
||||
}
|
||||
|
||||
result, err := e.Apply(ctx, edit)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("apply was not successful: %s", result.Error)
|
||||
}
|
||||
|
||||
// Verify the file structure is preserved
|
||||
newContent, _ := os.ReadFile(tmpFile)
|
||||
newStr := string(newContent)
|
||||
|
||||
// Check the file still starts with package declaration
|
||||
if !strings.HasPrefix(newStr, "package org") {
|
||||
t.Errorf("file corrupted: package declaration missing or moved\nContent:\n%s", newStr)
|
||||
}
|
||||
|
||||
// Check original content is preserved
|
||||
if !strings.Contains(newStr, "queryGetOrg") {
|
||||
t.Error("original queryGetOrg was lost")
|
||||
}
|
||||
if !strings.Contains(newStr, "queryListOrgs") {
|
||||
t.Error("original queryListOrgs was lost")
|
||||
}
|
||||
if !strings.Contains(newStr, "queryCreateOrg") {
|
||||
t.Error("original queryCreateOrg was lost")
|
||||
}
|
||||
|
||||
// Check new content was added
|
||||
if !strings.Contains(newStr, "queryGetGitHub") {
|
||||
t.Error("new queryGetGitHub was not added")
|
||||
}
|
||||
|
||||
// Verify insertion location: queryGetGitHub should appear AFTER queryListOrgs
|
||||
// but the exact position depends on where the first const block ends
|
||||
listOrgsIdx := strings.Index(newStr, "queryListOrgs")
|
||||
gitHubIdx := strings.Index(newStr, "queryGetGitHub")
|
||||
if gitHubIdx < listOrgsIdx {
|
||||
t.Errorf("queryGetGitHub inserted before queryListOrgs - wrong location")
|
||||
}
|
||||
|
||||
t.Logf("Result diff:\n%s", result.Diff)
|
||||
}
|
||||
|
||||
func TestRegressionInsertBeforeAtLine(t *testing.T) {
|
||||
// Regression test: Insert before should work correctly with AtLine
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
e := NewEngine(registry)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "main.go")
|
||||
|
||||
content := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func hello() {
|
||||
fmt.Println("hello")
|
||||
}
|
||||
|
||||
func goodbye() {
|
||||
fmt.Println("goodbye")
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert before goodbye function
|
||||
edit := &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditInsertBefore,
|
||||
Selector: ASTSelector{
|
||||
Kind: "function_declaration",
|
||||
Name: "goodbye",
|
||||
AtLine: 9,
|
||||
},
|
||||
NewContent: `func middle() {
|
||||
fmt.Println("middle")
|
||||
}`,
|
||||
}
|
||||
|
||||
result, err := e.Apply(ctx, edit)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("apply was not successful: %s", result.Error)
|
||||
}
|
||||
|
||||
newContent, _ := os.ReadFile(tmpFile)
|
||||
newStr := string(newContent)
|
||||
|
||||
// Verify order: hello -> middle -> goodbye
|
||||
helloIdx := strings.Index(newStr, "func hello()")
|
||||
middleIdx := strings.Index(newStr, "func middle()")
|
||||
goodbyeIdx := strings.Index(newStr, "func goodbye()")
|
||||
|
||||
if helloIdx == -1 || middleIdx == -1 || goodbyeIdx == -1 {
|
||||
t.Fatalf("missing functions in output:\n%s", newStr)
|
||||
}
|
||||
|
||||
if helloIdx >= middleIdx || middleIdx >= goodbyeIdx {
|
||||
t.Errorf("functions in wrong order: hello=%d, middle=%d, goodbye=%d", helloIdx, middleIdx, goodbyeIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegressionNestedStructures(t *testing.T) {
|
||||
// Regression test: Nested structures should select the correct node
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
e := NewEngine(registry)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "nested.go")
|
||||
|
||||
content := `package main
|
||||
|
||||
type Outer struct {
|
||||
Inner struct {
|
||||
Field string
|
||||
}
|
||||
Other int
|
||||
}
|
||||
|
||||
func main() {
|
||||
o := Outer{}
|
||||
_ = o
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Replace the Inner struct field - should not affect Outer
|
||||
edit := &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditReplace,
|
||||
Selector: ASTSelector{
|
||||
AtLine: 5, // Line with "Field string"
|
||||
},
|
||||
NewContent: `Name string`,
|
||||
}
|
||||
|
||||
result, err := e.Apply(ctx, edit)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("apply was not successful: %s", result.Error)
|
||||
}
|
||||
|
||||
newContent, _ := os.ReadFile(tmpFile)
|
||||
newStr := string(newContent)
|
||||
|
||||
// Verify structure is preserved
|
||||
if !strings.Contains(newStr, "type Outer struct") {
|
||||
t.Error("Outer struct declaration lost")
|
||||
}
|
||||
if !strings.Contains(newStr, "Inner struct") {
|
||||
t.Error("Inner struct declaration lost")
|
||||
}
|
||||
if !strings.Contains(newStr, "Other int") {
|
||||
t.Error("Other field lost")
|
||||
}
|
||||
if !strings.Contains(newStr, "Name string") {
|
||||
t.Error("Name field not added")
|
||||
}
|
||||
if strings.Contains(newStr, "Field string") {
|
||||
t.Error("Old Field string should be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegressionPreservesFileIntegrity(t *testing.T) {
|
||||
// Regression test: Edit should not corrupt unrelated parts of the file
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
e := NewEngine(registry)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "integrity.go")
|
||||
|
||||
content := `package main
|
||||
|
||||
// Copyright 2024 Example Corp
|
||||
// License: MIT
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const Version = "1.0.0"
|
||||
|
||||
func main() {
|
||||
fmt.Println(Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// End of file comment
|
||||
`
|
||||
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Replace the Version constant (line 11)
|
||||
edit := &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditReplace,
|
||||
Selector: ASTSelector{
|
||||
Kind: "const_declaration",
|
||||
AtLine: 11,
|
||||
},
|
||||
NewContent: `const Version = "2.0.0"`,
|
||||
}
|
||||
|
||||
result, err := e.Apply(ctx, edit)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("apply was not successful: %s", result.Error)
|
||||
}
|
||||
|
||||
newContent, _ := os.ReadFile(tmpFile)
|
||||
newStr := string(newContent)
|
||||
|
||||
// Verify all unrelated parts are preserved exactly
|
||||
checks := []string{
|
||||
"package main",
|
||||
"// Copyright 2024 Example Corp",
|
||||
"// License: MIT",
|
||||
`"fmt"`,
|
||||
`"os"`,
|
||||
"func main()",
|
||||
"fmt.Println(Version)",
|
||||
"os.Exit(0)",
|
||||
"// End of file comment",
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(newStr, check) {
|
||||
t.Errorf("file integrity violated: missing %q\nContent:\n%s", check, newStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the change was made
|
||||
if !strings.Contains(newStr, `"2.0.0"`) {
|
||||
t.Error("Version was not updated to 2.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegressionMultipleConstBlocks(t *testing.T) {
|
||||
// Regression test: Multiple const blocks - edit should target correct one
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
e := NewEngine(registry)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "consts.go")
|
||||
|
||||
content := `package main
|
||||
|
||||
const (
|
||||
A = 1
|
||||
B = 2
|
||||
)
|
||||
|
||||
const (
|
||||
C = 3
|
||||
D = 4
|
||||
)
|
||||
|
||||
const (
|
||||
E = 5
|
||||
F = 6
|
||||
)
|
||||
`
|
||||
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert after the second const block (containing C, D)
|
||||
edit := &ASTEdit{
|
||||
File: tmpFile,
|
||||
Operation: EditInsertAfter,
|
||||
Selector: ASTSelector{
|
||||
Kind: "const_declaration",
|
||||
AtLine: 9, // Line with C = 3
|
||||
},
|
||||
NewContent: `const X = 99`,
|
||||
}
|
||||
|
||||
result, err := e.Apply(ctx, edit)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("apply was not successful: %s", result.Error)
|
||||
}
|
||||
|
||||
newContent, _ := os.ReadFile(tmpFile)
|
||||
newStr := string(newContent)
|
||||
|
||||
// X should appear after D but before E
|
||||
dIdx := strings.Index(newStr, "D = 4")
|
||||
xIdx := strings.Index(newStr, "X = 99")
|
||||
eIdx := strings.Index(newStr, "E = 5")
|
||||
|
||||
if xIdx == -1 {
|
||||
t.Fatalf("X = 99 not found in output:\n%s", newStr)
|
||||
}
|
||||
|
||||
if dIdx >= xIdx || xIdx >= eIdx {
|
||||
t.Errorf("X inserted in wrong position: D=%d, X=%d, E=%d\nContent:\n%s", dIdx, xIdx, eIdx, newStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortBySpecificity(t *testing.T) {
|
||||
// Unit test for the sortBySpecificity helper function
|
||||
registry := parser.NewRegistry()
|
||||
defer registry.Close()
|
||||
|
||||
content := []byte(`package main
|
||||
|
||||
const (
|
||||
FOO = "foo"
|
||||
)
|
||||
`)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := registry.Parse(ctx, "test.go", content)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Collect all nodes that span line 4
|
||||
var nodesAtLine4 []*sitter.Node
|
||||
parser.WalkTree(result.Tree.RootNode(), func(n *sitter.Node) bool {
|
||||
startLine := int(n.StartPoint().Row) + 1
|
||||
endLine := int(n.EndPoint().Row) + 1
|
||||
if 4 >= startLine && 4 <= endLine {
|
||||
nodesAtLine4 = append(nodesAtLine4, n)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if len(nodesAtLine4) < 2 {
|
||||
t.Skip("not enough nodes to test sorting")
|
||||
}
|
||||
|
||||
// Sort and verify smallest declaration-like node comes first
|
||||
sorted := sortBySpecificity(nodesAtLine4)
|
||||
|
||||
// First node should be a declaration-like node, not source_file
|
||||
firstType := sorted[0].Type()
|
||||
if firstType == "source_file" {
|
||||
t.Errorf("source_file should not be first after sorting, got types: %v",
|
||||
nodeTypes(sorted))
|
||||
}
|
||||
|
||||
t.Logf("Sorted node types: %v", nodeTypes(sorted))
|
||||
}
|
||||
|
||||
func nodeTypes(nodes []*sitter.Node) []string {
|
||||
types := make([]string, len(nodes))
|
||||
for i, n := range nodes {
|
||||
types[i] = n.Type()
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func TestFindLineRange(t *testing.T) {
|
||||
e := NewEngine(parser.NewRegistry())
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ func ExtractDocComment(n *sitter.Node, content []byte, lang protocol.Language) *
|
||||
return extractPythonDocComment(n, content)
|
||||
case protocol.LangC, protocol.LangCpp:
|
||||
return extractCDocComment(n, content)
|
||||
case protocol.LangElixir:
|
||||
return extractElixirDocComment(n, content)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -548,3 +550,109 @@ func cleanPythonDocstring(doc string) string {
|
||||
|
||||
return strings.TrimSpace(doc)
|
||||
}
|
||||
|
||||
// extractElixirDocComment extracts Elixir documentation from @doc and @moduledoc attributes.
|
||||
// Elixir uses module attributes like @doc and @moduledoc for documentation.
|
||||
func extractElixirDocComment(n *sitter.Node, content []byte) *DocComment {
|
||||
// Look for @doc or @moduledoc attribute preceding this node
|
||||
prev := n.PrevSibling()
|
||||
|
||||
for prev != nil {
|
||||
// Check if this is an unary_operator with @ (module attribute)
|
||||
if prev.Type() == "unary_operator" {
|
||||
text := GetNodeText(prev, content)
|
||||
trimmed := strings.TrimSpace(text)
|
||||
|
||||
// Check for @doc or @moduledoc
|
||||
if strings.HasPrefix(trimmed, "@doc") || strings.HasPrefix(trimmed, "@moduledoc") {
|
||||
// Extract the documentation string
|
||||
docText := extractElixirDocString(prev, content)
|
||||
if docText != "" {
|
||||
return &DocComment{
|
||||
Text: docText,
|
||||
Raw: text,
|
||||
Style: CommentStyleDocstring,
|
||||
Tags: nil,
|
||||
StartLine: int(prev.StartPoint().Row) + 1,
|
||||
EndLine: int(prev.EndPoint().Row) + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for regular # comments
|
||||
if prev.Type() == "comment" {
|
||||
comments := collectPrecedingComments(n, content, []string{"comment"})
|
||||
if len(comments) > 0 {
|
||||
var parts []string
|
||||
var raw []string
|
||||
startLine := -1
|
||||
endLine := -1
|
||||
|
||||
for _, c := range comments {
|
||||
text := GetNodeText(c, content)
|
||||
raw = append(raw, text)
|
||||
|
||||
if startLine == -1 {
|
||||
startLine = int(c.StartPoint().Row) + 1
|
||||
}
|
||||
endLine = int(c.EndPoint().Row) + 1
|
||||
|
||||
// Clean # comment
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), "#"))
|
||||
if cleaned != "" {
|
||||
parts = append(parts, cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
return &DocComment{
|
||||
Text: strings.Join(parts, "\n"),
|
||||
Raw: strings.Join(raw, "\n"),
|
||||
Style: CommentStyleHash,
|
||||
Tags: nil,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
prev = prev.PrevSibling()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractElixirDocString extracts the documentation string from an Elixir @doc/@moduledoc attribute.
|
||||
func extractElixirDocString(n *sitter.Node, content []byte) string {
|
||||
// The doc attribute typically looks like:
|
||||
// @doc """
|
||||
// Documentation here
|
||||
// """
|
||||
// or
|
||||
// @doc "Single line doc"
|
||||
|
||||
text := GetNodeText(n, content)
|
||||
|
||||
// Find the string content after @doc or @moduledoc
|
||||
var docContent string
|
||||
|
||||
// Check for heredoc style (triple quotes)
|
||||
if idx := strings.Index(text, `"""`); idx != -1 {
|
||||
// Find the closing triple quotes
|
||||
rest := text[idx+3:]
|
||||
if endIdx := strings.Index(rest, `"""`); endIdx != -1 {
|
||||
docContent = rest[:endIdx]
|
||||
}
|
||||
} else if idx := strings.Index(text, `"`); idx != -1 {
|
||||
// Single quoted string
|
||||
rest := text[idx+1:]
|
||||
if endIdx := strings.Index(rest, `"`); endIdx != -1 {
|
||||
docContent = rest[:endIdx]
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(docContent)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
"github.com/smacker/go-tree-sitter/c"
|
||||
"github.com/smacker/go-tree-sitter/cpp"
|
||||
"github.com/smacker/go-tree-sitter/elixir"
|
||||
"github.com/smacker/go-tree-sitter/golang"
|
||||
"github.com/smacker/go-tree-sitter/html"
|
||||
"github.com/smacker/go-tree-sitter/javascript"
|
||||
@@ -88,10 +89,12 @@ func getLanguage(lang protocol.Language) (*sitter.Language, error) {
|
||||
case protocol.LangVue:
|
||||
// Vue SFC files use HTML-like template syntax, so we use the HTML parser
|
||||
return html.GetLanguage(), nil
|
||||
case protocol.LangElixir:
|
||||
return elixir.GetLanguage(), nil
|
||||
default:
|
||||
return nil, errors.New(errors.ErrInvalidLanguage, fmt.Sprintf("language %s is not supported", lang)).
|
||||
WithContext("language", string(lang)).
|
||||
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue")
|
||||
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
|
||||
return extractPythonSymbols(root, content, filename)
|
||||
case protocol.LangC, protocol.LangCpp:
|
||||
return extractCSymbols(root, content, filename)
|
||||
case protocol.LangElixir:
|
||||
return extractElixirSymbols(root, content, filename)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -472,3 +474,278 @@ func hasFunctionDeclarator(n *sitter.Node) bool {
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// extractElixirSymbols extracts symbols from Elixir code.
|
||||
// Elixir uses `defmodule` for modules, `def`/`defp` for functions, and `defmacro`/`defmacrop` for macros.
|
||||
func extractElixirSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
|
||||
var symbols []protocol.Symbol
|
||||
|
||||
WalkTree(root, func(n *sitter.Node) bool {
|
||||
var symbol *protocol.Symbol
|
||||
|
||||
switch n.Type() {
|
||||
case "call":
|
||||
symbol = extractElixirCall(n, content, filename)
|
||||
}
|
||||
|
||||
if symbol != nil {
|
||||
if doc := ExtractDocComment(n, content, protocol.LangElixir); doc != nil {
|
||||
symbol.Doc = FormatDocComment(doc)
|
||||
}
|
||||
symbols = append(symbols, *symbol)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return symbols
|
||||
}
|
||||
|
||||
// extractElixirCall extracts symbols from Elixir call nodes (def, defp, defmodule, defmacro, etc.).
|
||||
func extractElixirCall(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// Get the function being called (first child is usually the target)
|
||||
if n.NamedChildCount() < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
target := n.NamedChild(0)
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetText := GetNodeText(target, content)
|
||||
|
||||
switch targetText {
|
||||
case "defmodule":
|
||||
return extractElixirModule(n, content, filename)
|
||||
case "def", "defp":
|
||||
return extractElixirFunction(n, content, filename, targetText == "defp")
|
||||
case "defmacro", "defmacrop":
|
||||
return extractElixirMacro(n, content, filename)
|
||||
case "defstruct":
|
||||
return extractElixirStruct(n, content, filename)
|
||||
case "defprotocol":
|
||||
return extractElixirProtocol(n, content, filename)
|
||||
case "defimpl":
|
||||
return extractElixirImpl(n, content, filename)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractElixirModule extracts a module definition.
|
||||
func extractElixirModule(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// defmodule ModuleName do ... end
|
||||
// The module name is in the arguments
|
||||
args := n.ChildByFieldName("arguments")
|
||||
if args == nil {
|
||||
// Try finding it as the second named child
|
||||
if n.NamedChildCount() >= 2 {
|
||||
args = n.NamedChild(1)
|
||||
}
|
||||
}
|
||||
if args == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the alias (module name) in the arguments
|
||||
var moduleName string
|
||||
WalkTree(args, func(node *sitter.Node) bool {
|
||||
if node.Type() == "alias" {
|
||||
moduleName = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if moduleName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: moduleName,
|
||||
Kind: protocol.SymbolModule,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirFunction extracts a function definition.
|
||||
func extractElixirFunction(n *sitter.Node, content []byte, filename string, isPrivate bool) *protocol.Symbol {
|
||||
// def function_name(args) do ... end
|
||||
// The function name and args are in the arguments of the call
|
||||
if n.NamedChildCount() < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Second child contains the function definition
|
||||
funcDef := n.NamedChild(1)
|
||||
if funcDef == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var funcName string
|
||||
|
||||
// The function definition can be:
|
||||
// 1. A call node (function with args): func_name(arg1, arg2)
|
||||
// 2. An identifier (function without args): func_name
|
||||
switch funcDef.Type() {
|
||||
case "call":
|
||||
// Get the function name from the call target
|
||||
if funcDef.NamedChildCount() >= 1 {
|
||||
nameNode := funcDef.NamedChild(0)
|
||||
if nameNode != nil {
|
||||
funcName = GetNodeText(nameNode, content)
|
||||
}
|
||||
}
|
||||
case "identifier":
|
||||
funcName = GetNodeText(funcDef, content)
|
||||
case "binary_operator":
|
||||
// Guard clause: def func_name(args) when guard do ... end
|
||||
// The left side contains the actual function call
|
||||
WalkTree(funcDef, func(node *sitter.Node) bool {
|
||||
if node.Type() == "call" && node.NamedChildCount() >= 1 {
|
||||
nameNode := node.NamedChild(0)
|
||||
if nameNode != nil && nameNode.Type() == "identifier" {
|
||||
funcName = GetNodeText(nameNode, content)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if node.Type() == "identifier" && funcName == "" {
|
||||
funcName = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if funcName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
kind := protocol.SymbolFunction
|
||||
if isPrivate {
|
||||
funcName = funcName + " (private)"
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: funcName,
|
||||
Kind: kind,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirMacro extracts a macro definition.
|
||||
func extractElixirMacro(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// Similar to function extraction
|
||||
if n.NamedChildCount() < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
funcDef := n.NamedChild(1)
|
||||
if funcDef == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var macroName string
|
||||
|
||||
switch funcDef.Type() {
|
||||
case "call":
|
||||
if funcDef.NamedChildCount() >= 1 {
|
||||
nameNode := funcDef.NamedChild(0)
|
||||
if nameNode != nil {
|
||||
macroName = GetNodeText(nameNode, content)
|
||||
}
|
||||
}
|
||||
case "identifier":
|
||||
macroName = GetNodeText(funcDef, content)
|
||||
}
|
||||
|
||||
if macroName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: macroName + " (macro)",
|
||||
Kind: protocol.SymbolFunction,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirStruct extracts a struct definition.
|
||||
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
|
||||
return &protocol.Symbol{
|
||||
Name: "defstruct",
|
||||
Kind: protocol.SymbolStruct,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirProtocol extracts a protocol definition.
|
||||
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// defprotocol ProtocolName do ... end
|
||||
if n.NamedChildCount() < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := n.NamedChild(1)
|
||||
if args == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var protocolName string
|
||||
WalkTree(args, func(node *sitter.Node) bool {
|
||||
if node.Type() == "alias" {
|
||||
protocolName = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if protocolName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: protocolName,
|
||||
Kind: protocol.SymbolInterface,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirImpl extracts a protocol implementation.
|
||||
func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// defimpl Protocol, for: Type do ... end
|
||||
if n.NamedChildCount() < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := n.NamedChild(1)
|
||||
if args == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var implName string
|
||||
WalkTree(args, func(node *sitter.Node) bool {
|
||||
if node.Type() == "alias" {
|
||||
if implName == "" {
|
||||
implName = GetNodeText(node, content)
|
||||
} else {
|
||||
implName = implName + " for " + GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if implName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: implName,
|
||||
Kind: protocol.SymbolClass,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,3 +224,96 @@ int main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractElixirSymbols(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
content := `defmodule MyApp.User do
|
||||
@moduledoc """
|
||||
User module for the application.
|
||||
"""
|
||||
|
||||
defstruct [:name, :email]
|
||||
|
||||
@doc """
|
||||
Creates a new user.
|
||||
"""
|
||||
def new(name, email) do
|
||||
%__MODULE__{name: name, email: email}
|
||||
end
|
||||
|
||||
defp validate(user) do
|
||||
# Private validation function
|
||||
user
|
||||
end
|
||||
|
||||
defmacro is_user(term) do
|
||||
quote do
|
||||
is_struct(unquote(term), __MODULE__)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defprotocol Greeting do
|
||||
@doc "Greet the entity"
|
||||
def greet(entity)
|
||||
end
|
||||
|
||||
defimpl Greeting, for: MyApp.User do
|
||||
def greet(user) do
|
||||
"Hello, #{user.name}!"
|
||||
end
|
||||
end
|
||||
`
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := r.Parse(ctx, "test.ex", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangElixir, "test.ex")
|
||||
|
||||
// Check that we found some symbols
|
||||
if len(symbols) == 0 {
|
||||
t.Fatal("expected to find some symbols")
|
||||
}
|
||||
|
||||
// Look for specific expected symbols - we focus on top-level constructs
|
||||
// that the current implementation can reliably extract
|
||||
expectedSymbols := map[string]protocol.SymbolKind{
|
||||
"MyApp.User": protocol.SymbolModule,
|
||||
"Greeting": protocol.SymbolInterface,
|
||||
}
|
||||
|
||||
found := make(map[string]bool)
|
||||
for _, sym := range symbols {
|
||||
for name, expectedKind := range expectedSymbols {
|
||||
if sym.Name == name {
|
||||
found[name] = true
|
||||
if sym.Kind != expectedKind {
|
||||
t.Errorf("symbol %s: expected kind %s, got %s", sym.Name, expectedKind, sym.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name := range expectedSymbols {
|
||||
if !found[name] {
|
||||
t.Errorf("expected to find symbol %s, found symbols: %v", name, symbols)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we found the defstruct
|
||||
foundStruct := false
|
||||
for _, sym := range symbols {
|
||||
if sym.Kind == protocol.SymbolStruct {
|
||||
foundStruct = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundStruct {
|
||||
t.Error("expected to find a struct symbol")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,6 +670,8 @@ func languageToExtension(language string) string {
|
||||
return ".c"
|
||||
case "cpp", "c++":
|
||||
return ".cpp"
|
||||
case "elixir":
|
||||
return ".ex"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ const (
|
||||
LangVue Language = "vue"
|
||||
LangJSON Language = "json"
|
||||
LangYAML Language = "yaml"
|
||||
LangElixir Language = "elixir"
|
||||
LangUnknown Language = "unknown"
|
||||
)
|
||||
|
||||
@@ -87,6 +88,8 @@ func DetectLanguage(filename string) Language {
|
||||
return LangJSON
|
||||
case ".yaml", ".yml":
|
||||
return LangYAML
|
||||
case ".ex", ".exs":
|
||||
return LangElixir
|
||||
default:
|
||||
return LangUnknown
|
||||
}
|
||||
|
||||
@@ -29,10 +29,14 @@ func TestDetectLanguage(t *testing.T) {
|
||||
{"index.html", LangHTML},
|
||||
{"page.htm", LangHTML},
|
||||
{"Component.vue", LangVue},
|
||||
{"app.ex", LangElixir},
|
||||
{"app_test.exs", LangElixir},
|
||||
{"mix.exs", LangElixir},
|
||||
{"unknown.txt", LangUnknown},
|
||||
{"README", LangUnknown},
|
||||
{"path/to/file.go", LangGo},
|
||||
{"path/to/file.ts", LangTypeScript},
|
||||
{"path/to/file.ex", LangElixir},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
Executable
+388
@@ -0,0 +1,388 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MCP Filepuff Installation Script
|
||||
# Downloads and installs the latest version of mcp-filepuff
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# GitHub repository information
|
||||
REPO="lukaszraczylo/filepuff-mcp"
|
||||
BINARY_NAME="mcp-filepuff"
|
||||
|
||||
# Installation directory (will try ~/.local/bin first, then /usr/local/bin)
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
# Functions
|
||||
print_info() {
|
||||
printf "${GREEN}[INFO]${NC} %s\n" "$1"
|
||||
}
|
||||
|
||||
print_warn() {
|
||||
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
|
||||
}
|
||||
|
||||
detect_platform() {
|
||||
local os
|
||||
local arch
|
||||
|
||||
# Detect OS
|
||||
case "$(uname -s)" in
|
||||
Linux*) os="linux" ;;
|
||||
Darwin*) os="darwin" ;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
print_error "Windows is not directly supported. Please use WSL2."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
print_error "Unsupported operating system: $(uname -s)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect architecture
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch="amd64" ;;
|
||||
arm64|aarch64) arch="arm64" ;;
|
||||
*)
|
||||
print_error "Unsupported architecture: $(uname -m)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# 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
|
||||
|
||||
echo "${os}_${arch}"
|
||||
}
|
||||
|
||||
check_dependencies() {
|
||||
local missing_deps=()
|
||||
|
||||
# Check for required commands
|
||||
for cmd in curl tar; do
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
missing_deps+=("$cmd")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_deps[@]} -ne 0 ]; then
|
||||
print_error "Missing required dependencies: ${missing_deps[*]}"
|
||||
print_error "Please install them and try again."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_package_manager() {
|
||||
if command -v brew &> /dev/null; then
|
||||
echo "brew"
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
echo "apt"
|
||||
elif command -v yum &> /dev/null; then
|
||||
echo "yum"
|
||||
elif command -v pacman &> /dev/null; then
|
||||
echo "pacman"
|
||||
elif command -v apk &> /dev/null; then
|
||||
echo "apk"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
install_prerequisites() {
|
||||
local pkg_mgr="$1"
|
||||
local install_lsp="${2:-false}"
|
||||
|
||||
print_info "Checking prerequisites..."
|
||||
|
||||
# Check if ripgrep is installed
|
||||
if ! command -v rg &> /dev/null; then
|
||||
print_warn "ripgrep not found - required for file search functionality"
|
||||
|
||||
case "$pkg_mgr" in
|
||||
brew)
|
||||
print_info "Installing ripgrep via Homebrew..."
|
||||
brew install ripgrep
|
||||
;;
|
||||
apt)
|
||||
print_info "Installing ripgrep via apt..."
|
||||
sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
;;
|
||||
yum)
|
||||
print_info "Installing ripgrep via yum..."
|
||||
sudo yum install -y ripgrep
|
||||
;;
|
||||
pacman)
|
||||
print_info "Installing ripgrep via pacman..."
|
||||
sudo pacman -S --noconfirm ripgrep
|
||||
;;
|
||||
apk)
|
||||
print_info "Installing ripgrep via apk..."
|
||||
sudo apk add --no-cache ripgrep
|
||||
;;
|
||||
unknown)
|
||||
print_error "Could not detect package manager"
|
||||
print_error "Please install ripgrep manually: https://github.com/BurntSushi/ripgrep"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
print_info "✓ ripgrep is already installed"
|
||||
fi
|
||||
|
||||
if [ "$install_lsp" = "true" ]; then
|
||||
print_info "Installing LSP servers for enhanced IDE features..."
|
||||
|
||||
# Install gopls (Go LSP)
|
||||
if command -v go &> /dev/null && ! command -v gopls &> /dev/null; then
|
||||
print_info "Installing gopls (Go language server)..."
|
||||
go install golang.org/x/tools/gopls@latest
|
||||
fi
|
||||
|
||||
# Install typescript-language-server (TypeScript/JavaScript)
|
||||
if command -v npm &> /dev/null && ! command -v typescript-language-server &> /dev/null; then
|
||||
print_info "Installing typescript-language-server..."
|
||||
npm install -g typescript-language-server typescript
|
||||
fi
|
||||
|
||||
# Install pylsp (Python LSP)
|
||||
if command -v pip3 &> /dev/null && ! python3 -c "import pylsp" 2>/dev/null; then
|
||||
print_info "Installing python-lsp-server..."
|
||||
pip3 install python-lsp-server
|
||||
fi
|
||||
|
||||
# Install clangd (C/C++)
|
||||
if ! command -v clangd &> /dev/null; then
|
||||
case "$pkg_mgr" in
|
||||
brew)
|
||||
print_info "Installing clangd via Homebrew..."
|
||||
brew install llvm
|
||||
;;
|
||||
apt)
|
||||
print_info "Installing clangd via apt..."
|
||||
sudo apt-get install -y clangd
|
||||
;;
|
||||
yum)
|
||||
print_info "Installing clangd via yum..."
|
||||
sudo yum install -y clang-tools-extra
|
||||
;;
|
||||
pacman)
|
||||
print_info "Installing clangd via pacman..."
|
||||
sudo pacman -S --noconfirm clang
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
get_latest_version() {
|
||||
local version
|
||||
version=$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
print_error "Failed to fetch latest version from GitHub"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
download_and_install() {
|
||||
local version="$1"
|
||||
local platform="$2"
|
||||
local tmpdir
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
print_info "Downloading mcp-filepuff ${version} for ${platform}..."
|
||||
|
||||
# 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 archive
|
||||
if ! curl -sSfL "$archive_url" -o "$tmpdir/$archive_name"; then
|
||||
print_error "Failed to download $archive_url"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download checksums
|
||||
if ! curl -sSfL "$checksum_url" -o "$tmpdir/checksums.txt"; then
|
||||
print_warn "Failed to download checksums, skipping verification"
|
||||
else
|
||||
print_info "Verifying checksum..."
|
||||
cd "$tmpdir"
|
||||
|
||||
# Extract expected checksum for our archive
|
||||
local expected_checksum
|
||||
expected_checksum=$(grep "$archive_name" checksums.txt | awk '{print $1}')
|
||||
|
||||
if [ -z "$expected_checksum" ]; then
|
||||
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 "$archive_name" | awk '{print $1}')
|
||||
elif command -v shasum &> /dev/null; then
|
||||
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
|
||||
print_error "Checksum verification failed"
|
||||
print_error "Expected: $expected_checksum"
|
||||
print_error "Actual: $actual_checksum"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
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"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Install binary
|
||||
print_info "Installing to $INSTALL_DIR/$BINARY_NAME..."
|
||||
if [ -w "$INSTALL_DIR" ]; then
|
||||
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/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
|
||||
sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
|
||||
fi
|
||||
|
||||
print_info "Installation complete!"
|
||||
}
|
||||
|
||||
check_path() {
|
||||
if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
|
||||
print_warn "$INSTALL_DIR is not in your PATH"
|
||||
print_warn "Add it to your PATH by adding this line to your shell profile:"
|
||||
echo ""
|
||||
echo " export PATH=\"\$PATH:$INSTALL_DIR\""
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
verify_installation() {
|
||||
if command -v "$BINARY_NAME" &> /dev/null; then
|
||||
local installed_version
|
||||
installed_version=$("$BINARY_NAME" -version 2>&1 || echo "unknown")
|
||||
print_info "Successfully installed: $installed_version"
|
||||
else
|
||||
print_warn "Binary installed but not found in PATH"
|
||||
print_info "You can run it directly: $INSTALL_DIR/$BINARY_NAME"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
print_info "MCP Filepuff Installation Script"
|
||||
echo ""
|
||||
|
||||
# Parse command line arguments
|
||||
local install_lsp="false"
|
||||
local skip_prereqs="false"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--with-lsp)
|
||||
install_lsp="true"
|
||||
shift
|
||||
;;
|
||||
--skip-prereqs)
|
||||
skip_prereqs="true"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
echo "Usage: install.sh [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --with-lsp Install LSP servers (gopls, typescript-language-server, pylsp, clangd)"
|
||||
echo " --skip-prereqs Skip prerequisite installation (ripgrep, LSP servers)"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown option: $1"
|
||||
print_error "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies
|
||||
|
||||
# Detect platform
|
||||
local platform
|
||||
platform=$(detect_platform)
|
||||
print_info "Detected platform: $platform"
|
||||
|
||||
# Detect package manager
|
||||
local pkg_mgr
|
||||
pkg_mgr=$(detect_package_manager)
|
||||
print_info "Detected package manager: $pkg_mgr"
|
||||
echo ""
|
||||
|
||||
# Install prerequisites unless skipped
|
||||
if [ "$skip_prereqs" != "true" ]; then
|
||||
install_prerequisites "$pkg_mgr" "$install_lsp"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Get latest version
|
||||
local version
|
||||
version=$(get_latest_version)
|
||||
print_info "Latest version: $version"
|
||||
echo ""
|
||||
|
||||
# Download and install
|
||||
download_and_install "$version" "$platform"
|
||||
echo ""
|
||||
|
||||
# Check PATH
|
||||
check_path
|
||||
|
||||
# Verify installation
|
||||
verify_installation
|
||||
echo ""
|
||||
|
||||
print_info "To get started, run: $BINARY_NAME --help"
|
||||
|
||||
if [ "$install_lsp" != "true" ]; then
|
||||
echo ""
|
||||
print_info "Tip: Run with --with-lsp to install LSP servers for enhanced IDE features"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user