Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions cmd/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,23 @@ func TestComputeDiffWithDevNull(t *testing.T) {
t.Fatal("expected non-nil diff result and envelope for deleted file")
}
}

func TestComputeDiffUnsupportedLanguage(t *testing.T) {
dir := t.TempDir()
fileA := dir + "/a.unknown"
fileB := dir + "/b.unknown"

_ = os.WriteFile(fileA, []byte("line 1\nline 2\nline 3\n"), 0o644)
_ = os.WriteFile(fileB, []byte("line 1\nline 2 modified\nline 3\nline 4\n"), 0o644)

res, err := computeDiff(fileA, fileB)
if err != nil {
t.Fatalf("computeDiff failed for unsupported files: %v", err)
}
if res == nil || res.Envelope == nil {
t.Fatal("expected non-nil result and envelope for unsupported file diff")
}
if len(res.Envelope.Actions) == 0 {
t.Error("expected fallback line diff actions for unsupported files, got 0")
}
}
26 changes: 18 additions & 8 deletions cmd/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ func computeDiff(fileA, fileB string) (*diffResult, error) {
langA, _ := treesitter.DetectLanguage(fileA)
langB, _ := treesitter.DetectLanguage(fileB)

// Fall back to line diff if tree-sitter can't parse the file.
if langA == nil && langB == nil {
return nil, fmt.Errorf("unsupported language for files: %s, %s", fileA, fileB)
return &diffResult{
SrcBytes: srcBytes,
DstBytes: dstBytes,
SrcFile: fileA,
DstFile: fileB,
Envelope: serialize.BuildLineDiffEnvelope(srcBytes, dstBytes),
}, nil
}

if langA == nil {
Expand All @@ -45,13 +52,16 @@ func computeDiff(fileA, fileB string) (*diffResult, error) {
langB = langA
}

srcAST, err := treesitter.ParseWithLanguage(srcBytes, langA)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", fileA, err)
}
dstAST, err := treesitter.ParseWithLanguage(dstBytes, langB)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", fileB, err)
srcAST, _ := treesitter.ParseWithLanguage(srcBytes, langA)
dstAST, _ := treesitter.ParseWithLanguage(dstBytes, langB)
if srcAST == nil || dstAST == nil {
return &diffResult{
SrcBytes: srcBytes,
DstBytes: dstBytes,
SrcFile: fileA,
DstFile: fileB,
Envelope: serialize.BuildLineDiffEnvelope(srcBytes, dstBytes),
}, nil
}

matchResult := engine.Match(srcAST, dstAST)
Expand Down
60 changes: 60 additions & 0 deletions internal/engine/linediff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package engine

// LineDiff matches line indices from linesA to linesB using trimmed LCS.
// Returns a map from A's line index to B's line index.
func LineDiff(linesA, linesB []string) map[int]int {
matchedA := make(map[int]int)
m, n := len(linesA), len(linesB)

// Trim common prefix
start := 0
for start < m && start < n && linesA[start] == linesB[start] {
matchedA[start] = start
start++
}

// Trim common suffix
endA, endB := m-1, n-1
for endA >= start && endB >= start && linesA[endA] == linesB[endB] {
matchedA[endA] = endB
endA--
endB--
}

if start > endA || start > endB {
return matchedA
}

// 1D DP table for the remaining middle window
subA := linesA[start : endA+1]
subB := linesB[start : endB+1]
lenA, lenB := len(subA), len(subB)
stride := lenB + 1

dp := make([]int, (lenA+1)*stride)
for i := 1; i <= lenA; i++ {
for j := 1; j <= lenB; j++ {
if subA[i-1] == subB[j-1] {
dp[i*stride+j] = dp[(i-1)*stride+(j-1)] + 1
} else {
dp[i*stride+j] = max(dp[(i-1)*stride+j], dp[i*stride+(j-1)])
}
}
}

// Backtrack to find matching lines
i, j := lenA, lenB
for i > 0 && j > 0 {
if subA[i-1] == subB[j-1] {
matchedA[start+i-1] = start + j - 1
i--
j--
} else if dp[(i-1)*stride+j] >= dp[i*stride+(j-1)] {
i--
} else {
j--
}
}

return matchedA
}
55 changes: 55 additions & 0 deletions internal/engine/linediff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package engine

import (
"reflect"
"testing"
)

func TestLineDiff(t *testing.T) {
tests := []struct {
name string
linesA []string
linesB []string
expected map[int]int
}{
{
name: "basic match with insertions and deletions",
linesA: []string{"a", "b", "c", "d"},
linesB: []string{"a", "x", "c", "y", "d"},
expected: map[int]int{0: 0, 2: 2, 3: 4},
},
{
name: "identical files",
linesA: []string{"one", "two", "three"},
linesB: []string{"one", "two", "three"},
expected: map[int]int{0: 0, 1: 1, 2: 2},
},
{
name: "completely different files",
linesA: []string{"a", "b"},
linesB: []string{"x", "y"},
expected: map[int]int{},
},
{
name: "empty inputs",
linesA: []string{},
linesB: []string{},
expected: map[int]int{},
},
{
name: "prefix and suffix trim with middle changes",
linesA: []string{"head", "mid_old_1", "mid_old_2", "tail"},
linesB: []string{"head", "mid_new_1", "tail"},
expected: map[int]int{0: 0, 3: 2},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := LineDiff(tt.linesA, tt.linesB)
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("LineDiff() = %v, want %v", got, tt.expected)
}
})
}
}
58 changes: 58 additions & 0 deletions internal/serialize/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,64 @@ type NodeRef struct {
EndByte uint32 `json:"end_byte"`
}

// BuildLineDiffEnvelope creates a line-level diff envelope when tree-sitter can't parse a file.
func BuildLineDiffEnvelope(srcBytes, dstBytes []byte) *Envelope {
alignment := AlignLines(srcBytes, dstBytes, nil, nil, nil, nil)

buildLineOffsets := func(data []byte) []uint32 {
offsets := []uint32{0}
for i, b := range data {
if b == '\n' {
offsets = append(offsets, uint32(i+1))
}
}
return offsets
}

offsetsSrc := buildLineOffsets(srcBytes)
offsetsDst := buildLineOffsets(dstBytes)

getBounds := func(lineIdx int, offsets []uint32, maxLen int) (uint32, uint32) {
end := uint32(maxLen)
if lineIdx+1 < len(offsets) {
end = offsets[lineIdx+1]
}
return offsets[lineIdx], end
}

var actionsList []Action

for _, pair := range alignment {
if pair.RightLine == -1 && pair.LeftLine != -1 {
start, end := getBounds(pair.LeftLine, offsetsSrc, len(srcBytes))
actionsList = append(actionsList, Action{
Action: "delete",
Node: &NodeRef{
Tree: "before",
StartByte: start,
EndByte: end,
},
})
} else if pair.LeftLine == -1 && pair.RightLine != -1 {
start, end := getBounds(pair.RightLine, offsetsDst, len(dstBytes))
actionsList = append(actionsList, Action{
Action: "insert",
Node: &NodeRef{
Tree: "after",
StartByte: start,
EndByte: end,
},
})
}
}

return &Envelope{
Version: SchemaVersion,
Actions: actionsList,
LineAlignment: alignment,
}
}

// BuildEnvelope bundles the edit script, AST mappings, and metadata into a unified envelope.
func BuildEnvelope(es *actions.EditScript, ms *engine.Mapping, srcRoot, dstRoot *treesitter.ASTNode, srcBytes, dstBytes []byte) (*Envelope, error) {
if es == nil {
Expand Down
58 changes: 2 additions & 56 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,69 +376,15 @@ func hasConflictMarkers(data []byte) bool {
return bytes.Contains(data, []byte("<<<<<<<")) || bytes.Contains(data, []byte("=======")) || bytes.Contains(data, []byte(">>>>>>>"))
}

func generateLineDiff(srcBytes, dstBytes []byte) *serialize.Envelope {
alignment := serialize.AlignLines(srcBytes, dstBytes, nil, nil, nil, nil)

buildLineOffsets := func(data []byte) []uint32 {
offsets := []uint32{0}
for i, b := range data {
if b == '\n' {
offsets = append(offsets, uint32(i+1))
}
}
return offsets
}

offsetsSrc := buildLineOffsets(srcBytes)
offsetsDst := buildLineOffsets(dstBytes)

getBounds := func(lineIdx int, offsets []uint32, maxLen int) (uint32, uint32) {
end := uint32(maxLen)
if lineIdx+1 < len(offsets) {
end = offsets[lineIdx+1]
}
return offsets[lineIdx], end
}

var actions []serialize.Action

for _, pair := range alignment {
if pair.RightLine == -1 && pair.LeftLine != -1 {
start, end := getBounds(pair.LeftLine, offsetsSrc, len(srcBytes))
actions = append(actions, serialize.Action{
Action: "delete",
Node: &serialize.NodeRef{
StartByte: start,
EndByte: end,
},
})
} else if pair.LeftLine == -1 && pair.RightLine != -1 {
start, end := getBounds(pair.RightLine, offsetsDst, len(dstBytes))
actions = append(actions, serialize.Action{
Action: "insert",
Node: &serialize.NodeRef{
StartByte: start,
EndByte: end,
},
})
}
}

return &serialize.Envelope{
Actions: actions,
LineAlignment: alignment,
}
}

func computeBytesDiff(srcBytes, dstBytes []byte, srcFile, dstFile string, isConflict bool) (*serialize.Envelope, error) {
if isConflict || hasConflictMarkers(srcBytes) || hasConflictMarkers(dstBytes) {
return generateLineDiff(srcBytes, dstBytes), nil
return serialize.BuildLineDiffEnvelope(srcBytes, dstBytes), nil
}

srcAST, _ := treesitter.Parse(srcBytes, srcFile)
dstAST, _ := treesitter.Parse(dstBytes, dstFile)
if srcAST == nil || dstAST == nil {
return generateLineDiff(srcBytes, dstBytes), nil
return serialize.BuildLineDiffEnvelope(srcBytes, dstBytes), nil
}

matchResult := engine.Match(srcAST, dstAST)
Expand Down
Loading