~bigbes/sr-ht-spec

ref: e849de2a744d6ec98154c2823e7c4e8c2ffb3116 sr-ht-spec/prosediff/prosediff.go -rw-r--r-- 4.4 KiB
e849de2a — Eugene Blikh chore(beads): close spec-ejq.2, CI publish is green on build #251 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// 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
}