package docrevision import ( "fmt" "strings" ) // ComputeDiff produces a line-oriented structured diff between two markdown bodies. func ComputeDiff(fromMarkdown, toMarkdown string, fromBlocks, toBlocks []BlockNode) DiffResult { fromLines := splitLines(fromMarkdown) toLines := splitLines(toMarkdown) hunks := myersLineDiff(fromLines, toLines, fromBlocks, toBlocks) // Detect moves on raw delete/insert pairs before collapsing modifies. hunks = annotateMoves(hunks, fromBlocks, toBlocks) hunks = collapseAdjacentModify(hunks) sum := DiffSummary{} for _, h := range hunks { switch h.Kind { case "insert": sum.Inserts++ case "delete": sum.Deletes++ case "modify": sum.Modifies++ case "move": sum.Moves++ } } return DiffResult{Summary: sum, Hunks: hunks} } func splitLines(s string) []string { s = strings.TrimSuffix(s, "\n") if s == "" { return nil } return strings.Split(s, "\n") } func myersLineDiff(a, b []string, fromBlocks, toBlocks []BlockNode) []DiffHunk { n, m := len(a), len(b) // Simple LCS DP for correctness on moderate docs. dp := make([][]int, n+1) for i := range dp { dp[i] = make([]int, m+1) } for i := n - 1; i >= 0; i-- { for j := m - 1; j >= 0; j-- { if a[i] == b[j] { dp[i][j] = dp[i+1][j+1] + 1 } else if dp[i+1][j] >= dp[i][j+1] { dp[i][j] = dp[i+1][j] } else { dp[i][j] = dp[i][j+1] } } } var hunks []DiffHunk i, j := 0, 0 idx := 0 for i < n && j < m { if a[i] == b[j] { i++ j++ continue } if dp[i+1][j] >= dp[i][j+1] { h := DiffHunk{ ID: fmt.Sprintf("h%d", idx), Kind: "delete", BeforeText: a[i], LineStart: i + 1, LineEnd: i + 1, Section: sectionForLine(i, fromBlocks), } if blk := blockForLine(i, a, fromBlocks); blk != nil { h.BlockIDBefore = blk.ID } hunks = append(hunks, h) idx++ i++ } else { h := DiffHunk{ ID: fmt.Sprintf("h%d", idx), Kind: "insert", AfterText: b[j], LineStart: j + 1, LineEnd: j + 1, Section: sectionForLine(j, toBlocks), } if blk := blockForLine(j, b, toBlocks); blk != nil { h.BlockIDAfter = blk.ID } hunks = append(hunks, h) idx++ j++ } } for i < n { h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "delete", BeforeText: a[i], LineStart: i + 1, LineEnd: i + 1, Section: sectionForLine(i, fromBlocks)} if blk := blockForLine(i, a, fromBlocks); blk != nil { h.BlockIDBefore = blk.ID } hunks = append(hunks, h) idx++ i++ } for j < m { h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "insert", AfterText: b[j], LineStart: j + 1, LineEnd: j + 1, Section: sectionForLine(j, toBlocks)} if blk := blockForLine(j, b, toBlocks); blk != nil { h.BlockIDAfter = blk.ID } hunks = append(hunks, h) idx++ j++ } return hunks } func collapseAdjacentModify(hunks []DiffHunk) []DiffHunk { if len(hunks) == 0 { return hunks } out := make([]DiffHunk, 0, len(hunks)) for i := 0; i < len(hunks); i++ { if i+1 < len(hunks) && hunks[i].Kind == "delete" && hunks[i+1].Kind == "insert" { out = append(out, DiffHunk{ ID: hunks[i].ID, Kind: "modify", BeforeText: hunks[i].BeforeText, AfterText: hunks[i+1].AfterText, BlockIDBefore: hunks[i].BlockIDBefore, BlockIDAfter: hunks[i+1].BlockIDAfter, Section: firstNonEmpty(hunks[i].Section, hunks[i+1].Section), LineStart: hunks[i].LineStart, LineEnd: hunks[i+1].LineEnd, }) i++ continue } out = append(out, hunks[i]) } return out } func annotateMoves(hunks []DiffHunk, fromBlocks, toBlocks []BlockNode) []DiffHunk { fromByFP := map[string]string{} for _, b := range fromBlocks { fromByFP[b.Fingerprint] = b.ID } toByFP := map[string]string{} for _, b := range toBlocks { toByFP[b.Fingerprint] = b.ID } deletedFP := map[string]int{} insertedFP := map[string]int{} for i, h := range hunks { if h.Kind == "delete" { deletedFP[ContentFingerprint(h.BeforeText)] = i } if h.Kind == "insert" { insertedFP[ContentFingerprint(h.AfterText)] = i } } used := map[int]bool{} out := make([]DiffHunk, 0, len(hunks)) for i, h := range hunks { if used[i] { continue } if h.Kind == "delete" { fp := ContentFingerprint(h.BeforeText) if j, ok := insertedFP[fp]; ok && !used[j] { used[i] = true used[j] = true mh := DiffHunk{ ID: h.ID, Kind: "move", BeforeText: h.BeforeText, AfterText: hunks[j].AfterText, BlockIDBefore: h.BlockIDBefore, BlockIDAfter: hunks[j].BlockIDAfter, Section: fmt.Sprintf("%s → %s", h.Section, hunks[j].Section), LineStart: h.LineStart, LineEnd: hunks[j].LineEnd, } out = append(out, mh) continue } } if h.Kind == "insert" { fp := ContentFingerprint(h.AfterText) if _, ok := deletedFP[fp]; ok { // paired as move above if used[i] { continue } } } out = append(out, h) } return out } func sectionForLine(lineIdx int, blocks []BlockNode) string { // Approximate: find block whose order matches line; fallback heading path of nearest prior. if lineIdx < 0 || len(blocks) == 0 { return "" } best := "" for _, b := range blocks { if b.Order <= lineIdx { if b.HeadingPath != "" { best = b.HeadingPath } } } return best } func blockForLine(lineIdx int, lines []string, blocks []BlockNode) *BlockNode { if lineIdx < 0 || lineIdx >= len(lines) { return nil } fp := ContentFingerprint(lines[lineIdx]) for i := range blocks { if blocks[i].Fingerprint == fp { return &blocks[i] } } if lineIdx < len(blocks) { return &blocks[lineIdx] } return nil } func firstNonEmpty(a, b string) string { if a != "" { return a } return b }