diff --git a/internal/edit/edit.go b/internal/edit/edit.go index 23a4e64..9844f4d 100644 --- a/internal/edit/edit.go +++ b/internal/edit/edit.go @@ -495,6 +495,11 @@ func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([] 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[endByte:]...) case EditInsertBefore: @@ -508,9 +513,14 @@ func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([] case EditInsertAfter: insertion := newContent - if !strings.HasPrefix(insertion, "\n") { + // Ensure separation from preceding content + if endByte > 0 && content[endByte-1] != '\n' && !strings.HasPrefix(insertion, "\n") { insertion = "\n" + insertion } + // Ensure separation from following content + if !strings.HasSuffix(insertion, "\n") && endByte < uint32(len(content)) && content[endByte] != '\n' { + insertion += "\n" + } result = append(result, content[:endByte]...) result = append(result, []byte(insertion)...) result = append(result, content[endByte:]...) @@ -833,6 +843,11 @@ func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, 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: @@ -846,9 +861,14 @@ func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, case EditInsertAfter: insertion := indentedContent - if !strings.HasPrefix(insertion, "\n") { + // 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:]...) diff --git a/internal/edit/edit_test.go b/internal/edit/edit_test.go index 8137256..cd098a8 100644 --- a/internal/edit/edit_test.go +++ b/internal/edit/edit_test.go @@ -1402,3 +1402,311 @@ func TestFindLineRange(t *testing.T) { }) } } + +// ==================== Line merge regression tests ==================== +// These tests verify that edit operations don't merge adjacent lines. +// Each test checks exact line-by-line output, not just substring presence. + +func TestRegressionLineMergeReplace(t *testing.T) { + registry := parser.NewRegistry() + defer registry.Close() + e := NewEngine(registry) + + tests := []struct { + name string + content string + edit *ASTEdit + wantLines []string + }{ + { + name: "replace mid-line by line range preserves line boundaries", + content: "line1\nline2\nline3\nline4\n", + edit: &ASTEdit{ + Operation: EditReplace, + Selector: ASTSelector{AtLine: 2}, + NewContent: "replaced", + }, + wantLines: []string{"line1", "replaced", "line3", "line4", ""}, + }, + { + name: "replace multi-line range preserves boundaries", + content: "line1\nline2\nline3\nline4\n", + edit: &ASTEdit{ + Operation: EditReplace, + Selector: ASTSelector{AtLine: 2, LineEnd: 3}, + NewContent: "newA\nnewB", + }, + wantLines: []string{"line1", "newA", "newB", "line4", ""}, + }, + { + name: "replace last line without trailing newline", + content: "line1\nline2\nline3", + edit: &ASTEdit{ + Operation: EditReplace, + Selector: ASTSelector{AtLine: 3}, + NewContent: "replaced", + }, + wantLines: []string{"line1", "line2", "replaced"}, + }, + { + name: "replace single line with multi-line content", + content: "before\ntarget\nafter\n", + edit: &ASTEdit{ + Operation: EditReplace, + Selector: ASTSelector{AtLine: 2}, + NewContent: "new1\nnew2\nnew3", + }, + wantLines: []string{"before", "new1", "new2", "new3", "after", ""}, + }, + { + name: "replace by exact text preserves surrounding lines", + content: "aaa\nbbb\nccc\n", + edit: &ASTEdit{ + Operation: EditReplace, + Selector: ASTSelector{Text: "bbb"}, + NewContent: "xxx", + }, + wantLines: []string{"aaa", "xxx", "ccc", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil { + t.Fatalf("write: %v", err) + } + tt.edit.File = tmpFile + + result, err := e.Apply(context.Background(), tt.edit) + if err != nil { + t.Fatalf("apply: %v", err) + } + if !result.Success { + t.Fatalf("not successful: %s", result.Error) + } + + got, _ := os.ReadFile(tmpFile) + gotLines := strings.Split(string(got), "\n") + if len(gotLines) != len(tt.wantLines) { + t.Fatalf("line count = %d, want %d\ngot: %q\nwant: %q", len(gotLines), len(tt.wantLines), gotLines, tt.wantLines) + } + for i, want := range tt.wantLines { + if gotLines[i] != want { + t.Errorf("line %d = %q, want %q", i+1, gotLines[i], want) + } + } + }) + } +} + +func TestRegressionLineMergeInsertAfter(t *testing.T) { + registry := parser.NewRegistry() + defer registry.Close() + e := NewEngine(registry) + + tests := []struct { + name string + content string + edit *ASTEdit + wantLines []string + }{ + { + name: "insert after mid-line doesn't merge with next line", + content: "line1\nline2\nline3\n", + edit: &ASTEdit{ + Operation: EditInsertAfter, + Selector: ASTSelector{Text: "line2"}, + NewContent: "inserted", + }, + wantLines: []string{"line1", "line2", "inserted", "line3", ""}, + }, + { + name: "insert after last line without trailing newline", + content: "line1\nline2\nline3", + edit: &ASTEdit{ + Operation: EditInsertAfter, + Selector: ASTSelector{Text: "line3"}, + NewContent: "inserted", + }, + wantLines: []string{"line1", "line2", "line3", "inserted"}, + }, + { + name: "insert multi-line block after line", + content: "before\ntarget\nafter\n", + edit: &ASTEdit{ + Operation: EditInsertAfter, + Selector: ASTSelector{Text: "target"}, + NewContent: "new1\nnew2\nnew3", + }, + wantLines: []string{"before", "target", "new1", "new2", "new3", "after", ""}, + }, + { + name: "insert after by line number", + content: "aaa\nbbb\nccc\n", + edit: &ASTEdit{ + Operation: EditInsertAfter, + Selector: ASTSelector{AtLine: 2}, + NewContent: "inserted", + }, + wantLines: []string{"aaa", "bbb", "inserted", "ccc", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil { + t.Fatalf("write: %v", err) + } + tt.edit.File = tmpFile + + result, err := e.Apply(context.Background(), tt.edit) + if err != nil { + t.Fatalf("apply: %v", err) + } + if !result.Success { + t.Fatalf("not successful: %s", result.Error) + } + + got, _ := os.ReadFile(tmpFile) + gotLines := strings.Split(string(got), "\n") + if len(gotLines) != len(tt.wantLines) { + t.Fatalf("line count = %d, want %d\ngot: %q\nwant: %q", len(gotLines), len(tt.wantLines), gotLines, tt.wantLines) + } + for i, want := range tt.wantLines { + if gotLines[i] != want { + t.Errorf("line %d = %q, want %q", i+1, gotLines[i], want) + } + } + }) + } +} + +func TestRegressionLineMergeInsertBefore(t *testing.T) { + registry := parser.NewRegistry() + defer registry.Close() + e := NewEngine(registry) + + tests := []struct { + name string + content string + edit *ASTEdit + wantLines []string + }{ + { + name: "insert before mid-line doesn't merge", + content: "line1\nline2\nline3\n", + edit: &ASTEdit{ + Operation: EditInsertBefore, + Selector: ASTSelector{Text: "line2"}, + NewContent: "inserted", + }, + wantLines: []string{"line1", "inserted", "line2", "line3", ""}, + }, + { + name: "insert multi-line block before line", + content: "before\ntarget\nafter\n", + edit: &ASTEdit{ + Operation: EditInsertBefore, + Selector: ASTSelector{Text: "target"}, + NewContent: "new1\nnew2", + }, + wantLines: []string{"before", "new1", "new2", "target", "after", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil { + t.Fatalf("write: %v", err) + } + tt.edit.File = tmpFile + + result, err := e.Apply(context.Background(), tt.edit) + if err != nil { + t.Fatalf("apply: %v", err) + } + if !result.Success { + t.Fatalf("not successful: %s", result.Error) + } + + got, _ := os.ReadFile(tmpFile) + gotLines := strings.Split(string(got), "\n") + if len(gotLines) != len(tt.wantLines) { + t.Fatalf("line count = %d, want %d\ngot: %q\nwant: %q", len(gotLines), len(tt.wantLines), gotLines, tt.wantLines) + } + for i, want := range tt.wantLines { + if gotLines[i] != want { + t.Errorf("line %d = %q, want %q", i+1, gotLines[i], want) + } + } + }) + } +} + +func TestRegressionLineMergeDelete(t *testing.T) { + registry := parser.NewRegistry() + defer registry.Close() + e := NewEngine(registry) + + tests := []struct { + name string + content string + edit *ASTEdit + wantLines []string + }{ + { + name: "delete mid-line preserves surrounding lines", + content: "line1\nline2\nline3\n", + edit: &ASTEdit{ + Operation: EditDelete, + Selector: ASTSelector{AtLine: 2}, + }, + wantLines: []string{"line1", "line3", ""}, + }, + { + name: "delete line range preserves surrounding lines", + content: "line1\nline2\nline3\nline4\n", + edit: &ASTEdit{ + Operation: EditDelete, + Selector: ASTSelector{AtLine: 2, LineEnd: 3}, + }, + wantLines: []string{"line1", "line4", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil { + t.Fatalf("write: %v", err) + } + tt.edit.File = tmpFile + + result, err := e.Apply(context.Background(), tt.edit) + if err != nil { + t.Fatalf("apply: %v", err) + } + if !result.Success { + t.Fatalf("not successful: %s", result.Error) + } + + got, _ := os.ReadFile(tmpFile) + gotLines := strings.Split(string(got), "\n") + if len(gotLines) != len(tt.wantLines) { + t.Fatalf("line count = %d, want %d\ngot: %q\nwant: %q", len(gotLines), len(tt.wantLines), gotLines, tt.wantLines) + } + for i, want := range tt.wantLines { + if gotLines[i] != want { + t.Errorf("line %d = %q, want %q", i+1, gotLines[i], want) + } + } + }) + } +}