specdiff 0.2.0 → 0.3.0.rc2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d698240eae3e0f8f38c22b66df23efb1076b14879a7093c3d3e33aebddd37c9
4
- data.tar.gz: 29e6198145c5a9ede875a26066a33e599561fe82277c6eb8bdb475c291304c9b
3
+ metadata.gz: 8a33e470568352bc5fb3079f5aa3cf9933f5c02d7e28aae98847b9165a0d6a86
4
+ data.tar.gz: ad35a03f7da4fee967890515447eb0d2a2a4ec927d78d2458198177166aa43c6
5
5
  SHA512:
6
- metadata.gz: cb5b52f47732689bc8322b840436d4c27fb048dbe20e9e0d8501b3120137500b7a9230a9818c31e049aa22715c251166d807b4e01111695406470c6b314c13c5
7
- data.tar.gz: 103362c058c1e260dc9a9c3f1bd06b06d39ab25c1818e001c7160eb61ceb741d549b4f9c6fb974bb27614456ab2d34dfae6732bc6d842d42346bd62abe1fc331
6
+ metadata.gz: 6fa7b514db6ccb4a7216f317184e6de0dae0fb8ad762222c4d09ee589300ae4d71a38549710ceb8191e5276958fcdfc27e65dcb50f0353e64065492c58a28b8a
7
+ data.tar.gz: bf66592a0f9d787058dcdd3b27910863abdc70cdeb177ddfb84d6ddfa9973adee345fa8c90f821f6a8dbab94898db375414eeef8e9a306b532b9aa472f615280
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+
13
+ # local installed gem bundles, ala node_modules for npm
14
+ .gem-bundle/
data/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0.rc2] - 2024-04-05
11
+
12
+ ### Changed
13
+
14
+ - Rework how hashdiff's output gets printed.
15
+ - Rework switching heuristic between text diff/hashdiff in hash differ.
16
+
17
+ ### Fixed
18
+
19
+ - The RSpec integration now inspects hashes and arrays recursively. (Like rspec does by default)
20
+ - RSpec integration no longer breaks description output of matchers when using multi matchers (like .all or .and)
21
+ - The hash differ now deals with recursive hashes and arrays
22
+ - RSpec integration no longer breaks description output of matchers that are part of a diff inside an array or hash. (like when doing `match([have_attributes(...)])`)
23
+
24
+ ## [0.3.0-rc1] - 2024-04-02
25
+
26
+ ### Added
27
+
28
+ - Add rspec integration. `require "specdiff/rspec"` The rspec integration will cause rspec's differ to be replaced entirely with specdiff. It will also cause rspec's inspect (object formatter) to be replaced with Specdiff's inspect.
29
+ - Add `Specdiff.diff_inspect` method, which is a wrapper for `#inspect` with some extra logic to make diffs look better.
30
+ - Add `Specdiff.hashprint` method, which prints hashes/arrays recursively in a way that is friendly to a text differ.
31
+
32
+ ### Changed
33
+
34
+ - Improve contrast for some elements of the text differ
35
+ - Improve hash diffing. Introduce heuristic that decides whether a text diff of the hashes or the hashdiff gem's output is better for the situation.
36
+ - No longer produces a text diff of booleans
37
+ - No longer produces a text diff if both strings are a single line. (This would be useless since the diff is line-based.)
38
+
10
39
  ## [0.2.0] - 2023-12-04
11
40
 
12
41
  ### Changed
data/README.md CHANGED
@@ -11,7 +11,24 @@ dropping the content type requirement.
11
11
  Specdiff automagically detects the types of provided data and prints a suitable
12
12
  diff between them.
13
13
 
14
- Check out the examples directory to see what it might look like.
14
+ ## Cool, what does it look like?
15
+
16
+ When specdiff is enabled, webmock will produce a generic text diff using
17
+ [`Diff::LCS`](https://github.com/halostatue/diff-lcs) (basically the same
18
+ diff as rspec uses):
19
+
20
+ ![webmock_text_with_specdiff](./assets/webmock_text_with_specdiff.png)
21
+
22
+ It will also produce a json [hashdiff](https://github.com/liufengyun/hashdiff),
23
+ even if the request did not have the content type header:
24
+
25
+ ![d](./assets/webmock_json_with_specdiff.png)
26
+
27
+ (The output of the json diff is experimental, feedback would be great!)
28
+
29
+ You might also check out the `examples/` directory to play with it:
30
+
31
+ `$ cd examples/webmock && bundle install`
15
32
 
16
33
  ## Installation
17
34
 
@@ -116,17 +133,16 @@ High level description of the heuristic specdiff implements
116
133
 
117
134
  - [ ] unit tests are passing (`$ bundle exec rake test`)
118
135
  - [ ] linter is happy (`$ bundle exec rake lint`)
119
- - [ ] `$ cd examples/webmock && bundle install && bundle exec ruby json.rb` looks good
120
- - [ ] `$ bundle exec ruby text.rb` looks good
136
+ - [ ] `examples/` look good
137
+ - [ ] check the package size using `$ bundle exec inspect_build`, make sure you haven't added any large files by accident
121
138
  - [ ] update the version number in `version.rb`
122
- - [ ] make sure the `examples/` `Gemfile.lock` files are updated
139
+ - [ ] make sure the `examples/` `Gemfile.lock` files are updated (run bundle install)
140
+ - [ ] make sure `Gemfile.lock` is updated (run bundle install)
123
141
  - [ ] move unreleased changes to the next version in the [changelog](./CHANGELOG.md)
124
142
  - [ ] commit in the form "vX.X.X" and push
125
143
  - [ ] make sure the pipeline is green
126
144
  - [ ] `$ bundle exec rake release`
127
145
 
128
- To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
129
-
130
146
  ## Contributing
131
147
 
132
148
  Bug reports and pull requests are welcome on GitHub at https://github.com/odinhb/specdiff.
@@ -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
@@ -80,9 +80,9 @@ private
80
80
  elsif a.type == :text && b.type == :text
81
81
  Specdiff::Differ::Text
82
82
  elsif a.type == :hash && b.type == :hash
83
- Specdiff::Differ::Hashdiff
83
+ Specdiff::Differ::Hash
84
84
  elsif a.type == :array && b.type == :array
85
- Specdiff::Differ::Hashdiff
85
+ Specdiff::Differ::Hash
86
86
  else
87
87
  Specdiff::Differ::NotFound
88
88
  end
data/lib/specdiff/diff.rb CHANGED
@@ -19,10 +19,15 @@
19
19
  end
20
20
 
21
21
  def inspect
22
- if empty?
23
- "<Specdiff::Diff (empty)>"
24
- else
25
- "<Specdiff::Diff w/ #{raw&.bytesize || 0} bytes of #raw diff>"
26
- end
22
+ raw_diff = if empty?
23
+ "empty"
24
+ elsif differ == ::Specdiff::Differ::Text
25
+ bytes = raw&.bytesize || 0
26
+ "#{bytes} bytes of #raw diff"
27
+ else
28
+ "#{raw.inspect}"
29
+ end
30
+
31
+ "<Specdiff::Diff (#{a.type}/#{b.type}) (#{differ}) (#{raw_diff})>"
27
32
  end
28
33
  end
@@ -0,0 +1,179 @@
1
+ require "hashdiff"
2
+
3
+ class Specdiff::Differ::Hash
4
+ extend ::Specdiff::Colorize
5
+
6
+ # The percentage of changes that must (potentially) be a key rename in a hash
7
+ # for text diffing to kick in. Expressed as a fraction of 1.
8
+ KEY_CHANGE_PERCENTAGE_THRESHOLD = 0.8
9
+
10
+ # The number of changes that must be detected by hashdiff before we print some
11
+ # extra newlines to better group extra/missing/new_values visually.
12
+ TOTAL_CHANGES_FOR_GROUPING_THRESHOLD = 9
13
+
14
+ def self.diff(a, b)
15
+ # array_path: true returns the path as an array, which differentiates
16
+ # between symbol keys and string keys in hashes, while the string
17
+ # representation does not.
18
+ # hmm it really seems like use_lcs: true gives much less human-readable
19
+ # (human-comprehensible) output when arrays are involved.
20
+ hashdiff_diff = ::Hashdiff.diff(
21
+ a.value, b.value,
22
+ array_path: true,
23
+ use_lcs: false,
24
+ )
25
+
26
+ return hashdiff_diff if hashdiff_diff.empty?
27
+
28
+ change_percentage = _calculate_change_percentage(hashdiff_diff)
29
+
30
+ if change_percentage <= KEY_CHANGE_PERCENTAGE_THRESHOLD
31
+ hashdiff_diff
32
+ else
33
+ a_text = ::Specdiff.hashprint(a.value)
34
+ b_text = ::Specdiff.hashprint(b.value)
35
+
36
+ text_diff = ::Specdiff.diff(a_text, b_text)
37
+
38
+ if text_diff.empty?
39
+ []
40
+ else
41
+ text_diff
42
+ end
43
+ end
44
+ end
45
+
46
+ def self._calculate_change_percentage(hashdiff_diff)
47
+ extra_keys = hashdiff_diff.count { |element| element[0] == "+" }
48
+ missing_keys = hashdiff_diff.count { |element| element[0] == "-" }
49
+ new_values = hashdiff_diff.count { |element| element[0] == "~" }
50
+ # puts "hashdiff_diff: #{hashdiff_diff.inspect}"
51
+ # puts "extra_keys: #{extra_keys.inspect}"
52
+ # puts "missing_keys: #{missing_keys.inspect}"
53
+ # puts "new_values: #{new_values.inspect}"
54
+
55
+ potential_changed_keys = [extra_keys, missing_keys].min
56
+ adjusted_extra_keys = extra_keys - potential_changed_keys
57
+ adjusted_missing_keys = missing_keys - potential_changed_keys
58
+ # puts "potential_changed_keys: #{potential_changed_keys.inspect}"
59
+ # puts "adjusted_extra_keys: #{adjusted_extra_keys.inspect}"
60
+ # puts "adjusted_missing_keys: #{adjusted_missing_keys.inspect}"
61
+
62
+ non_changed_keys = adjusted_extra_keys + adjusted_missing_keys + new_values
63
+ total_changes = non_changed_keys + potential_changed_keys
64
+ # puts "non_changed_keys: #{non_changed_keys.inspect}"
65
+ # puts "total_changes: #{total_changes.inspect}"
66
+
67
+ key_change_fraction = Rational(potential_changed_keys, total_changes)
68
+ key_change_percentage = key_change_fraction.to_f
69
+ # puts "key_change_fraction: #{key_change_fraction.inspect}"
70
+ # puts "key_change_percentage: #{key_change_percentage.inspect}"
71
+
72
+ key_change_percentage
73
+ end
74
+
75
+ def self.empty?(diff)
76
+ diff.raw.empty?
77
+ end
78
+
79
+ NEWLINE = "\n"
80
+
81
+ def self.stringify(diff)
82
+ result = +""
83
+ return result if diff.empty?
84
+
85
+ total_changes = diff.raw.size
86
+ group_with_newlines = total_changes >= TOTAL_CHANGES_FOR_GROUPING_THRESHOLD
87
+
88
+ # hashdiff returns a structure like so:
89
+ # change[0] = '+', '-' or '~'. denoting type (addition, deletion or change)
90
+ # change[1] = the path to the change, in array form
91
+ # change[2] = the value, or the from value in case of '~'
92
+ # change[3] = the to value, only present when '~'
93
+ changes_grouped_by_type = diff.raw.group_by { |change| change[0] }
94
+ if (changes_grouped_by_type.keys - ["+", "-", "~"]).size > 0
95
+ $stderr.puts(
96
+ "Specdiff: hashdiff returned unexpected types: #{diff.raw.inspect}"
97
+ )
98
+ end
99
+
100
+ deletions = changes_grouped_by_type["-"] || []
101
+ additions = changes_grouped_by_type["+"] || []
102
+ value_changes = changes_grouped_by_type["~"] || []
103
+
104
+ result << "@@ +#{additions.size}/-#{deletions.size}/~#{value_changes.size} @@"
105
+ result << NEWLINE
106
+
107
+ deletions.each do |change|
108
+ value = change[2]
109
+ path = _stringify_path(change[1])
110
+
111
+ result << "missing key: #{path} (#{::Specdiff.diff_inspect(value)})"
112
+ result << NEWLINE
113
+ end
114
+
115
+ if deletions.any? && additions.any? && group_with_newlines
116
+ result << NEWLINE
117
+ end
118
+
119
+ additions.each do |change|
120
+ value = change[2]
121
+ path = _stringify_path(change[1])
122
+
123
+ result << " extra key: #{path} (#{::Specdiff.diff_inspect(value)})"
124
+ result << NEWLINE
125
+ end
126
+
127
+ if additions.any? && value_changes.any? && group_with_newlines
128
+ result << NEWLINE
129
+ end
130
+
131
+ value_changes.each do |change|
132
+ from = change[2]
133
+ to = change[3]
134
+ path = _stringify_path(change[1])
135
+
136
+ from_inspected = ::Specdiff.diff_inspect(from)
137
+ to_inspected = ::Specdiff.diff_inspect(to)
138
+ result << " new value: #{path} (#{from_inspected} -> #{to_inspected})"
139
+ result << NEWLINE
140
+ end
141
+
142
+ colorize_by_line(result) do |line|
143
+ if line.start_with?("missing key:")
144
+ red(line)
145
+ elsif line.start_with?(" extra key:")
146
+ green(line)
147
+ elsif line.start_with?(" new value:")
148
+ yellow(line)
149
+ elsif line.start_with?("@@")
150
+ cyan(line)
151
+ else
152
+ reset_color(line)
153
+ end
154
+ end
155
+ end
156
+
157
+ PATH_SEPARATOR = ".".freeze
158
+
159
+ def self._stringify_path(path)
160
+ result = +""
161
+
162
+ path.each do |component|
163
+ if component.is_a?(Numeric)
164
+ result.chomp!(PATH_SEPARATOR)
165
+ result << "[#{component}]"
166
+ elsif component.is_a?(Symbol)
167
+ # by not inspecting symbols they look prettier than strings, but you
168
+ # can still tell the difference in the printed output
169
+ result << component.to_s
170
+ else
171
+ result << component.inspect
172
+ end
173
+
174
+ result << PATH_SEPARATOR
175
+ end
176
+
177
+ result.chomp(PATH_SEPARATOR)
178
+ end
179
+ end
@@ -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
@@ -4,4 +4,4 @@ end
4
4
  # require only the builtin differs, plugins are optionally loaded later
5
5
  require_relative "differ/not_found"
6
6
  require_relative "differ/text"
7
- require_relative "differ/hashdiff"
7
+ require_relative "differ/hash"
@@ -0,0 +1,174 @@
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
+ @recursion_trail = []
17
+ @indentation_level = 0
18
+ @indentation_per_level = SPACE * INDENTATION_SPACES
19
+ @indent = ""
20
+ @skip_next_opening_indent = false
21
+
22
+ @output = StringIO.new
23
+
24
+ output(thing)
25
+
26
+ @output.string
27
+ end
28
+
29
+ private
30
+
31
+ def recalculate_indent
32
+ @indent = @indentation_per_level * @indentation_level
33
+ end
34
+
35
+ def increase_indentation
36
+ @indentation_level += 1
37
+ recalculate_indent
38
+ end
39
+
40
+ def decrease_indentation
41
+ @indentation_level -= 1
42
+ recalculate_indent
43
+ end
44
+
45
+ def skip_next_opening_indent
46
+ @skip_next_opening_indent = true
47
+
48
+ nil
49
+ end
50
+
51
+ def this_indent_should_be_skipped
52
+ if @skip_next_opening_indent
53
+ @skip_next_opening_indent = false
54
+ true
55
+ else
56
+ false
57
+ end
58
+ end
59
+
60
+ def track_recursion(thing)
61
+ @recursion_trail.push(thing)
62
+ result = yield
63
+ @recursion_trail.pop
64
+ result
65
+ end
66
+
67
+ def deja_vu?(current_place)
68
+ @recursion_trail.any? { |previous_place| previous_place == current_place }
69
+ end
70
+
71
+ # #=== allows us to rely on Module implementing #=== instead of relying on the
72
+ # thing (which could be any kind of wacky object) having to implement
73
+ # #is_a? or #kind_of?
74
+ def output(thing)
75
+ if deja_vu?(thing)
76
+ output_deja_vu(thing)
77
+ elsif Hash === thing
78
+ output_hash(thing)
79
+ elsif Array === thing
80
+ output_array(thing)
81
+ else
82
+ output_unknown(thing)
83
+ end
84
+ end
85
+
86
+ HASH_OPEN = "{".freeze
87
+ HASH_CLOSE = "}".freeze
88
+ HASHROCKET = "=>".freeze
89
+ COLON = ":".freeze
90
+
91
+ def output_hash(hash)
92
+ @output << @indent unless this_indent_should_be_skipped
93
+
94
+ @output << HASH_OPEN
95
+ @output << NEWLINE
96
+
97
+ increase_indentation
98
+ track_recursion(hash) do
99
+ hash.each do |key, value|
100
+ @output << @indent
101
+
102
+ if key.is_a?(Symbol)
103
+ @output << key
104
+ @output << COLON
105
+ @output << SPACE
106
+ else
107
+ @output << ::Specdiff.diff_inspect(key)
108
+ @output << SPACE
109
+ @output << HASHROCKET
110
+ @output << SPACE
111
+ end
112
+
113
+ skip_next_opening_indent
114
+ output(value)
115
+
116
+ @output << COMMA
117
+ @output << NEWLINE
118
+ end
119
+ end
120
+ decrease_indentation
121
+
122
+ @output << @indent
123
+ @output << HASH_CLOSE
124
+ end
125
+
126
+ ARRAY_OPEN = "[".freeze
127
+ ARRAY_CLOSE = "]".freeze
128
+
129
+ def output_array(array)
130
+ @output << @indent unless this_indent_should_be_skipped
131
+
132
+ @output << ARRAY_OPEN
133
+ @output << NEWLINE
134
+
135
+ increase_indentation
136
+ track_recursion(array) do
137
+ array.each do |element|
138
+ output(element)
139
+ @output << COMMA
140
+ @output << NEWLINE
141
+ end
142
+ end
143
+ decrease_indentation
144
+
145
+ @output << @indent
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
+
155
+ # The stdlib inspect code returns this when you have recursive structures.
156
+ STANDARD_INSPECT_RECURSIVE_ARRAY = "[...]".freeze
157
+ STANDARD_INSPECT_RECURSIVE_HASH = "{...}".freeze
158
+
159
+ def output_deja_vu(thing)
160
+ @output << @indent unless this_indent_should_be_skipped
161
+
162
+ case thing
163
+ when Array
164
+ # "#<Array ##{thing.object_id}>"
165
+ @output << STANDARD_INSPECT_RECURSIVE_ARRAY
166
+ when Hash
167
+ # "#<Hash ##{thing.object_id}>"
168
+ @output << STANDARD_INSPECT_RECURSIVE_HASH
169
+ else
170
+ # this should never happen
171
+ raise "Specdiff::Hashprint missing deja vu for: #{thing.inspect}"
172
+ end
173
+ end
174
+ end