diff --git a/internal/postprocess/normalize.go b/internal/postprocess/normalize.go index 811841bf..9792dc39 100644 --- a/internal/postprocess/normalize.go +++ b/internal/postprocess/normalize.go @@ -415,11 +415,16 @@ func normalizeMovesByStructure(es *actions.EditScript, ms *engine.Mapping) *acti // Pass 1: Find moves to demote and collect nodes to evict from the mapping. // We have to do this first because Chawathe emits Update actions before Move, // and we need to drop those orphaned updates in Pass 2. + toDemote := make(map[*treesitter.ASTNode]*treesitter.ASTNode) + demotedDescendants := make(map[*treesitter.ASTNode]struct{}) evicted := make(map[*treesitter.ASTNode]struct{}) for _, a := range es.Actions() { if a.Type != actions.Move || a.Node == nil { continue } + if _, ok := demotedDescendants[a.Node]; ok { + continue + } dstNode := a.DestNode if dstNode == nil { dstNode = ms.Src()[a.Node] @@ -434,49 +439,44 @@ func normalizeMovesByStructure(es *actions.EditScript, ms *engine.Mapping) *acti if !shouldDemoteMove(a.Node, dstNode, ms, r) { continue } + toDemote[a.Node] = dstNode for _, d := range a.Node.Descendants() { + demotedDescendants[d] = struct{}{} evicted[d] = struct{}{} } evicted[a.Node] = struct{}{} } - // Pass 2: Rebuild the edit script, demoting flagged moves and dropping - // orphaned updates on evicted nodes. + // Pass 2: Rebuild the edit script: demote flagged moves to delete+insert, + // and drop any orphaned updates or nested moves inside those subtrees. result := actions.NewEditScript() for _, a := range es.Actions() { - // Suppress orphaned Update actions on nodes whose paired Move was demoted. - if a.Type == actions.Update && a.Node != nil { - if _, ok := evicted[a.Node]; ok { - continue - } - } - - if a.Type != actions.Move || a.Node == nil { - result.Add(a) - continue - } - - dstNode := a.DestNode - if dstNode == nil { - dstNode = ms.Src()[a.Node] - } - if dstNode == nil { + if a.Node == nil { result.Add(a) continue } - r := rules.Get(a.Node.GetLanguage()) - if r == nil { + switch a.Type { + case actions.Update: + // Drop updates on nodes that are getting deleted anyway. + if _, ok := evicted[a.Node]; ok { + continue + } result.Add(a) - continue - } - - if !shouldDemoteMove(a.Node, dstNode, ms, r) { + case actions.Move: + // Skip nested moves inside an ancestor that's already turned into a subtree delete+insert. + if _, ok := demotedDescendants[a.Node]; ok { + continue + } + dstNode, shouldDemote := toDemote[a.Node] + if !shouldDemote { + result.Add(a) + continue + } + demoteMoveToDelIns(result, ms, a.Node, dstNode) + default: result.Add(a) - continue } - - demoteMoveToDelIns(result, ms, a.Node, dstNode) } return result } diff --git a/internal/postprocess/normalize_test.go b/internal/postprocess/normalize_test.go index f696031f..086cd237 100644 --- a/internal/postprocess/normalize_test.go +++ b/internal/postprocess/normalize_test.go @@ -1,6 +1,7 @@ package postprocess import ( + "slices" "testing" "github.com/HarshK97/diffmantic/internal/actions" @@ -1015,6 +1016,41 @@ func TestNormalizeMovesByStructure(t *testing.T) { t.Errorf("expected non-move actions to pass through, got %d", result.Size()) } }) + + t.Run("nested move inside demoted ancestor move is suppressed", func(t *testing.T) { + childSrc := mkNode("identifier", "val") + childSrc.Language = "go" + parentSrc := mkNode("expression_statement", "", childSrc) + parentSrc.Language = "go" + parentSrc.StartRow = 10 + parentSrc.EndRow = 10 + + childDst := mkNode("identifier", "val") + childDst.Language = "go" + parentDst := mkNode("expression_statement", "", childDst) + parentDst.Language = "go" + parentDst.StartRow = 500 + parentDst.EndRow = 500 + + msNested := engine.NewMapping() + msNested.Add(parentSrc, parentDst) + msNested.Add(childSrc, childDst) + + es := actions.NewEditScript() + es.Add(actions.Action{Type: actions.Move, Node: parentSrc, DestNode: parentDst}) + es.Add(actions.Action{Type: actions.Move, Node: childSrc, DestNode: childDst}) + + result := normalizeMovesByStructure(es, msNested) + if result.Size() != 2 { + t.Fatalf("expected exactly 2 actions (Delete+Insert) after ancestor demotion, got %d", result.Size()) + } + if idx := slices.IndexFunc(result.Actions(), func(a actions.Action) bool { + return a.Type == actions.Move + }); idx != -1 { + a := result.Actions()[idx] + t.Fatalf("expected no move actions to survive demotion of ancestor move, got %s on %s", a.Type, a.Node.Type) + } + }) } func TestSameScopeDeclaration(t *testing.T) {