// Package prosediff diffs two revisions of a markdown document the way a // human reads it: as blocks of prose, not as lines of text. // // Markdown reflows. A one-word edit rewraps a paragraph, and a line-oriented // differ then reports the whole paragraph as replaced — useless for reviewing // written text. This package instead: // // 1. segments each revision into blocks (headings, paragraphs, list items, // code fences, table rows, block quotes) using goldmark's parser; // 2. aligns the two block sequences, recognising unchanged, added, removed, // modified and (exactly) moved blocks; // 3. diffs word-by-word inside a modified prose block, and line-by-line // inside a modified code fence. // // Whitespace and line wrapping alone never produce a diff in prose. They // always do in code fences, which is the point of separating the two. // // The package renders plain text only. Rendering to HTML is the web layer's // job; Diff is the data structure it consumes. package prosediff // ChangeKind classifies what happened to one block. type ChangeKind string const ( // ChangeEqual: the block is present unchanged in both revisions. ChangeEqual ChangeKind = "equal" // ChangeInsert: the block exists only in the new revision. ChangeInsert ChangeKind = "insert" // ChangeDelete: the block exists only in the old revision. ChangeDelete ChangeKind = "delete" // ChangeModify: the same block, edited. Words (or Lines) carry the detail. ChangeModify ChangeKind = "modify" // ChangeMoveOut marks, at its old position, a block that moved elsewhere. ChangeMoveOut ChangeKind = "move_out" // ChangeMoveIn marks, at its new position, a block that moved from elsewhere. ChangeMoveIn ChangeKind = "move_in" ) // BlockChange is one entry of a diff, in reading order: for each changed // region, the old blocks first and then the new ones. type BlockChange struct { Kind ChangeKind // Old is the block in the old revision; nil for ChangeInsert. Old *Block // New is the block in the new revision; nil for ChangeDelete. New *Block // Words is the inline edit script for a modified prose block. Words []Span // Lines is the line-oriented edit script for a modified code fence, // frontmatter block or HTML block. Lines []Span // Similarity is the token-level similarity that justified pairing a // ChangeModify, in [0,1]. Zero for every other kind. Similarity float64 // StructureOnly marks a modification whose content is untouched: only // the heading level, list depth or quote depth changed. StructureOnly bool // Moved marks a ChangeModify whose block also changed position: it sits // inside a run of moved blocks. Its ChangeMoveOut counterpart appears at // the old position. Moved bool } // Stats summarizes a diff, cheap enough for a listing page. type Stats struct { BlocksEqual int BlocksInserted int BlocksDeleted int BlocksModified int BlocksMoved int WordsInserted int WordsDeleted int } // Changed reports whether the two revisions differ at all. func (s Stats) Changed() bool { return s.BlocksInserted+s.BlocksDeleted+s.BlocksModified+s.BlocksMoved > 0 } // Diff is the whole comparison of two document revisions. type Diff struct { OldBlocks []Block NewBlocks []Block Changes []BlockChange Stats Stats } // Compare segments both revisions and aligns them. It has no failure mode: // any byte slice is a parseable markdown document. func Compare(oldSrc, newSrc []byte) *Diff { old := Segment(oldSrc) nw := Segment(newSrc) d := &Diff{OldBlocks: old, NewBlocks: nw} d.Changes = align(old, nw) d.Stats = computeStats(d.Changes) return d } func computeStats(changes []BlockChange) Stats { var s Stats for _, c := range changes { switch c.Kind { case ChangeEqual: s.BlocksEqual++ case ChangeInsert: s.BlocksInserted++ s.WordsInserted += len(Tokenize(c.New.Text)) case ChangeDelete: s.BlocksDeleted++ s.WordsDeleted += len(Tokenize(c.Old.Text)) case ChangeModify: s.BlocksModified++ for _, sp := range c.Words { switch sp.Op { case OpInsert: s.WordsInserted += len(Tokenize(sp.Text)) case OpDelete: s.WordsDeleted += len(Tokenize(sp.Text)) } } for _, sp := range c.Lines { switch sp.Op { case OpInsert: s.WordsInserted += len(Tokenize(sp.Text)) case OpDelete: s.WordsDeleted += len(Tokenize(sp.Text)) } } case ChangeMoveIn: s.BlocksMoved++ } } return s }