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] 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 8469f0002f..3263e739ab 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 a79b2aa956..30afd3dd53 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 0000000000..99d8b863b5 --- /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