diff --git a/lib/difftastic/differ.rb b/lib/difftastic/differ.rb index e1f327d..47758ad 100644 --- a/lib/difftastic/differ.rb +++ b/lib/difftastic/differ.rb @@ -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 @@ -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) diff --git a/test/diff_objects_adaptive_test.rb b/test/diff_objects_adaptive_test.rb new file mode 100644 index 0000000..dd8ed28 --- /dev/null +++ b/test/diff_objects_adaptive_test.rb @@ -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