mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-06-05 22:23:50 +00:00
9d84c1253b
- [x] Add sortBySpecificity and shouldPrefer helper functions for node preference logic - [x] Implement isDeclarationLike pattern matching for declaration/statement node types - [x] Add AtLine selector specificity logic to prefer smallest meaningful nodes - [x] Add TestResolveSelectorAtLineSpecificity to verify correct node selection - [x] Add TestRegressionInsertAfterAtLine to prevent file corruption on insertions - [x] Add TestRegressionInsertBeforeAtLine to verify insert-before ordering - [x] Add TestRegressionNestedStructures to ensure nested nodes select correctly - [x] Add TestRegressionPreservesFileIntegrity to verify unrelated content preservation - [x] Add TestRegressionMultipleConstBlocks for multi-block const handling - [x] Add TestSortBySpecificity unit test for sorting logic
1332 lines
29 KiB
Go
1332 lines
29 KiB
Go
package edit
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
|
sitter "github.com/smacker/go-tree-sitter"
|
|
)
|
|
|
|
func TestValidateEdit(t *testing.T) {
|
|
e := NewEngine(parser.NewRegistry())
|
|
|
|
tests := []struct {
|
|
edit *ASTEdit
|
|
name string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid replace",
|
|
edit: &ASTEdit{
|
|
File: "test.go",
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{Kind: "function_declaration"},
|
|
NewContent: "func NewFunc() {}",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid delete",
|
|
edit: &ASTEdit{
|
|
File: "test.go",
|
|
Operation: EditDelete,
|
|
Selector: ASTSelector{Name: "oldFunc"},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "missing file",
|
|
edit: &ASTEdit{
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{Kind: "function_declaration"},
|
|
NewContent: "func NewFunc() {}",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "missing operation",
|
|
edit: &ASTEdit{
|
|
File: "test.go",
|
|
Selector: ASTSelector{Kind: "function_declaration"},
|
|
NewContent: "func NewFunc() {}",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "replace without content",
|
|
edit: &ASTEdit{
|
|
File: "test.go",
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{Kind: "function_declaration"},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty selector",
|
|
edit: &ASTEdit{
|
|
File: "test.go",
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{},
|
|
NewContent: "func NewFunc() {}",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unknown operation",
|
|
edit: &ASTEdit{
|
|
File: "test.go",
|
|
Operation: "unknown",
|
|
Selector: ASTSelector{Kind: "function_declaration"},
|
|
NewContent: "func NewFunc() {}",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := e.validateASTEdit(tt.edit)
|
|
if tt.wantErr && err == nil {
|
|
t.Error("expected error")
|
|
}
|
|
if !tt.wantErr && err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveSelector(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
content := []byte(`package main
|
|
|
|
func Hello() {
|
|
println("hello")
|
|
}
|
|
|
|
func Goodbye() {
|
|
println("goodbye")
|
|
}
|
|
`)
|
|
|
|
ctx := context.Background()
|
|
result, err := registry.Parse(ctx, "test.go", content)
|
|
if err != nil {
|
|
t.Fatalf("parse failed: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
sel ASTSelector
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "by kind",
|
|
sel: ASTSelector{Kind: "function_declaration"},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "by name",
|
|
sel: ASTSelector{Name: "Hello"},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "by kind and name",
|
|
sel: ASTSelector{Kind: "function_declaration", Name: "Goodbye"},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "by line",
|
|
sel: ASTSelector{AtLine: 3},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "no match",
|
|
sel: ASTSelector{Name: "NonExistent"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "index out of range",
|
|
sel: ASTSelector{Kind: "function_declaration", Index: 10},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
node, err := e.resolveSelector(tt.sel, result.Tree, content)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error")
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if node == nil {
|
|
t.Error("expected node")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyEdit(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
content := []byte(`package main
|
|
|
|
func Hello() {
|
|
println("hello")
|
|
}
|
|
`)
|
|
|
|
ctx := context.Background()
|
|
result, err := registry.Parse(ctx, "test.go", content)
|
|
if err != nil {
|
|
t.Fatalf("parse failed: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
operation EditOperation
|
|
newCode string
|
|
wantIn string // substring that should be in result
|
|
}{
|
|
{
|
|
name: "replace",
|
|
operation: EditReplace,
|
|
newCode: "func NewHello() {}",
|
|
wantIn: "NewHello",
|
|
},
|
|
{
|
|
name: "insert after",
|
|
operation: EditInsertAfter,
|
|
newCode: "func After() {}",
|
|
wantIn: "After",
|
|
},
|
|
{
|
|
name: "insert before",
|
|
operation: EditInsertBefore,
|
|
newCode: "func Before() {}",
|
|
wantIn: "Before",
|
|
},
|
|
{
|
|
name: "delete",
|
|
operation: EditDelete,
|
|
newCode: "",
|
|
wantIn: "package main", // Should still have package declaration
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Find the function node
|
|
node, err := e.resolveSelector(ASTSelector{Kind: "function_declaration"}, result.Tree, content)
|
|
if err != nil {
|
|
t.Fatalf("resolve failed: %v", err)
|
|
}
|
|
|
|
edit := &ASTEdit{
|
|
File: "test.go",
|
|
Operation: tt.operation,
|
|
NewContent: tt.newCode,
|
|
}
|
|
|
|
newContent, err := e.applyEdit(edit, node, content)
|
|
if err != nil {
|
|
t.Fatalf("apply failed: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(string(newContent), tt.wantIn) {
|
|
t.Errorf("result does not contain %q:\n%s", tt.wantIn, string(newContent))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreview(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
// Create a temp file
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "test.go")
|
|
|
|
content := `package main
|
|
|
|
func Hello() {
|
|
println("hello")
|
|
}
|
|
`
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
edit := &ASTEdit{
|
|
File: tmpFile,
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{Kind: "function_declaration"},
|
|
NewContent: "func NewHello() {\n\tprintln(\"new hello\")\n}",
|
|
}
|
|
|
|
result, err := e.Preview(ctx, edit)
|
|
if err != nil {
|
|
t.Fatalf("preview failed: %v", err)
|
|
}
|
|
|
|
if !result.Success {
|
|
t.Fatalf("preview was not successful: %s", result.Error)
|
|
}
|
|
|
|
if result.Applied {
|
|
t.Error("preview should not apply changes")
|
|
}
|
|
|
|
if result.Diff == "" {
|
|
t.Error("expected diff in result")
|
|
}
|
|
|
|
// Verify original file is unchanged
|
|
fileContent, _ := os.ReadFile(tmpFile)
|
|
if string(fileContent) != content {
|
|
t.Error("original file was modified during preview")
|
|
}
|
|
}
|
|
|
|
func TestApplyToFile(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
// Create a temp file
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "test.go")
|
|
|
|
content := `package main
|
|
|
|
func Hello() {
|
|
println("hello")
|
|
}
|
|
`
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
edit := &ASTEdit{
|
|
File: tmpFile,
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{Kind: "function_declaration"},
|
|
NewContent: "func NewHello() {\n\tprintln(\"new hello\")\n}",
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if !result.Applied {
|
|
t.Error("apply should set Applied=true")
|
|
}
|
|
|
|
// Verify file was modified
|
|
fileContent, _ := os.ReadFile(tmpFile)
|
|
if !strings.Contains(string(fileContent), "NewHello") {
|
|
t.Error("file was not modified")
|
|
}
|
|
}
|
|
|
|
func TestDetectIndentation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
want string
|
|
pos uint32
|
|
}{
|
|
{
|
|
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: " ",
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateDiff(t *testing.T) {
|
|
original := "line1\nline2\nline3"
|
|
modified := "line1\nmodified\nline3"
|
|
filename := "test.txt"
|
|
|
|
diff := generateDiff(original, modified, filename)
|
|
|
|
if !strings.Contains(diff, "---") {
|
|
t.Error("diff should contain --- header")
|
|
}
|
|
if !strings.Contains(diff, "+++") {
|
|
t.Error("diff should contain +++ header")
|
|
}
|
|
if !strings.Contains(diff, "-line2") {
|
|
t.Error("diff should show removed line")
|
|
}
|
|
if !strings.Contains(diff, "+modified") {
|
|
t.Error("diff should show added line")
|
|
}
|
|
}
|
|
|
|
// ==================== Text-based editing tests ====================
|
|
|
|
func TestTextEditWithExactText(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
// Create a temp markdown file
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "README.md")
|
|
|
|
content := `# My Project
|
|
|
|
## Installation
|
|
|
|
Run the following command:
|
|
|
|
## Usage
|
|
|
|
See the docs.
|
|
`
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
edit := &ASTEdit{
|
|
File: tmpFile,
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{Text: "## Installation"},
|
|
NewContent: "## Getting Started",
|
|
}
|
|
|
|
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 file was modified
|
|
fileContent, _ := os.ReadFile(tmpFile)
|
|
if !strings.Contains(string(fileContent), "## Getting Started") {
|
|
t.Error("file was not modified correctly")
|
|
}
|
|
if strings.Contains(string(fileContent), "## Installation") {
|
|
t.Error("old text should be replaced")
|
|
}
|
|
}
|
|
|
|
func TestTextEditWithLineRange(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
// Create a temp config file
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
content := `name: myapp
|
|
version: 1.0.0
|
|
database:
|
|
host: localhost
|
|
port: 5432
|
|
logging:
|
|
level: debug
|
|
`
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
edit := &ASTEdit{
|
|
File: tmpFile,
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{
|
|
AtLine: 3,
|
|
LineEnd: 5,
|
|
},
|
|
NewContent: "database:\n host: production.db.example.com\n port: 5433",
|
|
}
|
|
|
|
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 file was modified
|
|
fileContent, _ := os.ReadFile(tmpFile)
|
|
if !strings.Contains(string(fileContent), "production.db.example.com") {
|
|
t.Error("file was not modified correctly")
|
|
}
|
|
}
|
|
|
|
func TestTextEditWithRegexPattern(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
// Create a temp JSON file
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "package.json")
|
|
|
|
content := `{
|
|
"name": "my-package",
|
|
"version": "1.0.0",
|
|
"description": "A test package"
|
|
}
|
|
`
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
edit := &ASTEdit{
|
|
File: tmpFile,
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{TextPattern: `"version":\s*"[^"]+"`},
|
|
NewContent: `"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)
|
|
}
|
|
|
|
// Verify file was modified
|
|
fileContent, _ := os.ReadFile(tmpFile)
|
|
if !strings.Contains(string(fileContent), `"version": "2.0.0"`) {
|
|
t.Error("file was not modified correctly")
|
|
}
|
|
}
|
|
|
|
func TestTextEditInsertAfter(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
// Create a temp env file
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, ".env")
|
|
|
|
content := `DATABASE_URL=postgres://localhost/mydb
|
|
SECRET_KEY=abc123
|
|
`
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
edit := &ASTEdit{
|
|
File: tmpFile,
|
|
Operation: EditInsertAfter,
|
|
Selector: ASTSelector{Text: "DATABASE_URL=postgres://localhost/mydb"},
|
|
NewContent: "REDIS_URL=redis://localhost:6379",
|
|
}
|
|
|
|
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 file was modified
|
|
fileContent, _ := os.ReadFile(tmpFile)
|
|
if !strings.Contains(string(fileContent), "REDIS_URL=redis://localhost:6379") {
|
|
t.Error("file was not modified correctly")
|
|
}
|
|
}
|
|
|
|
func TestTextEditMultipleMatchesError(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
// Create a temp file with repeated text
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "test.txt")
|
|
|
|
content := `TODO: fix this
|
|
some code here
|
|
TODO: also fix this
|
|
more code
|
|
`
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
edit := &ASTEdit{
|
|
File: tmpFile,
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{Text: "TODO"},
|
|
NewContent: "DONE",
|
|
}
|
|
|
|
result, err := e.Apply(ctx, edit)
|
|
if err != nil {
|
|
t.Fatalf("apply failed: %v", err)
|
|
}
|
|
|
|
// Should fail because of multiple matches
|
|
if result.Success {
|
|
t.Error("expected error for multiple matches without index")
|
|
}
|
|
if !strings.Contains(result.Error, "matches") {
|
|
t.Errorf("error should mention multiple matches: %s", result.Error)
|
|
}
|
|
}
|
|
|
|
func TestTextEditWithIndex(t *testing.T) {
|
|
registry := parser.NewRegistry()
|
|
defer registry.Close()
|
|
e := NewEngine(registry)
|
|
|
|
// Create a temp file with repeated text
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "test.txt")
|
|
|
|
content := `TODO: fix this
|
|
some code here
|
|
TODO: also fix this
|
|
more code
|
|
`
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
edit := &ASTEdit{
|
|
File: tmpFile,
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{
|
|
Text: "TODO",
|
|
Index: 1, // Select second match
|
|
},
|
|
NewContent: "DONE",
|
|
}
|
|
|
|
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 only second TODO was replaced
|
|
fileContent, _ := os.ReadFile(tmpFile)
|
|
contentStr := string(fileContent)
|
|
if !strings.Contains(contentStr, "TODO: fix this") {
|
|
t.Error("first TODO should not be replaced")
|
|
}
|
|
if !strings.Contains(contentStr, "DONE: also fix this") {
|
|
t.Error("second TODO should be replaced")
|
|
}
|
|
}
|
|
|
|
func TestValidateTextEdit(t *testing.T) {
|
|
e := NewEngine(parser.NewRegistry())
|
|
|
|
tests := []struct {
|
|
edit *ASTEdit
|
|
name string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid text selector",
|
|
edit: &ASTEdit{
|
|
File: "test.md",
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{Text: "some text"},
|
|
NewContent: "new text",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid pattern selector",
|
|
edit: &ASTEdit{
|
|
File: "test.md",
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{TextPattern: "\\d+"},
|
|
NewContent: "replaced",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid line selector",
|
|
edit: &ASTEdit{
|
|
File: "test.md",
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{AtLine: 5},
|
|
NewContent: "new line",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty selector",
|
|
edit: &ASTEdit{
|
|
File: "test.md",
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{},
|
|
NewContent: "new text",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid regex pattern",
|
|
edit: &ASTEdit{
|
|
File: "test.md",
|
|
Operation: EditReplace,
|
|
Selector: ASTSelector{TextPattern: "[invalid"},
|
|
NewContent: "new text",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := e.validateTextEdit(tt.edit)
|
|
if tt.wantErr && err == nil {
|
|
t.Error("expected error")
|
|
}
|
|
if !tt.wantErr && err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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())
|
|
|
|
content := []byte("line1\nline2\nline3\nline4\nline5")
|
|
|
|
// Content: "line1\nline2\nline3\nline4\nline5" (no trailing newline)
|
|
// Positions: line1=0-5, \n=5, line2=6-10, \n=11, line3=12-16, \n=17, line4=18-22, \n=23, line5=24-28
|
|
tests := []struct {
|
|
name string
|
|
lineStart int
|
|
lineEnd int
|
|
wantStart int
|
|
wantEnd int
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "single line",
|
|
lineStart: 2,
|
|
lineEnd: 0, // defaults to lineStart
|
|
wantStart: 6,
|
|
wantEnd: 12, // includes trailing newline
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "range of lines",
|
|
lineStart: 2,
|
|
lineEnd: 4,
|
|
wantStart: 6,
|
|
wantEnd: 24, // through end of line4 including newline
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "first line",
|
|
lineStart: 1,
|
|
lineEnd: 1,
|
|
wantStart: 0,
|
|
wantEnd: 6, // includes trailing newline
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "line out of range",
|
|
lineStart: 10,
|
|
lineEnd: 10,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid line number",
|
|
lineStart: 0,
|
|
lineEnd: 1,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "end before start",
|
|
lineStart: 3,
|
|
lineEnd: 2,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
start, end, err := e.findLineRange(content, tt.lineStart, tt.lineEnd)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if start != tt.wantStart {
|
|
t.Errorf("start = %d, want %d", start, tt.wantStart)
|
|
}
|
|
if end != tt.wantEnd {
|
|
t.Errorf("end = %d, want %d", end, tt.wantEnd)
|
|
}
|
|
})
|
|
}
|
|
}
|