specdiff 0.1.1 → 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.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