From 2fd146a6c20f984233e8be1173bf1f7c2773036a Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Fri, 2 Feb 2024 20:25:51 +0100 Subject: [PATCH] handle structs add range operation unit tests --- .../providers/folding_range/special_token.ex | 5 +- .../providers/selection_ranges.ex | 189 ++----- .../lib/language_server/range_utils.ex | 156 ++++++ .../test/providers/selection_ranges_test.exs | 75 +++ .../language_server/test/range_utils_test.exs | 510 ++++++++++++++++++ 5 files changed, 791 insertions(+), 144 deletions(-) create mode 100644 apps/language_server/lib/language_server/range_utils.ex create mode 100644 apps/language_server/test/range_utils_test.exs diff --git a/apps/language_server/lib/language_server/providers/folding_range/special_token.ex b/apps/language_server/lib/language_server/providers/folding_range/special_token.ex index 14243b426..7d89da5ae 100644 --- a/apps/language_server/lib/language_server/providers/folding_range/special_token.ex +++ b/apps/language_server/lib/language_server/providers/folding_range/special_token.ex @@ -63,9 +63,8 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken do defp do_group_tokens([], acc), do: acc # Don't create folding ranges for @doc false - # TODO why? defp do_group_tokens( - [{:at_op, _, _}, {:identifier, _, doc_identifier}, {false, _, _} | rest] = tokens, + [{:at_op, _, _}, {:identifier, _, doc_identifier}, {false, _, _} | rest], acc ) when doc_identifier in @docs do @@ -74,7 +73,7 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken do # Start a folding range for `@doc` and `@moduledoc` defp do_group_tokens( - [{:at_op, _, _} = at_op, {:identifier, _, doc_identifier} = token | rest] = tokens, + [{:at_op, _, _} = at_op, {:identifier, _, doc_identifier} = token | rest], acc ) when doc_identifier in @docs do diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex index 710433cea..acb123091 100644 --- a/apps/language_server/lib/language_server/providers/selection_ranges.ex +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -8,12 +8,15 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do alias ElixirLS.LanguageServer.{SourceFile} alias ElixirLS.LanguageServer.Providers.FoldingRange import ElixirLS.LanguageServer.Protocol + import ElixirLS.LanguageServer.RangeUtils defp token_length(:end), do: 3 defp token_length(token) when token in [:"(", :"[", :"{", :")", :"]", :"}"], do: 1 defp token_length(token) when token in [:"<<", :">>", :do, :fn], do: 2 defp token_length(_), do: 0 + # @stop_tokens [:",", :";", :|] + def selection_ranges(text, positions) do lines = SourceFile.lines(text) full_file_range = full_range(lines) @@ -55,7 +58,7 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do cell_pair_ranges = ([full_file_range] ++ for {{start_line, start_character}, {end_line, _end_line_start_character}} <- - cell_pairs |> dbg, + cell_pairs, (start_line < line or (start_line == line and start_character <= character)) and end_line > line do line_length = lines |> Enum.at(end_line - 1) |> String.length() @@ -82,7 +85,7 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do end) |> List.flatten() - cell_pair_ranges = sort_ranges(cell_pair_ranges) + cell_pair_ranges = sort_ranges_widest_to_narrowest(cell_pair_ranges) token_pair_ranges = token_pairs @@ -201,18 +204,39 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do ast, {[full_file_range], []}, fn - {node, meta, _} = ast, {acc, parent_meta} -> - parent_meta_from_stack = - case parent_meta do + {node, meta, args} = ast, {acc, parent_ast} -> + parent_ast_from_stack = + case parent_ast do [] -> [] [item | _] -> item end {start_line, start_character} = - {Keyword.get(meta, :line, 0) - 1, Keyword.get(meta, :column, 0) - 1} + cond do + node == :%{} and match?({:%, _, _}, parent_ast_from_stack) -> + # get line and column from parent % node, current node meta points to { + {_, parent_meta, _} = parent_ast_from_stack + + {Keyword.get(parent_meta, :line, 0) - 1, + Keyword.get(parent_meta, :column, 0) - 1} + + true -> + {Keyword.get(meta, :line, 0) - 1, Keyword.get(meta, :column, 0) - 1} + end {end_line, end_character} = cond do + node == :__aliases__ -> + last = meta[:last] + + last_length = + case List.last(args) do + atom when is_atom(atom) -> atom |> to_string() |> String.length() + _ -> 0 + end + + {last[:line] - 1, last[:column] - 1 + last_length} + end_location = meta[:end_of_expression] -> {end_location[:line] - 1, end_location[:column] - 1} @@ -280,16 +304,15 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do if (start_line < line or (start_line == line and start_character <= character)) and (end_line > line or (end_line == line and end_character >= character)) do - # dbg(ast) {ast, {[range(start_line, start_character, end_line, end_character) | acc], - [meta | parent_meta]}} + [ast | parent_ast]}} else - {ast, {acc, [meta | parent_meta]}} + {ast, {acc, [ast | parent_ast]}} end - other, {acc, parent_meta} -> - {other, {acc, parent_meta}} + other, {acc, parent_ast} -> + {other, {acc, parent_ast}} end, fn {_, _meta, _} = ast, {acc, [_ | tail]} -> @@ -301,12 +324,11 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do ) acc - |> sort_ranges() + |> sort_ranges_widest_to_narrowest() _ -> [full_file_range] end - |> IO.inspect(label: "ast ranges") surround_context_ranges = [full_file_range] ++ @@ -318,13 +340,19 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do [range(start_line - 1, start_character - 1, end_line - 1, end_character - 1)] end - token_pair_ranges - |> merge_ranges(cell_pair_ranges |> dbg) - |> merge_ranges(special_token_group_ranges |> dbg) - |> merge_ranges(comment_block_ranges |> dbg) - |> merge_ranges(surround_context_ranges |> dbg) - |> merge_ranges(ast_ranges |> dbg) - |> dbg + merged_ranges = + token_pair_ranges + |> merge_ranges_lists(cell_pair_ranges) + |> merge_ranges_lists(special_token_group_ranges) + |> merge_ranges_lists(comment_block_ranges) + |> merge_ranges_lists(surround_context_ranges) + |> merge_ranges_lists(ast_ranges) + + if not increasingly_narrowing?(merged_ranges) do + raise "merged_ranges are not increasingly narrowing" + end + + merged_ranges |> Enum.reduce(nil, fn selection_range, parent -> range(start_line_elixir, start_character_elixir, end_line_elixir, end_character_elixir) = selection_range @@ -347,64 +375,6 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do "parent" => parent } end) - |> IO.inspect() - - # cursor_location = SourceFile.lsp_position_to_elixir(text, {line, character}) - end - end - - def merge_ranges(range_1, range_2) do - do_merge_ranges(range_1, range_2, []) - |> Enum.reverse() - end - - def do_merge_ranges([], [], acc) do - acc - end - - def do_merge_ranges([range | rest_1], [], acc) do - do_merge_ranges(rest_1, [], [range | acc]) - end - - def do_merge_ranges([], [range | rest_2], acc) do - do_merge_ranges([], rest_2, [range | acc]) - end - - def do_merge_ranges([range | rest_1], [range | rest_2], acc) do - do_merge_ranges(rest_1, rest_2, [range | acc]) - end - - def do_merge_ranges([range_1 | rest_1], [range_2 | rest_2], acc) do - IO.inspect({range_1, range_2}, label: "merging") - IO.inspect(acc, label: "acc") - - range_2 = - case acc do - [] -> - range_2 - - [last_range | _] -> - # we might have added a narrower range by favoring range_1 in the previous iteration - # compute intersection - intersection(last_range, range_2) - end - - cond do - left_in_right?(range_2, range_1) -> - # range_2 in range_1 - IO.puts("range_2 in range_1") - do_merge_ranges(rest_1, [range_2 | rest_2], [range_1 | acc]) - - left_in_right?(range_1, range_2) -> - # range_1 in range_2 - IO.puts("range_1 in range_2") - do_merge_ranges([range_1 | rest_1], rest_2, [range_2 | acc]) - - true -> - # ranges intersect - add union and favor range_1 - union_range = union(range_1, range_2) - IO.inspect(union_range, label: "union") - do_merge_ranges(rest_1, rest_2, [range_1, union_range | acc]) end end @@ -417,67 +387,4 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do range(0, 0, Enum.count(lines) - 1, utf8_size) end - - defp sort_ranges(ranges) do - ranges - |> Enum.sort_by(fn range(start_line, start_character, end_line, end_character) -> - {start_line - end_line, start_character - end_character} - end) - end - - defp union( - range(start_line_1, start_character_1, end_line_1, end_character_1), - range(start_line_2, start_character_2, end_line_2, end_character_2) - ) do - {start_line, start_character} = - cond do - start_line_1 < start_line_2 -> {start_line_1, start_character_1} - start_line_1 > start_line_2 -> {start_line_2, start_character_2} - true -> {start_line_1, min(start_character_1, start_character_2)} - end - - {end_line, end_character} = - cond do - end_line_1 < end_line_2 -> {end_line_2, end_character_2} - end_line_1 > end_line_2 -> {end_line_1, end_character_1} - true -> {end_line_1, max(end_character_1, end_character_2)} - end - - range(start_line, start_character, end_line, end_character) - end - - defp intersection( - range(start_line_1, start_character_1, end_line_1, end_character_1), - range(start_line_2, start_character_2, end_line_2, end_character_2) - ) do - {start_line, start_character} = - cond do - start_line_1 < start_line_2 -> {start_line_2, start_character_2} - start_line_1 > start_line_2 -> {start_line_1, start_character_1} - true -> {start_line_1, max(start_character_1, start_character_2)} - end - - {end_line, end_character} = - cond do - end_line_1 < end_line_2 -> {end_line_1, end_character_1} - end_line_1 > end_line_2 -> {end_line_2, end_character_2} - true -> {end_line_1, min(end_character_1, end_character_2)} - end - - if start_line > end_line or (start_line == end_line and start_character > end_character) do - raise ArgumentError, message: "no intersection" - end - - range(start_line, start_character, end_line, end_character) - end - - defp left_in_right?( - range(start_line_1, start_character_1, end_line_1, end_character_1), - range(start_line_2, start_character_2, end_line_2, end_character_2) - ) do - (start_line_1 > start_line_2 or - (start_line_1 == start_line_2 and start_character_1 >= start_character_2)) and - (end_line_1 < end_line_2 or - (end_line_1 == end_line_2 and end_character_1 <= end_character_2)) - end end diff --git a/apps/language_server/lib/language_server/range_utils.ex b/apps/language_server/lib/language_server/range_utils.ex new file mode 100644 index 000000000..e54fd536e --- /dev/null +++ b/apps/language_server/lib/language_server/range_utils.ex @@ -0,0 +1,156 @@ +defmodule ElixirLS.LanguageServer.RangeUtils do + @moduledoc """ + Utilities for working with ranges. + """ + + import ElixirLS.LanguageServer.Protocol + + @type range_t :: map + + def valid?(range(start_line, start_character, end_line, end_character)) + when is_integer(start_line) and is_integer(end_line) and is_integer(start_character) and + is_integer(end_character) do + (start_line >= 0 and end_line >= 0 and start_character >= 0 and end_character >= 0 and + start_line < end_line) or (start_line == end_line and start_character <= end_character) + end + + def valid?(_), do: false + + def increasingly_narrowing?([left]), do: valid?(left) + + def increasingly_narrowing?([left, right | rest]) do + valid?(left) and valid?(right) and left_in_right?(right, left) and + increasingly_narrowing?([right | rest]) + end + + @spec left_in_right?(range_t, range_t) :: boolean + def left_in_right?( + range(start_line_1, start_character_1, end_line_1, end_character_1), + range(start_line_2, start_character_2, end_line_2, end_character_2) + ) do + (start_line_1 > start_line_2 or + (start_line_1 == start_line_2 and start_character_1 >= start_character_2)) and + (end_line_1 < end_line_2 or + (end_line_1 == end_line_2 and end_character_1 <= end_character_2)) + end + + def sort_ranges_widest_to_narrowest(ranges) do + ranges + |> Enum.sort_by(fn range(start_line, start_character, end_line, end_character) -> + {start_line - end_line, start_character - end_character} + end) + end + + def union( + range(start_line_1, start_character_1, end_line_1, end_character_1) = left, + range(start_line_2, start_character_2, end_line_2, end_character_2) = right + ) do + _intersection = intersection(left, right) + + {start_line, start_character} = + cond do + start_line_1 < start_line_2 -> {start_line_1, start_character_1} + start_line_1 > start_line_2 -> {start_line_2, start_character_2} + true -> {start_line_1, min(start_character_1, start_character_2)} + end + + {end_line, end_character} = + cond do + end_line_1 < end_line_2 -> {end_line_2, end_character_2} + end_line_1 > end_line_2 -> {end_line_1, end_character_1} + true -> {end_line_1, max(end_character_1, end_character_2)} + end + + range(start_line, start_character, end_line, end_character) + end + + def intersection( + range(start_line_1, start_character_1, end_line_1, end_character_1), + range(start_line_2, start_character_2, end_line_2, end_character_2) + ) do + {start_line, start_character} = + cond do + start_line_1 < start_line_2 -> {start_line_2, start_character_2} + start_line_1 > start_line_2 -> {start_line_1, start_character_1} + true -> {start_line_1, max(start_character_1, start_character_2)} + end + + {end_line, end_character} = + cond do + end_line_1 < end_line_2 -> {end_line_1, end_character_1} + end_line_1 > end_line_2 -> {end_line_2, end_character_2} + true -> {end_line_1, min(end_character_1, end_character_2)} + end + + result = range(start_line, start_character, end_line, end_character) + + if not valid?(result) do + raise ArgumentError, message: "no intersection" + end + + result + end + + def merge_ranges_lists(ranges_1, ranges_2) do + if hd(ranges_1) != hd(ranges_2) do + raise ArgumentError, message: "range list do not start with the same range" + end + + if not increasingly_narrowing?(ranges_1) do + raise ArgumentError, message: "ranges_1 is not increasingly narrowing" + end + + if not increasingly_narrowing?(ranges_2) do + raise ArgumentError, message: "ranges_2 is not increasingly narrowing" + end + + do_merge_ranges(ranges_1, ranges_2, []) + |> Enum.reverse() + end + + defp do_merge_ranges([], [], acc) do + acc + end + + defp do_merge_ranges([range_1 | rest_1], [], acc) do + # range_1 is guaranteed to be increasingly narrowing + do_merge_ranges(rest_1, [], [range_1 | acc]) + end + + defp do_merge_ranges([], [range_2 | rest_2], acc) do + # we might have added a narrower range by favoring range_1 in the previous iteration + range_2 = trim_range_to_acc(range_2, acc) + + do_merge_ranges([], rest_2, [range_2 | acc]) + end + + defp do_merge_ranges([range | rest_1], [range | rest_2], acc) do + do_merge_ranges(rest_1, rest_2, [range | acc]) + end + + defp do_merge_ranges([range_1 | rest_1], [range_2 | rest_2], acc) do + # we might have added a narrower range by favoring range_1 in the previous iteration + range_2 = trim_range_to_acc(range_2, acc) + + cond do + left_in_right?(range_2, range_1) -> + # range_2 in range_1 + do_merge_ranges(rest_1, [range_2 | rest_2], [range_1 | acc]) + + left_in_right?(range_1, range_2) -> + # range_1 in range_2 + do_merge_ranges([range_1 | rest_1], rest_2, [range_2 | acc]) + + true -> + # ranges intersect - add union and favor range_1 + union_range = union(range_1, range_2) + do_merge_ranges(rest_1, rest_2, [range_1, union_range | acc]) + end + end + + defp trim_range_to_acc(range, []), do: range + + defp trim_range_to_acc(range, [acc_range | _]) do + intersection(range, acc_range) + end +end diff --git a/apps/language_server/test/providers/selection_ranges_test.exs b/apps/language_server/test/providers/selection_ranges_test.exs index db522f8c1..d954e2e89 100644 --- a/apps/language_server/test/providers/selection_ranges_test.exs +++ b/apps/language_server/test/providers/selection_ranges_test.exs @@ -447,4 +447,79 @@ defmodule ElixirLS.LanguageServer.Providers.SelectionRangesTest do assert end_character == SourceFile.lines(text) |> Enum.at(0) |> SourceFile.line_length_utf16() end + + describe "struct" do + test "inside {}" do + text = """ + %My.Struct{ + some: 123, + other: "abc" + } + """ + + ranges = get_ranges(text, 1, 2) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 4, 0) + # full struct + assert Enum.at(ranges, 1) == range(0, 0, 3, 1) + # full {} outside + assert Enum.at(ranges, 2) == range(0, 10, 3, 1) + # full {} inside + assert Enum.at(ranges, 3) == range(0, 11, 3, 0) + # full lines: + assert Enum.at(ranges, 4) == range(1, 0, 2, 14) + # trimmed lines: + assert Enum.at(ranges, 5) == range(1, 2, 2, 14) + # some: + # TODO split by , + assert Enum.at(ranges, 6) == range(1, 2, 1, 6) + end + + test "on alias" do + text = """ + %My.Struct{ + some: 123, + other: "abc" + } + """ + + ranges = get_ranges(text, 0, 2) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 4, 0) + # full struct + assert Enum.at(ranges, 1) == range(0, 0, 3, 1) + # %My.Struct + assert Enum.at(ranges, 3) == range(0, 0, 0, 10) + # My.Struct + assert Enum.at(ranges, 4) == range(0, 1, 0, 10) + end + end + + describe "comma separated" do + test "arg with match left side" do + text = """ + fun(%My{} = my, keyword: 123, other: "") + """ + + ranges = get_ranges(text, 0, 6) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # full call + assert Enum.at(ranges, 1) == range(0, 0, 0, 40) + # full () outside + assert Enum.at(ranges, 2) == range(0, 3, 0, 40) + # full () inside + assert Enum.at(ranges, 3) == range(0, 4, 0, 39) + # TODO split by , + # # %My{} = my, + # assert Enum.at(ranges, 4) == range(0, 4, 0, 15) + # # %My{} = my + # assert Enum.at(ranges, 5) == range(0, 4, 0, 14) + # # %My{} + # assert Enum.at(ranges, 5) == range(0, 4, 0, 9) + end + end end diff --git a/apps/language_server/test/range_utils_test.exs b/apps/language_server/test/range_utils_test.exs new file mode 100644 index 000000000..2fedc97f7 --- /dev/null +++ b/apps/language_server/test/range_utils_test.exs @@ -0,0 +1,510 @@ +defmodule ElixirLS.LanguageServer.RangeUtilsTest do + use ExUnit.Case + + import ElixirLS.LanguageServer.Protocol + import ElixirLS.LanguageServer.RangeUtils + + describe "valid?/1" do + test "returns true if range is valid" do + assert valid?(range(0, 0, 0, 0)) + assert valid?(range(1, 1, 1, 2)) + assert valid?(range(1, 1, 2, 0)) + assert valid?(range(1, 1, 2, 6)) + end + + test "returns false if range is invalid" do + refute valid?(range(1, 1, 1, 0)) + refute valid?(range(1, 1, 0, 1)) + refute valid?(range(-1, 1, 5, 5)) + refute valid?(range(1, -1, 5, 5)) + refute valid?(range(1, 1, -5, 5)) + refute valid?(range(1, 1, 5, -5)) + refute valid?(range(1, 1, 5, nil)) + refute valid?(range(1, 1, nil, 5)) + refute valid?(range(1, nil, 5, 5)) + refute valid?(range(nil, 1, 5, 5)) + end + end + + describe "left_in_right?" do + test "returns true if range 1 is inside range 2" do + range1 = range(2, 1, 3, 20) + range2 = range(1, 2, 4, 15) + assert left_in_right?(range1, range2) + end + + test "returns true if range 1 is inside range 2 columns equal" do + range1 = range(2, 1, 3, 20) + range2 = range(2, 1, 3, 20) + assert left_in_right?(range1, range2) + end + + test "returns true if range 1 is inside range 2 same line" do + range1 = range(1, 5, 1, 10) + range2 = range(1, 2, 1, 15) + assert left_in_right?(range1, range2) + end + + test "returns false if ranges overlap but range 1 is wider" do + range2 = range(2, 1, 3, 20) + + range1 = range(2, 0, 3, 20) + refute left_in_right?(range1, range2) + + range1 = range(2, 1, 3, 21) + refute left_in_right?(range1, range2) + + range1 = range(1, 1, 3, 21) + refute left_in_right?(range1, range2) + + range1 = range(2, 1, 4, 21) + refute left_in_right?(range1, range2) + end + + test "returns false if range 1 starts after range 2" do + range1 = range(3, 5, 4, 10) + range2 = range(1, 2, 2, 15) + refute left_in_right?(range1, range2) + end + + test "returns false if range 1 starts after range 2 same line" do + range1 = range(1, 16, 1, 18) + range2 = range(1, 2, 1, 15) + refute left_in_right?(range1, range2) + end + + test "returns false if range 1 ends before range 2" do + range1 = range(1, 5, 2, 10) + range2 = range(3, 7, 4, 15) + refute left_in_right?(range1, range2) + end + + test "returns false if range 1 ends before range 2 same line" do + range1 = range(1, 5, 1, 10) + range2 = range(1, 11, 1, 15) + refute left_in_right?(range1, range2) + end + end + + describe "sort_ranges_widest_to_narrowest/1" do + test "sorts ranges" do + ranges = [ + range(1, 5, 1, 10), + range(1, 5, 1, 5), + range(0, 0, 3, 10), + range(1, 1, 2, 15), + range(1, 4, 1, 20), + range(1, 3, 2, 10) + ] + + expected = [ + range(0, 0, 3, 10), + range(1, 1, 2, 15), + range(1, 3, 2, 10), + range(1, 4, 1, 20), + range(1, 5, 1, 10), + range(1, 5, 1, 5) + ] + + assert sort_ranges_widest_to_narrowest(ranges) == expected + end + end + + describe "increasingly_narrowing?/1" do + test "returns true if only one range" do + ranges = [ + range(0, 0, 3, 10) + ] + + assert increasingly_narrowing?(ranges) + end + + test "returns true if ranges are increasingly narrowing" do + ranges = [ + range(0, 0, 3, 10), + range(1, 1, 2, 15), + range(1, 3, 2, 10), + range(1, 4, 1, 20), + range(1, 5, 1, 10), + range(1, 5, 1, 5) + ] + + assert increasingly_narrowing?(ranges) + end + + test "returns false if order is broken" do + ranges = [ + range(0, 0, 3, 10), + range(1, 1, 3, 11) + ] + + refute increasingly_narrowing?(ranges) + end + end + + describe "union/2" do + test "right in left" do + left = range(1, 1, 4, 10) + right = range(2, 5, 3, 5) + + expected = left + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "right in left same line" do + left = range(1, 1, 1, 10) + right = range(1, 5, 1, 5) + + expected = left + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "right equal left" do + left = range(1, 1, 2, 10) + right = left + + expected = left + + assert union(left, right) == expected + end + + test "overlap" do + left = range(1, 1, 3, 10) + right = range(2, 5, 4, 15) + + expected = range(1, 1, 4, 15) + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "overlap same line" do + left = range(1, 1, 1, 10) + right = range(1, 5, 1, 15) + + expected = range(1, 1, 1, 15) + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "overlap same line one column" do + left = range(1, 1, 1, 10) + right = range(1, 10, 1, 15) + + expected = range(1, 1, 1, 15) + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "raises if ranges do not intersect" do + left = range(1, 1, 2, 5) + right = range(3, 1, 4, 1) + + assert_raise ArgumentError, "no intersection", fn -> + union(left, right) + end + + assert_raise ArgumentError, "no intersection", fn -> + union(right, left) + end + end + + test "raises if ranges do not intersect same line" do + left = range(1, 1, 1, 5) + right = range(1, 8, 1, 10) + + assert_raise ArgumentError, "no intersection", fn -> + union(left, right) + end + + assert_raise ArgumentError, "no intersection", fn -> + union(right, left) + end + end + end + + describe "intersection/2" do + test "right in left" do + left = range(1, 1, 4, 10) + right = range(2, 5, 3, 5) + + expected = right + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "right in left same line" do + left = range(1, 1, 1, 10) + right = range(1, 5, 1, 5) + + expected = right + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "right equal left" do + left = range(1, 1, 2, 10) + right = left + + expected = left + + assert intersection(left, right) == expected + end + + test "overlap" do + left = range(1, 1, 3, 10) + right = range(2, 5, 4, 15) + + expected = range(2, 5, 3, 10) + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "overlap same line" do + left = range(1, 1, 1, 10) + right = range(1, 5, 1, 15) + + expected = range(1, 5, 1, 10) + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "overlap same line one column" do + left = range(1, 1, 1, 10) + right = range(1, 10, 1, 15) + + expected = range(1, 10, 1, 10) + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "raises if ranges do not intersect" do + left = range(1, 1, 2, 5) + right = range(3, 1, 4, 1) + + assert_raise ArgumentError, "no intersection", fn -> + intersection(left, right) + end + + assert_raise ArgumentError, "no intersection", fn -> + intersection(right, left) + end + end + + test "raises if ranges do not intersect same line" do + left = range(1, 1, 1, 5) + right = range(1, 8, 1, 10) + + assert_raise ArgumentError, "no intersection", fn -> + intersection(left, right) + end + + assert_raise ArgumentError, "no intersection", fn -> + intersection(right, left) + end + end + end + + describe "merge_ranges_lists/2" do + test "equal length, 2 in 1" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(3, 1, 3, 5) + ] + + expected = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4), + range(3, 1, 3, 5) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "equal length, 1 in 2" do + range_1 = [ + range(1, 1, 5, 5), + range(3, 1, 3, 5) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + expected = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4), + range(3, 1, 3, 5) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "equal length, ranges intersect" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(2, 5, 4, 8) + ] + + expected = [ + range(1, 1, 5, 5), + # union + range(2, 2, 4, 8), + # preferred from range_1 + range(2, 2, 4, 4) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "equal length, ranges intersect, last range_2 wider than range_1" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4), + range(3, 6, 3, 8) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(2, 5, 4, 8), + range(2, 6, 4, 8) + ] + + expected = [ + range(1, 1, 5, 5), + # union + range(2, 2, 4, 8), + # preferred from range_1 + range(2, 2, 4, 4), + # intersection of range_2 and range_1 + range(2, 6, 4, 4), + # last range_1 range + range(3, 6, 3, 8) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "ranges intersect, last range_2 wider than range_1" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(2, 5, 4, 8), + range(2, 6, 4, 8) + ] + + expected = [ + range(1, 1, 5, 5), + # union + range(2, 2, 4, 8), + # preferred from range_1 + range(2, 2, 4, 4), + # intersection of range_2 and range_1 + range(2, 6, 4, 4) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "raises if range list do not start with the same range" do + range_1 = [ + range(2, 2, 4, 4), + range(1, 1, 5, 5) + ] + + range_2 = [ + range(1, 1, 3, 3), + range(2, 2, 2, 2) + ] + + assert_raise ArgumentError, fn -> + merge_ranges_lists(range_1, range_2) + end + end + + test "raises if range_1 is not increasingly narrowing" do + range_1 = [ + range(0, 0, 10, 10), + range(2, 2, 4, 4), + range(1, 1, 5, 5) + ] + + range_2 = [ + range(0, 0, 10, 10), + range(1, 1, 3, 3), + range(2, 2, 2, 2) + ] + + assert_raise ArgumentError, fn -> + merge_ranges_lists(range_1, range_2) + end + end + + test "raises if range_2 is not increasingly narrowing" do + range_1 = [ + range(0, 0, 10, 10), + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [ + range(0, 0, 10, 10), + range(2, 2, 2, 2), + range(1, 1, 3, 3) + ] + + assert_raise ArgumentError, fn -> + merge_ranges_lists(range_1, range_2) + end + end + + test "handles equal ranges" do + range_1 = [range(1, 1, 5, 5)] + range_2 = [range(1, 1, 5, 5)] + + assert merge_ranges_lists(range_1, range_2) == [range(1, 1, 5, 5)] + end + + test "handles one empty range" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [range(1, 1, 5, 5)] + + expected = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + end +end