fixup! Add Docker usage instructions to README

This commit is contained in:
2026-03-11 02:53:12 +00:00
parent 2b83fccb35
commit 267378181c
2 changed files with 330 additions and 2 deletions
+22 -2
View File
@@ -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:]...)
+308
View File
@@ -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)
}
}
})
}
}