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
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

permissions:
contents: read
Expand Down
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")
}
}
28 changes: 19 additions & 9 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,16 +52,19 @@ 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)
matchResult := engine.Match(srcAST, dstAST, srcBytes, dstBytes)
es := actions.GenerateEditScript(srcAST, dstAST, matchResult.Mappings)
es = postprocess.Run(es, matchResult.Mappings, srcAST, dstAST)

Expand Down
2 changes: 1 addition & 1 deletion internal/engine/declaration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ func TestMatchDeclarationIntegration(t *testing.T) {
),
)

r := Match(src, dst)
r := Match(src, dst, nil, nil)
srcFn := src.Children[0]
dstFn := dst.Children[0]
if !r.Mappings.Has(srcFn) {
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)
}
})
}
}
17 changes: 13 additions & 4 deletions internal/engine/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ type MatchResult struct {
Mappings *Mapping
}

func Match(t1, t2 *treesitter.ASTNode) *MatchResult {
func Match(t1, t2 *treesitter.ASTNode, srcA, srcB []byte) *MatchResult {
mappings := NewMapping()

part := NewLinePartition(srcA, srcB)

minHeight := 2
minDice := 0.5
mappings := TopDown(t1, t2, minHeight)

// Match AST nodes top-down, by declaration, and bottom-up using line partitioning.
TopDown(t1, t2, minHeight, mappings, part)
matchDeclarations(t1, t2, mappings)
BottomUp(t1, t2, mappings, minDice)

MatchUnmatchedLeaves(t1, t2, mappings)
MatchUnmatchedLeaves(t1, t2, mappings, part)

if !mappings.Has(t1) && !mappings.HasDst(t2) {
mappings.Add(t1, t2)
Expand All @@ -34,7 +40,7 @@ func Match(t1, t2 *treesitter.ASTNode) *MatchResult {
// MatchUnmatchedLeaves pairs unmatched leaf nodes of the same type and label using
// parent Dice similarity and positional scores to break ties. Leaves under unmatched
// parents are skipped since they belong to deleted or inserted blocks.
func MatchUnmatchedLeaves(t1Root, t2Root *treesitter.ASTNode, m *Mapping) {
func MatchUnmatchedLeaves(t1Root, t2Root *treesitter.ASTNode, m *Mapping, part *LinePartition) {
t2Nodes := PostOrder(t2Root)
for _, t1 := range PostOrder(t1Root) {
if m.Has(t1) || len(t1.Children) > 0 || t1.Label == "" {
Expand All @@ -59,6 +65,9 @@ func MatchUnmatchedLeaves(t1Root, t2Root *treesitter.ASTNode, m *Mapping) {
if m.HasDst(t2) || t2.Type != t1.Type || t2.Label != t1.Label || len(t2.Children) > 0 {
continue
}
if part != nil && !part.CanMatch(t1, t2) {
continue
}

// No matched parent on destination leaf -> it belongs to an inserted subtree.
if t2.Parent != nil && !m.HasDst(t2.Parent) {
Expand Down
13 changes: 7 additions & 6 deletions internal/engine/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestMatchIdenticalTrees(t *testing.T) {
),
)

r := Match(src, dst)
r := Match(src, dst, nil, nil)
if r == nil || r.Mappings == nil {
t.Fatal("Match returned nil")
}
Expand All @@ -40,7 +40,7 @@ func TestMatchDifferentLeafLabels(t *testing.T) {
src := testutil.Node("func", "main", testutil.Leaf("id", "x"))
dst := testutil.Node("func", "main", testutil.Leaf("id", "y"))

r := Match(src, dst)
r := Match(src, dst, nil, nil)
if r == nil {
t.Fatal("Match returned nil")
return
Expand All @@ -55,7 +55,7 @@ func TestMatchRootAlwaysMapped(t *testing.T) {
src := testutil.Node("func", "a", testutil.Leaf("id", "x"))
dst := testutil.Node("func", "b", testutil.Leaf("str", "hello"))

r := Match(src, dst)
r := Match(src, dst, nil, nil)
if !r.Mappings.Has(src) {
t.Error("src root should always be mapped")
}
Expand All @@ -67,7 +67,7 @@ func TestMatchRootAlwaysMapped(t *testing.T) {
func TestMatchSingleLeaves(t *testing.T) {
src := testutil.Leaf("id", "x")
dst := testutil.Leaf("id", "x")
r := Match(src, dst)
r := Match(src, dst, nil, nil)
if !r.Mappings.Has(src) || r.Mappings.Src()[src] != dst {
t.Error("single identical leaves should be mapped")
}
Expand All @@ -80,7 +80,7 @@ func TestMatchPairsPreOrder(t *testing.T) {
src := testutil.Node("block", "", c1, c2)
dst := testutil.Node("block", "", testutil.Leaf("id", "x"), testutil.Leaf("id", "y"))

r := Match(src, dst)
r := Match(src, dst, nil, nil)
if len(r.Mappings.Pairs) < 3 {
t.Fatalf("expected at least 3 pairs, got %d", len(r.Mappings.Pairs))
}
Expand Down Expand Up @@ -115,7 +115,8 @@ func TestTopDownUnambiguous(t *testing.T) {
testutil.Node("call", "", testutil.Leaf("id", "f")),
)

m := TopDown(src, dst, 2)
m := NewMapping()
TopDown(src, dst, 2, m, nil)
srcCall := src.Children[0]
if !m.Has(srcCall) {
t.Error("unambiguous isomorphic subtree should be mapped by TopDown")
Expand Down
84 changes: 84 additions & 0 deletions internal/engine/partition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package engine

import (
"strings"

"github.com/HarshK97/diffmantic/internal/treesitter"
)

// LinePartition partitions AST nodes into untouched lines (Group 1) and edited
// lines (Group 2) using line-level LCS matching ("Beyond GumTree"). This stops
// untouched lines from matching against edited regions across the file.
type LinePartition struct {
matchedA map[int]int // rowA -> rowB
matchedB map[int]int // rowB -> rowA
}

func NewLinePartition(srcA, srcB []byte) *LinePartition {
linesA := strings.Split(string(srcA), "\n")
linesB := strings.Split(string(srcB), "\n")
mA := LineDiff(linesA, linesB)
mB := make(map[int]int, len(mA))
for rA, rB := range mA {
mB[rB] = rA
}
return &LinePartition{matchedA: mA, matchedB: mB}
}

// IsGroup1A returns true if all lines spanned by n1 in file A are non-edited lines.
func (p *LinePartition) IsGroup1A(n1 *treesitter.ASTNode) (bool, int) {
if p == nil {
return false, -1
}
return p.isGroup1(p.matchedA, n1)
}

// IsGroup1B returns true if all lines spanned by n2 in file B are non-edited lines.
func (p *LinePartition) IsGroup1B(n2 *treesitter.ASTNode) (bool, int) {
if p == nil {
return false, -1
}
return p.isGroup1(p.matchedB, n2)
}

func (p *LinePartition) isGroup1(matched map[int]int, n *treesitter.ASTNode) (bool, int) {
if len(matched) == 0 || n == nil {
return false, -1
}
startRow := int(n.StartRow)
endRow := int(n.EndRow)
if startRow < 0 {
return false, -1
}
startOther, ok := matched[startRow]
if !ok {
return false, -1
}
for r := startRow; r <= endRow; r++ {
m, ok := matched[r]
if !ok || m != startOther+(r-startRow) {
return false, -1
}
}
return true, startOther
}

// CanMatch checks if two nodes are allowed to pair up:
// - Untouched nodes (Group 1) only match untouched nodes on corresponding lines.
// - Edited nodes (Group 2) only match other edited nodes.
// - Cross-matching between untouched and edited nodes is forbidden.
func (p *LinePartition) CanMatch(n1, n2 *treesitter.ASTNode) bool {
if p == nil || n1 == nil || n2 == nil {
return true
}
isG1A, startB := p.IsGroup1A(n1)
isG1B, startA := p.IsGroup1B(n2)

if isG1A && isG1B {
return int(n2.StartRow) == startB && int(n1.StartRow) == startA
}
if isG1A != isG1B {
return false
}
return true
}
Loading
Loading