specdiff 0.2.0 → 0.3.0.rc2

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.
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