From ba392b633d2beb0c531eb5f0605ebc1c7413c43f Mon Sep 17 00:00:00 2001 From: Harsh Kapse Date: Sun, 2 Aug 2026 20:13:32 +0530 Subject: [PATCH 1/2] feat(engine): add linediff with prefix and suffix trimming --- internal/engine/linediff.go | 60 ++++++++++++++++++++++++++++++++ internal/engine/linediff_test.go | 23 ++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 internal/engine/linediff.go create mode 100644 internal/engine/linediff_test.go diff --git a/internal/engine/linediff.go b/internal/engine/linediff.go new file mode 100644 index 0000000..0ecdf5f --- /dev/null +++ b/internal/engine/linediff.go @@ -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 +} diff --git a/internal/engine/linediff_test.go b/internal/engine/linediff_test.go new file mode 100644 index 0000000..7a92c33 --- /dev/null +++ b/internal/engine/linediff_test.go @@ -0,0 +1,23 @@ +package engine + +import "testing" + +func TestLineDiff(t *testing.T) { + linesA := []string{"a", "b", "c", "d"} + linesB := []string{"a", "x", "c", "y", "d"} + + matchedA := LineDiff(linesA, linesB) + + if matchedA[0] != 0 { + t.Errorf("expected A[0] to match B[0]") + } + if _, ok := matchedA[1]; ok { + t.Errorf("expected A[1] ('b') to not match") + } + if matchedA[2] != 2 { + t.Errorf("expected A[2] to match B[2]") + } + if matchedA[3] != 4 { + t.Errorf("expected A[3] to match B[4], got matchedA[3]=%d", matchedA[3]) + } +} From cebfdcec07c77933c2e8d8e657435c762672bffe Mon Sep 17 00:00:00 2001 From: Harsh Kapse Date: Mon, 3 Aug 2026 13:57:23 +0530 Subject: [PATCH 2/2] feat(cmd): add line diff fallback for unsupported files and parse failures --- cmd/diff_test.go | 20 ++++++++++ cmd/pipeline.go | 26 +++++++++---- internal/engine/linediff_test.go | 64 ++++++++++++++++++++++++-------- internal/serialize/json.go | 58 +++++++++++++++++++++++++++++ internal/tui/model.go | 58 +---------------------------- 5 files changed, 146 insertions(+), 80 deletions(-) diff --git a/cmd/diff_test.go b/cmd/diff_test.go index 01cb212..ab0b625 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -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") + } +} diff --git a/cmd/pipeline.go b/cmd/pipeline.go index 28062dc..ed26bab 100644 --- a/cmd/pipeline.go +++ b/cmd/pipeline.go @@ -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 { @@ -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) diff --git a/internal/engine/linediff_test.go b/internal/engine/linediff_test.go index 7a92c33..7f97be9 100644 --- a/internal/engine/linediff_test.go +++ b/internal/engine/linediff_test.go @@ -1,23 +1,55 @@ package engine -import "testing" +import ( + "reflect" + "testing" +) func TestLineDiff(t *testing.T) { - linesA := []string{"a", "b", "c", "d"} - linesB := []string{"a", "x", "c", "y", "d"} - - matchedA := LineDiff(linesA, linesB) - - if matchedA[0] != 0 { - t.Errorf("expected A[0] to match B[0]") - } - if _, ok := matchedA[1]; ok { - t.Errorf("expected A[1] ('b') to not match") + 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}, + }, } - if matchedA[2] != 2 { - t.Errorf("expected A[2] to match B[2]") - } - if matchedA[3] != 4 { - t.Errorf("expected A[3] to match B[4], got matchedA[3]=%d", matchedA[3]) + + 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) + } + }) } } diff --git a/internal/serialize/json.go b/internal/serialize/json.go index 1f4b91e..06c63c4 100644 --- a/internal/serialize/json.go +++ b/internal/serialize/json.go @@ -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 { diff --git a/internal/tui/model.go b/internal/tui/model.go index 4206cc7..0de6342 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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)