specdiff 0.1.1 → 0.3.0.pre.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,68 @@
1
+ Bundler.require
2
+ require "specdiff/rspec"
3
+
4
+ RSpec.configure do |config|
5
+ # rspec-expectations config goes here. You can use an alternate
6
+ # assertion/expectation library such as wrong or the stdlib/minitest
7
+ # assertions if you prefer.
8
+ config.expect_with :rspec do |expectations|
9
+ # This option will default to `true` in RSpec 4. It makes the `description`
10
+ # and `failure_message` of custom matchers include text for helper methods
11
+ # defined using `chain`, e.g.:
12
+ # be_bigger_than(2).and_smaller_than(4).description
13
+ # # => "be bigger than 2 and smaller than 4"
14
+ # ...rather than:
15
+ # # => "be bigger than 2"
16
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
17
+
18
+ expectations.syntax = :expect
19
+ end
20
+
21
+ # rspec-mocks config goes here. You can use an alternate test double
22
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
23
+ config.mock_with :rspec do |mocks|
24
+ # Prevents you from mocking or stubbing a method that does not exist on
25
+ # a real object. This is generally recommended, and will default to
26
+ # `true` in RSpec 4.
27
+ mocks.verify_partial_doubles = true
28
+ end
29
+
30
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
31
+ # have no way to turn it off -- the option exists only for backwards
32
+ # compatibility in RSpec 3). It causes shared context metadata to be
33
+ # inherited by the metadata hash of host groups and examples, rather than
34
+ # triggering implicit auto-inclusion in groups with matching metadata.
35
+ config.shared_context_metadata_behavior = :apply_to_host_groups
36
+
37
+ # enable test focusing
38
+ config.filter_run_when_matching :focus
39
+
40
+ # Enable flags like --only-failures and --next-failure
41
+ config.example_status_persistence_file_path = ".rspec_status"
42
+
43
+ # Limits the available syntax to the non-monkey patched syntax that is
44
+ # recommended. For more details, see:
45
+ # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
46
+ config.disable_monkey_patching!
47
+
48
+ # This setting enables warnings. It's recommended, but in some cases may
49
+ # be too noisy due to issues in dependencies.
50
+ config.warnings = true
51
+
52
+ # Many RSpec users commonly either run the entire suite or an individual
53
+ # file, and it's useful to allow more verbose output when running an
54
+ # individual spec file.
55
+ if config.files_to_run.one?
56
+ # Use the documentation formatter for detailed output,
57
+ # unless a formatter has already been configured
58
+ # (e.g. via a command-line flag).
59
+ config.default_formatter = "doc"
60
+ end
61
+ end
62
+
63
+ class MyBasicObjectClass < BasicObject
64
+ end
65
+
66
+ class ConstantForTheSolePurposeOfUndefiningInspect
67
+ undef_method :inspect
68
+ end
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ../..
3
3
  specs:
4
- specdiff (0.1.0)
4
+ specdiff (0.3.0.pre.rc1)
5
5
  diff-lcs (~> 1.5)
6
6
  hashdiff (~> 1.0)
7
7
 
@@ -12,7 +12,7 @@ GEM
12
12
  public_suffix (>= 2.0.2, < 6.0)
13
13
  crack (0.4.5)
14
14
  rexml
15
- diff-lcs (1.5.0)
15
+ diff-lcs (1.5.1)
16
16
  domain_name (0.6.20231109)
17
17
  ffi (1.16.3)
18
18
  ffi-compiler (1.0.1)
data/glossary.txt CHANGED
@@ -1,3 +1,8 @@
1
+ "raw diff"
2
+ the return value from a differ or plugin's #diff method
3
+ this can be anything, from an array of arrays (hashdiff) to a string with a
4
+ git diff inside
5
+
1
6
  diff
2
7
  the return value from the Specdiff::diff method.
3
8
  this is not the direct return value from a plugin/differ, that is the
@@ -8,17 +13,34 @@ differ
8
13
  human-comprehensible diff output for your terminal
9
14
 
10
15
  plugin
11
- external differ (responding to more methods), able to be provided by third
12
- parties or end users themselves
16
+ external differ (has to respond to more methods)
17
+ may be provided from outside the gem (for example by a user drowning in xml)
18
+
19
+ "built in differ"
20
+ differ living in the specdiff/differ directory
21
+
22
+ "built in plugin"
23
+ plugin shipped with the gem, but needs to be loaded using Specdiff.load!
13
24
 
14
25
  type
15
26
  a symbol like :text, :json or :hash which denotes the type of data in a way
16
27
  which is useful for picking a differ
17
28
 
29
+ a plugin returns a type from its #id method
30
+
18
31
  :text
19
32
  a string which likely contains plaintext data of some kind
20
33
 
34
+ "plugin type"
35
+ a type added by loading a plugin (not built into specdiff)
36
+
21
37
  side
22
- an object containing either side of a comparison that needs to be made
23
- when defining a plugin, the diff method receives two sides: a, b
24
- most importantly, a side contains a value and a type
38
+ an object containing a value and a type
39
+
40
+ used to represent the two sides to a comparison
41
+
42
+ when defining a plugin, you receive two sides: a and b, to various methods
43
+
44
+ compare
45
+ the procedure that implements the main function of specdiff including
46
+ accounting for any plugin types and differs
@@ -25,7 +25,12 @@ module Specdiff::Colorize
25
25
  "\e[33m#{text}\e[0m"
26
26
  end
27
27
 
28
+ # this color may have bad contrast
28
29
  def blue(text)
29
30
  "\e[34m#{text}\e[0m"
30
31
  end
32
+
33
+ def cyan(text)
34
+ "\e[36m#{text}\e[0m"
35
+ end
31
36
  end
@@ -0,0 +1,95 @@
1
+ class Specdiff::Compare
2
+ Side = Struct.new(:value, :type, keyword_init: true)
3
+
4
+ def self.call(...)
5
+ new.call(...)
6
+ end
7
+
8
+ def call(raw_a, raw_b)
9
+ a = parse_side(raw_a)
10
+ b = parse_side(raw_b)
11
+
12
+ if a.type == :text && b.type == :binary
13
+ new_b = try_reencode(b.value, a.value.encoding)
14
+ if new_b
15
+ b = b.dup
16
+ b.type = :text
17
+ b.value = new_b
18
+ end
19
+ elsif a.type == :binary && b.type == :text
20
+ new_a = try_reencode(a.value, b.value.encoding)
21
+ if new_a
22
+ a = a.dup
23
+ a.type = :text
24
+ a.value = new_a
25
+ end
26
+ end
27
+
28
+ differ = pick_differ(a, b)
29
+ raw = differ.diff(a, b)
30
+
31
+ if raw.is_a?(::Specdiff::Diff) # detect recursive plugins, such as json
32
+ raw
33
+ else
34
+ ::Specdiff::Diff.new(raw: raw, differ: differ, a: a, b: b)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def parse_side(raw_value)
41
+ type = detect_type(raw_value)
42
+
43
+ Side.new(value: raw_value, type: type)
44
+ end
45
+
46
+ def detect_type(thing)
47
+ if (type = detect_plugin_types(thing))
48
+ type
49
+ elsif thing.is_a?(Hash)
50
+ :hash
51
+ elsif thing.is_a?(Array)
52
+ :array
53
+ elsif thing.is_a?(String) && thing.encoding == Encoding::BINARY
54
+ :binary
55
+ elsif thing.is_a?(String)
56
+ :text
57
+ elsif thing.nil?
58
+ :nil
59
+ else
60
+ :unknown
61
+ end
62
+ end
63
+
64
+ def detect_plugin_types(thing)
65
+ Specdiff.plugins
66
+ .filter { |plugin| plugin.respond_to?(:detect_type) }
67
+ .detect { |plugin| plugin.detect_type(thing) }
68
+ &.id
69
+ end
70
+
71
+ def try_reencode(binary_string, target_encoding)
72
+ binary_string.encode(target_encoding)
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ def pick_differ(a, b)
78
+ if (differ = pick_plugin_differ(a, b))
79
+ differ
80
+ elsif a.type == :text && b.type == :text
81
+ Specdiff::Differ::Text
82
+ elsif a.type == :hash && b.type == :hash
83
+ Specdiff::Differ::Hashdiff
84
+ elsif a.type == :array && b.type == :array
85
+ Specdiff::Differ::Hashdiff
86
+ else
87
+ Specdiff::Differ::NotFound
88
+ end
89
+ end
90
+
91
+ def pick_plugin_differ(a, b)
92
+ Specdiff.plugins
93
+ .detect { |plugin| plugin.compatible?(a, b) }
94
+ end
95
+ end
@@ -5,23 +5,26 @@ module Specdiff
5
5
  end
6
6
  end
7
7
 
8
- # Read the configuration
9
- def self.config
10
- threadlocal[:config] ||= default_configuration
8
+ class << self
9
+ attr_reader :config
11
10
  end
12
11
 
12
+ DEFAULT = Config.new(colorize: true).freeze
13
+ @config = DEFAULT.dup
14
+
13
15
  # private, used for testing
14
16
  def self._set_config(new_config)
15
- threadlocal[:config] = new_config
17
+ @config = new_config
16
18
  end
17
19
 
18
20
  # Set the configuration
19
21
  def self.configure
20
- yield(config)
22
+ yield(@config)
23
+ @config
21
24
  end
22
25
 
23
26
  # Generates the default configuration
24
27
  def self.default_configuration
25
- Config.new(colorize: true)
28
+ DEFAULT
26
29
  end
27
30
  end
data/lib/specdiff/diff.rb CHANGED
@@ -21,8 +21,10 @@
21
21
  def inspect
22
22
  if empty?
23
23
  "<Specdiff::Diff (empty)>"
24
- else
24
+ elsif raw.respond_to?(:bytesize)
25
25
  "<Specdiff::Diff w/ #{raw&.bytesize || 0} bytes of #raw diff>"
26
+ else
27
+ "<Specdiff::Diff #{raw.inspect}>"
26
28
  end
27
29
  end
28
30
  end
@@ -1,20 +1,69 @@
1
1
  require "hashdiff"
2
- require "pp"
3
2
 
4
3
  class Specdiff::Differ::Hashdiff
5
4
  extend ::Specdiff::Colorize
6
5
 
6
+ VALUE_CHANGE_PERCENTAGE_THRESHOLD = 0.2
7
+ TOTAL_CHANGES_FOR_GROUPING_THRESHOLD = 9
8
+
9
+ NEWLINE = "\n"
10
+
7
11
  def self.diff(a, b)
8
12
  # array_path: true returns the path as an array, which differentiates
9
13
  # between symbol keys and string keys in hashes, while the string
10
14
  # representation does not.
11
15
  # hmm it really seems like use_lcs: true gives much less human-readable
12
16
  # (human-comprehensible) output when arrays are involved.
13
- Hashdiff.diff(
17
+ hashdiff_diff = ::Hashdiff.diff(
14
18
  a.value, b.value,
15
19
  array_path: true,
16
20
  use_lcs: false,
17
21
  )
22
+
23
+ return hashdiff_diff if hashdiff_diff.empty?
24
+
25
+ change_percentage = _calculate_change_percentage(hashdiff_diff)
26
+
27
+ if change_percentage >= VALUE_CHANGE_PERCENTAGE_THRESHOLD
28
+ hashdiff_diff
29
+ else
30
+ a_text = ::Specdiff.hashprint(a.value)
31
+ b_text = ::Specdiff.hashprint(b.value)
32
+
33
+ diff = ::Specdiff.diff(a_text, b_text)
34
+
35
+ if diff.empty?
36
+ []
37
+ else
38
+ diff.a.type = a.type
39
+ diff.b.type = b.type
40
+
41
+ diff
42
+ end
43
+ end
44
+ end
45
+
46
+ def self._calculate_change_percentage(hashdiff_diff)
47
+ value_change_count = hashdiff_diff.count { |element| element[0] == "~" }
48
+ addition_count = hashdiff_diff.count { |element| element[0] == "+" }
49
+ deletion_count = hashdiff_diff.count { |element| element[0] == "-" }
50
+ # puts "hashdiff_diff: #{hashdiff_diff.inspect}"
51
+ # puts "value_change_count: #{value_change_count.inspect}"
52
+ # puts "addition_count: #{addition_count.inspect}"
53
+ # puts "deletion_count: #{deletion_count.inspect}"
54
+
55
+ total_number_of_changes = [
56
+ value_change_count,
57
+ addition_count,
58
+ deletion_count,
59
+ ].sum
60
+
61
+ change_fraction = Rational(value_change_count, total_number_of_changes)
62
+ change_percentage = change_fraction.to_f
63
+ # puts "change_fraction: #{change_fraction.inspect}"
64
+ # puts "change_percentage: #{change_percentage.inspect}"
65
+
66
+ change_percentage
18
67
  end
19
68
 
20
69
  def self.empty?(diff)
@@ -22,41 +71,68 @@ class Specdiff::Differ::Hashdiff
22
71
  end
23
72
 
24
73
  def self.stringify(diff)
25
- diff.raw.pretty_inspect
26
-
27
74
  result = +""
28
75
 
29
- diff.raw.each do |change|
30
- type = change[0] # the char '+', '-' or '~'
31
- path = change[1] # for example key1.key2[2] (as ["key1", :key2, 2])
32
- path2 = _stringify_path(path)
76
+ total_changes = diff.raw.size
77
+ group_with_newlines = total_changes >= TOTAL_CHANGES_FOR_GROUPING_THRESHOLD
78
+
79
+ # hashdiff returns a structure like so:
80
+ # change[0] = '+', '-' or '~'. denoting type (addition, deletion or change)
81
+ # change[1] = the path to the change, in array form
82
+ # change[2] = the value, or the from value in case of '~'
83
+ # change[3] = the to value, only present when '~'
84
+ changes_grouped_by_type = diff.raw.group_by { |change| change[0] }
85
+ if (changes_grouped_by_type.keys - ["+", "-", "~"]).size > 0
86
+ $stderr.puts(
87
+ "Specdiff: hashdiff returned unexpected types: #{diff.raw.inspect}"
88
+ )
89
+ end
33
90
 
34
- if type == "+"
35
- value = change[2]
91
+ deletions = changes_grouped_by_type["-"] || []
92
+ additions = changes_grouped_by_type["+"] || []
93
+ value_changes = changes_grouped_by_type["~"] || []
36
94
 
37
- result << "added #{path2} with value #{value.inspect}"
38
- elsif type == "-"
39
- value = change[2]
95
+ deletions.each do |change|
96
+ value = change[2]
97
+ path = _stringify_path(change[1])
40
98
 
41
- result << "removed #{path2} with value #{value.inspect}"
42
- elsif type == "~"
43
- from = change[2]
44
- to = change[3]
99
+ result << "missing key: #{path} (#{::Specdiff.diff_inspect(value)})"
100
+ result << NEWLINE
101
+ end
45
102
 
46
- result << "changed #{path2} from #{from.inspect} to #{to.inspect}"
47
- else
48
- result << change.inspect
49
- end
103
+ if deletions.any? && additions.any? && group_with_newlines
104
+ result << NEWLINE
105
+ end
106
+
107
+ additions.each do |change|
108
+ value = change[2]
109
+ path = _stringify_path(change[1])
110
+
111
+ result << " new key: #{path} (#{::Specdiff.diff_inspect(value)})"
112
+ result << NEWLINE
113
+ end
114
+
115
+ if additions.any? && value_changes.any? && group_with_newlines
116
+ result << NEWLINE
117
+ end
118
+
119
+ value_changes.each do |change|
120
+ from = change[2]
121
+ to = change[3]
122
+ path = _stringify_path(change[1])
50
123
 
51
- result << "\n"
124
+ from_inspected = ::Specdiff.diff_inspect(from)
125
+ to_inspected = ::Specdiff.diff_inspect(to)
126
+ result << "changed key: #{path} (#{from_inspected} -> #{to_inspected})"
127
+ result << NEWLINE
52
128
  end
53
129
 
54
130
  colorize_by_line(result) do |line|
55
- if line.start_with?("removed")
131
+ if line.start_with?("missing key:")
56
132
  red(line)
57
- elsif line.start_with?("added")
133
+ elsif line.start_with?(/\s+new key:/)
58
134
  green(line)
59
- elsif line.start_with?("changed")
135
+ elsif line.start_with?("changed key:")
60
136
  yellow(line)
61
137
  else
62
138
  reset_color(line)
@@ -2,7 +2,7 @@
2
2
  class Specdiff::Differ::NotFound
3
3
  def self.diff(a, b)
4
4
  comparison = "!="
5
- comparison = "=" if a.value == b.value
5
+ comparison = "==" if a.value == b.value
6
6
 
7
7
  a_representation = _representation_for(a)
8
8
  b_representation = _representation_for(b)
@@ -15,20 +15,32 @@ class Specdiff::Differ::Text
15
15
  b_value = b.value
16
16
 
17
17
  if a_value.encoding != b_value.encoding
18
- return <<~MSG
18
+ return colorize_by_line(<<~MSG) do |line|
19
19
  Strings have different encodings:
20
20
  #{a.value.encoding.inspect} != #{b.value.encoding.inspect}
21
21
  MSG
22
+ # makes it stand out a bit more from the red of rspec output
23
+ reset_color(line)
24
+ end
22
25
  end
23
26
 
24
27
  diff = ""
25
28
 
29
+ # if there are no newlines then the text differ doesn't produce any valuable
30
+ # output. "word diffing" would improve this case.
31
+ if a_value.count(NEWLINE) <= 1 && b_value.count(NEWLINE) <= 1
32
+ return diff
33
+ end
34
+
26
35
  a_lines = a_value.split(NEWLINE).map! { _1.chomp }
27
36
  b_lines = b_value.split(NEWLINE).map! { _1.chomp }
37
+
38
+ file_length_difference = 0
39
+
28
40
  hunks = ::Diff::LCS.diff(a_lines, b_lines).map do |piece|
29
41
  ::Diff::LCS::Hunk.new(
30
- a_lines, b_lines, piece, CONTEXT_LINES, 0,
31
- )
42
+ a_lines, b_lines, piece, CONTEXT_LINES, file_length_difference,
43
+ ).tap { |hunk| file_length_difference = hunk.file_length_difference }
32
44
  end
33
45
 
34
46
  hunks.each_cons(2) do |prev_hunk, current_hunk|
@@ -36,7 +48,7 @@ class Specdiff::Differ::Text
36
48
  if current_hunk.overlaps?(prev_hunk)
37
49
  current_hunk.merge(prev_hunk)
38
50
  else
39
- diff << prev_hunk.diff(:unified).to_s
51
+ diff << prev_hunk.diff(:unified)
40
52
  end
41
53
  ensure
42
54
  diff << NEWLINE
@@ -44,12 +56,14 @@ class Specdiff::Differ::Text
44
56
  end
45
57
 
46
58
  if hunks.last
47
- diff << hunks.last.diff(:unified).to_s
59
+ diff << NEWLINE
60
+ diff << hunks.last.diff(:unified)
48
61
  end
49
62
 
50
63
  return diff if diff == ""
51
64
 
52
- diff << "\n"
65
+ diff << NEWLINE
66
+ diff.lstrip!
53
67
 
54
68
  return colorize_by_line(diff) do |line|
55
69
  case line[0].chr
@@ -59,7 +73,7 @@ class Specdiff::Differ::Text
59
73
  red(line)
60
74
  when "@"
61
75
  if line[1].chr == "@"
62
- blue(line)
76
+ cyan(line)
63
77
  else
64
78
  reset_color(line)
65
79
  end
@@ -1,97 +1,4 @@
1
- class Specdiff::Differ
2
- Side = Struct.new(:value, :type, keyword_init: true)
3
-
4
- def self.call(...)
5
- new.call(...)
6
- end
7
-
8
- def call(raw_a, raw_b)
9
- a = parse_side(raw_a)
10
- b = parse_side(raw_b)
11
-
12
- if a.type == :text && b.type == :binary
13
- new_b = try_reencode(b.value, a.value.encoding)
14
- if new_b
15
- b = b.dup
16
- b.type = :text
17
- b.value = new_b
18
- end
19
- elsif a.type == :binary && b.type == :text
20
- new_a = try_reencode(a.value, b.value.encoding)
21
- if new_a
22
- a = a.dup
23
- a.type = :text
24
- a.value = new_a
25
- end
26
- end
27
-
28
- differ = pick_differ(a, b)
29
- raw = differ.diff(a, b)
30
-
31
- if raw.is_a?(::Specdiff::Diff) # detect recursive plugins, such as json
32
- raw
33
- else
34
- ::Specdiff::Diff.new(raw: raw, differ: differ, a: a, b: b)
35
- end
36
- end
37
-
38
- private
39
-
40
- def parse_side(raw_value)
41
- type = detect_type(raw_value)
42
-
43
- Side.new(value: raw_value, type: type)
44
- end
45
-
46
- def detect_type(thing)
47
- if (type = detect_plugin_types(thing))
48
- type
49
- elsif thing.is_a?(Hash)
50
- :hash
51
- elsif thing.is_a?(Array)
52
- :array
53
- elsif thing.is_a?(String) && thing.encoding == Encoding::BINARY
54
- :binary
55
- elsif thing.is_a?(String)
56
- :text
57
- elsif thing.nil?
58
- :nil
59
- else
60
- :unknown
61
- end
62
- end
63
-
64
- def detect_plugin_types(thing)
65
- Specdiff.plugins
66
- .filter { |plugin| plugin.respond_to?(:detect_type) }
67
- .detect { |plugin| plugin.detect_type(thing) }
68
- &.id
69
- end
70
-
71
- def try_reencode(binary_string, target_encoding)
72
- binary_string.encode(target_encoding)
73
- rescue StandardError
74
- nil
75
- end
76
-
77
- def pick_differ(a, b)
78
- if (differ = pick_plugin_differ(a, b))
79
- differ
80
- elsif a.type == :text && b.type == :text
81
- Specdiff::Differ::Text
82
- elsif a.type == :hash && b.type == :hash
83
- Specdiff::Differ::Hashdiff
84
- elsif a.type == :array && b.type == :array
85
- Specdiff::Differ::Hashdiff
86
- else
87
- Specdiff::Differ::NotFound
88
- end
89
- end
90
-
91
- def pick_plugin_differ(a, b)
92
- Specdiff.plugins
93
- .detect { |plugin| plugin.compatible?(a, b) }
94
- end
1
+ module Specdiff::Differ
95
2
  end
96
3
 
97
4
  # require only the builtin differs, plugins are optionally loaded later