Skip to content

Commit 39bb97f

Browse files
committed
Add ability to diff complex arrays
By "complex" data structures we mean arrays within arrays and hashes within arrays (and everything under the sun inside of them). In order to diff a complex array, we need to have the ability to show a diff that reaches into inner arrays or hashes and shows the diff for them too. That is, we need to compare each item in the array, and if we have two elements which are different, and they are themselves arrays or hashes, we need to descend into them and diff each of their elements as well. We can already diff two complex hashes in this manner because we have custom diffing logic for hashes, where an added key is treated as an insert, a missing key is treated as a delete, and a changed key is treated as a delete + an insert. To show a diff for two hashes, we merely go back over the diff information and look for a delete followed by an insert, treating it as a change, and if the changed value is an array or hash we know to descend. However, this approach does not work for arrays because we are using the patience algorithm to diff their contents. The patience algorithm is commonly used to diff two text files on a line level, and it is helpful because it groups together successful additions and deletions so as to create a cleaner diff. This grouping algorithm is completely useless for arrays, though, because it does not allow us to detect changed values effectively. Another solution which *does* allow us to detect changes, however, is the diff-lcs gem, and that's exactly what this commit introduces.
1 parent bed0006 commit 39bb97f

File tree

18 files changed

+380
-154
lines changed

18 files changed

+380
-154
lines changed

Gemfile.lock

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ PATH
3131
remote: .
3232
specs:
3333
super_diff (0.0.1)
34+
diff-lcs
3435
patience_diff
3536

3637
GEM
@@ -80,4 +81,4 @@ DEPENDENCIES
8081
super_diff!
8182

8283
BUNDLED WITH
83-
1.16.2
84+
1.16.4

lib/super_diff.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "patience_diff"
2+
require "diff-lcs"
23
require "pry-byebug"
34

45
require_relative "super_diff/csi"
@@ -15,6 +16,9 @@
1516
require_relative "super_diff/equality_matchers"
1617
require_relative "super_diff/equality_matcher"
1718

19+
require_relative "super_diff/operations/unary_operation"
20+
require_relative "super_diff/operations/binary_operation"
21+
1822
require_relative "super_diff/operation_sequences/base"
1923
require_relative "super_diff/operation_sequences/array"
2024
require_relative "super_diff/operation_sequences/hash"
@@ -23,6 +27,7 @@
2327
require_relative "super_diff/operational_sequencers/base"
2428
require_relative "super_diff/operational_sequencers/array"
2529
require_relative "super_diff/operational_sequencers/hash"
30+
require_relative "super_diff/operational_sequencers/multi_line_string"
2631
require_relative "super_diff/operational_sequencers/object"
2732
require_relative "super_diff/operational_sequencers"
2833
require_relative "super_diff/operational_sequencer"

lib/super_diff/diff_formatters/array.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def call
1010
open_token: "[",
1111
close_token: "]",
1212
collection_prefix: collection_prefix,
13-
build_item_prefix: -> (op) { "" },
13+
build_item_prefix: -> (operation) { "" },
1414
operations: operations,
1515
indent_level: indent_level,
1616
add_comma: add_comma?

lib/super_diff/diff_formatters/collection.rb

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,23 @@ def lines
4444
end
4545

4646
def contents
47-
operations.map do |op|
48-
if op.name == :change
49-
max_size = [op.left_collection.size, op.right_collection.size].max
50-
add_comma = op.index < max_size - 1
51-
op.child_operations.to_diff(
47+
operations.map do |operation|
48+
if operation.name == :change
49+
operation.child_operations.to_diff(
5250
indent_level: indent_level + 1,
53-
collection_prefix: build_item_prefix.call(op),
54-
add_comma: add_comma
51+
collection_prefix: build_item_prefix.call(operation),
52+
add_comma: operation.should_add_comma_after_displaying?
5553
)
5654
else
57-
collection = op.collection
58-
icon = ICONS.fetch(op.name, " ")
59-
style_name = STYLES.fetch(op.name, :normal)
55+
icon = ICONS.fetch(operation.name, " ")
56+
style_name = STYLES.fetch(operation.name, :normal)
6057
chunk = build_chunk(
61-
Helpers.inspect_object(op.value, single_line: false),
62-
prefix: build_item_prefix.call(op),
58+
Helpers.inspect_object(operation.value, single_line: false),
59+
prefix: build_item_prefix.call(operation),
6360
icon: icon
6461
)
6562

66-
if op.index < collection.size - 1
63+
if operation.should_add_comma_after_displaying?
6764
chunk << ","
6865
end
6966

lib/super_diff/diff_formatters/hash.rb

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@ def call
1010
open_token: "{",
1111
close_token: "}",
1212
collection_prefix: collection_prefix,
13-
build_item_prefix: -> (op) {
14-
if op.key.is_a?(Symbol)
15-
"#{op.key}: "
13+
build_item_prefix: -> (operation) {
14+
key =
15+
if operation.respond_to?(:left_key)
16+
operation.left_key
17+
else
18+
operation.key
19+
end
20+
21+
if key.is_a?(Symbol)
22+
"#{key}: "
1623
else
17-
"#{op.key.inspect} => "
24+
"#{key.inspect} => "
1825
end
1926
},
2027
operations: operations,

lib/super_diff/diff_formatters/multi_line_string.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ def call
1212
private
1313

1414
def lines
15-
operations.inject([]) do |array, op|
16-
case op.name
15+
operations.inject([]) do |array, operation|
16+
case operation.name
1717
when :change
18-
array << Helpers.style(:deleted, "- #{op.left_value}")
19-
array << Helpers.style(:inserted, "+ #{op.right_value}")
18+
array << Helpers.style(:deleted, "- #{operation.left_value}")
19+
array << Helpers.style(:inserted, "+ #{operation.right_value}")
2020
when :delete
21-
array << Helpers.style(:deleted, "- #{op.value}")
21+
array << Helpers.style(:deleted, "- #{operation.value}")
2222
when :insert
23-
array << Helpers.style(:inserted, "+ #{op.value}")
23+
array << Helpers.style(:inserted, "+ #{operation.value}")
2424
else
25-
array << Helpers.style(:normal, " #{op.value}")
25+
array << Helpers.style(:normal, " #{operation.value}")
2626
end
2727
end
2828
end

lib/super_diff/diff_formatters/object.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def call
1010
open_token: "#<#{value_class} {",
1111
close_token: "}>",
1212
collection_prefix: collection_prefix,
13-
build_item_prefix: -> (op) { "#{op.key}: " },
13+
build_item_prefix: -> (operation) { "#{operation.key}: " },
1414
operations: operations,
1515
indent_level: indent_level,
1616
add_comma: add_comma?

lib/super_diff/differs/string.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def styled_lines_for(icon, style_name, collection)
3939
end
4040

4141
def unstyled_line_for(icon, collection)
42-
value = collection[op.index]
42+
value = collection[operation.index]
4343
line = "#{icon} #{indentation}#{value.inspect}"
4444

4545
if index_in_collection < collection.length - 1

lib/super_diff/equality_matchers/multi_line_string.rb

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,7 @@ module SuperDiff
22
module EqualityMatchers
33
class MultiLineString < Base
44
def self.applies_to?(value)
5-
value.class == ::String && value.include?("\n")
6-
end
7-
8-
def initialize(
9-
expected:,
10-
actual:,
11-
extra_operational_sequencer_classes:,
12-
extra_diff_formatter_classes:
13-
)
14-
@expected = split_into_lines(expected)
15-
@actual = split_into_lines(actual)
16-
17-
@original_expected = @expected.join
18-
@original_actual = @actual.join
5+
value.is_a?(::String) && value.include?("\n")
196
end
207

218
def fail
@@ -25,13 +12,13 @@ def fail
2512
#{
2613
Helpers.style(
2714
:deleted,
28-
"Expected: #{Helpers.inspect_object(original_expected)}"
15+
"Expected: #{Helpers.inspect_object(expected)}"
2916
)
3017
}
3118
#{
3219
Helpers.style(
3320
:inserted,
34-
" Actual: #{Helpers.inspect_object(original_actual)}"
21+
" Actual: #{Helpers.inspect_object(actual)}"
3522
)
3623
}
3724
@@ -43,18 +30,15 @@ def fail
4330

4431
private
4532

46-
attr_reader :original_expected, :original_actual
47-
48-
def split_into_lines(str)
49-
str.split(/(\n)/).map { |v| v.tr("\n", "⏎") }.each_slice(2).map(&:join)
50-
end
51-
5233
def diff
5334
DiffFormatters::MultiLineString.call(operations, indent_level: 0)
5435
end
5536

5637
def operations
57-
OperationalSequencers::Array.call(expected: expected, actual: actual)
38+
OperationalSequencers::MultiLineString.call(
39+
expected: expected,
40+
actual: actual
41+
)
5842
end
5943
end
6044
end

lib/super_diff/operational_sequencers/array.rb

Lines changed: 104 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,57 +8,119 @@ def self.applies_to?(value)
88
def initialize(*args)
99
super(*args)
1010

11-
@sequence_matcher = PatienceDiff::SequenceMatcher.new
11+
@lcs_callbacks = LcsCallbacks.new(
12+
expected: expected,
13+
actual: actual,
14+
extra_operational_sequencer_classes: extra_operational_sequencer_classes,
15+
extra_diff_formatter_classes: extra_diff_formatter_classes
16+
)
1217
end
1318

14-
protected
19+
def call
20+
Diff::LCS.traverse_balanced(expected, actual, lcs_callbacks)
21+
lcs_callbacks.operations
22+
end
23+
24+
private
25+
26+
attr_reader :lcs_callbacks
27+
28+
class LcsCallbacks
29+
attr_reader :operations
1530

16-
def unary_operations
17-
opcodes.flat_map do |code, a_start, a_end, b_start, b_end|
18-
if code == :delete
19-
(a_start..a_end).map do |index|
20-
UnaryOperation.new(
21-
name: :delete,
22-
collection: expected,
23-
key: index,
24-
index: index,
25-
value: expected[index]
26-
)
27-
end
28-
elsif code == :insert
29-
(b_start..b_end).map do |index|
30-
UnaryOperation.new(
31-
name: :insert,
32-
collection: actual,
33-
key: index,
34-
index: index,
35-
value: actual[index]
36-
)
37-
end
31+
def initialize(
32+
expected:,
33+
actual:,
34+
extra_operational_sequencer_classes:,
35+
extra_diff_formatter_classes:
36+
)
37+
@expected = expected
38+
@actual = actual
39+
@operations = OperationSequences::Array.new([])
40+
@extra_operational_sequencer_classes =
41+
extra_operational_sequencer_classes
42+
@extra_diff_formatter_classes = extra_diff_formatter_classes
43+
end
44+
45+
def match(event)
46+
operations << ::SuperDiff::Operations::UnaryOperation.new(
47+
name: :noop,
48+
collection: actual,
49+
key: event.new_position,
50+
value: event.new_element,
51+
index: event.new_position
52+
)
53+
end
54+
55+
def discard_a(event)
56+
operations << ::SuperDiff::Operations::UnaryOperation.new(
57+
name: :delete,
58+
collection: expected,
59+
key: event.old_position,
60+
value: event.old_element,
61+
index: event.old_position
62+
)
63+
end
64+
65+
def discard_b(event)
66+
operations << ::SuperDiff::Operations::UnaryOperation.new(
67+
name: :insert,
68+
collection: actual,
69+
key: event.new_position,
70+
value: event.new_element,
71+
index: event.new_position
72+
)
73+
end
74+
75+
def change(event)
76+
child_operations = sequence(event.old_element, event.new_element)
77+
78+
if child_operations
79+
operations << ::SuperDiff::Operations::BinaryOperation.new(
80+
name: :change,
81+
left_collection: expected,
82+
right_collection: actual,
83+
left_key: event.old_position,
84+
right_key: event.new_position,
85+
left_value: event.old_element,
86+
right_value: event.new_element,
87+
left_index: event.old_position,
88+
right_index: event.new_position,
89+
child_operations: child_operations
90+
)
3891
else
39-
(b_start..b_end).map do |index|
40-
UnaryOperation.new(
41-
name: :noop,
42-
collection: actual,
43-
key: index,
44-
index: index,
45-
value: actual[index]
46-
)
47-
end
92+
operations << Operations::UnaryOperation.new(
93+
name: :delete,
94+
collection: expected,
95+
key: event.old_position,
96+
value: event.old_element,
97+
index: event.old_position
98+
)
99+
operations << Operations::UnaryOperation.new(
100+
name: :insert,
101+
collection: actual,
102+
key: event.new_position,
103+
value: event.new_element,
104+
index: event.new_position
105+
)
48106
end
49107
end
50-
end
51108

52-
def operation_sequence_class
53-
OperationSequences::Array
54-
end
55-
56-
private
109+
private
57110

58-
attr_reader :sequence_matcher
111+
attr_reader :expected, :actual, :extra_operational_sequencer_classes,
112+
:extra_diff_formatter_classes
59113

60-
def opcodes
61-
sequence_matcher.diff_opcodes(expected, actual)
114+
def sequence(expected, actual)
115+
OperationalSequencer.call(
116+
expected: expected,
117+
actual: actual,
118+
extra_classes: extra_operational_sequencer_classes,
119+
extra_diff_formatter_classes: extra_diff_formatter_classes
120+
)
121+
rescue NoOperationalSequencerAvailableError
122+
nil
123+
end
62124
end
63125
end
64126
end

0 commit comments

Comments
 (0)