// 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
}