From 1540f0596dd761743f1ce6b1fe7c0b575471d862 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:59:45 +0200 Subject: [PATCH 1/2] Support recursive type descriptors --- lib/elixir/lib/module/types/descr.ex | 802 ++++++++++++------ .../test/elixir/module/types/descr_test.exs | 7 + .../elixir/module/types/recursive_test.exs | 563 ++++++++++++ 3 files changed, 1132 insertions(+), 240 deletions(-) create mode 100644 lib/elixir/test/elixir/module/types/recursive_test.exs diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 8469f0002fe..3263e739ab1 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -93,6 +93,7 @@ defmodule Module.Types.Descr do @compile {:inline, unfold: 1} defp unfold(:term), do: unfolded_term() + defp unfold({_, _, _} = node), do: to_descr(node) |> unfold() defp unfold(other), do: other defp unfolded_term, do: @term @@ -120,6 +121,48 @@ defmodule Module.Types.Descr do @boolset :sets.from_list([true, false], version: 2) def boolean(), do: %{atom: {:union, @boolset}} + ## Nodes + + defp make_node(id, state, generator), do: {id, state, generator} + + def to_descr(:term), do: :term + def to_descr(descr = %{}), do: descr + + def to_descr({_id, state, generator}) do + recur = fn name -> + {id, _if_set_id, generator} = Map.fetch!(state, name) + make_node(id, state, generator) + end + + generator.(recur) + end + + # Component emptiness can revisit generated BDDs before reaching the original + # recursive node again. Keep the full BDD in the key; the hash is only a discriminator. + defp empty_bdd_seen_key(:fun, arity, bdd) do + {:bdd_seen, :fun, arity, bdd_hash(bdd), bdd} + end + + defp empty_bdd_seen_key(kind, bdd) do + {:bdd_seen, kind, bdd_hash(bdd), bdd} + end + + @doc """ + Builds recursive type nodes from mutually recursive equations. + + Generators receive `recur`, which returns the node for a named equation. + """ + def recursive(equations) when is_map(equations) do + state = + Map.new(equations, fn {name, generator} -> + {name, {make_ref(), make_ref(), generator}} + end) + + Map.new(state, fn {name, {id, _if_set_id, generator}} -> + {name, make_node(id, state, generator)} + end) + end + @doc """ Gets the upper bound of a gradual type. @@ -315,6 +358,10 @@ defmodule Module.Types.Descr do # `not_set()` has no meaning outside of map types. def not_set(), do: @not_set + def if_set({_, _, _} = node) do + node |> unfold() |> if_set() + end + def if_set(:term), do: term_or_optional() # If type contains a :dynamic part, :optional gets added there. @@ -370,19 +417,22 @@ defmodule Module.Types.Descr do defp pop_optional_static(:term), do: {false, :term} - defp pop_optional_static(type) do + defp pop_optional_static(%{} = type) do case :maps.take(:optional, type) do :error -> {false, type} {1, type} -> {true, type} end end + defp pop_optional_static(type), do: {false, type} + ## Set operations @doc """ Returns true if the type has a gradual part. """ def gradual?(:term), do: false + def gradual?({_, _, _} = node), do: gradual?(to_descr(node)) def gradual?(descr), do: is_map_key(descr, :dynamic) @doc """ @@ -406,6 +456,7 @@ defmodule Module.Types.Descr do @compile {:inline, pop_dynamic: 1} defp pop_dynamic(:term), do: {:term, :term} + defp pop_dynamic({_, _, _} = node), do: pop_dynamic(to_descr(node)) defp pop_dynamic(descr), do: Map.pop(descr, :dynamic, descr) defp put_dynamic(:term, dynamic), do: optional_to_term(%{dynamic: dynamic}) @@ -413,6 +464,14 @@ defmodule Module.Types.Descr do defp put_dynamic(_static, dynamic) when dynamic == @none, do: @none defp put_dynamic(static, dynamic), do: Map.put(static, :dynamic, dynamic) + defp split_dynamic(:term), do: {:term, :term, false} + defp split_dynamic({_, _, _} = node), do: {node, node, false} + + defp split_dynamic(%{dynamic: dynamic} = descr), + do: {dynamic, Map.delete(descr, :dynamic), true} + + defp split_dynamic(descr), do: {descr, descr, false} + @doc """ Computes the union of two descrs. """ @@ -421,6 +480,12 @@ defmodule Module.Types.Descr do def bare_union(none, other) when none == @none, do: other def bare_union(other, none) when none == @none, do: other + def bare_union({_, _, _} = left, right), + do: bare_union(to_descr(left), to_descr(right)) + + def bare_union(left, {_, _, _} = right), + do: bare_union(to_descr(left), to_descr(right)) + def bare_union(left, right) do is_gradual_left = gradual?(left) is_gradual_right = gradual?(right) @@ -459,6 +524,12 @@ defmodule Module.Types.Descr do def bare_intersection(:term, other), do: remove_optional(other) def bare_intersection(other, :term), do: remove_optional(other) + def bare_intersection({_, _, _} = left, right), + do: bare_intersection(to_descr(left), to_descr(right)) + + def bare_intersection(left, {_, _, _} = right), + do: bare_intersection(to_descr(left), to_descr(right)) + def bare_intersection(left, right) do is_gradual_left = gradual?(left) is_gradual_right = gradual?(right) @@ -502,6 +573,12 @@ defmodule Module.Types.Descr do def bare_difference(left, :term), do: keep_optional(left) def bare_difference(left, none) when none == @none, do: left + def bare_difference({_, _, _} = left, right), + do: bare_difference(to_descr(left), to_descr(right)) + + def bare_difference(left, {_, _, _} = right), + do: bare_difference(to_descr(left), to_descr(right)) + def bare_difference(left, right) do if gradual?(left) or gradual?(right) do {left_dynamic, left_static} = pop_dynamic(left) @@ -544,8 +621,21 @@ defmodule Module.Types.Descr do the type is non-empty as we normalize then during construction. """ def empty?(:term), do: false + def empty?(%{} = descr), do: empty_seen?(descr, %{}) + def empty?({_, _, _} = node), do: empty_seen?(node, %{}) + + defp empty_seen?(:term, _seen), do: false + + defp empty_seen?({id, _state, _generator} = node, seen) do + if :erlang.is_map_key(id, seen) do + true + else + seen = Map.put(seen, id, true) + empty_seen?(to_descr(node), seen) + end + end - def empty?(%{} = descr) do + defp empty_seen?(%{} = descr, seen) do case :maps.get(:dynamic, descr, descr) do :term -> false @@ -553,26 +643,28 @@ defmodule Module.Types.Descr do value when value == @none -> true + {_, _, _} = node -> + empty_seen?(node, seen) + descr -> not Map.has_key?(descr, :atom) and not Map.has_key?(descr, :bitmap) and not Map.has_key?(descr, :optional) and - (not Map.has_key?(descr, :tuple) or tuple_empty?(descr.tuple)) and - (not Map.has_key?(descr, :map) or map_empty?(descr.map)) and - (not Map.has_key?(descr, :list) or list_empty?(descr.list)) and - (not Map.has_key?(descr, :fun) or fun_empty?(descr.fun)) + (not Map.has_key?(descr, :tuple) or tuple_empty?(descr.tuple, seen)) and + (not Map.has_key?(descr, :map) or map_empty?(descr.map, seen)) and + (not Map.has_key?(descr, :list) or list_empty?(descr.list, seen)) and + (not Map.has_key?(descr, :fun) or fun_empty?(descr.fun, seen)) end end defp empty_or_optional?(type), do: empty?(remove_optional(type)) - # For atom, bitmap, tuple, and optional, if the key is present, - # then they are not empty, - defp empty_key?(:fun, value), do: fun_empty?(value) - defp empty_key?(:map, value), do: map_empty?(value) - defp empty_key?(:list, value), do: list_empty?(value) - defp empty_key?(:tuple, value), do: tuple_empty?(value) - defp empty_key?(_, _value), do: false + defp empty_key?(key, value), do: empty_key?(key, value, %{}) + defp empty_key?(:fun, value, seen), do: fun_empty?(value, seen) + defp empty_key?(:map, value, seen), do: map_empty?(value, seen) + defp empty_key?(:list, value, seen), do: list_empty?(value, seen) + defp empty_key?(:tuple, value, seen), do: tuple_empty?(value, seen) + defp empty_key?(_, _value, _seen), do: false @doc """ Converts all floats or integers into numbers. @@ -861,33 +953,42 @@ defmodule Module.Types.Descr do defp subtype_static?(same, same), do: true defp subtype_static?(left, right), do: empty_difference_subtype?(left, right) + # Internal static subtype helper: unlike subtype?/2, it only checks empty?(left \ right) with seen. + defp subtype_seen?(left, right, seen), + do: empty_seen?(bare_difference(left, right), seen) + # This function is designed to compute the difference during subtyping efficiently. # Do not use it for anything else. - defp empty_difference_subtype?(%{dynamic: dyn_left} = left, %{dynamic: dyn_right} = right) do + defp empty_difference_subtype?(left, right), do: empty_difference_subtype?(left, right, %{}) + + defp empty_difference_subtype?(%{dynamic: dyn_left} = left, %{dynamic: dyn_right} = right, seen) do # Dynamic will either exist on both sides or on none - empty_difference_subtype?(dyn_left, dyn_right) and - empty_difference_subtype?(Map.delete(left, :dynamic), Map.delete(right, :dynamic)) + empty_difference_subtype?(dyn_left, dyn_right, seen) and + empty_difference_subtype?(Map.delete(left, :dynamic), Map.delete(right, :dynamic), seen) end - defp empty_difference_subtype?(left, :term), do: keep_optional(left) == @none + defp empty_difference_subtype?(left, :term, _seen), do: keep_optional(left) == @none - defp empty_difference_subtype?(left, right) do - iterator_empty_difference_subtype?(:maps.next(:maps.iterator(unfold(left))), unfold(right)) + defp empty_difference_subtype?(left, right, seen) do + left = unfold(left) + right = unfold(right) + + iterator_empty_difference_subtype?(:maps.next(:maps.iterator(left)), right, seen) end - defp iterator_empty_difference_subtype?({key, v1, iterator}, map) do + defp iterator_empty_difference_subtype?({key, v1, iterator}, map, seen) do case map do %{^key => v2} -> value = bare_difference(key, v1, v2) - value in @empty_difference or empty_key?(key, value) + value in @empty_difference or empty_key?(key, value, seen) %{} -> - empty_key?(key, v1) + empty_key?(key, v1, seen) end and - iterator_empty_difference_subtype?(:maps.next(iterator), map) + iterator_empty_difference_subtype?(:maps.next(iterator), map, seen) end - defp iterator_empty_difference_subtype?(:none, _map), do: true + defp iterator_empty_difference_subtype?(:none, _map, _seen), do: true @doc """ Check if a type is equal to another. @@ -910,8 +1011,10 @@ defmodule Module.Types.Descr do # Two gradual types are disjoint if their upper bounds are disjoint. def disjoint?(left, right) do - left_upper = unfold(left) |> Map.get(:dynamic, left) - right_upper = unfold(right) |> Map.get(:dynamic, right) + left = unfold(left) + right = unfold(right) + left_upper = Map.get(left, :dynamic, left) |> unfold() + right_upper = Map.get(right, :dynamic, right) |> unfold() not non_disjoint_intersection?(left_upper, right_upper) end @@ -1328,19 +1431,19 @@ defmodule Module.Types.Descr do # - `lower_bound(t)` extracts the lower bound (most specific type) of a gradual type. defp fun_descr(args, output) when is_list(args) do arity = length(args) - dynamic_arguments? = Enum.any?(args, &gradual?/1) - dynamic_output? = gradual?(output) - if dynamic_arguments? or dynamic_output? do - input_static = if dynamic_arguments?, do: Enum.map(args, &upper_bound/1), else: args - input_dynamic = if dynamic_arguments?, do: Enum.map(args, &lower_bound/1), else: args + {static_input_args, dynamic_input_args, dynamic_arguments?} = + Enum.reduce(args, {[], [], false}, fn arg, {static_acc, dynamic_acc, dynamic?} -> + {dynamic_arg, static_arg, arg_dynamic?} = split_dynamic(arg) + {[dynamic_arg | static_acc], [static_arg | dynamic_acc], dynamic? or arg_dynamic?} + end) - output_static = if dynamic_output?, do: lower_bound(output), else: output - output_dynamic = if dynamic_output?, do: upper_bound(output), else: output + {dynamic_output, static_output, dynamic_output?} = split_dynamic(output) + if dynamic_arguments? or dynamic_output? do %{ - fun: fun_new(arity, input_static, output_static), - dynamic: %{fun: fun_new(arity, input_dynamic, output_dynamic)} + fun: fun_new(arity, :lists.reverse(static_input_args), static_output), + dynamic: %{fun: fun_new(arity, :lists.reverse(dynamic_input_args), dynamic_output)} } else # No dynamic components, use standard function type @@ -1720,11 +1823,18 @@ defmodule Module.Types.Descr do do: bdd_to_dnf(bdd) |> Enum.filter(fn {pos, neg} -> not fun_line_empty?(pos, neg) end) # Check if all functions types for all arities are empty. - defp fun_empty?({:negation, _}), do: false + defp fun_empty?({:negation, _}, _seen), do: false - defp fun_empty?({:union, repr}) do - Enum.all?(repr, fn {_ar, bdd} -> - Enum.all?(bdd_to_dnf(bdd), fn {pos, neg} -> fun_line_empty?(pos, neg) end) + defp fun_empty?({:union, repr}, seen) do + Enum.all?(repr, fn {arity, bdd} -> + key = empty_bdd_seen_key(:fun, arity, bdd) + + if :erlang.is_map_key(key, seen) do + true + else + seen = Map.put(seen, key, true) + Enum.all?(bdd_to_dnf(bdd), fn {pos, neg} -> fun_line_empty?(pos, neg, seen) end) + end end) end @@ -1735,9 +1845,10 @@ defmodule Module.Types.Descr do # # - `{[fun(1)], []}` is not empty # - `{[fun(integer() -> atom())], [fun(none() -> term())]}` is empty - defp fun_line_empty?([], _), do: false + defp fun_line_empty?(positives, negatives), do: fun_line_empty?(positives, negatives, %{}) + defp fun_line_empty?([], _, _seen), do: false - defp fun_line_empty?(positives, negatives) do + defp fun_line_empty?(positives, negatives, seen) do # Check if any negative function negates the whole positive intersection # e.g. (integer() -> atom()) is negated by: # @@ -1748,8 +1859,8 @@ defmodule Module.Types.Descr do Enum.any?(negatives, fn bdd_leaf(neg_arguments, neg_return) -> # Check if the negative function's domain is a supertype of the positive # domain and if the phi function determines emptiness. - subtype?(args_to_domain(neg_arguments), fetch_domain(positives)) and - phi_starter(neg_arguments, neg_return, positives) + subtype_seen?(args_to_domain(neg_arguments), fetch_domain(positives), seen) and + phi_starter(neg_arguments, neg_return, positives, seen) end) end @@ -1775,12 +1886,12 @@ defmodule Module.Types.Descr do # Returns true if the intersection of the positives is a subtype of (t1,...,tn)->(not t). # # See [Castagna and Lanvin (2024)](https://arxiv.org/abs/2408.14345), Theorem 4.2. - defp phi_starter(arguments, return, positives) do + defp phi_starter(arguments, return, positives, seen) do # Optimization: When all positive functions have non-empty domains, # we can simplify the phi function check to a direct subtyping test. # This avoids the expensive recursive phi computation by checking only that applying the # input to the positive intersection yields a subtype of the return - case disjoint_non_empty_domains?({arguments, return}, positives) do + case disjoint_non_empty_domains?({arguments, return}, positives, seen) do :disjoint_non_empty -> apply_disjoint(arguments, positives) |> subtype?(return) @@ -1790,17 +1901,20 @@ defmodule Module.Types.Descr do _ -> # Initialize memoization cache for the recursive phi computation arguments = Enum.map(arguments, &{false, &1}) - {result, _cache} = phi(arguments, {false, bare_negation(return)}, positives, %{}) + {result, _cache} = phi(arguments, {false, bare_negation(return)}, positives, %{}, seen) result end end - defp phi(args, {b, t}, [], cache) do - result = Enum.any?(args, fn {bool, typ} -> bool and empty?(typ) end) or (b and empty?(t)) + defp phi(args, {b, t}, [], cache, seen) do + result = + Enum.any?(args, fn {bool, typ} -> bool and empty_seen?(typ, seen) end) or + (b and empty_seen?(t, seen)) + {result, Map.put(cache, {args, {b, t}, []}, result)} end - defp phi(args, {b, ret}, [bdd_leaf(arguments, return) = positive | rest_positive], cache) do + defp phi(args, {b, ret}, [bdd_leaf(arguments, return) = positive | rest_positive], cache, seen) do # Create cache key from function arguments cache_key = {args, {b, ret}, [positive | rest_positive]} @@ -1810,7 +1924,8 @@ defmodule Module.Types.Descr do %{} -> # Compute result and cache it - {result1, cache} = phi(args, {true, bare_intersection(ret, return)}, rest_positive, cache) + {result1, cache} = + phi(args, {true, bare_intersection(ret, return)}, rest_positive, cache, seen) if not result1 do cache = Map.put(cache, cache_key, false) @@ -1822,7 +1937,7 @@ defmodule Module.Types.Descr do {new_result, new_cache} = args |> List.update_at(index, fn {_, arg} -> {true, bare_difference(arg, type)} end) - |> phi({b, ret}, rest_positive, acc_cache) + |> phi({b, ret}, rest_positive, acc_cache, seen) if new_result do {:cont, {index + 1, acc_result and new_result, new_cache}} @@ -1837,12 +1952,12 @@ defmodule Module.Types.Descr do end end - defp disjoint_non_empty_domains?({arguments, _return}, positives) do + defp disjoint_non_empty_domains?({arguments, _return}, positives, seen) do b1 = all_disjoint_arguments?(positives) b2 = - Enum.all?(arguments, fn arg -> not empty?(arg) end) and - all_non_empty_arguments?(positives) + Enum.all?(arguments, fn arg -> not empty_seen?(arg, seen) end) and + all_non_empty_arguments?(positives, seen) cond do b1 and b2 -> :disjoint_non_empty @@ -1851,9 +1966,9 @@ defmodule Module.Types.Descr do end end - defp all_non_empty_arguments?(positives) do + defp all_non_empty_arguments?(positives, seen) do Enum.all?(positives, fn bdd_leaf(args, _ret) -> - Enum.all?(args, fn arg -> not empty?(arg) end) + Enum.all?(args, fn arg -> not empty_seen?(arg, seen) end) end) end @@ -2149,9 +2264,9 @@ defmodule Module.Types.Descr do # # none() types can be given and, while stored, it means the list type is empty. defp list_descr(list_type, last_type, empty?) do - dynamic? = gradual?(list_type) or gradual?(last_type) - {dynamic_list_type, static_list_type} = pop_dynamic(list_type) - {dynamic_last_type, static_last_type} = pop_dynamic(last_type) + {dynamic_list_type, static_list_type, dynamic_list?} = split_dynamic(list_type) + {dynamic_last_type, static_last_type, dynamic_last?} = split_dynamic(last_type) + dynamic? = dynamic_list? or dynamic_last? dynamic_descr = list_descr_static(dynamic_list_type, dynamic_last_type, empty?) # Just a syntactic check, to avoid a recursive empty? call static_empty? = static_list_type == @none or static_last_type == @none @@ -2174,33 +2289,38 @@ defmodule Module.Types.Descr do defp list_descr_static(list_type, last_type, empty?) do list_part = - if last_type == :term do - list_new(:term, :term) - else - case :maps.take(:list, last_type) do - :error -> - list_new(list_type, last_type) - - {bdd, last_type_no_list} -> - # `last_type` may itself represent one or more list types. - # Our goal is to fold those list types into `list_type` while retaining the - # possible type of the final element (which can be `[]` or any non-list value). - # - # The list types inside `last_type` are stored in a BDD that includes possible - # negations, so we must evaluate each node with its sign taken into account. - # - # A negation only matters when the negated list type is a supertype of the - # corresponding positive list type; in that case we subtract the negated - # variant from the positive one. This is done in list_bdd_to_pos_dnf/1. - {list_type, last_type} = - list_bdd_to_pos_dnf(bdd) - |> Enum.reduce({list_type, last_type_no_list}, fn - {list, last, _negs}, {acc_list, acc_last} -> - {bare_union(list, acc_list), bare_union(last, acc_last)} - end) + case last_type do + :term -> + list_new(:term, :term) - list_new(list_type, last_type) - end + {_, _, _} -> + list_new(list_type, last_type) + + %{} -> + case :maps.take(:list, last_type) do + :error -> + list_new(list_type, last_type) + + {bdd, last_type_no_list} -> + # `last_type` may itself represent one or more list types. + # Our goal is to fold those list types into `list_type` while retaining the + # possible type of the final element (which can be `[]` or any non-list value). + # + # The list types inside `last_type` are stored in a BDD that includes possible + # negations, so we must evaluate each node with its sign taken into account. + # + # A negation only matters when the negated list type is a supertype of the + # corresponding positive list type; in that case we subtract the negated + # variant from the positive one. This is done in list_bdd_to_pos_dnf/1. + {list_type, last_type} = + list_bdd_to_pos_dnf(bdd) + |> Enum.reduce({list_type, last_type_no_list}, fn + {list, last, _negs}, {acc_list, acc_last} -> + {bare_union(list, acc_list), bare_union(last, acc_last)} + end) + + list_new(list_type, last_type) + end end if empty?, do: %{list: list_part, bitmap: @bit_empty_list}, else: %{list: list_part} @@ -2208,14 +2328,17 @@ defmodule Module.Types.Descr do defp list_new(list_type, last_type), do: bdd_leaf_new(list_type, last_type) - defp non_empty_list_literals_intersection(list_literals) do + defp non_empty_list_literals_intersection(list_literals), + do: non_empty_list_literals_intersection(list_literals, %{}) + + defp non_empty_list_literals_intersection(list_literals, seen) do {list, last} = Enum.reduce(list_literals, {:term, :term}, fn bdd_leaf(next_list, next_last), {list, last} -> {bare_intersection(list, next_list), bare_intersection(last, next_last)} end) - if empty?(list) or empty?(last), do: :empty, else: {list, last} + if empty_seen?(list, seen) or empty_seen?(last, seen), do: :empty, else: {list, last} end # Takes all the lines from the root to the leaves finishing with a 1, @@ -2252,6 +2375,10 @@ defmodule Module.Types.Descr do end defp list_tail_unfold(:term), do: @not_non_empty_list + + defp list_tail_unfold({_, _, _} = node), + do: Map.delete(to_descr(node), :list) + defp list_tail_unfold(other), do: Map.delete(other, :list) @doc """ @@ -2374,30 +2501,44 @@ defmodule Module.Types.Descr do defp list_difference(bdd_leaf(:term, :term), bdd2), do: bdd_negation(bdd2) defp list_difference(bdd1, bdd2), do: bdd_difference(bdd1, bdd2) - defp list_empty?(@non_empty_list_top), do: false + defp list_empty?(@non_empty_list_top, _seen), do: false - defp list_empty?(bdd) do - bdd_to_dnf(bdd) - |> Enum.all?(fn {pos, negs} -> - case non_empty_list_literals_intersection(pos) do - :empty -> true - {list, last} -> list_line_empty?(list, last, negs) - end - end) + defp list_empty?(bdd, seen) do + key = empty_bdd_seen_key(:list, bdd) + + if :erlang.is_map_key(key, seen) do + true + else + seen = Map.put(seen, key, true) + + bdd_to_dnf(bdd) + |> Enum.all?(fn {pos, negs} -> + case non_empty_list_literals_intersection(pos, seen) do + :empty -> true + {list, last} -> list_line_empty?(list, last, negs, seen) + end + end) + end end - defp list_line_empty?(list_type, last_type, negs) do + defp list_line_empty?(list_type, last_type, negs, seen) do last_type = list_tail_unfold(last_type) # To make a list {list, last} empty with some negative lists: # 1. Ignore negative lists which do not have a list type that is a supertype of the positive one. # 2. Each of the list supertypes: # a. either completely covers the type, if its last type is a supertype of the positive one, # b. or it removes part of the last type. - empty?(list_type) or empty?(last_type) or + empty_seen?(list_type, seen) or empty_seen?(last_type, seen) or Enum.reduce_while(negs, last_type, fn bdd_leaf(neg_type, neg_last), acc_last_type -> - if subtype?(list_type, neg_type) do - d = bare_difference(acc_last_type, neg_last) - if empty?(d), do: {:halt, nil}, else: {:cont, d} + if subtype_seen?(list_type, neg_type, seen) do + neg_last = list_tail_unfold(neg_last) + + if subtype_seen?(acc_last_type, neg_last, seen) do + {:halt, nil} + else + d = bare_difference(acc_last_type, neg_last) + if empty_seen?(d, seen), do: {:halt, nil}, else: {:cont, d} + end else {:cont, acc_last_type} end @@ -2809,7 +2950,7 @@ defmodule Module.Types.Descr do end defp map_put_domain(domain, domain_keys, value) when is_list(domain_keys) do - map_put_domain(domain, :lists.usort(domain_keys), if_set(value), value) + map_put_domain(domain, :lists.usort(domain_keys), map_domain_if_set(value), value) end defp map_put_domain([{k1, v1} | t1], [k2 | _] = keys, initial, value) when k1 < k2 do @@ -2817,7 +2958,7 @@ defmodule Module.Types.Descr do end defp map_put_domain([{k1, v1} | t1], [k1 | keys], _initial, value) do - [{k1, bare_union(v1, value)} | map_put_domain(t1, keys, if_set(value), value)] + [{k1, bare_union(v1, value)} | map_put_domain(t1, keys, map_domain_if_set(value), value)] end defp map_put_domain(domain, [k2 | keys], initial, value) do @@ -2826,6 +2967,25 @@ defmodule Module.Types.Descr do defp map_put_domain(domain, [], _initial, _value), do: domain + # Map domains store if_set(value). For recursive nodes, keep that application lazy + # so constructing the descriptor does not unfold the same map domain forever. + defp map_domain_if_set({id, state, _generator} = node) do + case if_set_node_id(id, state) do + :already_if_set -> node + if_set_id -> make_node(if_set_id, state, fn _recur -> node |> unfold() |> if_set() end) + end + end + + defp map_domain_if_set(value), do: if_set(value) + + defp if_set_node_id(id, state) do + Enum.find_value(state, fn + {_name, {^id, if_set_id, _generator}} -> if_set_id + {_name, {_id, ^id, _generator}} -> :already_if_set + _ -> nil + end) + end + defp map_descr_pairs(pairs) do {fields, domains, dynamic_fields, dynamic_domains, dynamic?, static_possible?} = Enum.reduce(pairs, {[], @fields_new, [], @fields_new, false, false}, fn {key, value}, acc -> @@ -2841,8 +3001,8 @@ defmodule Module.Types.Descr do value, {fields, domains, dynamic_fields, dynamic_domains, dynamic?, static_empty?} ) do - {dynamic_value, static_value} = pop_dynamic(value) - dynamic? = dynamic? or gradual?(value) + {dynamic_value, static_value, value_dynamic?} = split_dynamic(value) + dynamic? = dynamic? or value_dynamic? static_empty? = static_empty? or static_value == @none if is_atom(key) do @@ -2899,14 +3059,19 @@ defmodule Module.Types.Descr do defp map_difference(bdd_leaf(:open, []), {_, _, _, _, _} = bdd2), do: bdd_negation(bdd2) defp map_difference(bdd1, bdd2), do: bdd_difference(bdd1, bdd2) + defp map_literal_intersection(tag1, map1, tag2, map2), + do: map_literal_intersection(tag1, map1, tag2, map2, &bare_intersection/2, %{}) + + defp map_literal_intersection(tag1, map1, tag2, map2, intersection_fun) + when is_function(intersection_fun, 2), + do: map_literal_intersection(tag1, map1, tag2, map2, intersection_fun, %{}) + # Intersects two map literals; throws if their intersection is empty. # Both open: the result is open. - defp map_literal_intersection(tag1, map1, tag2, map2, intersection_fun \\ &bare_intersection/2) - - defp map_literal_intersection(:open, map1, :open, map2, intersection_fun) do + defp map_literal_intersection(:open, map1, :open, map2, intersection_fun, seen) do new_fields = fields_merge( - fn _, type1, type2 -> non_empty_intersection!(type1, type2, intersection_fun) end, + fn _, type1, type2 -> non_empty_intersection!(type1, type2, intersection_fun, seen) end, map1, map2 ) @@ -2915,21 +3080,28 @@ defmodule Module.Types.Descr do end # Both closed: the result is closed. - defp map_literal_intersection(:closed, map1, :closed, map2, intersection_fun) do - {:closed, map_literal_intersection_closed(map1, map2, intersection_fun)} + defp map_literal_intersection(:closed, map1, :closed, map2, intersection_fun, seen) do + {:closed, map_literal_intersection_closed(map1, map2, intersection_fun, seen)} end # Open and closed: result is closed, all fields from open should be in closed, except not_set ones. - defp map_literal_intersection(:open, open, :closed, closed, intersection_fun) do - {:closed, map_literal_intersection_open_closed(open, closed, intersection_fun)} + defp map_literal_intersection(:open, open, :closed, closed, intersection_fun, seen) do + {:closed, map_literal_intersection_open_closed(open, closed, intersection_fun, seen)} end - defp map_literal_intersection(:closed, closed, :open, open, intersection_fun) do - {:closed, map_literal_intersection_open_closed(open, closed, intersection_fun)} + defp map_literal_intersection(:closed, closed, :open, open, intersection_fun, seen) do + {:closed, map_literal_intersection_open_closed(open, closed, intersection_fun, seen)} end # At least one tag is a tag-domain pair. - defp map_literal_intersection(tag_or_domains1, map1, tag_or_domains2, map2, intersection_fun) do + defp map_literal_intersection( + tag_or_domains1, + map1, + tag_or_domains2, + map2, + intersection_fun, + seen + ) do # For a closed map with domains intersected with an open map with domains: # 1. The result is closed (more restrictive) # 2. We need to check each domain in the open map against the closed map @@ -2947,7 +3119,7 @@ defmodule Module.Types.Descr do # using default values when a key is not present. {tag_or_domains, fields_merge_with_defaults(map1, default1, map2, default2, fn _key, v1, v2 -> - non_empty_intersection!(v1, v2, intersection_fun) + non_empty_intersection!(v1, v2, intersection_fun, seen) end)} end @@ -2985,29 +3157,44 @@ defmodule Module.Types.Descr do defp map_domain_intersection_fields(_, _), do: [] - defp map_literal_intersection_open_closed([{k1, v1} | t1], [{k2, _} | _] = l2, intersection_fun) + defp map_literal_intersection_open_closed( + [{k1, v1} | t1], + [{k2, _} | _] = l2, + intersection_fun, + seen + ) when k1 < k2 do # If the type in the open map is optional, we continue case v1 do - %{optional: 1} -> map_literal_intersection_open_closed(t1, l2, intersection_fun) + %{optional: 1} -> map_literal_intersection_open_closed(t1, l2, intersection_fun, seen) _ -> throw(:empty) end end - defp map_literal_intersection_open_closed([{k1, _} | _] = l1, [{k2, v2} | t2], intersection_fun) + defp map_literal_intersection_open_closed( + [{k1, _} | _] = l1, + [{k2, v2} | t2], + intersection_fun, + seen + ) when k1 > k2 do # Anything in the closed map not in open is preserved - [{k2, v2} | map_literal_intersection_open_closed(l1, t2, intersection_fun)] + [{k2, v2} | map_literal_intersection_open_closed(l1, t2, intersection_fun, seen)] end - defp map_literal_intersection_open_closed([{key, v1} | t1], [{_, v2} | t2], intersection_fun) do + defp map_literal_intersection_open_closed( + [{key, v1} | t1], + [{_, v2} | t2], + intersection_fun, + seen + ) do [ - {key, non_empty_intersection!(v1, v2, intersection_fun)} - | map_literal_intersection_open_closed(t1, t2, intersection_fun) + {key, non_empty_intersection!(v1, v2, intersection_fun, seen)} + | map_literal_intersection_open_closed(t1, t2, intersection_fun, seen) ] end - defp map_literal_intersection_open_closed(t1, t2, _intersection_fun) do + defp map_literal_intersection_open_closed(t1, t2, _intersection_fun, _seen) do if Enum.all?(t1, fn {_, v} -> match?(%{optional: 1}, v) end) do t2 else @@ -3015,32 +3202,42 @@ defmodule Module.Types.Descr do end end - defp map_literal_intersection_closed([{k1, v1} | t1], [{k2, _} | _] = l2, intersection_fun) + defp map_literal_intersection_closed( + [{k1, v1} | t1], + [{k2, _} | _] = l2, + intersection_fun, + seen + ) when k1 < k2 do if is_optional_static(v1) do - map_literal_intersection_closed(t1, l2, intersection_fun) + map_literal_intersection_closed(t1, l2, intersection_fun, seen) else throw(:empty) end end - defp map_literal_intersection_closed([{k1, _} | _] = l1, [{k2, v2} | t2], intersection_fun) + defp map_literal_intersection_closed( + [{k1, _} | _] = l1, + [{k2, v2} | t2], + intersection_fun, + seen + ) when k1 > k2 do if is_optional_static(v2) do - map_literal_intersection_closed(l1, t2, intersection_fun) + map_literal_intersection_closed(l1, t2, intersection_fun, seen) else throw(:empty) end end - defp map_literal_intersection_closed([{key, v1} | t1], [{_, v2} | t2], intersection_fun) do + defp map_literal_intersection_closed([{key, v1} | t1], [{_, v2} | t2], intersection_fun, seen) do [ - {key, non_empty_intersection!(v1, v2, intersection_fun)} - | map_literal_intersection_closed(t1, t2, intersection_fun) + {key, non_empty_intersection!(v1, v2, intersection_fun, seen)} + | map_literal_intersection_closed(t1, t2, intersection_fun, seen) ] end - defp map_literal_intersection_closed(t1, t2, _intersection_fun) do + defp map_literal_intersection_closed(t1, t2, _intersection_fun, _seen) do if Enum.any?(t1, fn {_, v} -> not is_optional_static(v) end) or Enum.any?(t2, fn {_, v} -> not is_optional_static(v) end) do throw(:empty) @@ -3054,6 +3251,11 @@ defmodule Module.Types.Descr do if empty?(type), do: throw(:empty), else: type end + defp non_empty_intersection!(type1, type2, intersection_fun, seen) do + type = intersection_fun.(type1, type2) + if empty_seen?(type, seen), do: throw(:empty), else: type + end + defp map_bdd_to_dnf_remove_empty(bdd) do bdd_to_dnf(bdd) |> Enum.reduce([], fn {pos, negs}, acc -> @@ -4109,10 +4311,16 @@ defmodule Module.Types.Descr do {[], [], nil, to_domain_keys(key_descr), []} end - defp non_empty_map_literals_intersection(maps, intersection_fun \\ &bare_intersection/2) do + defp non_empty_map_literals_intersection(maps), + do: non_empty_map_literals_intersection(maps, %{}, &bare_intersection/2) + + defp non_empty_map_literals_intersection(maps, seen) when is_map(seen), + do: non_empty_map_literals_intersection(maps, seen, &bare_intersection/2) + + defp non_empty_map_literals_intersection(maps, seen, intersection_fun) do try do Enum.reduce(maps, {:open, []}, fn bdd_leaf(next_tag, next_fields), {tag, fields} -> - map_literal_intersection(tag, fields, next_tag, next_fields, intersection_fun) + map_literal_intersection(tag, fields, next_tag, next_fields, intersection_fun, seen) end) catch :empty -> :empty @@ -4122,58 +4330,79 @@ defmodule Module.Types.Descr do # Short-circuits if it finds a non-empty map literal in the union. # Since the algorithm is recursive, we implement the short-circuiting # as throw/catch. - defp map_empty?(bdd) do - bdd_to_dnf(bdd) - |> Enum.all?(fn {pos, negs} -> - case non_empty_map_literals_intersection(pos) do - :empty -> - true + defp map_empty?(bdd), do: map_empty?(bdd, %{}) - {tag, fields} -> - # We check the emptiness of the fields because non_empty_map_literal_intersection - # will not return :empty on fields that are set to none() and that exist - # just in one map, but not the other. - init_map_line_empty?(tag, fields, negs) - end - end) + defp map_empty?(bdd, seen) do + key = empty_bdd_seen_key(:map, bdd) + + if :erlang.is_map_key(key, seen) do + true + else + seen = Map.put(seen, key, true) + + bdd_to_dnf(bdd) + |> Enum.all?(fn {pos, negs} -> + case non_empty_map_literals_intersection(pos, seen) do + :empty -> + true + + {tag, fields} -> + # We check the emptiness of the fields because non_empty_map_literal_intersection + # will not return :empty on fields that are set to none() and that exist + # just in one map, but not the other. + init_map_line_empty?(tag, fields, negs, seen) + end + end) + end end - defp init_map_line_empty?(tag, fields, negs) do - Enum.any?(fields_to_list(fields), fn {_key, type} -> empty?(type) end) or - map_line_empty?(tag, fields, negs) + defp init_map_line_empty?(tag, fields, negs), do: init_map_line_empty?(tag, fields, negs, %{}) + + defp init_map_line_empty?(tag, fields, negs, seen) do + Enum.any?(fields_to_list(fields), fn {_key, type} -> empty_seen?(type, seen) end) or + map_line_empty?(tag, fields, negs, seen) end # These positives get checked once when calling init_map_line_empty?, and then every time # an intersection or difference is computed, its emptiness is checked again. # So they are all necessarily non-empty. - defp map_line_empty?(_, _pos, []), do: false - defp map_line_empty?(_, _, [bdd_leaf(:open, []) | _]), do: true + defp map_line_empty?(_, _pos, [], _seen), do: false + + defp map_line_empty?(_, _, [bdd_leaf(:open, []) | _], _seen), do: true - defp map_line_empty?(:open, fs, [bdd_leaf(:closed, _) | negs]), - do: map_line_empty?(:open, fs, negs) + defp map_line_empty?(:open, fs, [bdd_leaf(:closed, _) | negs], seen), + do: map_line_empty?(:open, fs, negs, seen) - defp map_line_empty?(tag, fields, [bdd_leaf(neg_tag, neg_fields) | negs]) do - if map_check_domain_keys?(tag, neg_tag) do + defp map_line_empty?(tag, fields, [bdd_leaf(neg_tag, neg_fields) | negs], seen) do + if map_check_domain_keys?(tag, neg_tag, seen) do if tag == :closed or neg_tag == :open do # This implements the same map line check as tuples - map_line_meet_empty?(fields, neg_fields, tag, neg_tag, [], negs) + map_line_meet_empty?(fields, neg_fields, tag, neg_tag, [], negs, seen) else - map_line_fields_empty?(fields, neg_fields, tag, neg_tag, fields, negs) + map_line_fields_empty?(fields, neg_fields, tag, neg_tag, fields, negs, seen) end else - map_line_empty?(tag, fields, negs) + map_line_empty?(tag, fields, negs, seen) end catch - :closed -> map_line_empty?(tag, fields, negs) - end - - defp map_line_meet_empty?([{k1, v1} | t1], [{k2, _} | _] = l2, tag, neg_tag, acc_meet, negs) + :closed -> map_line_empty?(tag, fields, negs, seen) + end + + defp map_line_meet_empty?( + [{k1, v1} | t1], + [{k2, _} | _] = l2, + tag, + neg_tag, + acc_meet, + negs, + seen + ) when k1 < k2 do cond do # The key is only in the positive map, which means the difference # with a negative open tag (all possible types) tag will surely be empty. neg_tag == :open -> - map_line_meet_empty?(t1, l2, tag, neg_tag, [{k1, v1} | acc_meet], negs) + map_line_meet_empty?(t1, l2, tag, neg_tag, [{k1, v1} | acc_meet], negs, seen) # In this case the difference will never be empty, so we can skip ahead. neg_tag == :closed and not is_optional_static(v1) -> @@ -4181,11 +4410,19 @@ defmodule Module.Types.Descr do true -> v2 = map_key_tag_to_type(neg_tag) - map_line_meet_empty?(k1, v1, v2, t1, l2, tag, neg_tag, acc_meet, negs) + map_line_meet_empty?(k1, v1, v2, t1, l2, tag, neg_tag, acc_meet, negs, seen) end end - defp map_line_meet_empty?([{k1, _} | _] = l1, [{k2, v2} | t2], tag, neg_tag, acc_meet, negs) + defp map_line_meet_empty?( + [{k1, _} | _] = l1, + [{k2, v2} | t2], + tag, + neg_tag, + acc_meet, + negs, + seen + ) when k1 > k2 do # The keys is only in the negative map and the positive map is closed, # in that case, this field is not_set(), and its difference with the @@ -4194,113 +4431,157 @@ defmodule Module.Types.Descr do throw(:closed) else v1 = map_key_tag_to_type(tag) - map_line_meet_empty?(k2, v1, v2, l1, t2, tag, neg_tag, acc_meet, negs) + map_line_meet_empty?(k2, v1, v2, l1, t2, tag, neg_tag, acc_meet, negs, seen) end end - defp map_line_meet_empty?([{k, v1} | t1], [{_, v2} | t2], tag, neg_tag, acc_meet, negs) do - map_line_meet_empty?(k, v1, v2, t1, t2, tag, neg_tag, acc_meet, negs) + defp map_line_meet_empty?( + [{k, v1} | t1], + [{_, v2} | t2], + tag, + neg_tag, + acc_meet, + negs, + seen + ) do + map_line_meet_empty?(k, v1, v2, t1, t2, tag, neg_tag, acc_meet, negs, seen) end - defp map_line_meet_empty?([{k1, v1} | t1], [], tag, neg_tag, acc_meet, negs) do + defp map_line_meet_empty?([{k1, v1} | t1], [], tag, neg_tag, acc_meet, negs, seen) do v2 = map_key_tag_to_type(neg_tag) - map_line_meet_empty?(k1, v1, v2, t1, [], tag, neg_tag, acc_meet, negs) + map_line_meet_empty?(k1, v1, v2, t1, [], tag, neg_tag, acc_meet, negs, seen) end - defp map_line_meet_empty?([], [{k2, v2} | t2], tag, neg_tag, acc_meet, negs) do + defp map_line_meet_empty?([], [{k2, v2} | t2], tag, neg_tag, acc_meet, negs, seen) do v1 = map_key_tag_to_type(tag) - map_line_meet_empty?(k2, v1, v2, [], t2, tag, neg_tag, acc_meet, negs) + map_line_meet_empty?(k2, v1, v2, [], t2, tag, neg_tag, acc_meet, negs, seen) end - defp map_line_meet_empty?([], [], _tag, _neg_tag, _acc_meet, _negs) do + defp map_line_meet_empty?([], [], _tag, _neg_tag, _acc_meet, _negs, _seen) do true end - defp map_line_meet_empty?(key, type, neg_type, t1, t2, tag, neg_tag, acc_meet, negs) do + defp map_line_meet_empty?(key, type, neg_type, t1, t2, tag, neg_tag, acc_meet, negs, seen) do diff = bare_difference(type, neg_type) meet = bare_intersection(type, neg_type) - (empty?(diff) or map_line_empty?(tag, Enum.reverse(acc_meet, [{key, diff} | t1]), negs)) and - (empty?(meet) or map_line_meet_empty?(t1, t2, tag, neg_tag, [{key, meet} | acc_meet], negs)) - end - - defp map_line_fields_empty?([{k1, v1} | t1], [{k2, _} | _] = l2, tag, neg_tag, fields, negs) + (empty_seen?(diff, seen) or + map_line_empty?(tag, Enum.reverse(acc_meet, [{key, diff} | t1]), negs, seen)) and + (empty_seen?(meet, seen) or + map_line_meet_empty?(t1, t2, tag, neg_tag, [{key, meet} | acc_meet], negs, seen)) + end + + defp map_line_fields_empty?( + [{k1, v1} | t1], + [{k2, _} | _] = l2, + tag, + neg_tag, + fields, + negs, + seen + ) when k1 < k2 do cond do # The key is only in the positive map, which means the difference # with a negative open tag (all possible types) tag will surely be empty. neg_tag == :open -> - map_line_fields_empty?(t1, l2, tag, neg_tag, fields, negs) + map_line_fields_empty?(t1, l2, tag, neg_tag, fields, negs, seen) # In this case the difference will never be empty, so we can skip ahead. neg_tag == :closed and not is_optional_static(v1) -> throw(:closed) true -> - map_line_fields_empty_recur?(k1, v1, map_key_tag_to_type(neg_tag), tag, fields, negs) and - map_line_fields_empty?(t1, l2, tag, neg_tag, fields, negs) - end - end - - defp map_line_fields_empty?([{k1, _} | _] = l1, [{k2, v2} | t2], tag, neg_tag, fields, negs) + map_line_fields_empty_recur?( + k1, + v1, + map_key_tag_to_type(neg_tag), + tag, + fields, + negs, + seen + ) and + map_line_fields_empty?(t1, l2, tag, neg_tag, fields, negs, seen) + end + end + + defp map_line_fields_empty?( + [{k1, _} | _] = l1, + [{k2, v2} | t2], + tag, + neg_tag, + fields, + negs, + seen + ) when k1 > k2 do # The keys is only in the negative map and the positive map is closed, # in that case, this field is not_set(), and its difference with the # negative map type is empty iff the negative type is optional. if tag == :closed do if is_optional_static(v2) do - map_line_fields_empty?(l1, t2, tag, neg_tag, fields, negs) + map_line_fields_empty?(l1, t2, tag, neg_tag, fields, negs, seen) else throw(:closed) end else - map_line_fields_empty_recur?(k2, map_key_tag_to_type(tag), v2, tag, fields, negs) and - map_line_fields_empty?(l1, t2, tag, neg_tag, fields, negs) + map_line_fields_empty_recur?(k2, map_key_tag_to_type(tag), v2, tag, fields, negs, seen) and + map_line_fields_empty?(l1, t2, tag, neg_tag, fields, negs, seen) end end - defp map_line_fields_empty?([{key, v1} | t1], [{_, v2} | t2], tag, neg_tag, fields, negs) do - map_line_fields_empty_recur?(key, v1, v2, tag, fields, negs) and - map_line_fields_empty?(t1, t2, tag, neg_tag, fields, negs) + defp map_line_fields_empty?( + [{key, v1} | t1], + [{_, v2} | t2], + tag, + neg_tag, + fields, + negs, + seen + ) do + map_line_fields_empty_recur?(key, v1, v2, tag, fields, negs, seen) and + map_line_fields_empty?(t1, t2, tag, neg_tag, fields, negs, seen) end - defp map_line_fields_empty?(t1, t2, tag, neg_tag, fields, negs) do + defp map_line_fields_empty?(t1, t2, tag, neg_tag, fields, negs, seen) do Enum.all?(t1, fn {key, v1} -> - map_line_fields_empty_recur?(key, v1, map_key_tag_to_type(neg_tag), tag, fields, negs) + map_line_fields_empty_recur?(key, v1, map_key_tag_to_type(neg_tag), tag, fields, negs, seen) end) and Enum.all?(t2, fn {key, v2} -> - map_line_fields_empty_recur?(key, map_key_tag_to_type(tag), v2, tag, fields, negs) + map_line_fields_empty_recur?(key, map_key_tag_to_type(tag), v2, tag, fields, negs, seen) end) end - defp map_line_fields_empty_recur?(key, v1, v2, tag, fields, negs) do + defp map_line_fields_empty_recur?(key, v1, v2, tag, fields, negs, seen) do diff = bare_difference(v1, v2) - empty?(diff) or map_line_empty?(tag, fields_store(key, diff, fields), negs) + empty_seen?(diff, seen) or map_line_empty?(tag, fields_store(key, diff, fields), negs, seen) end # Verify the domain condition from equation (22) in paper ICFP'23 https://www.irif.fr/~gc/papers/icfp23.pdf # which is that every domain key type in the positive map is a subtype # of the corresponding domain key type in the negative map. - defp map_check_domain_keys?(:closed, _), do: true - defp map_check_domain_keys?(_, :open), do: true + defp map_check_domain_keys?(:closed, _, _seen), do: true + defp map_check_domain_keys?(_, :open, _seen), do: true # An open map is a subtype iff the negative domains are all present as term_or_optional() - defp map_check_domain_keys?(:open, neg_domains) do + defp map_check_domain_keys?(:open, neg_domains, seen) do fields_size(neg_domains) == length(@domain_key_types) and Enum.all?(fields_to_list(neg_domains), fn {_domain_key, type} -> - subtype?(term_or_optional(), type) + subtype_seen?(term_or_optional(), type, seen) end) end # A positive domains is smaller than a closed map iff all its keys are empty or optional - defp map_check_domain_keys?(pos_domains, :closed) do - Enum.all?(fields_to_list(pos_domains), fn {_domain_key, type} -> empty_or_optional?(type) end) + defp map_check_domain_keys?(pos_domains, :closed, seen) do + Enum.all?(fields_to_list(pos_domains), fn {_domain_key, type} -> + empty_seen?(remove_optional(type), seen) + end) end # Component-wise comparison of domains - defp map_check_domain_keys?(pos_domains, neg_domains) do + defp map_check_domain_keys?(pos_domains, neg_domains, seen) do Enum.all?(fields_to_list(pos_domains), fn {domain_key, type} -> - subtype?(type, fields_get(neg_domains, domain_key, not_set())) + subtype_seen?(type, fields_get(neg_domains, domain_key, not_set()), seen) end) end @@ -4583,13 +4864,13 @@ defmodule Module.Types.Descr do defp tuple_descr_static(tag, fields), do: %{tuple: tuple_new(tag, :lists.reverse(fields))} defp tuple_descr_fields([value | rest], acc, dynamic_acc, dynamic?, static_empty?) do - {dynamic_value, static_value} = pop_dynamic(value) + {dynamic_value, static_value, value_dynamic?} = split_dynamic(value) tuple_descr_fields( rest, [static_value | acc], [dynamic_value | dynamic_acc], - dynamic? or gradual?(value), + dynamic? or value_dynamic?, static_empty? or static_value == @none ) end @@ -4667,9 +4948,15 @@ defmodule Module.Types.Descr do do: bdd_negation(bdd2) defp tuple_difference(bdd1, bdd2), - do: bdd_difference(bdd1, bdd2, &opt_tuple_leaf_difference/3) + do: bdd_difference(bdd1, bdd2) + + defp non_empty_tuple_literals_intersection(tuples), + do: non_empty_tuple_literals_intersection(tuples, %{}, &bare_intersection/2) - defp non_empty_tuple_literals_intersection(tuples, intersection_fun \\ &bare_intersection/2) do + defp non_empty_tuple_literals_intersection(tuples, seen) when is_map(seen), + do: non_empty_tuple_literals_intersection(tuples, seen, &bare_intersection/2) + + defp non_empty_tuple_literals_intersection(tuples, seen, intersection_fun) do try do Enum.reduce(tuples, {:open, []}, fn bdd_leaf(tag1, elements1), {tag2, elements2} -> case tuple_sizes_strategy(tag1, length(elements1), tag2, length(elements2)) do @@ -4685,7 +4972,7 @@ defmodule Module.Types.Descr do :empty -> :empty else {tag, elements} -> - if Enum.any?(elements, &empty?/1) do + if Enum.any?(elements, &empty_seen?(&1, seen)) do :empty else {tag, elements} @@ -4700,27 +4987,37 @@ defmodule Module.Types.Descr do zip_intersection(rest1, rest2, [intersection_fun.(type1, type2) | acc], intersection_fun) end - defp tuple_empty?(bdd) do - bdd_to_dnf(bdd) - |> Enum.all?(fn {pos, negs} -> - case non_empty_tuple_literals_intersection(pos) do - :empty -> true - {tag, fields} -> tuple_line_empty?(tag, fields, negs) - end - end) + defp tuple_empty?(bdd), do: tuple_empty?(bdd, %{}) + + defp tuple_empty?(bdd, seen) do + key = empty_bdd_seen_key(:tuple, bdd) + + if :erlang.is_map_key(key, seen) do + true + else + seen = Map.put(seen, key, true) + + bdd_to_dnf(bdd) + |> Enum.all?(fn {pos, negs} -> + case non_empty_tuple_literals_intersection(pos, seen) do + :empty -> true + {_tag, _fields} when negs == [] -> false + {tag, fields} -> tuple_line_empty?(tag, fields, negs, seen) + end + end) + end end - # No negations, so not empty unless there's an empty type - # Note: since the extraction from the BDD is done in a way that guarantees that - # the elements are non-empty, we can avoid checking for empty types there. - # Otherwise, tuple_empty?(_, elements, []) would be Enum.any?(elements, &empty?/1) - defp tuple_line_empty?(_, _, []), do: false + defp tuple_line_empty?(tag, elements, negs), do: tuple_line_empty?(tag, elements, negs, %{}) + + defp tuple_line_empty?(_, _, [], _seen), do: false + # Open empty negation makes it empty - defp tuple_line_empty?(_, _, [bdd_leaf(:open, []) | _]), do: true + defp tuple_line_empty?(_, _, [bdd_leaf(:open, []) | _], _seen), do: true # Open positive can't be emptied by a single closed negative - defp tuple_line_empty?(:open, _pos, [bdd_leaf(:closed, _)]), do: false + defp tuple_line_empty?(:open, _pos, [bdd_leaf(:closed, _)], _seen), do: false - defp tuple_line_empty?(tag, elements, [bdd_leaf(neg_tag, neg_elements) | negs]) do + defp tuple_line_empty?(tag, elements, [bdd_leaf(neg_tag, neg_elements) | negs], seen) do n = length(elements) m = length(neg_elements) @@ -4728,37 +5025,40 @@ defmodule Module.Types.Descr do # 1. When removing larger tuples from a fixed-size positive tuple # 2. When removing smaller tuples from larger tuples if (tag == :closed and n < m) or (neg_tag == :closed and n > m) do - tuple_line_empty?(tag, elements, negs) + tuple_line_empty?(tag, elements, negs, seen) else - tuple_elements_empty?([], tag, elements, neg_elements, negs) and - tuple_empty_arity?(n, m, tag, elements, neg_tag, negs) + tuple_elements_empty?([], tag, elements, neg_elements, negs, seen) and + tuple_empty_arity?(n, m, tag, elements, neg_tag, negs, seen) end end # Recursively check elements for emptiness - defp tuple_elements_empty?(_, _, _, [], _), do: true + defp tuple_elements_empty?(_, _, _, [], _, _seen), do: true - defp tuple_elements_empty?(acc_meet, tag, elements, [neg_type | neg_elements], negs) do + defp tuple_elements_empty?(acc_meet, tag, elements, [neg_type | neg_elements], negs, seen) do # Handles the case where {tag, elements} is an open tuple, like {:open, []} {ty, elements} = List.pop_at(elements, 0, term()) - diff = bare_difference(ty, neg_type) - meet = bare_intersection(ty, neg_type) # In this case, there is no intersection between the positive and this negative. # So we should just "go next" - (empty?(diff) or tuple_line_empty?(tag, Enum.reverse(acc_meet, [diff | elements]), negs)) and - (empty?(meet) or tuple_elements_empty?([meet | acc_meet], tag, elements, neg_elements, negs)) + diff = bare_difference(ty, neg_type) + meet = bare_intersection(ty, neg_type) + + (empty_seen?(diff, seen) or + tuple_line_empty?(tag, Enum.reverse(acc_meet, [diff | elements]), negs, seen)) and + (empty_seen?(meet, seen) or + tuple_elements_empty?([meet | acc_meet], tag, elements, neg_elements, negs, seen)) end # Determines if the set difference is empty when: # - Positive tuple: {tag, elements} of size n # - Negative tuple: open or closed tuples of size m - defp tuple_empty_arity?(n, m, tag, elements, neg_tag, negs) do + defp tuple_empty_arity?(n, m, tag, elements, neg_tag, negs, seen) do # The tuples to consider are all those of size n to m - 1, and if the negative tuple is # closed, we also need to consider tuples of size greater than m + 1. tag == :closed or - (Enum.all?(n..(m - 1)//1, &tuple_line_empty?(:closed, tuple_fill(elements, &1), negs)) and - (neg_tag == :open or tuple_line_empty?(:open, tuple_fill(elements, m + 1), negs))) + (Enum.all?(n..(m - 1)//1, &tuple_line_empty?(:closed, tuple_fill(elements, &1), negs, seen)) and + (neg_tag == :open or tuple_line_empty?(:open, tuple_fill(elements, m + 1), negs, seen))) end defp tuple_eliminate_negations(tag, elements, negs) do @@ -6105,6 +6405,12 @@ defmodule Module.Types.Descr do def opt_union(none, other) when none == @none, do: other def opt_union(other, none) when none == @none, do: other + def opt_union({_, _, _} = left, right), + do: opt_union(to_descr(left), to_descr(right)) + + def opt_union(left, {_, _, _} = right), + do: opt_union(to_descr(left), to_descr(right)) + def opt_union(left, right) do is_gradual_left = gradual?(left) is_gradual_right = gradual?(right) @@ -6143,6 +6449,12 @@ defmodule Module.Types.Descr do def opt_intersection(:term, other), do: remove_optional(other) def opt_intersection(other, :term), do: remove_optional(other) + def opt_intersection({_, _, _} = left, right), + do: opt_intersection(to_descr(left), to_descr(right)) + + def opt_intersection(left, {_, _, _} = right), + do: opt_intersection(to_descr(left), to_descr(right)) + def opt_intersection(left, right) do is_gradual_left = gradual?(left) is_gradual_right = gradual?(right) @@ -6185,6 +6497,12 @@ defmodule Module.Types.Descr do def opt_difference(left, :term), do: keep_optional(left) def opt_difference(left, none) when none == @none, do: left + def opt_difference({_, _, _} = left, right), + do: opt_difference(to_descr(left), to_descr(right)) + + def opt_difference(left, {_, _, _} = right), + do: opt_difference(to_descr(left), to_descr(right)) + def opt_difference(left, right) do if gradual?(left) or gradual?(right) do {left_dynamic, left_static} = pop_dynamic(left) @@ -6216,6 +6534,10 @@ defmodule Module.Types.Descr do Compute the negation of a type. """ def opt_negation(:term), do: none() + + def opt_negation({_, _, _} = node), + do: opt_negation(to_descr(node)) + def opt_negation(%{} = descr), do: opt_difference(term(), descr) @compile {:inline, maybe_opt_union: 2} diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index a79b2aa956c..30afd3dd533 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -302,6 +302,13 @@ defmodule Module.Types.DescrTest do end describe "if_set" do + test "unfolds recursive nodes" do + %{X: node} = recursive(%{X: fn _recur -> integer() end}) + + assert if_set(node) == if_set(integer()) + refute is_map_key(if_set(node), :dynamic) + end + test "preserves static parts alongside dynamic term" do type = opt_union(atom([:value]), dynamic()) |> if_set() diff --git a/lib/elixir/test/elixir/module/types/recursive_test.exs b/lib/elixir/test/elixir/module/types/recursive_test.exs new file mode 100644 index 00000000000..99d8b863b5b --- /dev/null +++ b/lib/elixir/test/elixir/module/types/recursive_test.exs @@ -0,0 +1,563 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2021 The Elixir Team + +Code.require_file("type_helper.exs", __DIR__) + +defmodule Module.Types.RecursiveTest do + use ExUnit.Case, async: true + + import Module.Types.Descr + + describe "recursive types" do + defp recursive_node(descr) do + recursive(%{X: fn _recur -> descr end}) + |> Map.fetch!(:X) + end + + defp assert_finishes(fun) do + task = Task.async(fun) + + case Task.yield(task, 1_000) || Task.shutdown(task, :brutal_kill) do + {:ok, result} -> result + nil -> flunk("operation did not finish") + end + end + + # linked_list(T) = {T, linked_list(T)} | nil + defp linked_list(descr) do + recursive(%{ + X: fn recur -> tuple([descr, recur.(:X)]) |> bare_union(atom([nil])) end + }) + |> Map.fetch!(:X) + |> to_descr() + end + + defp int_linked_list(), do: linked_list(integer()) + defp number_linked_list(), do: linked_list(opt_union(integer(), float())) + + # binary_tree(T) = {T, binary_tree(T), binary_tree(T)} | nil + defp binary_tree(descr) do + recursive(%{ + T: fn recur -> + tuple([descr, recur.(:T), recur.(:T)]) + |> bare_union(atom([nil])) + end + }) + |> Map.fetch!(:T) + |> to_descr() + end + + test "node infrastructure" do + descr = integer() + + assert to_descr(recursive_node(descr)) == descr + + assert to_descr(:term) == :term + + d = opt_union(integer(), atom()) + assert to_descr(d) == d + + node = + recursive(%{ + X: fn _ -> integer() end + }) + |> Map.fetch!(:X) + + assert to_descr(node) == integer() + + d = opt_union(tuple([integer(), atom()]), list(float())) + assert to_descr(recursive_node(d)) == d + end + + test "constructors accept nodes and descrs" do + n1 = recursive_node(integer()) + n2 = recursive_node(atom()) + + result = tuple([n1, n2]) + refute empty?(result) + + n = recursive_node(integer()) + result = tuple([n, float()]) + assert equal?(result, tuple([integer(), float()])) + + n = recursive_node(integer()) + from_node = list(n) + from_descr = list(integer()) + assert equal?(from_node, from_descr) + + elem_node = recursive_node(integer()) + tail_node = recursive_node(empty_list()) + + from_nodes = non_empty_list(elem_node, tail_node) + from_descrs = non_empty_list(integer(), empty_list()) + assert equal?(from_nodes, from_descrs) + + n = recursive_node(integer()) + result = closed_map(a: n) + refute empty?(result) + + n = recursive_node(atom()) + result = open_map(b: n) + refute empty?(result) + end + + test "real-world types" do + ## integer linked list: X = {integer(), X} | nil + int_list = int_linked_list() + assert is_map_key(int_list, :tuple) and is_map_key(int_list, :atom) + + # {integer(), nil} <: X + assert subtype?(tuple([integer(), atom([nil])]), int_list) + # nil <: X + assert subtype?(atom([nil]), int_list) + # {integer(), {integer(), nil}} <: X + assert subtype?(tuple([integer(), tuple([integer(), atom([nil])])]), int_list) + # {integer(), integer()} json_value} + %{Json: json_value_node} = + recursive(%{ + Json: fn recur -> + scalar = + atom([nil]) + |> bare_union(boolean()) + |> bare_union(integer()) + |> bare_union(float()) + |> bare_union(binary()) + + array = list(recur.(:Json)) + object = open_map([{to_domain_keys(binary()), recur.(:Json)}]) + + scalar + |> bare_union(array) + |> bare_union(object) + end + }) + + json_value = to_descr(json_value_node) + + assert subtype?(atom([nil]), json_value) + assert subtype?(list(binary()), json_value) + assert subtype?(empty_map(), json_value) + refute subtype?(pid(), json_value) + + ## IO.chardata recursive type + %{Char: chardata_node} = + recursive(%{ + Char: fn recur -> + head = integer() |> bare_union(binary()) + tail = recur.(:Char) + + binary() + |> bare_union(empty_list()) + |> bare_union(non_empty_list(head, tail)) + end + }) + + chardata = to_descr(chardata_node) + + assert subtype?(binary(), chardata) + assert subtype?(empty_list(), chardata) + assert subtype?(non_empty_list(integer(), empty_list()), chardata) + assert subtype?(non_empty_list(binary(), binary()), chardata) + refute subtype?(pid(), chardata) + + ## expression trees + # Expr = integer() | {atom, Expr, Expr}, Binop = {atom, Expr, Expr} + %{Expr: nexpr_node, Binop: nbinop_node} = + recursive(%{ + Expr: fn recur -> + bare_union( + integer(), + tuple([ + atom(), + recur.(:Expr), + recur.(:Expr) + ]) + ) + end, + Binop: fn recur -> + tuple([atom(), recur.(:Expr), recur.(:Expr)]) + end + }) + + texpr = to_descr(nexpr_node) + tbinop = to_descr(nbinop_node) + + refute empty?(texpr) + refute empty?(tbinop) + + # 42 is an Expr + assert subtype?(integer(), texpr) + + # {:+, 1, 2} is an Expr and a Binop + assert subtype?(tuple([atom(), integer(), integer()]), texpr) + assert subtype?(tuple([atom(), integer(), integer()]), tbinop) + + # {:*, {:+, 1, 2}, 3} is an Expr + inner = tuple([atom(), integer(), integer()]) + assert subtype?(tuple([atom(), inner, integer()]), texpr) + end + + test "emptiness" do + # X = {X, X} is empty (no base case) + %{X: nx} = recursive(%{X: fn recur -> tuple([recur.(:X), recur.(:X)]) end}) + tx = to_descr(nx) + + assert empty?(tx) + + # X = {X} is empty, so {X} is empty too + %{X: nx} = recursive(%{X: fn recur -> tuple([recur.(:X)]) end}) + tx = to_descr(nx) + ttx = tuple([tx]) + + assert empty?(tx) + assert empty?(ttx) + + # X = {integer()} | {X, X} has base case, not empty + %{X: nx} = + recursive(%{ + X: fn recur -> bare_union(tuple([integer()]), tuple([recur.(:X), recur.(:X)])) end + }) + + tx = to_descr(nx) + + refute empty?(tx) + + ## mutual recursion + # X = {int,Y} | nil, Y = {bool,X} | nil: both not empty + %{X: node_x, Y: node_y} = + recursive(%{ + X: fn recur -> tuple([integer(), recur.(:Y)]) |> bare_union(atom([nil])) end, + Y: fn recur -> tuple([boolean(), recur.(:X)]) |> bare_union(atom([nil])) end + }) + + tx = to_descr(node_x) + ty = to_descr(node_y) + + refute empty?(tx) + refute empty?(ty) + + # {int, {bool, nil}} <: X + inner_y = tuple([boolean(), atom([nil])]) + assert subtype?(tuple([integer(), inner_y]), tx) + + # {bool, {int, nil}} <: Y + inner_x = tuple([integer(), atom([nil])]) + assert subtype?(tuple([boolean(), inner_x]), ty) + + # {int, {bool, {int, nil}}} <: X + level3 = tuple([integer(), atom([nil])]) + level2 = tuple([boolean(), level3]) + assert subtype?(tuple([integer(), level2]), tx) + + # X = {Y}|nil, Y = {X}: X not empty, Y not empty (Y can hold {nil}) + %{X: nx, Y: ny} = + recursive(%{ + X: fn recur -> tuple([recur.(:Y)]) |> bare_union(atom([nil])) end, + Y: fn recur -> tuple([recur.(:X)]) end + }) + + tx = to_descr(nx) + ty = to_descr(ny) + refute empty?(tx) + refute empty?(ty) + + ## cycle detection + cases = [ + # X -> {Y}, Y -> {Z}, Z -> {X} + {"3-cycle no base", true, + %{ + X: fn recur -> tuple([recur.(:Y)]) end, + Y: fn recur -> tuple([recur.(:Z)]) end, + Z: fn recur -> tuple([recur.(:X)]) end + }}, + # X -> {Y}, Y -> {Z}, Z -> {X} or nil + {"3-cycle with base", false, + %{ + X: fn recur -> tuple([recur.(:Y)]) end, + Y: fn recur -> tuple([recur.(:Z)]) end, + Z: fn recur -> tuple([recur.(:X)]) |> bare_union(atom([nil])) end + }} + ] + + for {desc, expected_empty, gen} <- cases do + nodes = recursive(gen) + + for {_key, node} <- nodes do + t = to_descr(node) + + if expected_empty do + assert empty?(t), "#{desc}: expected empty but wasn't" + else + refute empty?(t), "#{desc}: expected not empty but was" + end + end + end + + ## binary trees + # Tree = {atom, Tree, Tree} | nil: not empty + refute empty?(binary_tree(atom())) + + ## list-head recursion with base case is not empty + # X = non_empty_list(X, []) | non_empty_list(integer(), []) + %{X: nx} = + recursive(%{ + X: fn recur -> + non_empty_list(recur.(:X), empty_list()) + |> bare_union(non_empty_list(integer(), empty_list())) + end + }) + + tx = to_descr(nx) + refute empty?(tx) + end + + test "recursive map domains" do + # X = %{atom() => X} + %{X: node} = + recursive(%{ + X: fn recur -> open_map([{to_domain_keys(atom()), recur.(:X)}]) end + }) + + tx = to_descr(node) + rebuilt = open_map([{to_domain_keys(atom()), tx}]) + closed = closed_map([{to_domain_keys(atom()), tx}]) + + assert assert_finishes(fn -> subtype?(tx, rebuilt) end) + refute assert_finishes(fn -> empty?(opt_intersection(tx, rebuilt)) end) + refute assert_finishes(fn -> subtype?(tx, closed) end) + refute assert_finishes(fn -> disjoint?(tx, closed) end) + end + + test "subtyping" do + ## recursion on list tail + # X = non_empty_list(integer(), X) | [] + tx = + recursive(%{ + X: fn recur -> non_empty_list(integer(), recur.(:X)) |> bare_union(empty_list()) end + }) + |> Map.fetch!(:X) + |> to_descr() + + # [] <: X + assert subtype?(empty_list(), tx) + + # non_empty_list(integer()) <: X (terminates with []) + assert subtype?(non_empty_list(integer()), tx) + + ## binary trees + int_bin_tree = binary_tree(integer()) + + # nil is a valid tree + assert subtype?(atom([nil]), int_bin_tree) + + # {42, nil, nil} is a valid tree + assert subtype?(tuple([integer(), atom([nil]), atom([nil])]), int_bin_tree) + + # {1, {2, nil, nil}, {3, nil, nil}} is a valid tree + leaf = tuple([integer(), atom([nil]), atom([nil])]) + assert subtype?(tuple([integer(), leaf, leaf]), int_bin_tree) + + # wrong arity: {1, nil} not a subtype + refute subtype?(tuple([integer(), atom([nil])]), int_bin_tree) + + # wrong tag type: {float, nil, nil} not a subtype + refute subtype?(tuple([float(), atom([nil]), atom([nil])]), int_bin_tree) + + # X = non_empty_list(integer(), X) | non_empty_list(integer()) | [] + # Y = list(integer()) + gen = %{ + X: fn recur -> + non_empty_list(integer(), recur.(:X)) + |> bare_union(non_empty_list(integer(), empty_list())) + |> bare_union(empty_list()) + end + } + + tx = recursive(gen)[:X] |> to_descr() + assert equal?(tx, list(integer())) + + # X = %{outer: %{inner: X}} | nil + # Y = %{outer: %{inner: Y}} | %{outer: %{inner: integer}} | nil + %{X: nx, Y: ny} = + recursive(%{ + X: fn recur -> + closed_map(outer: closed_map(inner: recur.(:X))) |> bare_union(atom([nil])) + end, + Y: fn recur -> + closed_map(outer: closed_map(inner: recur.(:Y))) + |> bare_union(closed_map(outer: closed_map(inner: integer()))) + |> bare_union(atom([nil])) + end + }) + + tx = to_descr(nx) + ty = to_descr(ny) + assert subtype?(tx, ty), "nested map wrapping map with recursive field" + + # X = nil | {integer, X} + # Y = nil | {integer or float, Y} + %{X: nx, Y: ny} = + recursive(%{ + X: fn recur -> tuple([integer(), recur.(:X)]) |> bare_union(atom([nil])) end, + Y: fn recur -> + tuple([bare_union(integer(), float()), recur.(:Y)]) |> bare_union(atom([nil])) + end + }) + + tx = to_descr(nx) + ty = to_descr(ny) + assert subtype?(tx, ty) + end + + test "set operations accept nodes" do + n = recursive_node(integer()) + assert equal?(bare_union(n, float()), bare_union(integer(), float())) + assert equal?(bare_union(atom(), n), bare_union(atom(), integer())) + assert equal?(opt_union(n, float()), bare_union(integer(), float())) + + n = recursive_node(bare_union(integer(), atom())) + assert equal?(bare_intersection(n, integer()), integer()) + assert equal?(opt_intersection(n, integer()), integer()) + assert equal?(opt_intersection(atom(), n), atom()) + + n = recursive_node(bare_union(integer(), float())) + float_node = recursive_node(float()) + assert equal?(bare_difference(n, float()), integer()) + assert equal?(opt_difference(n, float()), integer()) + assert equal?(opt_difference(bare_union(integer(), float()), float_node), integer()) + assert equal?(opt_negation(float_node), opt_negation(float())) + end + + test "set operations on nodes built with constructors" do + ## map intersection inside nodes + # X = %{a: %{a: X}} | %{a: %{a: atom()}} + # Y = %{a: %{a: X}} | %{a: %{a: atom()}} + %{X: nx, Y: ny} = + recursive(%{ + X: fn recur -> + closed_map(a: closed_map(a: recur.(:X))) + |> bare_union(closed_map(a: closed_map(a: atom()))) + end, + Y: fn recur -> + closed_map(a: closed_map(a: recur.(:Y))) + |> bare_union(closed_map(a: closed_map(a: atom()))) + end + }) + + tx = to_descr(nx) + ty = to_descr(ny) + + refute empty?(opt_intersection(tx, ty)) + assert equal?(opt_union(tx, ty), tx) + + # X = {{X}} | {{atom()}} + # Y = {{Y}} | {{atom()}} + %{X: nx, Y: ny} = + recursive(%{ + X: fn recur -> tuple([tuple([recur.(:X)])]) |> bare_union(tuple([tuple([atom()])])) end, + Y: fn recur -> tuple([tuple([recur.(:Y)])]) |> bare_union(tuple([tuple([atom()])])) end + }) + + tx = to_descr(nx) + ty = to_descr(ny) + refute empty?(opt_intersection(tx, ty)) + assert equal?(opt_union(tx, ty), tx) + end + + test "type operators" do + ## tuple operators on descr with recursive node element + # X = {integer(), X} | {integer(), atom()} + %{X: nx} = + recursive(%{ + X: fn recur -> + tuple([integer(), recur.(:X)]) |> bare_union(tuple([integer(), atom()])) + end + }) + + t = to_descr(nx) + + assert {false, type} = tuple_fetch(t, 0) + assert equal?(type, integer()) + assert {false, _type} = tuple_fetch(t, 1) + + result = tuple_values(t) + assert subtype?(integer(), result) + + result = tuple_delete_at(t, 0) + assert {false, _type} = tuple_fetch(result, 0) + + result = tuple_insert_at(t, 0, boolean()) + assert {false, type} = tuple_fetch(result, 0) + assert equal?(type, boolean()) + + # X = {X} | {atom()} + %{X: nx} = + recursive(%{X: fn recur -> tuple([recur.(:X)]) |> bare_union(tuple([atom()])) end}) + + tx = to_descr(nx) + t = opt_difference(tx, tuple([atom()])) + assert {false, type} = tuple_fetch(t, 0) + assert equal?(type, tx) + + ## map_fetch_key on descr with recursive node value + # X = %{a: integer(), b: X} | %{a: integer(), b: atom()} + %{X: nx} = + recursive(%{ + X: fn recur -> + closed_map(a: integer(), b: recur.(:X)) + |> bare_union(closed_map(a: integer(), b: atom())) + end + }) + + assert {false, type} = map_fetch_key(to_descr(nx), :a) + assert equal?(type, integer()) + assert {false, _type} = map_fetch_key(to_descr(nx), :b) + + ## list_hd and list_tl on descr with recursive node tail + # X = non_empty_list(integer(), X) | non_empty_list(integer(), []) + %{X: nx} = + recursive(%{ + X: fn recur -> + non_empty_list(integer(), recur.(:X)) + |> bare_union(non_empty_list(integer(), empty_list())) + end + }) + + assert {:ok, type} = list_hd(to_descr(nx)) + assert equal?(type, integer()) + assert {:ok, _type} = list_tl(to_descr(nx)) + + ## list_hd on descr with recursive node as head element + # X = non_empty_list(X, []) | non_empty_list(atom(), []) + %{X: nx} = + recursive(%{ + X: fn recur -> + non_empty_list(recur.(:X), empty_list()) + |> bare_union(non_empty_list(atom(), empty_list())) + end + }) + + assert {:ok, _type} = list_hd(to_descr(nx)) + + ## fun_apply with recursive node argument + # X = {X} | {atom()} + %{X: nx} = + recursive(%{ + X: fn recur -> tuple([recur.(:X)]) |> bare_union(tuple([atom()])) end + }) + + assert {:ok, type} = fun_apply(fun([term()], integer()), [nx]) + assert equal?(type, integer()) + end + end +end From f889551d23ddb91ce6c85a6ac87724ca3c37ab5c Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:16:41 +0200 Subject: [PATCH 2/2] Make opt ops work on recursive types; tests --- lib/elixir/lib/module/types/descr.ex | 372 ++++++++----- .../elixir/module/types/recursive_test.exs | 513 +++++++++++++++++- 2 files changed, 735 insertions(+), 150 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 3263e739ab1..c388f37fd7a 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -657,8 +657,6 @@ defmodule Module.Types.Descr do end end - defp empty_or_optional?(type), do: empty?(remove_optional(type)) - defp empty_key?(key, value), do: empty_key?(key, value, %{}) defp empty_key?(:fun, value, seen), do: fun_empty?(value, seen) defp empty_key?(:map, value, seen), do: map_empty?(value, seen) @@ -1078,11 +1076,11 @@ defmodule Module.Types.Descr do cond do empty?(left_static) -> - dynamic = opt_intersection_static(unfold(left_dynamic), unfold(right_dynamic)) + dynamic = opt_intersection_static(unfold(left_dynamic), unfold(right_dynamic), %{}) if empty?(dynamic), do: {:error, left}, else: {:ok, dynamic(dynamic)} subtype_static?(left_static, right_dynamic) -> - dynamic = opt_intersection_static(unfold(left_dynamic), unfold(right_dynamic)) + dynamic = opt_intersection_static(unfold(left_dynamic), unfold(right_dynamic), %{}) {:ok, opt_union(dynamic(dynamic), left_static)} true -> @@ -3109,7 +3107,7 @@ defmodule Module.Types.Descr do default2 = map_key_tag_to_type(tag_or_domains2) # Compute the new domain - tag_or_domains = map_domain_intersection(tag_or_domains1, tag_or_domains2) + tag_or_domains = map_domain_intersection(tag_or_domains1, tag_or_domains2, seen) # Go over all fields in map1 and map2 with default atom types atom1 and atom2 # 1. If key is in both maps, compute non empty intersection (:error if it is none) @@ -3124,38 +3122,42 @@ defmodule Module.Types.Descr do end # Compute the intersection of two tags or tag-domain pairs. - defp map_domain_intersection(:closed, _), do: :closed - defp map_domain_intersection(_, :closed), do: :closed - defp map_domain_intersection(:open, tag_or_domains), do: tag_or_domains - defp map_domain_intersection(tag_or_domains, :open), do: tag_or_domains - - defp map_domain_intersection(domains1, domains2) do + # + # The `seen` set is the emptiness one: recursive domain values reach this + # function from within `map_empty?`, and restarting the emptiness check + # from scratch on such values would never detect the cycle. + defp map_domain_intersection(:closed, _, _seen), do: :closed + defp map_domain_intersection(_, :closed, _seen), do: :closed + defp map_domain_intersection(:open, tag_or_domains, _seen), do: tag_or_domains + defp map_domain_intersection(tag_or_domains, :open, _seen), do: tag_or_domains + + defp map_domain_intersection(domains1, domains2, seen) do # If the explicit domains are empty, use simple atom tags - case map_domain_intersection_fields(domains1, domains2) do + case map_domain_intersection_fields(domains1, domains2, seen) do [] -> :closed new_domains -> new_domains end end - defp map_domain_intersection_fields([{k1, _} | t1], [{k2, _} | _] = l2) when k1 < k2 do - map_domain_intersection_fields(t1, l2) + defp map_domain_intersection_fields([{k1, _} | t1], [{k2, _} | _] = l2, seen) when k1 < k2 do + map_domain_intersection_fields(t1, l2, seen) end - defp map_domain_intersection_fields([{k1, _} | _] = l1, [{k2, _} | t2]) when k1 > k2 do - map_domain_intersection_fields(l1, t2) + defp map_domain_intersection_fields([{k1, _} | _] = l1, [{k2, _} | t2], seen) when k1 > k2 do + map_domain_intersection_fields(l1, t2, seen) end - defp map_domain_intersection_fields([{k, type1} | t1], [{_, type2} | t2]) do + defp map_domain_intersection_fields([{k, type1} | t1], [{_, type2} | t2], seen) do inter = bare_intersection(type1, type2) - if empty_or_optional?(inter) do - map_domain_intersection_fields(t1, t2) + if empty_seen?(remove_optional(inter), seen) do + map_domain_intersection_fields(t1, t2, seen) else - [{k, inter} | map_domain_intersection_fields(t1, t2)] + [{k, inter} | map_domain_intersection_fields(t1, t2, seen)] end end - defp map_domain_intersection_fields(_, _), do: [] + defp map_domain_intersection_fields(_, _, _seen), do: [] defp map_literal_intersection_open_closed( [{k1, v1} | t1], @@ -3873,7 +3875,7 @@ defmodule Module.Types.Descr do # Optimization: if there are no negatives, we can directly remove the key. {tag, fields, []}, {value, bdd} -> {fst, snd} = map_pop_key_bdd(tag, fields, key) - {maybe_opt_union(value, fn -> fst end), opt_map_union(bdd, snd)} + {maybe_opt_union(value, fn -> fst end), opt_map_union(bdd, snd, %{})} {tag, fields, negs}, {value, bdd} -> {fst, snd} = map_pop_key_bdd(tag, fields, key) @@ -3901,9 +3903,9 @@ defmodule Module.Types.Descr do end end), if keep_snd? do - opt_map_union(bdd, snd) + opt_map_union(bdd, snd, %{}) else - Enum.reduce(pairs, bdd, &opt_map_union(elem(&1, 1), &2)) + Enum.reduce(pairs, bdd, &opt_map_union(elem(&1, 1), &2, %{})) end} end end) @@ -6397,138 +6399,169 @@ defmodule Module.Types.Descr do ## Optimizations + # Unlike the bare operations, the optimized operations recurse into tuple + # elements, map field values and list heads/tails. On recursive types this + # recursion may revisit the same pair of BDDs over and over, never reaching + # a base case. To guarantee termination we thread a `seen` set through the + # optimized operations, keyed by the operation and the two BDDs being + # combined. When a pair is revisited, we fall back to the bare operation on + # that pair, which is exact and does not recurse into components. + # + # The `seen` set must not be reused across entry points: every public + # opt_* call starts with a fresh one. + # + # BDDs embed their hash as the first tuple element (see `bdd_leaf_new`), + # so these keys compare cheaply on mismatch. + defp opt_bdd_seen(seen, op, kind, bdd1, bdd2) do + key = {op, kind, bdd1, bdd2} + + case seen do + %{^key => _} -> :seen + %{} -> {:ok, Map.put(seen, key, true)} + end + end + @doc """ Computes the union of two descrs using optimized composite operations. """ - def opt_union(:term, other), do: optional_to_term(other) - def opt_union(other, :term), do: optional_to_term(other) - def opt_union(none, other) when none == @none, do: other - def opt_union(other, none) when none == @none, do: other + def opt_union(left, right), do: opt_union(left, right, %{}) - def opt_union({_, _, _} = left, right), - do: opt_union(to_descr(left), to_descr(right)) + defp opt_union(:term, other, _seen), do: optional_to_term(other) + defp opt_union(other, :term, _seen), do: optional_to_term(other) + defp opt_union(none, other, _seen) when none == @none, do: other + defp opt_union(other, none, _seen) when none == @none, do: other - def opt_union(left, {_, _, _} = right), - do: opt_union(to_descr(left), to_descr(right)) + defp opt_union({_, _, _} = left, right, seen), + do: opt_union(to_descr(left), to_descr(right), seen) - def opt_union(left, right) do + defp opt_union(left, {_, _, _} = right, seen), + do: opt_union(to_descr(left), to_descr(right), seen) + + defp opt_union(left, right, seen) do is_gradual_left = gradual?(left) is_gradual_right = gradual?(right) cond do is_gradual_left and not is_gradual_right -> right_with_dynamic = Map.put(unfold(right), :dynamic, right) - opt_union_static(left, right_with_dynamic) + opt_union_static(left, right_with_dynamic, seen) is_gradual_right and not is_gradual_left -> left_with_dynamic = Map.put(unfold(left), :dynamic, left) - opt_union_static(left_with_dynamic, right) + opt_union_static(left_with_dynamic, right, seen) true -> - opt_union_static(left, right) + opt_union_static(left, right, seen) end end - @compile {:inline, opt_union_static: 2} - defp opt_union_static(left, right) do - symmetrical_merge(left, right, &opt_union/3) + @compile {:inline, opt_union_static: 3} + defp opt_union_static(left, right, seen) do + symmetrical_merge(left, right, &opt_union(&1, &2, &3, seen)) end - defp opt_union(:atom, v1, v2), do: atom_union(v1, v2) - defp opt_union(:bitmap, v1, v2), do: v1 ||| v2 - defp opt_union(:dynamic, v1, v2), do: dynamic_union(v1, v2, &opt_union/3) - defp opt_union(:list, v1, v2), do: list_union(v1, v2) - defp opt_union(:map, v1, v2), do: opt_map_union(v1, v2) - defp opt_union(:optional, 1, 1), do: 1 - defp opt_union(:tuple, v1, v2), do: opt_tuple_union(v1, v2) - defp opt_union(:fun, v1, v2), do: fun_union(v1, v2) + defp opt_union(:atom, v1, v2, _seen), do: atom_union(v1, v2) + defp opt_union(:bitmap, v1, v2, _seen), do: v1 ||| v2 + + defp opt_union(:dynamic, v1, v2, seen), + do: dynamic_union(v1, v2, &opt_union(&1, &2, &3, seen)) + + defp opt_union(:list, v1, v2, _seen), do: list_union(v1, v2) + defp opt_union(:map, v1, v2, seen), do: opt_map_union(v1, v2, seen) + defp opt_union(:optional, 1, 1, _seen), do: 1 + defp opt_union(:tuple, v1, v2, _seen), do: opt_tuple_union(v1, v2) + defp opt_union(:fun, v1, v2, _seen), do: fun_union(v1, v2) @doc """ Computes the intersection of two descrs using optimized composite operations. """ - def opt_intersection(:term, other), do: remove_optional(other) - def opt_intersection(other, :term), do: remove_optional(other) + def opt_intersection(left, right), do: opt_intersection(left, right, %{}) - def opt_intersection({_, _, _} = left, right), - do: opt_intersection(to_descr(left), to_descr(right)) + defp opt_intersection(:term, other, _seen), do: remove_optional(other) + defp opt_intersection(other, :term, _seen), do: remove_optional(other) - def opt_intersection(left, {_, _, _} = right), - do: opt_intersection(to_descr(left), to_descr(right)) + defp opt_intersection({_, _, _} = left, right, seen), + do: opt_intersection(to_descr(left), to_descr(right), seen) - def opt_intersection(left, right) do + defp opt_intersection(left, {_, _, _} = right, seen), + do: opt_intersection(to_descr(left), to_descr(right), seen) + + defp opt_intersection(left, right, seen) do is_gradual_left = gradual?(left) is_gradual_right = gradual?(right) cond do is_gradual_left and not is_gradual_right -> right_with_dynamic = Map.put(unfold(right), :dynamic, right) - opt_intersection_static(left, right_with_dynamic) + opt_intersection_static(left, right_with_dynamic, seen) is_gradual_right and not is_gradual_left -> left_with_dynamic = Map.put(unfold(left), :dynamic, left) - opt_intersection_static(left_with_dynamic, right) + opt_intersection_static(left_with_dynamic, right, seen) true -> - opt_intersection_static(left, right) + opt_intersection_static(left, right, seen) end end - @compile {:inline, opt_intersection_static: 2} - defp opt_intersection_static(left, right) do - symmetrical_intersection(left, right, &opt_intersection/3) + @compile {:inline, opt_intersection_static: 3} + defp opt_intersection_static(left, right, seen) do + symmetrical_intersection(left, right, &opt_intersection(&1, &2, &3, seen)) end - defp opt_intersection(:atom, v1, v2), do: atom_intersection(v1, v2) - defp opt_intersection(:bitmap, v1, v2), do: v1 &&& v2 - defp opt_intersection(:list, v1, v2), do: opt_list_intersection(v1, v2) - defp opt_intersection(:map, v1, v2), do: opt_map_intersection(v1, v2) - defp opt_intersection(:optional, 1, 1), do: 1 - defp opt_intersection(:tuple, v1, v2), do: opt_tuple_intersection(v1, v2) - defp opt_intersection(:fun, v1, v2), do: fun_intersection(v1, v2) + defp opt_intersection(:atom, v1, v2, _seen), do: atom_intersection(v1, v2) + defp opt_intersection(:bitmap, v1, v2, _seen), do: v1 &&& v2 + defp opt_intersection(:list, v1, v2, seen), do: opt_list_intersection(v1, v2, seen) + defp opt_intersection(:map, v1, v2, seen), do: opt_map_intersection(v1, v2, seen) + defp opt_intersection(:optional, 1, 1, _seen), do: 1 + defp opt_intersection(:tuple, v1, v2, seen), do: opt_tuple_intersection(v1, v2, seen) + defp opt_intersection(:fun, v1, v2, _seen), do: fun_intersection(v1, v2) - defp opt_intersection(:dynamic, v1, v2) do - descr = dynamic_intersection(v1, v2, &opt_intersection/3) + defp opt_intersection(:dynamic, v1, v2, seen) do + descr = dynamic_intersection(v1, v2, &opt_intersection(&1, &2, &3, seen)) if descr == @none, do: 0, else: descr end @doc """ Computes the difference of two descrs using optimized composite operations. """ - def opt_difference(left, :term), do: keep_optional(left) - def opt_difference(left, none) when none == @none, do: left + def opt_difference(left, right), do: opt_difference(left, right, %{}) + + defp opt_difference(left, :term, _seen), do: keep_optional(left) + defp opt_difference(left, none, _seen) when none == @none, do: left - def opt_difference({_, _, _} = left, right), - do: opt_difference(to_descr(left), to_descr(right)) + defp opt_difference({_, _, _} = left, right, seen), + do: opt_difference(to_descr(left), to_descr(right), seen) - def opt_difference(left, {_, _, _} = right), - do: opt_difference(to_descr(left), to_descr(right)) + defp opt_difference(left, {_, _, _} = right, seen), + do: opt_difference(to_descr(left), to_descr(right), seen) - def opt_difference(left, right) do + defp opt_difference(left, right, seen) do if gradual?(left) or gradual?(right) do {left_dynamic, left_static} = pop_dynamic(left) {right_dynamic, right_static} = pop_dynamic(right) - dynamic_part = opt_difference_static(left_dynamic, right_static) + dynamic_part = opt_difference_static(left_dynamic, right_static, seen) - opt_difference_static(left_static, right_dynamic) + opt_difference_static(left_static, right_dynamic, seen) |> put_dynamic(dynamic_part) else - opt_difference_static(left, right) + opt_difference_static(left, right, seen) end end - @compile {:inline, opt_difference_static: 2} - defp opt_difference_static(left, right) do - dynamic_difference(left, right, &opt_difference/3) + @compile {:inline, opt_difference_static: 3} + defp opt_difference_static(left, right, seen) do + dynamic_difference(left, right, &opt_difference(&1, &2, &3, seen)) end # Returning 0 from the callback is taken as none() for that subtype. - defp opt_difference(:atom, v1, v2), do: atom_difference(v1, v2) - defp opt_difference(:bitmap, v1, v2), do: v1 - (v1 &&& v2) - defp opt_difference(:list, v1, v2), do: opt_list_difference(v1, v2) - defp opt_difference(:map, v1, v2), do: opt_map_difference(v1, v2) - defp opt_difference(:optional, 1, 1), do: 0 - defp opt_difference(:tuple, v1, v2), do: opt_tuple_difference(v1, v2) - defp opt_difference(:fun, v1, v2), do: fun_difference(v1, v2) + defp opt_difference(:atom, v1, v2, _seen), do: atom_difference(v1, v2) + defp opt_difference(:bitmap, v1, v2, _seen), do: v1 - (v1 &&& v2) + defp opt_difference(:list, v1, v2, seen), do: opt_list_difference(v1, v2, seen) + defp opt_difference(:map, v1, v2, seen), do: opt_map_difference(v1, v2, seen) + defp opt_difference(:optional, 1, 1, _seen), do: 0 + defp opt_difference(:tuple, v1, v2, _seen), do: opt_tuple_difference(v1, v2) + defp opt_difference(:fun, v1, v2, _seen), do: fun_difference(v1, v2) @doc """ Compute the negation of a type. @@ -6544,24 +6577,30 @@ defmodule Module.Types.Descr do defp maybe_opt_union(nil, _fun), do: nil defp maybe_opt_union(descr, fun), do: opt_union(descr, fun.()) - defp opt_list_intersection(bdd_leaf(:term, :term), bdd), do: bdd - defp opt_list_intersection(bdd, bdd_leaf(:term, :term)), do: bdd + defp opt_list_intersection(bdd_leaf(:term, :term), bdd, _seen), do: bdd + defp opt_list_intersection(bdd, bdd_leaf(:term, :term), _seen), do: bdd + + defp opt_list_intersection(bdd1, bdd2, seen) do + case opt_bdd_seen(seen, :intersection, :list, bdd1, bdd2) do + :seen -> list_intersection(bdd1, bdd2) + {:ok, seen} -> bdd_intersection(bdd1, bdd2, &opt_list_leaf_intersection(&1, &2, seen)) + end + end - defp opt_list_intersection(bdd1, bdd2), - do: bdd_intersection(bdd1, bdd2, &opt_list_leaf_intersection/2) + defp opt_list_leaf_intersection(bdd_leaf(list1, last1), bdd_leaf(list2, last2), seen) do + intersection_fun = &opt_intersection(&1, &2, seen) - defp opt_list_leaf_intersection(bdd_leaf(list1, last1), bdd_leaf(list2, last2)) do try do - list = non_empty_intersection!(list1, list2, &opt_intersection/2) - last = non_empty_intersection!(last1, last2, &opt_intersection/2) + list = non_empty_intersection!(list1, list2, intersection_fun) + last = non_empty_intersection!(last1, last2, intersection_fun) bdd_leaf_new(list, last) catch :empty -> :bdd_bot end end - defp opt_list_difference(bdd_leaf(:term, :term), bdd_leaf(:term, :term)), do: :bdd_bot - defp opt_list_difference(bdd_leaf(:term, :term), bdd2), do: bdd_negation(bdd2) + defp opt_list_difference(bdd_leaf(:term, :term), bdd_leaf(:term, :term), _seen), do: :bdd_bot + defp opt_list_difference(bdd_leaf(:term, :term), bdd2, _seen), do: bdd_negation(bdd2) # Computes the difference between two BDD (Binary Decision Diagram) list types. # It progressively subtracts each type in bdd2 from all types in bdd1. @@ -6572,17 +6611,23 @@ defmodule Module.Types.Descr do # b) If only the last type differs, subtracts it # 3. Base case: adds bdd2 type to negations of bdd1 type # The result may be larger than the initial bdd1, which is maintained in the accumulator. - defp opt_list_difference(bdd_leaf(list1, last1) = bdd1, bdd_leaf(list2, last2) = bdd2) do - if subtype?(list1, list2) do - if subtype?(last1, last2), - do: :bdd_bot, - else: bdd_leaf_new(list1, opt_difference(last1, last2)) - else - bdd_difference(bdd1, bdd2, &opt_list_leaf_difference/3) + defp opt_list_difference(bdd_leaf(list1, last1) = bdd1, bdd_leaf(list2, last2) = bdd2, seen) do + cond do + not subtype?(list1, list2) -> + bdd_difference(bdd1, bdd2, &opt_list_leaf_difference/3) + + subtype?(last1, last2) -> + :bdd_bot + + true -> + case opt_bdd_seen(seen, :difference, :list, bdd1, bdd2) do + :seen -> list_difference(bdd1, bdd2) + {:ok, seen} -> bdd_leaf_new(list1, opt_difference(last1, last2, seen)) + end end end - defp opt_list_difference(bdd1, bdd2), + defp opt_list_difference(bdd1, bdd2, _seen), do: bdd_difference(bdd1, bdd2, &opt_list_leaf_difference/3) defp opt_list_leaf_difference(bdd_leaf(list1, last1), bdd_leaf(list2, last2), _) do @@ -6593,31 +6638,48 @@ defmodule Module.Types.Descr do end end - defp opt_map_union(bdd_leaf(tag1, fields1), bdd_leaf(tag2, fields2)) do - case opt_map_union(tag1, fields1, tag2, fields2) do - {tag, fields} -> bdd_leaf_new(tag, fields) - nil -> bdd_union(bdd_leaf_new(tag1, fields1), bdd_leaf_new(tag2, fields2)) + defp opt_map_union(bdd_leaf(tag1, fields1) = bdd1, bdd_leaf(tag2, fields2) = bdd2, seen) do + case opt_bdd_seen(seen, :union, :map, bdd1, bdd2) do + :seen -> + map_union(bdd1, bdd2) + + {:ok, seen} -> + case opt_map_union(tag1, fields1, tag2, fields2, seen) do + {tag, fields} -> bdd_leaf_new(tag, fields) + nil -> bdd_union(bdd1, bdd2) + end end end - defp opt_map_union(bdd1, bdd2), do: map_union(bdd1, bdd2) + defp opt_map_union(bdd1, bdd2, _seen), do: map_union(bdd1, bdd2) - defp opt_map_union(:open, [], _, _), do: {:open, @fields_new} - defp opt_map_union(_, _, :open, []), do: {:open, @fields_new} + defp opt_map_union(:open, [], _, _, _seen), do: {:open, @fields_new} + defp opt_map_union(_, _, :open, [], _seen), do: {:open, @fields_new} - defp opt_map_union(tag1, pos1, tag2, pos2) + defp opt_map_union(tag1, pos1, tag2, pos2, seen) when is_atom(tag1) and is_atom(tag2) do case opt_map_union_strategy(pos1, pos2, tag1, tag2, :all_equal) do - :all_equal when tag1 == :open -> {tag1, pos1} - :all_equal -> {tag2, pos2} - {:one_key_difference, key, v1, v2} -> {tag1, fields_store(key, opt_union(v1, v2), pos1)} - :left_subtype_of_right -> {tag2, pos2} - :right_subtype_of_left -> {tag1, pos1} - :none -> nil + :all_equal when tag1 == :open -> + {tag1, pos1} + + :all_equal -> + {tag2, pos2} + + {:one_key_difference, key, v1, v2} -> + {tag1, fields_store(key, opt_union(v1, v2, seen), pos1)} + + :left_subtype_of_right -> + {tag2, pos2} + + :right_subtype_of_left -> + {tag1, pos1} + + :none -> + nil end end - defp opt_map_union(_, _, _, _), do: nil + defp opt_map_union(_, _, _, _, _seen), do: nil defp opt_map_union_strategy([{k1, _} | t1], [{k2, _} | _] = l2, tag1, tag2, status) when k1 < k2 do @@ -6751,26 +6813,38 @@ defmodule Module.Types.Descr do end end - defp opt_map_intersection(bdd_leaf(:open, []), bdd), do: bdd - defp opt_map_intersection(bdd, bdd_leaf(:open, [])), do: bdd + defp opt_map_intersection(bdd_leaf(:open, []), bdd, _seen), do: bdd + defp opt_map_intersection(bdd, bdd_leaf(:open, []), _seen), do: bdd - defp opt_map_intersection(bdd1, bdd2), - do: bdd_intersection(bdd1, bdd2, &opt_map_leaf_intersection/2) + defp opt_map_intersection(bdd1, bdd2, seen) do + case opt_bdd_seen(seen, :intersection, :map, bdd1, bdd2) do + :seen -> map_intersection(bdd1, bdd2) + {:ok, seen} -> bdd_intersection(bdd1, bdd2, &opt_map_leaf_intersection(&1, &2, seen)) + end + end - defp opt_map_leaf_intersection(bdd_leaf(tag1, fields1), bdd_leaf(tag2, fields2)) do + defp opt_map_leaf_intersection(bdd_leaf(tag1, fields1), bdd_leaf(tag2, fields2), seen) do try do - {tag, fields} = map_literal_intersection(tag1, fields1, tag2, fields2, &opt_intersection/2) + {tag, fields} = + map_literal_intersection(tag1, fields1, tag2, fields2, &opt_intersection(&1, &2, seen)) + bdd_leaf_new(tag, fields) catch :empty -> :bdd_bot end end - defp opt_map_difference(_, bdd_leaf(:open, [])), do: :bdd_bot - defp opt_map_difference(bdd_leaf(:open, []), {_, _, _, _, _} = bdd2), do: bdd_negation(bdd2) + defp opt_map_difference(_, bdd_leaf(:open, []), _seen), do: :bdd_bot - defp opt_map_difference(bdd1, bdd2), - do: bdd_difference(bdd1, bdd2, &opt_map_leaf_difference/3) + defp opt_map_difference(bdd_leaf(:open, []), {_, _, _, _, _} = bdd2, _seen), + do: bdd_negation(bdd2) + + defp opt_map_difference(bdd1, bdd2, seen) do + case opt_bdd_seen(seen, :difference, :map, bdd1, bdd2) do + :seen -> map_difference(bdd1, bdd2) + {:ok, seen} -> bdd_difference(bdd1, bdd2, &opt_map_leaf_difference(&1, &2, &3, seen)) + end + end # This clause optimizes differences with an open single-key right side: # @@ -6794,7 +6868,7 @@ defmodule Module.Types.Descr do # Therefore, this clause is only used when `type` is `:intersection` or `:none`. # `:union` falls through to the general clause below. The reason we have # this long comment is because this was a regression in the past. - defp opt_map_leaf_difference(bdd_leaf(tag, fields), bdd_leaf(:open, [{key, v2}]), type) + defp opt_map_leaf_difference(bdd_leaf(tag, fields), bdd_leaf(:open, [{key, v2}]), type, seen) when type != :union do {found?, v1} = case fields_find(key, fields) do @@ -6815,11 +6889,11 @@ defmodule Module.Types.Descr do :disjoint true -> - opt_map_leaf_one_key_difference(tag, fields, key, v1, v2, type) + opt_map_leaf_one_key_difference(tag, fields, key, v1, v2, type, seen) end end - defp opt_map_leaf_difference(bdd_leaf(tag, fields), bdd_leaf(neg_tag, neg_fields), type) do + defp opt_map_leaf_difference(bdd_leaf(tag, fields), bdd_leaf(neg_tag, neg_fields), type, seen) do case opt_map_difference_strategy(fields, neg_fields, tag, neg_tag) do :disjoint -> :disjoint @@ -6828,15 +6902,15 @@ defmodule Module.Types.Descr do :subtype {:one_key_difference, key, v1, v2} -> - opt_map_leaf_one_key_difference(tag, fields, key, v1, v2, type) + opt_map_leaf_one_key_difference(tag, fields, key, v1, v2, type, seen) :none -> :none end end - defp opt_map_leaf_one_key_difference(tag, fields, key, v1, v2, type) do - v_diff = opt_difference(v1, v2) + defp opt_map_leaf_one_key_difference(tag, fields, key, v1, v2, type, seen) do + v_diff = opt_difference(v1, v2, seen) if empty?(v_diff) do :subtype @@ -6849,10 +6923,10 @@ defmodule Module.Types.Descr do :bdd_bot :union -> - bdd_leaf_new(tag, fields_store(key, opt_union(v1, v2), fields)) + bdd_leaf_new(tag, fields_store(key, opt_union(v1, v2, seen), fields)) :intersection -> - v_int = opt_intersection(v1, v2) + v_int = opt_intersection(v1, v2, seen) if empty?(v_int), do: :bdd_bot, @@ -7052,21 +7126,27 @@ defmodule Module.Types.Descr do {tag1, fields1, []} = map {tag2, fields2, []} = candidate - case opt_map_union(tag1, fields1, tag2, fields2) do + case opt_map_union(tag1, fields1, tag2, fields2, %{}) do nil -> [candidate | opt_map_fuse_with_first_fusible(map, rest)] # we found a fusible candidate, we're done {tag, fields} -> [{tag, fields, []} | rest] end end - defp opt_tuple_intersection(bdd_leaf(:open, []), bdd), do: bdd - defp opt_tuple_intersection(bdd, bdd_leaf(:open, [])), do: bdd + defp opt_tuple_intersection(bdd_leaf(:open, []), bdd, _seen), do: bdd + defp opt_tuple_intersection(bdd, bdd_leaf(:open, []), _seen), do: bdd + + defp opt_tuple_intersection(bdd1, bdd2, seen) do + case opt_bdd_seen(seen, :intersection, :tuple, bdd1, bdd2) do + :seen -> tuple_intersection(bdd1, bdd2) + {:ok, seen} -> bdd_intersection(bdd1, bdd2, &opt_tuple_leaf_intersection(&1, &2, seen)) + end + end - defp opt_tuple_intersection(bdd1, bdd2), - do: bdd_intersection(bdd1, bdd2, &opt_tuple_leaf_intersection/2) + defp opt_tuple_leaf_intersection(bdd_leaf(tag1, elements1), bdd_leaf(tag2, elements2), seen) do + intersection_fun = &opt_intersection(&1, &2, seen) - defp opt_tuple_leaf_intersection(bdd_leaf(tag1, elements1), bdd_leaf(tag2, elements2)) do - case tuple_literal_intersection(tag1, elements1, tag2, elements2, &opt_intersection/2) do + case tuple_literal_intersection(tag1, elements1, tag2, elements2, intersection_fun) do {tag, elements} -> bdd_leaf_new(tag, elements) :empty -> :bdd_bot end diff --git a/lib/elixir/test/elixir/module/types/recursive_test.exs b/lib/elixir/test/elixir/module/types/recursive_test.exs index 99d8b863b5b..0ad6cbc9e58 100644 --- a/lib/elixir/test/elixir/module/types/recursive_test.exs +++ b/lib/elixir/test/elixir/module/types/recursive_test.exs @@ -14,12 +14,47 @@ defmodule Module.Types.RecursiveTest do |> Map.fetch!(:X) end - defp assert_finishes(fun) do - task = Task.async(fun) + @finish_timeout 1_000 + # In words (~200MB); runaway recursion trips this well before the timeout. + @finish_max_heap 25_000_000 + + # Runs fun in a process with bounded heap and bounded time, so runaway + # recursion fails cleanly instead of taking down the machine. + # Returns {:ok, result} or {:error, message}. + defp run_guarded(fun) do + parent = self() + ref = make_ref() + + {pid, mref} = + spawn_monitor(fn -> + Process.flag(:max_heap_size, %{size: @finish_max_heap, kill: true, error_logger: false}) + send(parent, {ref, fun.()}) + end) + + receive do + {^ref, result} -> + Process.demonitor(mref, [:flush]) + {:ok, result} + + {:DOWN, ^mref, :process, ^pid, reason} -> + {:error, "operation died (#{inspect(reason)}), runaway recursion"} + after + @finish_timeout -> + Process.exit(pid, :kill) + + receive do + {:DOWN, ^mref, :process, ^pid, _} -> :ok + end + + {:error, "operation did not finish within #{@finish_timeout}ms"} + end + end - case Task.yield(task, 1_000) || Task.shutdown(task, :brutal_kill) do + # Returns the result of fun, failing the test if it does not finish. + defp assert_finishes(fun) do + case run_guarded(fun) do {:ok, result} -> result - nil -> flunk("operation did not finish") + {:error, message} -> flunk(message) end end @@ -560,4 +595,474 @@ defmodule Module.Types.RecursiveTest do assert equal?(type, integer()) end end + + describe "optimized set operations on recursive types" do + # The opt_* operations recurse into tuple elements, map field values and + # list heads/tails. When those contain recursive nodes, the operations must + # detect that they are revisiting the same pair of BDDs and terminate. + # + # Loop-prone checks run through assert_finishes/1 so that a reintroduced + # loop fails the test instead of exhausting the machine. Results are + # checked against a closed-form expectation where one exists, and against + # the bare_* operations (recursion-safe by construction) otherwise. + + ## Recursive type builders. Each call builds fresh nodes, so two calls + ## produce alpha-equivalent types with distinct node identities. + + # X = {integer(), X} | nil, as a node + defp int_list_node() do + recursive(%{X: fn recur -> tuple([integer(), recur.(:X)]) |> bare_union(atom([nil])) end}) + |> Map.fetch!(:X) + end + + # M = %{a: M} | nil, as a node + defp rec_map_node() do + recursive(%{M: fn recur -> closed_map(a: recur.(:M)) |> bare_union(atom([nil])) end}) + |> Map.fetch!(:M) + end + + defp rec_map(), do: to_descr(rec_map_node()) + + # M = %{a: M, b: descr} | nil + defp rec_map_with(descr) do + recursive(%{ + M: fn recur -> closed_map(a: recur.(:M), b: descr) |> bare_union(atom([nil])) end + }) + |> Map.fetch!(:M) + |> to_descr() + end + + # M = %{atom() => M} + defp domain_map() do + recursive(%{M: fn recur -> open_map([{to_domain_keys(atom()), recur.(:M)}]) end}) + |> Map.fetch!(:M) + |> to_descr() + end + + # L = non_empty_list(integer(), L) | [] + defp tail_list() do + recursive(%{ + L: fn recur -> non_empty_list(integer(), recur.(:L)) |> bare_union(empty_list()) end + }) + |> Map.fetch!(:L) + |> to_descr() + end + + # L = non_empty_list(integer(), L) | atom(terminators) — an improper list + defp improper_list(terminators) do + recursive(%{ + L: fn recur -> non_empty_list(integer(), recur.(:L)) |> bare_union(atom(terminators)) end + }) + |> Map.fetch!(:L) + |> to_descr() + end + + # E = {E, E} — no base case, so semantically empty + defp empty_rec() do + recursive(%{E: fn recur -> tuple([recur.(:E), recur.(:E)]) end}) + |> Map.fetch!(:E) + |> to_descr() + end + + # X = {integer(), Y} | nil and Y = {boolean(), X} | nil + defp mutual_tuples() do + %{X: nx, Y: ny} = + recursive(%{ + X: fn recur -> tuple([integer(), recur.(:Y)]) |> bare_union(atom([nil])) end, + Y: fn recur -> tuple([boolean(), recur.(:X)]) |> bare_union(atom([nil])) end + }) + + {to_descr(nx), to_descr(ny)} + end + + test "PR example: X = nil | {integer(), X} intersected with itself" do + node = int_list_node() + tx = to_descr(node) + + # All argument shapes must terminate: node/node, node/descr, descr/descr + assert assert_finishes(fn -> equal?(opt_intersection(node, node), tx) end) + assert assert_finishes(fn -> equal?(opt_intersection(node, tx), tx) end) + assert assert_finishes(fn -> equal?(opt_intersection(tx, node), tx) end) + assert assert_finishes(fn -> equal?(opt_intersection(tx, tx), tx) end) + + # Remaining operations on the same type + assert assert_finishes(fn -> equal?(opt_union(tx, tx), tx) end) + assert assert_finishes(fn -> empty?(opt_difference(tx, tx)) end) + + neg = assert_finishes(fn -> opt_negation(tx) end) + assert assert_finishes(fn -> empty?(opt_intersection(tx, neg)) end) + assert assert_finishes(fn -> equal?(opt_union(tx, neg), term()) end) + assert assert_finishes(fn -> equal?(opt_negation(neg), tx) end) + end + + test "fast paths accept nodes" do + node = int_list_node() + tx = to_descr(node) + + assert equal?(opt_union(none(), node), tx) + assert equal?(opt_union(node, none()), tx) + assert equal?(opt_union(term(), node), term()) + assert equal?(opt_intersection(term(), node), tx) + assert equal?(opt_intersection(node, term()), tx) + assert equal?(opt_difference(node, none()), tx) + assert equal?(opt_difference(node, term()), none()) + end + + test "intersection of alpha-equivalent recursive types" do + # Same shape built twice: distinct node identities, same semantics. + # The seen set must recognize repetition by BDD, not by node identity. + head_list = fn -> + recursive(%{ + L: fn recur -> + non_empty_list(recur.(:L), empty_list()) + |> bare_union(non_empty_list(integer(), empty_list())) + end + }) + |> Map.fetch!(:L) + |> to_descr() + end + + builders = [ + tuple_list: &int_linked_list/0, + binary_tree: fn -> binary_tree(integer()) end, + map: &rec_map/0, + map_domain: &domain_map/0, + tail_list: &tail_list/0, + head_list: head_list + ] + + for {label, builder} <- builders do + a = builder.() + b = builder.() + + assert assert_finishes(fn -> equal?(opt_intersection(a, b), a) end), + "#{label}: intersection of alpha-equivalent copies should equal the type" + end + end + + test "intersection of overlapping recursive types" do + int_list = int_linked_list() + number_list = number_linked_list() + + # int lists are a subtype of number lists + assert assert_finishes(fn -> equal?(opt_intersection(int_list, number_list), int_list) end) + assert assert_finishes(fn -> equal?(opt_intersection(number_list, int_list), int_list) end) + + # int and float lists only share the base case + float_list = linked_list(float()) + + assert assert_finishes(fn -> + equal?(opt_intersection(int_list, float_list), atom([nil])) + end) + + # trees + int_tree = binary_tree(integer()) + number_tree = binary_tree(bare_union(integer(), float())) + assert assert_finishes(fn -> equal?(opt_intersection(int_tree, number_tree), int_tree) end) + + # mutual recursion crosses two equations before repeating + {tx, ty} = mutual_tuples() + assert assert_finishes(fn -> equal?(opt_intersection(tx, tx), tx) end) + assert assert_finishes(fn -> equal?(opt_intersection(tx, ty), atom([nil])) end) + + # recursive maps where one field is wider + t_int = rec_map_with(integer()) + t_number = rec_map_with(bare_union(integer(), float())) + assert assert_finishes(fn -> equal?(opt_intersection(t_int, t_number), t_int) end) + + # an empty recursive type intersected with a non-empty one + assert assert_finishes(fn -> empty?(opt_intersection(empty_rec(), int_list)) end) + end + + test "union of recursive types" do + int_list = int_linked_list() + number_list = number_linked_list() + + # same descr and alpha-equivalent copies + assert assert_finishes(fn -> equal?(opt_union(int_list, int_list), int_list) end) + assert assert_finishes(fn -> equal?(opt_union(int_list, int_linked_list()), int_list) end) + + # subtype absorption + assert assert_finishes(fn -> equal?(opt_union(int_list, number_list), number_list) end) + assert assert_finishes(fn -> equal?(opt_union(number_list, int_list), number_list) end) + + # map union with one differing key holding recursive nodes: this is the + # one_key_difference strategy, which recursively unions the field values + m1 = rec_map() + m2 = rec_map() + assert assert_finishes(fn -> equal?(opt_union(m1, m2), m1) end) + + # one differing non-recursive key next to a shared recursive node + node = rec_map_node() + + assert assert_finishes(fn -> + union = + opt_union(closed_map(a: node, b: integer()), closed_map(a: node, b: float())) + + equal?(union, closed_map(a: node, b: bare_union(integer(), float()))) + end) + + # tuple union with one differing index holding recursive nodes + assert assert_finishes(fn -> + union = opt_union(tuple([integer(), node]), tuple([float(), node])) + equal?(union, tuple([bare_union(integer(), float()), node])) + end) + + # empty recursive type is a neutral element + assert assert_finishes(fn -> equal?(opt_union(empty_rec(), int_list), int_list) end) + end + + test "difference of recursive types" do + int_list = int_linked_list() + number_list = number_linked_list() + + # differences that should be empty + assert assert_finishes(fn -> empty?(opt_difference(int_list, int_list)) end) + assert assert_finishes(fn -> empty?(opt_difference(int_list, int_linked_list())) end) + assert assert_finishes(fn -> empty?(opt_difference(int_list, number_list)) end) + + # a strict difference: number lists that are not int lists + diff = assert_finishes(fn -> opt_difference(number_list, int_list) end) + refute assert_finishes(fn -> empty?(diff) end) + assert assert_finishes(fn -> subtype?(tuple([float(), atom([nil])]), diff) end) + + # recursive maps, alpha-equivalent: one_key_difference recursion + assert assert_finishes(fn -> empty?(opt_difference(rec_map(), rec_map())) end) + + # improper lists with different terminators: recursion through the tail + ta = improper_list([:a, :b]) + tb = improper_list([:a]) + diff = assert_finishes(fn -> opt_difference(ta, tb) end) + assert assert_finishes(fn -> subtype?(atom([:b]), diff) end) + refute assert_finishes(fn -> empty?(diff) end) + assert assert_finishes(fn -> empty?(opt_difference(tb, ta)) end) + end + + test "difference reaching the same recursive pair under different operations" do + # opt_map_leaf_one_key_difference computes the difference, union or + # intersection of the same pair of field values depending on the shape + # of the surrounding BDD. The seen set must key on the operation as well. + # No closed forms here, so results are checked against the bare ops. + node_a = rec_map_node() + node_b = rec_map_node() + + m_at = closed_map(a: node_a, t: atom()) + m_az = closed_map(a: node_a, t: atom([:z])) + m_bt = closed_map(a: node_b, t: atom()) + m_bz = closed_map(a: node_b, t: atom([:z])) + + # a left BDD with union/negation structure makes the leaf difference + # request the union of the recursive field values (type: :union) + for left <- [bare_difference(m_at, m_az), bare_union(m_at, m_az)] do + assert assert_finishes(fn -> + equal?(opt_difference(left, m_bt), bare_difference(left, m_bt)) + end) + end + + # a negated right side exercises the same leaves under negation + neg = bare_negation(m_bt) + + assert assert_finishes(fn -> + equal?(opt_difference(m_at, neg), bare_difference(m_at, neg)) + end) + + assert assert_finishes(fn -> + equal?(opt_intersection(m_at, neg), bare_intersection(m_at, neg)) + end) + + # a structured right side exercises the intersection context (type: :intersection) + for right <- [bare_difference(m_bt, m_bz), bare_union(m_bt, m_bz)] do + assert assert_finishes(fn -> + equal?(opt_difference(m_at, right), bare_difference(m_at, right)) + end) + end + + # double negation through opt operations + ta = to_descr(node_a) + assert assert_finishes(fn -> equal?(opt_negation(opt_negation(ta)), ta) end) + end + + test "operations on dynamic recursive types" do + int_list = int_linked_list() + number_list = number_linked_list() + + combos = [ + {dynamic(int_list), int_list}, + {int_list, dynamic(int_list)}, + {dynamic(int_list), dynamic(number_list)}, + {dynamic(int_list), number_list}, + {number_list, dynamic(int_list)} + ] + + for {left, right} <- combos, op <- [:union, :intersection, :difference] do + assert assert_finishes(fn -> + equal?(opt_op(op, left, right), bare_op(op, left, right)) + end), + "opt_#{op} disagrees with bare_#{op} on dynamic combination" + end + + # gradual element inside the recursive definition itself + build = fn -> + recursive(%{ + X: fn recur -> tuple([dynamic(integer()), recur.(:X)]) |> bare_union(atom([nil])) end + }) + |> Map.fetch!(:X) + |> to_descr() + end + + tx = build.() + ty = build.() + + # Gradual parts hidden behind a recursive node are not lifted to the + # top of the descr (split_dynamic/1 treats nodes as static), so the + # equal? oracle is imprecise on these types: even bare_intersection of + # tx with an alpha-equivalent copy is not equal? to tx. We require + # termination and agreement on concrete witnesses instead. + witness = tuple([dynamic(integer()), atom([nil])]) + + for op <- [:union, :intersection, :difference] do + assert assert_finishes(fn -> + opt = opt_op(op, tx, ty) + bare = bare_op(op, tx, ty) + + subtype?(witness, opt) == subtype?(witness, bare) and + empty?(opt) == empty?(bare) + end), + "gradual element #{op}: opt and bare disagree on witnesses" + end + end + + test "operations on nested mixed-constructor recursion" do + # map <-> tuple mutual recursion: P = %{items: Q} | nil, Q = {integer(), P} | nil + build_pq = fn -> + recursive(%{ + P: fn recur -> closed_map(items: recur.(:Q)) |> bare_union(atom([nil])) end, + Q: fn recur -> tuple([integer(), recur.(:P)]) |> bare_union(atom([nil])) end + }) + |> Map.fetch!(:P) + |> to_descr() + end + + tp1 = build_pq.() + tp2 = build_pq.() + + assert assert_finishes(fn -> equal?(opt_intersection(tp1, tp2), tp1) end) + assert assert_finishes(fn -> equal?(opt_union(tp1, tp2), tp1) end) + assert assert_finishes(fn -> empty?(opt_difference(tp1, tp2)) end) + + # recursion through a list of maps: E = list(%{next: E} | nil) + build_e = fn -> + recursive(%{ + E: fn recur -> list(bare_union(closed_map(next: recur.(:E)), atom([nil]))) end + }) + |> Map.fetch!(:E) + |> to_descr() + end + + te1 = build_e.() + te2 = build_e.() + assert assert_finishes(fn -> equal?(opt_intersection(te1, te2), te1) end) + assert assert_finishes(fn -> equal?(opt_union(te1, te2), te1) end) + assert assert_finishes(fn -> empty?(opt_difference(te1, te2)) end) + + # json against an alpha-equivalent copy, checked against the bare ops + build_json = fn -> + recursive(%{ + Json: fn recur -> + scalar = + atom([nil]) + |> bare_union(boolean()) + |> bare_union(integer()) + |> bare_union(float()) + |> bare_union(binary()) + + array = list(recur.(:Json)) + object = open_map([{to_domain_keys(binary()), recur.(:Json)}]) + + scalar |> bare_union(array) |> bare_union(object) + end + }) + |> Map.fetch!(:Json) + |> to_descr() + end + + json1 = build_json.() + json2 = build_json.() + + for op <- [:union, :intersection, :difference] do + assert assert_finishes(fn -> + equal?(opt_op(op, json1, json2), bare_op(op, json1, json2)) + end), + "opt_#{op} disagrees with bare_#{op} on json" + end + end + + ## The systematic sweep: every opt operation must agree with its bare + ## counterpart over a zoo of recursive types. Failures are accumulated + ## so a regression reports every broken combination at once. + + defp opt_op(:union, left, right), do: opt_union(left, right) + defp opt_op(:intersection, left, right), do: opt_intersection(left, right) + defp opt_op(:difference, left, right), do: opt_difference(left, right) + + defp bare_op(:union, left, right), do: bare_union(left, right) + defp bare_op(:intersection, left, right), do: bare_intersection(left, right) + defp bare_op(:difference, left, right), do: bare_difference(left, right) + + # Returns a singleton error list if fun does not finish or returns false. + defp agreement_error(fun) do + case run_guarded(fun) do + {:ok, true} -> [] + {:ok, false} -> ["optimized and bare results disagree"] + {:error, message} -> [message] + end + end + + # Covers every structural kind the optimized operations recurse into, + # plus non-recursive controls. + defp opt_zoo do + {mutual, _} = mutual_tuples() + + [ + int_list: int_linked_list(), + number_list: number_linked_list(), + int_tree: binary_tree(integer()), + mutual: mutual, + rec_map: rec_map(), + rec_map_int: rec_map_with(integer()), + map_domain: domain_map(), + tail_list: tail_list(), + improper_list: improper_list([:stop, :halt]), + empty_rec: empty_rec(), + plain_tuple: tuple([integer(), atom()]), + plain_scalar: bare_union(atom([nil]), integer()) + ] + end + + test "systematic: opt operations agree with bare operations over the zoo" do + # Two zoos, so same-name pairs are alpha-equivalent, not identical + zoo1 = opt_zoo() + zoo2 = opt_zoo() + + binary_errors = + for {name1, t1} <- zoo1, + {name2, t2} <- zoo2, + op <- [:union, :intersection, :difference], + error <- agreement_error(fn -> equal?(opt_op(op, t1, t2), bare_op(op, t1, t2)) end) do + "#{name1} #{op} #{name2}: #{error}" + end + + negation_errors = + for {name, t} <- zoo1, + error <- agreement_error(fn -> equal?(opt_negation(t), bare_negation(t)) end) do + "negation of #{name}: #{error}" + end + + errors = binary_errors ++ negation_errors + + if errors != [] do + flunk("#{length(errors)} optimized operation(s) failed:\n" <> Enum.join(errors, "\n")) + end + end + end end