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
46 changes: 41 additions & 5 deletions lib/difftastic/differ.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@

class Difftastic::Differ
DEFAULT_TAB_WIDTH = 2

def initialize(background: nil, color: nil, syntax_highlight: nil, context: nil, width: nil, tab_width: nil, parse_error_limit: nil, underline_highlights: true, left_label: nil, right_label: nil, display: "side-by-side-show-both")
DEFAULT_MAX_DEPTH = 5
DEFAULT_MAX_ITEMS = 10
DEFAULT_MAX_DEPTH_CAP = 20
DEFAULT_MAX_ITEMS_CAP = 40
MAX_DEPTH_INCREMENT = 5
MAX_ITEMS_INCREMENT = 10
DIFF_UNAVAILABLE_MESSAGE = "[Diff unavailable: exceeded depth/size display limits]"

def initialize(background: nil, color: nil, syntax_highlight: nil, context: nil, width: nil, tab_width: nil, parse_error_limit: nil, underline_highlights: true, left_label: nil, right_label: nil, display: "side-by-side-show-both", max_depth: nil, max_items: nil, max_depth_cap: nil, max_items_cap: nil, max_depth_increment: nil, max_items_increment: nil)
@show_paths = false
@background = background => :dark | :light | nil
@color = color => :always | :never | :auto | nil
Expand All @@ -16,15 +23,44 @@ def initialize(background: nil, color: nil, syntax_highlight: nil, context: nil,
@left_label = left_label => String | nil
@right_label = right_label => String | nil
@display = display
@max_depth = max_depth => Integer | nil
@max_items = max_items => Integer | nil
@max_depth_cap = max_depth_cap => Integer | nil
@max_items_cap = max_items_cap => Integer | nil
@max_depth_increment = max_depth_increment => Integer | nil
@max_items_increment = max_items_increment => Integer | nil
end

def diff_objects(old, new)
tab_width = @tab_width || DEFAULT_TAB_WIDTH
max_depth = @max_depth || DEFAULT_MAX_DEPTH
max_items = @max_items || DEFAULT_MAX_ITEMS
max_depth_cap = @max_depth_cap || DEFAULT_MAX_DEPTH_CAP
max_items_cap = @max_items_cap || DEFAULT_MAX_ITEMS_CAP
max_depth_increment = @max_depth_increment || MAX_DEPTH_INCREMENT
max_items_increment = @max_items_increment || MAX_ITEMS_INCREMENT

loop do
old_str = Difftastic.pretty(old, tab_width:, max_depth:, max_items:)
new_str = Difftastic.pretty(new, tab_width:, max_depth:, max_items:)

# If prettified strings don't differ, the strings probably were truncated to max_depth and/or max_items.
# PrettyPlease then correctly did not return non-matching strings.
# In that case we increase max_depth & max_items in the loop up to DEFAULT_MAX_ITEMS_CAP or DEFAULT_MAX_DEPTH_CAP.
if old_str != new_str
return diff_strings(old_str, new_str, file_extension: "rb")
end

old = Difftastic.pretty(old, tab_width:)
new = Difftastic.pretty(new, tab_width:)
# If we've hit both caps, stop trying
if max_depth >= max_depth_cap && max_items >= max_items_cap
return DIFF_UNAVAILABLE_MESSAGE
end

diff_strings(old, new, file_extension: "rb")
# Increase limits and retry, while never increasing to more than max_depth_cap/max_items_cap.
# Both values are matched to perform 3 retries, but neither value would increase beyond its cap anyway.
max_depth = [max_depth + max_depth_increment, max_depth_cap].min
max_items = [max_items + max_items_increment, max_items_cap].min
end
end

def diff_ada(old, new)
Expand Down
249 changes: 249 additions & 0 deletions test/diff_objects_adaptive_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# frozen_string_literal: true

require_relative "test_helper"

class DiffObjectsAdaptiveTest < Minitest::Spec
# Use smaller values than the gem's defaults to keep tests fast.
# The adaptive logic works the same regardless of the actual values.
TEST_MAX_DEPTH = 3
TEST_MAX_ITEMS = 5
TEST_MAX_DEPTH_CAP = 10
TEST_MAX_ITEMS_CAP = 12
TEST_INCREMENT = 1
DIFF_UNAVAILABLE_MESSAGE = Difftastic::Differ::DIFF_UNAVAILABLE_MESSAGE

def differ(**options)
Difftastic::Differ.new(
color: :never,
max_depth: TEST_MAX_DEPTH,
max_items: TEST_MAX_ITEMS,
max_depth_cap: TEST_MAX_DEPTH_CAP,
max_items_cap: TEST_MAX_ITEMS_CAP,
max_depth_increment: TEST_INCREMENT,
max_items_increment: TEST_INCREMENT,
**options # overrides defaults when provided
)
end

# Example:
# nested_hash(4, "x")
# # => { l1: { l2: { l3: { l4: "x" } } } }
def nested_hash(depth, nested_value)
(1..depth)
.reverse_each
.reduce(nested_value) do |inner, i|
{ "l#{i}": inner }
end
end

# Example:
# array_with_diff_at(4, "x")
# # => [1, 2, 3, "x"]
def array_with_diff_at(position, value)
(1...position).to_a + [value]
end

describe "adaptive max_depth" do
it "shows diff at exactly TEST_MAX_DEPTH" do
old = nested_hash(TEST_MAX_DEPTH, "old")
new = nested_hash(TEST_MAX_DEPTH, "new")

output = differ.diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end

it "shows diff at TEST_MAX_DEPTH + 1 (requires adaptation)" do
depth = TEST_MAX_DEPTH + 1
old = nested_hash(depth, "old")
new = nested_hash(depth, "new")

output = differ.diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end

it "shows diff at 2 * TEST_MAX_DEPTH (requires multiple adaptations)" do
depth = 2 * TEST_MAX_DEPTH
old = nested_hash(depth, "old")
new = nested_hash(depth, "new")

output = differ.diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end
end

describe "adaptive max_items" do
it "shows diff at exactly TEST_MAX_ITEMS" do
old = array_with_diff_at(TEST_MAX_ITEMS, "old")
new = array_with_diff_at(TEST_MAX_ITEMS, "new")

output = differ.diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end

it "shows diff at TEST_MAX_ITEMS + 1 (requires adaptation)" do
position = TEST_MAX_ITEMS + 1
old = array_with_diff_at(position, "old")
new = array_with_diff_at(position, "new")

output = differ.diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end

it "shows diff at 2 * TEST_MAX_ITEMS (requires multiple adaptations)" do
position = 2 * TEST_MAX_ITEMS
old = array_with_diff_at(position, "old")
new = array_with_diff_at(position, "new")

output = differ.diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end
end

describe "configurable starting values" do
it "respects custom max_depth" do
depth = TEST_MAX_DEPTH_CAP - 1
old = nested_hash(depth, "old")
new = nested_hash(depth, "new")

output = differ(max_depth: 1).diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end

it "respects custom max_items" do
position = TEST_MAX_ITEMS_CAP - 1
old = array_with_diff_at(position, "old")
new = array_with_diff_at(position, "new")

output = differ(max_items: 1).diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end
end

describe "configurable caps" do
it "respects custom max_depth_cap" do
depth = TEST_MAX_DEPTH - 1
old = nested_hash(depth, "old")
new = nested_hash(depth, "new")

output = differ(max_depth_cap: TEST_MAX_DEPTH).diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end

it "respects custom max_items_cap" do
position = TEST_MAX_ITEMS - 1
old = array_with_diff_at(position, "old")
new = array_with_diff_at(position, "new")

output = differ(max_items_cap: TEST_MAX_ITEMS).diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end

it "returns unavailable when depth exceeds custom max_depth_cap" do
cap = 2
old = nested_hash(cap, "old")
new = nested_hash(cap, "new")

# Both caps must be hit (&&), and starting max must be smaller than cap to trigger truncation
output = differ(max_depth: 1, max_depth_cap: cap, max_items_cap: 1).diff_objects(old, new)

assert_includes output, DIFF_UNAVAILABLE_MESSAGE
refute_includes output, "old"
refute_includes output, "new"
end

it "returns unavailable when position exceeds custom max_items_cap" do
cap = 2
old = array_with_diff_at(cap, "old")
new = array_with_diff_at(cap, "new")

# Both caps must be hit (&&), and starting max must be smaller than cap to trigger truncation
output = differ(max_items: 1, max_depth_cap: 1, max_items_cap: cap).diff_objects(old, new)

assert_includes output, DIFF_UNAVAILABLE_MESSAGE
refute_includes output, "old"
refute_includes output, "new"
end

it "uses starting max_items when higher than cap without adaptation" do
cap = 2
old = array_with_diff_at(cap, "old")
new = array_with_diff_at(cap, "new")

# With max_items: 5 (default) > max_items_cap: 2, no truncation occurs, diff found immediately
output = differ(max_items_cap: cap).diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end

it "uses starting max_depth when higher than cap without adaptation" do
cap = 2
old = nested_hash(cap, "old")
new = nested_hash(cap, "new")

# With max_depth: 3 (default) > max_depth_cap: 2, no truncation occurs, diff found immediately
output = differ(max_depth_cap: cap).diff_objects(old, new)

refute_includes output, DIFF_UNAVAILABLE_MESSAGE
assert_includes output, "old"
assert_includes output, "new"
end
end

describe "loop termination at caps" do
it "returns unavailable message when depth exceeds max_depth_cap" do
depth = TEST_MAX_DEPTH_CAP + 1
old = nested_hash(depth, "old")
new = nested_hash(depth, "new")

output = differ.diff_objects(old, new)

assert_includes output, DIFF_UNAVAILABLE_MESSAGE
refute_includes output, "old"
refute_includes output, "new"
end

it "returns unavailable message when position exceeds max_items_cap" do
position = TEST_MAX_ITEMS_CAP + 1
old = array_with_diff_at(position, "old")
new = array_with_diff_at(position, "new")

output = differ.diff_objects(old, new)

assert_includes output, DIFF_UNAVAILABLE_MESSAGE
refute_includes output, "old"
refute_includes output, "new"
end
end
end