specdiff 0.2.0 → 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.2.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)
@@ -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
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
@@ -3,17 +3,67 @@ require "hashdiff"
3
3
  class Specdiff::Differ::Hashdiff
4
4
  extend ::Specdiff::Colorize
5
5
 
6
+ VALUE_CHANGE_PERCENTAGE_THRESHOLD = 0.2
7
+ TOTAL_CHANGES_FOR_GROUPING_THRESHOLD = 9
8
+
9
+ NEWLINE = "\n"
10
+
6
11
  def self.diff(a, b)
7
12
  # array_path: true returns the path as an array, which differentiates
8
13
  # between symbol keys and string keys in hashes, while the string
9
14
  # representation does not.
10
15
  # hmm it really seems like use_lcs: true gives much less human-readable
11
16
  # (human-comprehensible) output when arrays are involved.
12
- ::Hashdiff.diff(
17
+ hashdiff_diff = ::Hashdiff.diff(
13
18
  a.value, b.value,
14
19
  array_path: true,
15
20
  use_lcs: false,
16
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
17
67
  end
18
68
 
19
69
  def self.empty?(diff)
@@ -23,37 +73,66 @@ class Specdiff::Differ::Hashdiff
23
73
  def self.stringify(diff)
24
74
  result = +""
25
75
 
26
- diff.raw.each do |change|
27
- type = change[0] # the char '+', '-' or '~'
28
- path = change[1] # for example key1.key2[2] (as ["key1", :key2, 2])
29
- 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
30
90
 
31
- if type == "+"
32
- value = change[2]
91
+ deletions = changes_grouped_by_type["-"] || []
92
+ additions = changes_grouped_by_type["+"] || []
93
+ value_changes = changes_grouped_by_type["~"] || []
33
94
 
34
- result << "added #{path2} with value #{value.inspect}"
35
- elsif type == "-"
36
- value = change[2]
95
+ deletions.each do |change|
96
+ value = change[2]
97
+ path = _stringify_path(change[1])
37
98
 
38
- result << "removed #{path2} with value #{value.inspect}"
39
- elsif type == "~"
40
- from = change[2]
41
- to = change[3]
99
+ result << "missing key: #{path} (#{::Specdiff.diff_inspect(value)})"
100
+ result << NEWLINE
101
+ end
42
102
 
43
- result << "changed #{path2} from #{from.inspect} to #{to.inspect}"
44
- else
45
- result << change.inspect
46
- 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])
47
123
 
48
- 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
49
128
  end
50
129
 
51
130
  colorize_by_line(result) do |line|
52
- if line.start_with?("removed")
131
+ if line.start_with?("missing key:")
53
132
  red(line)
54
- elsif line.start_with?("added")
133
+ elsif line.start_with?(/\s+new key:/)
55
134
  green(line)
56
- elsif line.start_with?("changed")
135
+ elsif line.start_with?("changed key:")
57
136
  yellow(line)
58
137
  else
59
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
@@ -0,0 +1,154 @@
1
+ # Tries to print nested hash/array structures such that they are as convenient
2
+ # as possible for a diffing algorithm designed for text (like source code).
3
+ # Basically tries to reproduce consistent literal source code form for ruby
4
+ # hashes and arrays and objects (although it falls back to #inspect).
5
+ class Specdiff::Hashprint
6
+ def self.call(...)
7
+ new.call(...)
8
+ end
9
+
10
+ INDENTATION_SPACES = 2
11
+ SPACE = " ".freeze
12
+ COMMA = ",".freeze
13
+ NEWLINE = "\n".freeze
14
+
15
+ def call(thing)
16
+ @indentation_level = 0
17
+ @indentation_per_level = SPACE * INDENTATION_SPACES
18
+ @indent = ""
19
+ @skip_next_opening_indent = false
20
+
21
+ @output = StringIO.new
22
+
23
+ output(thing)
24
+
25
+ @output.string
26
+ end
27
+
28
+ private
29
+
30
+ def recalculate_indent
31
+ @indent = @indentation_per_level * @indentation_level
32
+ end
33
+
34
+ def increase_indentation
35
+ @indentation_level += 1
36
+ recalculate_indent
37
+ end
38
+
39
+ def decrease_indentation
40
+ @indentation_level -= 1
41
+ recalculate_indent
42
+ end
43
+
44
+ def with_indentation_level(temporary_level)
45
+ old_level = @indentation_level
46
+ @indentation_level = temporary_level
47
+ recalculate_indent
48
+
49
+ yield
50
+
51
+ @indentation_level = old_level
52
+ recalculate_indent
53
+ end
54
+
55
+ def skip_next_opening_indent
56
+ @skip_next_opening_indent = true
57
+
58
+ nil
59
+ end
60
+
61
+ def this_indent_should_be_skipped
62
+ if @skip_next_opening_indent
63
+ @skip_next_opening_indent = false
64
+ true
65
+ else
66
+ false
67
+ end
68
+ end
69
+
70
+ # #=== allows us to rely on Module implementing #=== instead of relying on the
71
+ # thing (which could be any kind of wacky object) having to implement
72
+ # #is_a? or #kind_of?
73
+ def output(thing)
74
+ if Hash === thing
75
+ output_hash(thing)
76
+ elsif Array === thing
77
+ output_array(thing)
78
+ else
79
+ output_unknown(thing)
80
+ end
81
+ end
82
+
83
+ HASH_OPEN = "{".freeze
84
+ HASH_CLOSE = "}".freeze
85
+ HASHROCKET = "=>".freeze
86
+ COLON = ":".freeze
87
+
88
+ def output_hash(hash)
89
+ @output << @indent unless this_indent_should_be_skipped
90
+
91
+ @output << HASH_OPEN
92
+ # unless hash.empty?
93
+ @output << NEWLINE
94
+
95
+ increase_indentation
96
+ hash.each do |key, value|
97
+ @output << @indent
98
+
99
+ if key.is_a?(Symbol)
100
+ @output << key
101
+ @output << COLON
102
+ @output << SPACE
103
+ else
104
+ @output << ::Specdiff.diff_inspect(key)
105
+ @output << SPACE
106
+ @output << HASHROCKET
107
+ @output << SPACE
108
+ end
109
+
110
+ skip_next_opening_indent
111
+ output(value)
112
+
113
+ @output << COMMA
114
+ @output << NEWLINE
115
+ end
116
+ decrease_indentation
117
+
118
+ @output << @indent
119
+ # end
120
+
121
+ @output << HASH_CLOSE
122
+ end
123
+
124
+ ARRAY_OPEN = "[".freeze
125
+ ARRAY_CLOSE = "]".freeze
126
+
127
+ def output_array(array)
128
+ @output << @indent unless this_indent_should_be_skipped
129
+
130
+ @output << ARRAY_OPEN
131
+
132
+ # unless array.empty?
133
+ @output << NEWLINE
134
+
135
+ increase_indentation
136
+ array.each do |element|
137
+ output(element)
138
+ @output << COMMA
139
+ @output << NEWLINE
140
+ end
141
+ decrease_indentation
142
+
143
+ @output << @indent
144
+ # end
145
+
146
+ @output << ARRAY_CLOSE
147
+ end
148
+
149
+ def output_unknown(thing)
150
+ @output << @indent unless this_indent_should_be_skipped
151
+
152
+ @output << ::Specdiff.diff_inspect(thing)
153
+ end
154
+ end
@@ -0,0 +1,41 @@
1
+ class Specdiff::Inspect
2
+ TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z"
3
+ DATE_FORMAT = "%Y-%m-%d"
4
+
5
+ def self.call(...)
6
+ new.call(...)
7
+ end
8
+
9
+ # #=== allows us to rely on Module implementing #=== instead of relying on the
10
+ # thing (which could be any kind of wacky object) having to implement
11
+ # #is_a? or #kind_of?
12
+ def call(thing)
13
+ if Time === thing
14
+ "#<Time: #{thing.strftime(TIME_FORMAT)}>"
15
+ elsif DateTime === thing
16
+ "#<DateTime: #{thing.rfc3339}>"
17
+ elsif Date === thing
18
+ "#<Date: #{thing.strftime(DATE_FORMAT)}>"
19
+ elsif defined?(BigDecimal) && BigDecimal === thing
20
+ "#<BigDecimal: #{thing.to_s('F')}>"
21
+ else
22
+ begin
23
+ thing.inspect
24
+ rescue NoMethodError
25
+ inspect_anyway(thing)
26
+ end
27
+ end
28
+ end
29
+
30
+ private def inspect_anyway(uninspectable)
31
+ "#<uninspectable #{class_of(uninspectable)}>"
32
+ end
33
+
34
+ private def class_of(uninspectable)
35
+ uninspectable.class
36
+ rescue NoMethodError
37
+ singleton_class = class << uninspectable; self; end
38
+ singleton_class.ancestors
39
+ .find { |ancestor| !ancestor.equal?(singleton_class) }
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ class RSpec::Support::Differ
2
+ alias old_diff diff
3
+
4
+ def diff(actual, expected)
5
+ diff = ::Specdiff.diff(expected, actual)
6
+ if diff.empty?
7
+ ""
8
+ else
9
+ "\n#{diff}"
10
+ end
11
+ end
12
+ end
13
+
14
+ # This stops rspec from truncating strings w/ ellipsis, as well as making the
15
+ # "inspect" output consistent with specdiff's.
16
+ class RSpec::Support::ObjectFormatter
17
+ class SpecdiffCustomInspector < BaseInspector
18
+ def self.can_inspect?(_)
19
+ true
20
+ end
21
+
22
+ def inspect
23
+ ::Specdiff.diff_inspect(object)
24
+ end
25
+ end
26
+
27
+ remove_const("INSPECTOR_CLASSES")
28
+ const_set("INSPECTOR_CLASSES", [SpecdiffCustomInspector])
29
+
30
+ def format(object)
31
+ ::Specdiff.diff_inspect(object)
32
+ end
33
+ end
@@ -1,3 +1,3 @@
1
1
  module Specdiff
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0-rc1"
3
3
  end
@@ -1 +1,41 @@
1
- require_relative "webmock/request_body_diff"
1
+ require "hashdiff"
2
+ require "json"
3
+
4
+ module WebMock
5
+ class RequestBodyDiff
6
+ def initialize(request_signature, request_stub)
7
+ @request_signature = request_signature
8
+ @request_stub = request_stub
9
+ end
10
+
11
+ PrettyPrintableThingy = Struct.new(:specdiff) do
12
+ # webmock does not print the diff if it responds true to this.
13
+ def empty?
14
+ specdiff.empty?
15
+ end
16
+
17
+ # webmock prints the diff by passing us to PP.pp, which in turn uses this
18
+ # method.
19
+ def pretty_print(pp)
20
+ pp.text("\r") # remove a space that isn't supposed to be there
21
+ pp.text(specdiff.to_s)
22
+ end
23
+ end
24
+
25
+ def body_diff
26
+ specdiff = Specdiff.diff(request_stub_body, request_signature.body)
27
+ PrettyPrintableThingy.new(specdiff)
28
+ end
29
+
30
+ attr_reader :request_signature, :request_stub
31
+ private :request_signature, :request_stub
32
+
33
+ private
34
+
35
+ def request_stub_body
36
+ request_stub.request_pattern &&
37
+ request_stub.request_pattern.body_pattern &&
38
+ request_stub.request_pattern.body_pattern.pattern
39
+ end
40
+ end
41
+ end
data/lib/specdiff.rb CHANGED
@@ -1,13 +1,27 @@
1
1
  require_relative "specdiff/version"
2
2
  require_relative "specdiff/config"
3
3
  require_relative "specdiff/colorize"
4
+ require_relative "specdiff/inspect"
5
+ require_relative "specdiff/hashprint"
4
6
  require_relative "specdiff/compare"
5
7
 
6
8
  module Specdiff
7
- # Diff two things
9
+ # Compare two things, returns a Specdiff::Diff.
8
10
  def self.diff(...)
9
11
  ::Specdiff::Compare.call(...)
10
12
  end
13
+
14
+ # Use Specdiff's implementation for turning a nested hash/array structure
15
+ # into a string. Optimized for diff quality.
16
+ def self.hashprint(...)
17
+ ::Specdiff::Hashprint.call(...)
18
+ end
19
+
20
+ # Use Specdiff's inspect, which has some extra logic layered in for
21
+ # dates/time/bigdecimal. For most objects this just delegates to #inspect.
22
+ def self.diff_inspect(...)
23
+ ::Specdiff::Inspect.call(...)
24
+ end
11
25
  end
12
26
 
13
27
  require_relative "specdiff/diff"