| 1 |
|
// Package prosediff diffs two revisions of a markdown document the way a |
| 2 |
|
// human reads it: as blocks of prose, not as lines of text. |
| 3 |
|
// |
| 4 |
|
// Markdown reflows. A one-word edit rewraps a paragraph, and a line-oriented |
| 5 |
|
// differ then reports the whole paragraph as replaced — useless for reviewing |
| 6 |
|
// written text. This package instead: |
| 7 |
|
// |
| 8 |
|
// 1. segments each revision into blocks (headings, paragraphs, list items, |
| 9 |
|
// code fences, table rows, block quotes) using goldmark's parser; |
| 10 |
|
// 2. aligns the two block sequences, recognising unchanged, added, removed, |
| 11 |
|
// modified and (exactly) moved blocks; |
| 12 |
|
// 3. diffs word-by-word inside a modified prose block, and line-by-line |
| 13 |
|
// inside a modified code fence. |
| 14 |
|
// |
| 15 |
|
// Whitespace and line wrapping alone never produce a diff in prose. They |
| 16 |
|
// always do in code fences, which is the point of separating the two. |
| 17 |
|
// |
| 18 |
|
// The package renders plain text only. Rendering to HTML is the web layer's |
| 19 |
|
// job; Diff is the data structure it consumes. |
| 20 |
|
package prosediff |
| 21 |
|
|
| 22 |
|
// ChangeKind classifies what happened to one block. |
| 23 |
|
type ChangeKind string |
| 24 |
|
|
| 25 |
|
const ( |
| 26 |
|
// ChangeEqual: the block is present unchanged in both revisions. |
| 27 |
|
ChangeEqual ChangeKind = "equal" |
| 28 |
|
// ChangeInsert: the block exists only in the new revision. |
| 29 |
|
ChangeInsert ChangeKind = "insert" |
| 30 |
|
// ChangeDelete: the block exists only in the old revision. |
| 31 |
|
ChangeDelete ChangeKind = "delete" |
| 32 |
|
// ChangeModify: the same block, edited. Words (or Lines) carry the detail. |
| 33 |
|
ChangeModify ChangeKind = "modify" |
| 34 |
|
// ChangeMoveOut marks, at its old position, a block that moved elsewhere. |
| 35 |
|
ChangeMoveOut ChangeKind = "move_out" |
| 36 |
|
// ChangeMoveIn marks, at its new position, a block that moved from elsewhere. |
| 37 |
|
ChangeMoveIn ChangeKind = "move_in" |
| 38 |
|
) |
| 39 |
|
|
| 40 |
|
// BlockChange is one entry of a diff, in reading order: for each changed |
| 41 |
|
// region, the old blocks first and then the new ones. |
| 42 |
|
type BlockChange struct { |
| 43 |
|
Kind ChangeKind |
| 44 |
|
|
| 45 |
|
// Old is the block in the old revision; nil for ChangeInsert. |
| 46 |
|
Old *Block |
| 47 |
|
// New is the block in the new revision; nil for ChangeDelete. |
| 48 |
|
New *Block |
| 49 |
|
|
| 50 |
|
// Words is the inline edit script for a modified prose block. |
| 51 |
|
Words []Span |
| 52 |
|
// Lines is the line-oriented edit script for a modified code fence, |
| 53 |
|
// frontmatter block or HTML block. |
| 54 |
|
Lines []Span |
| 55 |
|
|
| 56 |
|
// Similarity is the token-level similarity that justified pairing a |
| 57 |
|
// ChangeModify, in [0,1]. Zero for every other kind. |
| 58 |
|
Similarity float64 |
| 59 |
|
|
| 60 |
|
// StructureOnly marks a modification whose content is untouched: only |
| 61 |
|
// the heading level, list depth or quote depth changed. |
| 62 |
|
StructureOnly bool |
| 63 |
|
|
| 64 |
|
// Moved marks a ChangeModify whose block also changed position: it sits |
| 65 |
|
// inside a run of moved blocks. Its ChangeMoveOut counterpart appears at |
| 66 |
|
// the old position. |
| 67 |
|
Moved bool |
| 68 |
|
} |
| 69 |
|
|
| 70 |
|
// Stats summarizes a diff, cheap enough for a listing page. |
| 71 |
|
type Stats struct { |
| 72 |
|
BlocksEqual int |
| 73 |
|
BlocksInserted int |
| 74 |
|
BlocksDeleted int |
| 75 |
|
BlocksModified int |
| 76 |
|
BlocksMoved int |
| 77 |
|
WordsInserted int |
| 78 |
|
WordsDeleted int |
| 79 |
|
} |
| 80 |
|
|
| 81 |
|
// Changed reports whether the two revisions differ at all. |
| 82 |
2 |
func (s Stats) Changed() bool { |
| 83 |
2 |
return s.BlocksInserted+s.BlocksDeleted+s.BlocksModified+s.BlocksMoved > 0 |
| 84 |
2 |
} |
| 85 |
|
|
| 86 |
|
// Diff is the whole comparison of two document revisions. |
| 87 |
|
type Diff struct { |
| 88 |
|
OldBlocks []Block |
| 89 |
|
NewBlocks []Block |
| 90 |
|
Changes []BlockChange |
| 91 |
|
Stats Stats |
| 92 |
|
} |
| 93 |
|
|
| 94 |
|
// Compare segments both revisions and aligns them. It has no failure mode: |
| 95 |
|
// any byte slice is a parseable markdown document. |
| 96 |
32 |
func Compare(oldSrc, newSrc []byte) *Diff { |
| 97 |
32 |
old := Segment(oldSrc) |
| 98 |
32 |
nw := Segment(newSrc) |
| 99 |
32 |
d := &Diff{OldBlocks: old, NewBlocks: nw} |
| 100 |
32 |
d.Changes = align(old, nw) |
| 101 |
32 |
d.Stats = computeStats(d.Changes) |
| 102 |
32 |
return d |
| 103 |
32 |
} |
| 104 |
|
|
| 105 |
32 |
func computeStats(changes []BlockChange) Stats { |
| 106 |
32 |
var s Stats |
| 107 |
96 |
for _, c := range changes { |
| 108 |
96 |
switch c.Kind { |
| 109 |
50 |
case ChangeEqual: |
| 110 |
50 |
s.BlocksEqual++ |
| 111 |
11 |
case ChangeInsert: |
| 112 |
11 |
s.BlocksInserted++ |
| 113 |
11 |
s.WordsInserted += len(Tokenize(c.New.Text)) |
| 114 |
5 |
case ChangeDelete: |
| 115 |
5 |
s.BlocksDeleted++ |
| 116 |
5 |
s.WordsDeleted += len(Tokenize(c.Old.Text)) |
| 117 |
19 |
case ChangeModify: |
| 118 |
19 |
s.BlocksModified++ |
| 119 |
54 |
for _, sp := range c.Words { |
| 120 |
54 |
switch sp.Op { |
| 121 |
14 |
case OpInsert: |
| 122 |
14 |
s.WordsInserted += len(Tokenize(sp.Text)) |
| 123 |
13 |
case OpDelete: |
| 124 |
13 |
s.WordsDeleted += len(Tokenize(sp.Text)) |
| 125 |
|
} |
| 126 |
|
} |
| 127 |
19 |
for _, sp := range c.Lines { |
| 128 |
15 |
switch sp.Op { |
| 129 |
4 |
case OpInsert: |
| 130 |
4 |
s.WordsInserted += len(Tokenize(sp.Text)) |
| 131 |
4 |
case OpDelete: |
| 132 |
4 |
s.WordsDeleted += len(Tokenize(sp.Text)) |
| 133 |
|
} |
| 134 |
|
} |
| 135 |
5 |
case ChangeMoveIn: |
| 136 |
5 |
s.BlocksMoved++ |
| 137 |
|
} |
| 138 |
|
} |
| 139 |
32 |
return s |
| 140 |
|
} |