Skip to content

Commit f032b7f

Browse files
authored
Merge pull request #3 from gordonguthrie/master
Fix of bugs documented in the new regression tests.
2 parents 6073a5f + 9b8b1fd commit f032b7f

File tree

4 files changed

+330
-66
lines changed

4 files changed

+330
-66
lines changed

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,31 @@ iex(2)> Diff.patch("test", patches, &Enum.join/1)
4848
"taste"
4949
```
5050

51+
## Diff.annotated_patch
5152

52-
`Diff.diff` and `Diff.patch` both take as a first parameter a term that has an implementation of the `Diff.Diffable` protocol.
53+
`Diff.annotated_patch` takes a a data structure of annotations, and uses them when applying the patch.
54+
55+
Usage:
56+
```elixir
57+
iex(1)> annotations = [
58+
...(1)> %{delete: %{before: "<span class='deleted'>",
59+
...(1)> after: "</span>"}},
60+
...(1)> %{insert: %{before: "<span class='inserted'>",
61+
...(1)> after: "</span>"}},
62+
...(1)> %{modified: %{before: "<span class='modified'>",
63+
...(1)> after: "</span>"}}
64+
...(1)> ]
65+
[%{delete: %{after: "</span>", before: "<span class='deleted'>"}},
66+
%{insert: %{after: "</span>", before: "<span class='inserted'>"}},
67+
%{modified: %{after: "</span>", before: "<span class='modified'>"}}]
68+
iex(2)> patches = Diff.diff("test", "tast")
69+
[%Diff.Modified{element: ["a"], index: 1, length: 1, old_element: ["e"]}]
70+
iex(3)> Diff.annotated_patch("test", patches, annotations)
71+
iex(3)> Diff.annotated_patch("test", patches, annotations)
72+
["t", "<span class='modified'>", "a", "</span>", "s", "t"]
73+
```
74+
75+
It takes the same optional join function as `Diff.patch` as you would expect
76+
77+
`Diff.diff`, `Diff.patch` and `Diff.annotated_patch` all take as a first parameter a term that has an implementation of the `Diff.Diffable` protocol.
5378
By default one exist for `BitString` and `List`

lib/diff.ex

Lines changed: 140 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -25,44 +25,79 @@ defmodule Diff do
2525
defstruct [:element, :index, :length]
2626
end
2727

28+
@doc"""
29+
Applies with patches with supplied annotation (top and tail)
30+
This is used to generate visual diffs, etc
31+
Shares the same code as patch
32+
"""
33+
def annotated_patch(original, patches, annotations, from_list_fn \\ fn(list) -> list end) do
34+
apply_patches(original, patches, annotations, from_list_fn)
35+
end
36+
2837
@doc """
2938
Applies the patches from a previous diff to the given string.
3039
Will return the patched version as a list unless a from_list_fn/1 is supplied.
3140
This function will takes the patched list as input and outputs the result.
3241
"""
3342
def patch(original, patches, from_list_fn \\ fn(list) -> list end) do
43+
apply_patches(original, patches, [], from_list_fn)
44+
end
45+
46+
defp apply_patches(original, patches, annotations, from_list_fn) do
3447
original = Diffable.to_list(original)
3548

36-
Enum.reduce(patches, original, fn(patch, changed) ->
37-
do_patch(changed, patch)
38-
end)
39-
|> from_list_fn.()
49+
patchfn = fn(patch, {increment, changed}) ->
50+
do_patch({increment, changed}, patch, annotations)
51+
end
52+
53+
increment = 0
54+
{_, returnlist} = Enum.reduce(patches, {increment, original}, patchfn)
55+
from_list_fn.(returnlist)
4056
end
4157

42-
defp do_patch(original, %Diff.Insert{element: element, index: index}) do
43-
{ left, right } = Enum.split(original, index)
44-
left ++ element ++ right
58+
defp do_patch({incr, original}, %Diff.Insert{element: element, index: index},
59+
annotations) do
60+
{ left, right } = Enum.split(original, index + incr)
61+
{newelement, newincr} = annotate(element, :insert, annotations, incr)
62+
return = left ++ newelement ++ right
63+
{newincr, return}
4564
end
4665

47-
defp do_patch(original, %Diff.Delete{ element: _, index: index, length: length }) do
48-
{ left, deleted } = Enum.split(original, index)
49-
{ _, right } = Enum.split(deleted, length)
50-
left ++ right
66+
defp do_patch({incr, original}, %Diff.Delete{ element: element, index: index,
67+
length: length }, annotations) do
68+
{ left, deleted } = Enum.split(original, index + incr)
69+
{ actuallydeleted, right } = Enum.split(deleted, length)
70+
case element do
71+
^actuallydeleted ->
72+
{newelement, newincr} = annotate(element, :deleted, annotations, incr)
73+
return = left ++ newelement ++ right
74+
{newincr, return}
75+
_other ->
76+
exit("failed delete")
77+
end
5178
end
5279

53-
defp do_patch(original, %Diff.Modified{element: element, old_element: _, index: index, length: length}) do
54-
{ left, deleted } = Enum.split(original, index)
80+
defp do_patch({incr, original},
81+
%Diff.Modified{ element: element, old_element: _,
82+
index: index, length: length},
83+
annotations) do
84+
{ left, deleted } = Enum.split(original, index + incr)
5585
{ _, right } = Enum.split(deleted, length)
56-
left ++ element ++ right
86+
{newelement, newincr} = annotate(element, :modified, annotations, incr)
87+
return = left ++ newelement ++ right
88+
{newincr, return}
5789
end
5890

59-
defp do_patch(original, %Diff.Unchanged{}) do
60-
original
91+
defp do_patch({incr, original}, %Diff.Unchanged{}, _annotations) do
92+
{incr, original}
6193
end
6294

63-
defp do_patch(original, %Diff.Ignored{element: element, index: index}) do
64-
{ left, right } = Enum.split(original, index)
65-
left ++ element ++ right
95+
defp do_patch({incr, original}, %Diff.Ignored{element: element, index: index},
96+
annotations) do
97+
{ left, right } = Enum.split(original, index + incr)
98+
{newelement, newincr} = annotate(element, :ignored, annotations, incr)
99+
return = left ++ newelement ++ right
100+
{newincr, return}
66101
end
67102

68103
@doc"""
@@ -85,12 +120,15 @@ defmodule Diff do
85120
end
86121

87122
defp longest_common_subsequence(x, y, x_length, y_length) do
88-
matrix = Matrix.new(x_length + 1, y_length + 1)
89123

90-
matrix = Enum.reduce(1..x_length, matrix, fn(i, matrix) ->
124+
matrix = Matrix.new(x_length + 1, y_length + 1)
91125

92-
Enum.reduce(1..y_length, matrix, fn(j, matrix) ->
126+
# a reduction over a 2D array requires a closure inside an anonymous function
127+
# sorry but there is nothing to be done about that
128+
rowreductionFn = fn(i, matrix) ->
93129

130+
# setup the second closure
131+
columnreductionFn = fn(j, matrix) ->
94132
if Enum.fetch!(x, i-1) == Enum.fetch!(y, j-1) do
95133
value = Matrix.get(matrix, i-1, j-1)
96134
Matrix.put(matrix, i, j, value + 1)
@@ -100,89 +138,114 @@ defmodule Diff do
100138

101139
Matrix.put(matrix, i, j, max(original_value, changed_value))
102140
end
141+
end
103142

104-
end)
143+
Enum.reduce(1..y_length, matrix, columnreductionFn)
144+
145+
end
105146

106-
end)
147+
_matrix = Enum.reduce(1..x_length, matrix, rowreductionFn)
107148

108-
matrix
109149
end
110150

111151
defp build_diff(matrix, x, y, i, j, edits, options) do
112152
cond do
113153
i > 0 and j > 0 and Enum.fetch!(x, i-1) == Enum.fetch!(y, j-1) ->
114-
if Dict.get(options, :keep_unchanged, false) do
115-
edits = edits ++ [{:unchanged, Enum.fetch!(x, i-1), i-1}]
116-
end
117-
118-
build_diff(matrix, x, y, i-1, j-1, edits, options)
154+
newedits = if Dict.get(options, :keep_unchanged, false) do
155+
edits ++ [{:unchanged, Enum.fetch!(x, i-1), i-1}]
156+
else
157+
edits
158+
end
159+
build_diff(matrix, x, y, i-1, j-1, newedits, options)
119160
j > 0 and (i == 0 or Matrix.get(matrix, i, j-1) >= Matrix.get(matrix,i-1, j)) ->
120-
build_diff(matrix, x, y, i, j-1, edits ++ [{:insert, Enum.fetch!(y, j-1), j-1}], options)
161+
newedit = {:insert, Enum.fetch!(y, j-1), j-1}
162+
build_diff(matrix, x, y, i, j-1, edits ++ [newedit], options)
121163
i > 0 and (j == 0 or Matrix.get(matrix, i, j-1) < Matrix.get(matrix, i-1, j)) ->
122-
build_diff(matrix, x, y, i-1, j, edits ++ [{:delete, Enum.fetch!(x, i-1), i-1}], options)
164+
newdelete = {:delete, Enum.fetch!(x, i-1), j}
165+
build_diff(matrix, x, y, i-1, j, edits ++ [newdelete], options)
123166
true ->
124167
edits |> Enum.reverse
125168
end
126169
end
127170

128171
defp build_changes(edits, options) do
129-
Enum.reduce(edits, [], fn({type, char, index}, changes) ->
172+
173+
# we now have a set of individual letter changes
174+
# but if there is a series of inserts or deletes then
175+
# we need to reduce them into single multichar changes
176+
mergeindividualchangesFn = fn({type, char, index}, changes) ->
130177
if changes == [] do
131-
changes ++ [change(type, char, index)]
178+
changes ++ [make_change(type, char, index)]
132179
else
133180
change = List.last(changes)
134181
regex = Dict.get(options, :ignore)
135-
136182
cond do
137183
regex && Regex.match?(regex, char) ->
138-
changes ++ [change(:ignored, char, index)]
139-
is_type(change, type) && index == (change.index + change.length) ->
140-
change = %{change | element: change.element ++ [char], length: change.length + 1 }
141-
142-
if regex && Regex.match?(regex, Enum.join(change.element)) do
143-
change = %Ignored{ element: change.element, index: change.index, length: change.length }
144-
end
145-
184+
changes ++ [make_change(:ignored, char, index)]
185+
# one branch for deletes
186+
is_type(change, type) && type == :delete && index == change.index ->
187+
change = if regex && Regex.match?(regex, Enum.join(change.element)) do
188+
%Ignored{ element: change.element, index: change.index,
189+
length: change.length }
190+
else
191+
%{change | element: change.element ++ [char], length:
192+
change.length + 1 }
193+
end
194+
List.replace_at(changes, length(changes)-1, change)
195+
# a different branch for everyone else
196+
is_type(change, type) && type != :delete && index == (change.index + change.length) ->
197+
change = if regex && Regex.match?(regex, Enum.join(change.element)) do
198+
%Ignored{ element: change.element, index: change.index,
199+
length: change.length }
200+
else
201+
%{change | element: change.element ++ [char], length:
202+
change.length + 1 }
203+
end
146204
List.replace_at(changes, length(changes)-1, change)
147205
true ->
148-
changes ++ [change(type, char, index)]
149-
206+
changes ++ [make_change(type, char, index)]
150207
end
151208
end
209+
end
152210

153-
154-
end)
155-
156-
|> Enum.reduce([], fn(x, changes) ->
211+
# if we change a single letter it will be a consecutive delete/insert
212+
# this reduction merges them into a single modified statement
213+
makemodifiedFn = fn(x, changes) ->
157214
if changes == [] do
158215
[x]
159216
else
160217
last_change = List.last(changes)
161-
162-
if is_type(last_change, :delete) and is_type(x, :insert) and last_change.index == x.index and last_change.length == x.length do
163-
last_change = %Modified{ element: x.element, old_element: last_change.element, index: x.index, length: x.length }
218+
if is_type(last_change, :delete)
219+
and is_type(x, :insert)
220+
and last_change.index == x.index
221+
and last_change.length == x.length do
222+
last_change = %Modified{ element: x.element, old_element: last_change.element,
223+
index: x.index, length: x.length }
164224
List.replace_at(changes, length(changes) - 1, last_change)
165225
else
166226
changes ++ [x]
167227
end
168228
end
169-
end)
170-
end
229+
end
171230

231+
# Now do both these sets of reduction on the edits
232+
Enum.reduce(edits, [], mergeindividualchangesFn)
233+
|> Enum.reduce([], makemodifiedFn)
234+
end
172235

173-
defp change(:insert, char, index) do
236+
defp make_change(:insert, char, index) do
174237
%Insert{ element: [char], index: index, length: 1 }
175238
end
176239

177-
defp change(:delete, char, index) do
240+
defp make_change(:delete, char, index) do
178241
%Delete{ element: [char], index: index, length: 1 }
179242
end
180243

181-
defp change(:unchanged, char, index) do
244+
defp make_change(:unchanged, char, index) do
182245
%Unchanged{ element: [char], index: index, length: 1 }
183246
end
184247

185-
defp change(:ignored, char, index) do
248+
defp make_change(:ignored, char, index) do
186249
%Ignored{ element: [char], index: index, length: 1 }
187250
end
188251

@@ -206,4 +269,23 @@ defmodule Diff do
206269
false
207270
end
208271

272+
defp annotate(list, type, annotations, increment) do
273+
annotation = for a <- annotations,
274+
Map.get(a, type) != nil, do: Map.get(a, type)
275+
case {type, annotation} do
276+
{:deleted, []} -> {[], increment}
277+
{_, []} -> {list, increment}
278+
{:deleted, [annotation]} -> apply_deletion(list, annotation, increment)
279+
{_, [annotation]} -> apply_annotation(list, annotation, increment)
280+
end
281+
end
282+
283+
defp apply_deletion(list, annotation, increment) do
284+
{[annotation.before] ++ list ++ [annotation.after], increment + 2}
285+
end
286+
287+
defp apply_annotation(list, annotation, increment) do
288+
{[annotation.before] ++ list ++ [annotation.after], increment + 2}
289+
end
290+
209291
end

test/diff_annotation_test.exs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule Diff.Annotation.Test do
2+
use ExUnit.Case
3+
4+
test "no results when strings match" do
5+
original = "test"
6+
changed = "test"
7+
patches = Diff.diff(original, changed)
8+
assert Diff.annotated_patch(original, patches, get_annotations(),
9+
&Enum.join/1) == changed
10+
end
11+
12+
test "do a modification" do
13+
original = "test"
14+
changed = "tast"
15+
patches = Diff.diff(original, changed)
16+
final = Diff.annotated_patch(original, patches, get_annotations(),
17+
&Enum.join/1)
18+
assert final, "t<span class='modified'>a</span>st"
19+
end
20+
21+
test "do a deletion" do
22+
original = "test"
23+
changed = "tst"
24+
patches = Diff.diff(original, changed)
25+
final = Diff.annotated_patch(original, patches, get_annotations(),
26+
&Enum.join/1)
27+
assert final, "t<span class='deleted'>a</span>st"
28+
end
29+
30+
test "do an insertion" do
31+
original = "test"
32+
changed = "teast"
33+
patches = Diff.diff(original, changed)
34+
final = Diff.annotated_patch(original, patches, get_annotations(),
35+
&Enum.join/1)
36+
assert final, "te<span class='inserted'>a</span>st"
37+
end
38+
39+
test "do a mixed test" do
40+
original = "abcdefghijklmnopqrst"
41+
changed = "abcefgh1ikkmnqrxxst"
42+
patches = Diff.diff(original, changed)
43+
final = Diff.annotated_patch(original, patches, get_annotations(),
44+
&Enum.join/1)
45+
assert final, """
46+
abc<span class='deleted'>d</span>
47+
fgh
48+
<span class='inserted'>1</span>
49+
ik
50+
<span class='modified'>k</span>
51+
mn
52+
abc<span class='deleted'>op</span>
53+
qr
54+
<span class='inserted'>xx</span>
55+
st
56+
"""
57+
end
58+
59+
defp get_annotations() do
60+
[
61+
%{delete: %{before: "<span class='deleted'>",
62+
after: "</span>"}},
63+
%{insert: %{before: "<span class='inserted'>",
64+
after: "</span>"}},
65+
%{modified: %{before: "<span class='modified'>",
66+
after: "</span>"}}
67+
]
68+
end
69+
70+
end

0 commit comments

Comments
 (0)