diff --git a/README.md b/README.md index 91583334..ad5debc3 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,17 @@ you'd get this instead: [user-docs]: ./docs/users/getting-started.md +### Optional Extensions + +If you need diffs for binary strings (`Encoding::ASCII_8BIT`), +require the binary string integration: + +```ruby +require "super_diff/binary_string" +``` + +This enables hex-dump diffs and keeps binary data out of the expectation text. + ## Support My goal for this library is to improve your development experience. diff --git a/docs/users/getting-started.md b/docs/users/getting-started.md index 019b1ec5..1edb4aad 100644 --- a/docs/users/getting-started.md +++ b/docs/users/getting-started.md @@ -86,6 +86,18 @@ such as matchers. You can now continue on to [customizing SuperDiff](./customization.md). +## Binary Strings + +SuperDiff can diff binary strings (`Encoding::ASCII_8BIT`) using a hex-dump +format and a binary-safe inspection label. +To enable this, add: + +```ruby +require "super_diff/binary_string" +``` + +You can create binary strings with `String#b` or by forcing the encoding. + ## Using parts of SuperDiff directly Although SuperDiff is primarily designed to integrate with RSpec, diff --git a/lib/super_diff/binary_string.rb b/lib/super_diff/binary_string.rb new file mode 100644 index 00000000..2ed9d50c --- /dev/null +++ b/lib/super_diff/binary_string.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'super_diff/binary_string/differs' +require 'super_diff/binary_string/inspection_tree_builders' +require 'super_diff/binary_string/operation_trees' +require 'super_diff/binary_string/operation_tree_builders' +require 'super_diff/binary_string/operation_tree_flatteners' + +module SuperDiff + module BinaryString + def self.applies_to?(*values) + values.all? { |value| value.is_a?(::String) && value.encoding == Encoding::ASCII_8BIT } + end + + SuperDiff.configure do |config| + config.prepend_extra_differ_classes(Differs::BinaryString) + config.prepend_extra_operation_tree_builder_classes( + OperationTreeBuilders::BinaryString + ) + config.prepend_extra_operation_tree_classes( + OperationTrees::BinaryString + ) + config.prepend_extra_inspection_tree_builder_classes( + InspectionTreeBuilders::BinaryString + ) + end + end +end diff --git a/lib/super_diff/binary_string/differs.rb b/lib/super_diff/binary_string/differs.rb new file mode 100644 index 00000000..61f09934 --- /dev/null +++ b/lib/super_diff/binary_string/differs.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module Differs + autoload( + :BinaryString, + 'super_diff/binary_string/differs/binary_string' + ) + end + end +end diff --git a/lib/super_diff/binary_string/differs/binary_string.rb b/lib/super_diff/binary_string/differs/binary_string.rb new file mode 100644 index 00000000..d8425be2 --- /dev/null +++ b/lib/super_diff/binary_string/differs/binary_string.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module Differs + class BinaryString < Core::AbstractDiffer + def self.applies_to?(expected, actual) + SuperDiff::BinaryString.applies_to?(expected, actual) + end + + protected + + def operation_tree_builder_class + OperationTreeBuilders::BinaryString + end + end + end + end +end diff --git a/lib/super_diff/binary_string/inspection_tree_builders.rb b/lib/super_diff/binary_string/inspection_tree_builders.rb new file mode 100644 index 00000000..428db74f --- /dev/null +++ b/lib/super_diff/binary_string/inspection_tree_builders.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module InspectionTreeBuilders + autoload( + :BinaryString, + 'super_diff/binary_string/inspection_tree_builders/binary_string' + ) + end + end +end diff --git a/lib/super_diff/binary_string/inspection_tree_builders/binary_string.rb b/lib/super_diff/binary_string/inspection_tree_builders/binary_string.rb new file mode 100644 index 00000000..739ebaa1 --- /dev/null +++ b/lib/super_diff/binary_string/inspection_tree_builders/binary_string.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module InspectionTreeBuilders + class BinaryString < Core::AbstractInspectionTreeBuilder + def self.applies_to?(value) + SuperDiff::BinaryString.applies_to?(value) + end + + def call + Core::InspectionTree.new do |t| + t.add_text "" + end + end + end + end + end +end diff --git a/lib/super_diff/binary_string/operation_tree_builders.rb b/lib/super_diff/binary_string/operation_tree_builders.rb new file mode 100644 index 00000000..a4e54f7a --- /dev/null +++ b/lib/super_diff/binary_string/operation_tree_builders.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module OperationTreeBuilders + autoload( + :BinaryString, + 'super_diff/binary_string/operation_tree_builders/binary_string' + ) + end + end +end diff --git a/lib/super_diff/binary_string/operation_tree_builders/binary_string.rb b/lib/super_diff/binary_string/operation_tree_builders/binary_string.rb new file mode 100644 index 00000000..3da290bb --- /dev/null +++ b/lib/super_diff/binary_string/operation_tree_builders/binary_string.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module OperationTreeBuilders + class BinaryString < Basic::OperationTreeBuilders::MultilineString + BYTES_PER_LINE = 16 + private_constant :BYTES_PER_LINE + + def self.applies_to?(expected, actual) + SuperDiff::BinaryString.applies_to?(expected, actual) + end + + def initialize(*args) + args.first[:expected] = binary_to_hex(args.first[:expected]) + args.first[:actual] = binary_to_hex(args.first[:actual]) + + super + end + + protected + + def build_operation_tree + OperationTrees::BinaryString.new([]) + end + + # Prevent creation of BinaryOperation objects which the flattener + # cannot handle + def should_compare?(_operation, _next_operation) + false + end + + private + + def split_into_lines(string) + super.map { |line| line.delete_suffix("\n") }.reject(&:empty?) + end + + def binary_to_hex(data) + data + .each_byte + .each_slice(BYTES_PER_LINE) + .with_index + .map { |bytes, index| format_hex_line(index * BYTES_PER_LINE, bytes) } + .join("\n") + end + + def format_hex_line(offset, bytes) + hex_pairs = bytes + .map { |b| format('%02x', b) } + .each_slice(2) + .map(&:join) + .join(' ') + + ascii = bytes.map { |b| printable_char(b) }.join + + format('%08x: %-39s %s', offset, hex_pairs, ascii) + end + + def printable_char(byte) + byte >= 32 && byte < 127 ? byte.chr : '.' + end + end + end + end +end diff --git a/lib/super_diff/binary_string/operation_tree_flatteners.rb b/lib/super_diff/binary_string/operation_tree_flatteners.rb new file mode 100644 index 00000000..5d68caf4 --- /dev/null +++ b/lib/super_diff/binary_string/operation_tree_flatteners.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module OperationTreeFlatteners + autoload( + :BinaryString, + 'super_diff/binary_string/operation_tree_flatteners/binary_string' + ) + end + end +end diff --git a/lib/super_diff/binary_string/operation_tree_flatteners/binary_string.rb b/lib/super_diff/binary_string/operation_tree_flatteners/binary_string.rb new file mode 100644 index 00000000..e02ec6d2 --- /dev/null +++ b/lib/super_diff/binary_string/operation_tree_flatteners/binary_string.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module OperationTreeFlatteners + class BinaryString < Core::AbstractOperationTreeFlattener + def build_tiered_lines + operation_tree.map do |operation| + Core::Line.new( + type: operation.name, + indentation_level: indentation_level, + value: operation.value + ) + end + end + end + end + end +end diff --git a/lib/super_diff/binary_string/operation_trees.rb b/lib/super_diff/binary_string/operation_trees.rb new file mode 100644 index 00000000..d6dd8175 --- /dev/null +++ b/lib/super_diff/binary_string/operation_trees.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module OperationTrees + autoload( + :BinaryString, + 'super_diff/binary_string/operation_trees/binary_string' + ) + end + end +end diff --git a/lib/super_diff/binary_string/operation_trees/binary_string.rb b/lib/super_diff/binary_string/operation_trees/binary_string.rb new file mode 100644 index 00000000..c72947cf --- /dev/null +++ b/lib/super_diff/binary_string/operation_trees/binary_string.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SuperDiff + module BinaryString + module OperationTrees + class BinaryString < Core::AbstractOperationTree + def self.applies_to?(value) + SuperDiff::BinaryString.applies_to?(value) + end + + protected + + def operation_tree_flattener_class + OperationTreeFlatteners::BinaryString + end + end + end + end +end diff --git a/lib/super_diff/core/tiered_lines_elider.rb b/lib/super_diff/core/tiered_lines_elider.rb index 148ac6be..98e320bb 100644 --- a/lib/super_diff/core/tiered_lines_elider.rb +++ b/lib/super_diff/core/tiered_lines_elider.rb @@ -108,10 +108,8 @@ def one_dimensional_line_tree? end def all_indentation_levels - lines - .map(&:indentation_level) - .select(&:positive?) - .uniq + levels = lines.map(&:indentation_level).uniq + normalized_indentation_levels(levels) end def find_boxes_to_elide_within(pane) @@ -146,13 +144,10 @@ def normalized_box_groups_at_decreasing_indentation_levels_within(pane) def box_groups_at_decreasing_indentation_levels_within(pane) boxes_within_pane = boxes.select { |box| box.fits_fully_within?(pane) } + levels = boxes_within_pane.map(&:indentation_level).uniq + possible_indentation_levels = - boxes_within_pane - .map(&:indentation_level) - .select(&:positive?) - .uniq - .sort - .reverse + normalized_indentation_levels(levels).sort.reverse possible_indentation_levels.map do |indentation_level| boxes_within_pane.select do |box| @@ -174,6 +169,14 @@ def filter_out_boxes_fully_contained_in_others(boxes) end end + def normalized_indentation_levels(levels) + # For flat structures (strings), include level 0 + return levels if levels.all?(&:zero?) + + # For nested structures (arrays, hashes), exclude level 0 (brackets) + levels.select(&:positive?) + end + def combine_congruent_boxes(boxes) combine(boxes, on: :indentation_level) end diff --git a/lib/super_diff/rspec/differ.rb b/lib/super_diff/rspec/differ.rb index b0b21af6..02ac3f1c 100644 --- a/lib/super_diff/rspec/differ.rb +++ b/lib/super_diff/rspec/differ.rb @@ -42,10 +42,16 @@ def comparing_proc_values? end def comparing_singleline_strings? + return false if comparing_binary_strings? + expected.is_a?(String) && actual.is_a?(String) && !expected.include?("\n") && !actual.include?("\n") end + def comparing_binary_strings? + defined?(BinaryString) && BinaryString.applies_to?(expected, actual) + end + def helpers @helpers ||= RSpecHelpers.new end diff --git a/spec/integration/rspec/binary_string_spec.rb b/spec/integration/rspec/binary_string_spec.rb new file mode 100644 index 00000000..9a64310d --- /dev/null +++ b/spec/integration/rspec/binary_string_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'super_diff/binary_string' + +RSpec.describe 'Integration with binary strings', type: :integration do + context 'when comparing two different binary strings' do + it 'produces the correct failure message' do + as_both_colored_and_uncolored do |color_enabled| + snippet = <<~TEST.strip + require 'super_diff/binary_string' + actual = "Hello".b + expected = "World".b + expect(actual).to eq(expected) + TEST + program = + make_plain_test_program(snippet, color_enabled: color_enabled) + + expected_output = + build_expected_output( + color_enabled: color_enabled, + snippet: 'expect(actual).to eq(expected)', + expectation: + proc do + line do + plain 'Expected ' + actual '' + plain ' to eq ' + expected '' + plain '.' + end + end, + diff: + proc do + expected_line '- 00000000: 576f 726c 64 World' + actual_line '+ 00000000: 4865 6c6c 6f Hello' + end + ) + + expect(program).to produce_output_when_run(expected_output).in_color( + color_enabled + ) + end + end + end + + context 'when comparing binary strings spanning multiple lines' do + it 'produces a multi-line hex dump diff' do + as_both_colored_and_uncolored do |color_enabled| + snippet = <<~TEST.strip + require 'super_diff/binary_string' + actual = ("A" * 20).b + expected = ("A" * 16 + "B" * 4).b + expect(actual).to eq(expected) + TEST + program = + make_plain_test_program(snippet, color_enabled: color_enabled) + + expected_output = + build_expected_output( + color_enabled: color_enabled, + snippet: 'expect(actual).to eq(expected)', + expectation: + proc do + line do + plain 'Expected ' + actual '' + plain ' to eq ' + expected '' + plain '.' + end + end, + diff: + proc do + plain_line ' 00000000: 4141 4141 4141 4141 4141 4141 4141 4141 AAAAAAAAAAAAAAAA' + expected_line '- 00000010: 4242 4242 BBBB' + actual_line '+ 00000010: 4141 4141 AAAA' + end + ) + + expect(program).to produce_output_when_run(expected_output).in_color( + color_enabled + ) + end + end + end +end diff --git a/spec/unit/binary_string/inspection_tree_builders/binary_string_spec.rb b/spec/unit/binary_string/inspection_tree_builders/binary_string_spec.rb new file mode 100644 index 00000000..27db2c1f --- /dev/null +++ b/spec/unit/binary_string/inspection_tree_builders/binary_string_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'super_diff/binary_string' + +RSpec.describe SuperDiff::BinaryString::InspectionTreeBuilders::BinaryString do + describe '#call' do + it 'renders byte counts for binary strings' do + [ + ['Hello World!'.b, 12], + [''.b, 0], + ["\xff\xfe\x00\x01".b, 4] + ].each do |value, bytes| + tree = described_class.call(value) + result = tree.render_to_string(object: value) + expect(result).to eq("") + end + end + end +end diff --git a/spec/unit/binary_string/operation_tree_builders/binary_string_spec.rb b/spec/unit/binary_string/operation_tree_builders/binary_string_spec.rb new file mode 100644 index 00000000..2b40fa8b --- /dev/null +++ b/spec/unit/binary_string/operation_tree_builders/binary_string_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'super_diff/binary_string' + +RSpec.describe SuperDiff::BinaryString::OperationTreeBuilders::BinaryString, + type: :unit do + describe 'hex dump format (xxd style)' do + subject(:operations) do + described_class.call(expected: expected, actual: actual) + end + + # These tests verify output matches xxd format: + # $ printf 'Hello World' | xxd + # 00000000: 4865 6c6c 6f20 576f 726c 64 Hello World + # + # $ printf 'The quick brown fox jumps over the lazy dog.' | xxd + # 00000000: 5468 6520 7175 6963 6b20 6272 6f77 6e20 The quick brown + # 00000010: 666f 7820 6a75 6d70 7320 6f76 6572 2074 fox jumps over t + # 00000020: 6865 206c 617a 7920 646f 672e he lazy dog. + + context 'with a single line of data (xxd format verification)' do + let(:expected) { 'Hello World'.b } + let(:actual) { 'Hello World'.b } + + it 'matches xxd output format exactly' do + expect(operations.first.value).to eq( + '00000000: 4865 6c6c 6f20 576f 726c 64 Hello World' + ) + end + end + + context 'with multiple lines of data (xxd format verification)' do + let(:expected) { 'The quick brown fox jumps over the lazy dog.'.b } + let(:actual) { 'The quick brown fox jumps over the lazy dog.'.b } + + it 'matches xxd output format exactly' do + ops = operations.to_a + expect(ops[0].value).to eq( + '00000000: 5468 6520 7175 6963 6b20 6272 6f77 6e20 The quick brown ' + ) + expect(ops[1].value).to eq( + '00000010: 666f 7820 6a75 6d70 7320 6f76 6572 2074 fox jumps over t' + ) + expect(ops[2].value).to eq( + '00000020: 6865 206c 617a 7920 646f 672e he lazy dog.' + ) + end + end + + context 'with binary data containing non-printable characters' do + let(:expected) { "\x00\x01\x41\xff\xfe".b } + let(:actual) { "\x00\x01\x41\xff\xfe".b } + + it 'formats non-printable characters as dots and preserves printable ones' do + expect(operations.first.value).to eq( + '00000000: 0001 41ff fe ..A..' + ) + end + end + + end +end diff --git a/spec/unit/binary_string/operation_tree_flatteners/binary_string_spec.rb b/spec/unit/binary_string/operation_tree_flatteners/binary_string_spec.rb new file mode 100644 index 00000000..580f6a2b --- /dev/null +++ b/spec/unit/binary_string/operation_tree_flatteners/binary_string_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'super_diff/binary_string' + +RSpec.describe SuperDiff::BinaryString::OperationTreeFlatteners::BinaryString do + it 'returns a series of lines from printing each value' do + collection = Array.new(3) { :some_value } + cases = [ + { + operations: [], + expected: [] + }, + { + operations: [ + operation_double( + collection, + name: :noop, + value: '00000000: 4865 6c6c 6f Hello', + index: 0 + ), + operation_double( + collection, + name: :noop, + value: '00000010: 576f 726c 64 World', + index: 1 + ) + ], + expected: [ + line_matcher( + type: :noop, + value: '00000000: 4865 6c6c 6f Hello' + ), + line_matcher( + type: :noop, + value: '00000010: 576f 726c 64 World' + ) + ] + }, + { + operations: [ + operation_double( + collection, + name: :noop, + value: '00000000: 4865 6c6c 6f Hello', + index: 0 + ), + operation_double( + collection, + name: :delete, + value: '00000010: 4141 4141 41 AAAAA', + index: 1 + ), + operation_double( + collection, + name: :insert, + value: '00000010: 4242 4242 42 BBBBB', + index: 1 + ) + ], + expected: [ + line_matcher( + type: :noop, + value: '00000000: 4865 6c6c 6f Hello' + ), + line_matcher( + type: :delete, + value: '00000010: 4141 4141 41 AAAAA' + ), + line_matcher( + type: :insert, + value: '00000010: 4242 4242 42 BBBBB' + ) + ] + } + ] + + cases.each do |spec| + operation_tree = + SuperDiff::BinaryString::OperationTrees::BinaryString.new( + spec[:operations] + ) + flattened_operation_tree = described_class.call(operation_tree) + + expect(flattened_operation_tree).to match(spec[:expected]) + end + end + + def operation_double(collection, name:, value:, index:) + double( + :operation, + name: name, + collection: collection, + value: value, + index: index + ) + end + + def line_matcher(type:, value:) + an_object_having_attributes( + type: type, + indentation_level: 0, + prefix: '', + value: value, + add_comma: false + ) + end +end diff --git a/spec/unit/core/tiered_lines_elider_spec.rb b/spec/unit/core/tiered_lines_elider_spec.rb index 87c369f0..f5da95e4 100644 --- a/spec/unit/core/tiered_lines_elider_spec.rb +++ b/spec/unit/core/tiered_lines_elider_spec.rb @@ -311,6 +311,94 @@ end context 'and the line tree contains non-noops in addition to noops' do + context 'and the line tree is flat (indentation level 0)' do + it 'elides the beginning of the noop so as to put it at the maximum' do + # Diff: + # + # "one" + # "two" + # "three" + # - "four" + # + "FOUR" + + lines = [ + an_actual_line( + type: :noop, + indentation_level: 0, + value: %("one") + ), + an_actual_line( + type: :noop, + indentation_level: 0, + value: %("two") + ), + an_actual_line( + type: :noop, + indentation_level: 0, + value: %("three") + ), + an_actual_line( + type: :delete, + indentation_level: 0, + value: %("four") + ), + an_actual_line( + type: :insert, + indentation_level: 0, + value: %("FOUR") + ) + ] + + line_tree_with_elisions = + with_configuration( + diff_elision_enabled: true, + diff_elision_maximum: 2 + ) { described_class.call(lines) } + + # Result: + # + # # ... + # "three" + # - "four" + # + "FOUR" + + expect(line_tree_with_elisions).to match( + [ + an_expected_elision( + indentation_level: 0, + children: [ + an_expected_line( + type: :noop, + indentation_level: 0, + value: %("one") + ), + an_expected_line( + type: :noop, + indentation_level: 0, + value: %("two") + ) + ] + ), + an_expected_line( + type: :noop, + indentation_level: 0, + value: %("three") + ), + an_expected_line( + type: :delete, + indentation_level: 0, + value: %("four") + ), + an_expected_line( + type: :insert, + indentation_level: 0, + value: %("FOUR") + ) + ] + ) + end + end + context 'and the only noops that exist are above the only non-noops that exist' do it 'elides the beginning of the noop so as to put it at the maximum' do # Diff: @@ -449,6 +537,7 @@ ] ) end + end context 'and the only noops that exist are below the only non-noops that exist' do