[OCaml] Mobile-friendly clone of cgit.
1
(** Line-oriented diffs between two blobs.
2
3
Computes a Myers diff — the greedy O(ND) algorithm from "An O(ND)
4
Difference Algorithm and Its Variations" (Myers, 1986), the same algorithm
5
[git diff] uses by default — and groups the result into hunks with the
6
surrounding context. Cost scales with the number of differences, not with
7
file size, so a small change in a large file is cheap. When two files
8
differ in more than [max_differences] lines after trimming their common
9
prefix and suffix, the diff falls back to listing every remaining old line
10
as deleted followed by every remaining new line as added.
11
12
The module is named for the granularity it works at: whole lines, matched as
13
opaque units, with no word- or character-level refinement.
14
15
It produces data only. Rendering it is {!module:Ui.Diff}'s job. *)
16
17
type line_kind = Context | Addition | Deletion
18
19
type line = {
20
kind : line_kind;
21
old_number : int option;
22
new_number : int option;
23
text : string;
24
}
25
26
type hunk = {
27
old_start : int;
28
old_count : int;
29
new_start : int;
30
new_count : int;
31
lines : line list;
32
}
33
34
type file = {
35
path : string;
36
old_hash : string option;
37
new_hash : string option;
38
old_mode : int option;
39
new_mode : int option;
40
binary : bool;
41
hunks : hunk list;
42
}
43
44
let split_lines content =
45
match String.split_on_char '\n' content with
46
| [] -> []
47
| lines ->
48
if content = "" then []
49
else if String.ends_with ~suffix:"\n" content then
50
List.rev lines |> List.tl |> List.rev
51
else lines
52
53
(* The cap on the edit distance D the search explores. The backtracking trace
54
grows as D² words — about 8 MB at this cap — and the forward pass costs
55
O((N+M)·D), so the cap bounds both for pathological pairs. A commit that
56
changes over a thousand lines in one file (after prefix/suffix trimming)
57
degrades to all-deletions-then-all-additions rather than failing. *)
58
let max_differences = 1_024
59
60
(* One edit-script step over the trimmed middle of the two files. *)
61
type edit = Keep | Delete | Insert
62
63
(* Myers' greedy shortest-edit-script search over two int arrays (lines are
64
interned before the search, so equality is an integer compare).
65
66
Variables follow the paper so the code can be checked against it: the edit
67
graph runs from (0, 0) to (n, m), [d] is the edit distance explored so far,
68
[k = x - y] indexes diagonals, and [v] holds, per diagonal, the furthest x
69
reached. Each round snapshots the slice of [v] its backtrack step needs
70
(diagonals -(d+1)..d+1), so trace memory is O(D²) rather than O(D·(N+M)).
71
72
Returns the edit script in order, or [None] when the files differ in more
73
than [max_differences] lines. *)
74
let myers old_ids new_ids =
75
let n = Array.length old_ids and m = Array.length new_ids in
76
let bound = min (n + m) max_differences in
77
let offset = bound + 1 in
78
let v = Array.make ((2 * bound) + 3) 0 in
79
let trace = ref [] in
80
let exception Found of int in
81
let found_d =
82
try
83
for d = 0 to bound do
84
let slice =
85
Array.init ((2 * d) + 3) (fun i -> v.(i - (d + 1) + offset))
86
in
87
trace := slice :: !trace;
88
let k = ref (-d) in
89
while !k <= d do
90
let start_x =
91
if
92
!k = -d
93
|| (!k <> d && v.(!k - 1 + offset) < v.(!k + 1 + offset))
94
then v.(!k + 1 + offset)
95
else v.(!k - 1 + offset) + 1
96
in
97
let x = ref start_x in
98
let y = ref (start_x - !k) in
99
while !x < n && !y < m && old_ids.(!x) = new_ids.(!y) do
100
incr x;
101
incr y
102
done;
103
v.(!k + offset) <- !x;
104
if !x >= n && !y >= m then raise (Found d);
105
k := !k + 2
106
done
107
done;
108
None
109
with Found d -> Some d
110
in
111
match found_d with
112
| None -> None
113
| Some found_d ->
114
(* Walk back from (n, m), reading each round's snapshot to find where
115
the path entered it: a vertical step is an insertion, a horizontal
116
step a deletion, and the diagonal run before it is kept lines. *)
117
let edits = ref [] in
118
let x = ref n and y = ref m in
119
let rec go d = function
120
| [] -> ()
121
| slice :: earlier ->
122
let slice_offset = d + 1 in
123
let k = !x - !y in
124
let previous_k =
125
if
126
k = -d
127
|| (k <> d
128
&& slice.(k - 1 + slice_offset)
129
< slice.(k + 1 + slice_offset))
130
then k + 1
131
else k - 1
132
in
133
let previous_x = slice.(previous_k + slice_offset) in
134
let previous_y = previous_x - previous_k in
135
while !x > previous_x && !y > previous_y do
136
edits := Keep :: !edits;
137
decr x;
138
decr y
139
done;
140
if d > 0 then
141
edits := (if !x = previous_x then Insert else Delete) :: !edits;
142
x := previous_x;
143
y := previous_y;
144
go (d - 1) earlier
145
in
146
go found_d !trace;
147
Some !edits
148
149
let of_contents old_content new_content =
150
let old_lines = Array.of_list (split_lines old_content) in
151
let new_lines = Array.of_list (split_lines new_content) in
152
let old_length = Array.length old_lines in
153
let new_length = Array.length new_lines in
154
(* Trim the common prefix and suffix before searching: they are context by
155
definition, and the difference cap should apply to what actually
156
differs. *)
157
let prefix = ref 0 in
158
while
159
!prefix < old_length
160
&& !prefix < new_length
161
&& old_lines.(!prefix) = new_lines.(!prefix)
162
do
163
incr prefix
164
done;
165
let prefix = !prefix in
166
let suffix = ref 0 in
167
while
168
!suffix < old_length - prefix
169
&& !suffix < new_length - prefix
170
&& old_lines.(old_length - 1 - !suffix) = new_lines.(new_length - 1 - !suffix)
171
do
172
incr suffix
173
done;
174
let suffix = !suffix in
175
let middle_old = old_length - prefix - suffix in
176
let middle_new = new_length - prefix - suffix in
177
(* Intern the middle lines so the search compares integers, not strings. *)
178
let ids = Hashtbl.create 64 in
179
let next_id = ref 0 in
180
let id_of line =
181
match Hashtbl.find_opt ids line with
182
| Some id -> id
183
| None ->
184
let id = !next_id in
185
incr next_id;
186
Hashtbl.add ids line id;
187
id
188
in
189
let old_ids = Array.init middle_old (fun i -> id_of old_lines.(prefix + i)) in
190
let new_ids = Array.init middle_new (fun i -> id_of new_lines.(prefix + i)) in
191
let edits =
192
match myers old_ids new_ids with
193
| Some edits -> edits
194
| None ->
195
(* Too many differences: list the middle as wholesale replacement. *)
196
List.init middle_old (fun _ -> Delete)
197
@ List.init middle_new (fun _ -> Insert)
198
in
199
(* Reassemble: prefix context, the edit script over the middle, suffix
200
context, numbering lines in each file as we go. *)
201
let context index =
202
{
203
kind = Context;
204
old_number = Some (index + 1);
205
new_number = Some (index + 1);
206
text = old_lines.(index);
207
}
208
in
209
let prefix_lines = List.init prefix context in
210
let old_index = ref prefix and new_index = ref prefix in
211
let middle_lines =
212
List.map
213
(fun edit ->
214
match edit with
215
| Keep ->
216
let line =
217
{
218
kind = Context;
219
old_number = Some (!old_index + 1);
220
new_number = Some (!new_index + 1);
221
text = old_lines.(!old_index);
222
}
223
in
224
incr old_index;
225
incr new_index;
226
line
227
| Delete ->
228
let line =
229
{
230
kind = Deletion;
231
old_number = Some (!old_index + 1);
232
new_number = None;
233
text = old_lines.(!old_index);
234
}
235
in
236
incr old_index;
237
line
238
| Insert ->
239
let line =
240
{
241
kind = Addition;
242
old_number = None;
243
new_number = Some (!new_index + 1);
244
text = new_lines.(!new_index);
245
}
246
in
247
incr new_index;
248
line)
249
edits
250
in
251
let suffix_lines =
252
List.init suffix (fun i ->
253
{
254
kind = Context;
255
old_number = Some (old_length - suffix + i + 1);
256
new_number = Some (new_length - suffix + i + 1);
257
text = old_lines.(old_length - suffix + i);
258
})
259
in
260
prefix_lines @ middle_lines @ suffix_lines
261
262
let hunks ?(context = 3) lines =
263
let lines = Array.of_list lines in
264
let length = Array.length lines in
265
let changed =
266
Array.to_list (Array.mapi (fun index line -> (index, line.kind)) lines)
267
|> List.filter_map (function
268
| index, (Addition | Deletion) -> Some index
269
| _, Context -> None)
270
in
271
let ranges =
272
let add_range ranges index =
273
let first = max 0 (index - context) in
274
let last = min (length - 1) (index + context) in
275
match ranges with
276
| (range_first, range_last) :: rest when first <= range_last + 1 ->
277
(range_first, max range_last last) :: rest
278
| _ -> (first, last) :: ranges
279
in
280
List.fold_left add_range [] changed |> List.rev
281
in
282
let number_or_zero get_number slice =
283
List.find_map get_number slice |> Option.value ~default:0
284
in
285
let make_hunk (first, last) =
286
let rec slice index acc =
287
if index > last then List.rev acc
288
else slice (index + 1) (lines.(index) :: acc)
289
in
290
let lines = slice first [] in
291
{
292
old_start = number_or_zero (fun line -> line.old_number) lines;
293
old_count =
294
List.fold_left
295
(fun count line ->
296
if Option.is_some line.old_number then count + 1 else count)
297
0 lines;
298
new_start = number_or_zero (fun line -> line.new_number) lines;
299
new_count =
300
List.fold_left
301
(fun count line ->
302
if Option.is_some line.new_number then count + 1 else count)
303
0 lines;
304
lines;
305
}
306
in
307
List.map make_hunk ranges
308