specdiff 0.2.0 → 0.3.0.pre.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"