Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions docs/users/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions lib/super_diff/binary_string.rb
Original file line number Diff line number Diff line change
@@ -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
)
Comment on lines +17 to +19
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call makes the operation tree builder available to the entire diffing system, which makes for some funky output when diffing binary strings in collections:

puts SuperDiff.diff({x: Random.bytes(32)}, {x: Random.bytes(32)})
  {
-   x: 00000000: afef a454 4376 72a6 729d 314d e1db 5bef  ...TCvr.r.1M..[.
-   00000010: 07a3 ab95 8424 7b62 fd2d 6d0a ed59 1dd8  .....${b.-m..Y..
+   00000000: 34e3 98e1 b17a 2855 4745 7583 584c 9123  4....z(UGEu.XL.#
+   00000010: 9d5a cacc 84a1 8275 1b42 3fa5 a81b d91f  .Z.....u.B?.....
  }

puts SuperDiff.diff([Random.bytes(32)], [Random.bytes(32)])
  [
-   00000000: bddb e405 5d1d c757 2922 162f 0c26 4c68  ....]..W)"./.&Lh
-   00000010: 1289 d606 03e0 69e1 a655 4b36 4054 ee7f  ......i..UK6@T..
+   00000000: df19 aa09 3357 65ed 6d8d 751a 9fda a81a  ....3We.m.u.....
+   00000010: b3cc 4dea 7708 e728 5907 5647 4c5c f81b  ..M.w..(Y.VGL\..
  ]

So, let's remove this. The Differs::BinaryString will still be available to diff top-level binary strings, and it will still use the BinaryString op tree builder.

Suggested change
config.prepend_extra_operation_tree_builder_classes(
OperationTreeBuilders::BinaryString
)

config.prepend_extra_operation_tree_classes(
OperationTrees::BinaryString
)
Comment on lines +20 to +22
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What effect will this have on the ObjectHavingAttributes matcher?

Can you add a test for this, or drop it if it has an adverse effect?

config.prepend_extra_inspection_tree_builder_classes(
InspectionTreeBuilders::BinaryString
)
Comment on lines +23 to +25
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, in contrast to the above, let's keep the binary string inspection tree builder – it makes the output a lot cleaner for this extension's use case:

puts SuperDiff.diff({x: Random.bytes(32)}, {x: Random.bytes(32)})
  {
-   x: "~\xC0\x88v\xFF9^[\x82p\x89%\xEE0\xEF\xDB\xC3`\xD8\xDF\xA0\xE0\xC6\x8F2\xEC\xC6\xBF\xA6\x8D\v\xC2"
+   x: "\x0F\x8A\e\xAE.J*\xE5X2l5\x92\x97\x03\xA2\xFF\xE0\xA8xQ\x8A\x97)\x88>}y@\xDDy\xD0"
  }

require 'super_diff/binary_string'

puts SuperDiff.diff({x: Random.bytes(32)}, {x: Random.bytes(32)})
  {
-   x: <binary string (32 bytes)>
+   x: <binary string (32 bytes)>
  }

end
end
end
12 changes: 12 additions & 0 deletions lib/super_diff/binary_string/differs.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions lib/super_diff/binary_string/differs/binary_string.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions lib/super_diff/binary_string/inspection_tree_builders.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 "<binary string (#{object.bytesize} bytes)>"
end
end
end
end
end
end
12 changes: 12 additions & 0 deletions lib/super_diff/binary_string/operation_tree_builders.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +29 to +31
Copy link
Copy Markdown
Collaborator

@jas14 jas14 Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. I ended up going down a rabbit hole here.

In practice, there are currently no plain string operation tree builders / differs, so this should never happen.

However, in theory, this is correct: if there were any operation tree builders that applied to two non-binary-encoded1 strings, the AbstractOperationTreeBuilder would produce a binary operation, and the flattener might blow up.

I'd normally ask for a test for this case, but since the same is true for the MultilineString operation tree builder, I'll separately look into 1) adding a failing test for the MultilineString superclass, and 2) probably hoisting this override up into that class, too.

EDIT: #304 is now in main, feel free to merge and get rid of this method.

Footnotes

  1. these are the formatted hex dump lines, not the original strings!


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('%<offset>08x: %<hex>-39s %<ascii>s', offset:, hex: hex_pairs, ascii:)
end

def printable_char(byte)
byte >= 32 && byte < 127 ? byte.chr : '.'
end
end
end
end
end
12 changes: 12 additions & 0 deletions lib/super_diff/binary_string/operation_tree_flatteners.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions lib/super_diff/binary_string/operation_trees.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions lib/super_diff/binary_string/operation_trees/binary_string.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions lib/super_diff/rspec/differ.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions spec/integration/rspec/binary_string_spec.rb
Original file line number Diff line number Diff line change
@@ -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 '<binary string (5 bytes)>'
plain ' to eq '
expected '<binary string (5 bytes)>'
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 '<binary string (20 bytes)>'
plain ' to eq '
expected '<binary string (20 bytes)>'
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
Loading
Loading