diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 0bb3ea069f..8e30de30aa 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -88,6 +88,8 @@ defmodule Module.Types.Descr do defp unfold(other), do: other defp unfolded_term, do: @term + # Special case: empty atom list is equivalent to none() + def atom([]), do: @none def atom(as), do: %{atom: atom_new(as)} def atom(), do: %{atom: @atom_top} def binary(), do: %{bitmap: @bit_binary} @@ -2666,6 +2668,239 @@ defmodule Module.Types.Descr do end) end + @doc """ + Returns the union of all possible key types in a map type. + If a key is not in this union, then it cannot be present in the map type. + For closed maps, returns the union of atom keys and domain key types. + For open maps, returns term() since any key type is possible. + """ + def map_keys(:term), do: :badmap + + def map_keys(descr) do + case :maps.take(:dynamic, descr) do + :error -> + if descr_key?(descr, :map) and map_only?(descr) do + process_map_keys(descr.map) + else + :badmap + end + + {dynamic, static} -> + if descr_key?(dynamic, :map) and map_only?(static) do + dynamic_keys = process_map_keys(dynamic.map) + + # If dynamic returns :badmap, the result is :badmap + if dynamic_keys == :badmap do + :badmap + else + # Only process static part if it has a :map key + if descr_key?(static, :map) do + static_keys = process_map_keys(static.map) + + # If static returns :badmap, the result is :badmap + if static_keys == :badmap do + :badmap + else + dynamic(dynamic_keys) |> union(static_keys) + end + else + # Static part is empty, so just return dynamic + dynamic(dynamic_keys) + end + end + else + :badmap + end + end + end + + @doc """ + Returns the union of all value types that a map type may contain. + For closed maps, returns the union of all field values and domain values. + For open maps, returns term() since any value type is possible. + """ + def map_values(:term), do: :badmap + + def map_values(descr) do + case :maps.take(:dynamic, descr) do + :error -> + if descr_key?(descr, :map) and map_only?(descr) do + process_map_values(Map.get(descr, :map, :bdd_bot)) + else + :badmap + end + + {dynamic, static} -> + if descr_key?(dynamic, :map) and map_only?(static) do + dynamic_values = process_map_values(Map.get(dynamic, :map, :bdd_bot)) + + # If dynamic returns :badmap, the result is :badmap + if dynamic_values == :badmap do + :badmap + else + # Only process static part if it has a :map key + if descr_key?(static, :map) do + static_values = process_map_values(Map.get(static, :map, :bdd_bot)) + + # If static returns :badmap, the result is :badmap + if static_values == :badmap do + :badmap + else + dynamic(dynamic_values) |> union(static_values) + end + else + # Static part is empty, so just return dynamic + dynamic(dynamic_values) + end + end + else + :badmap + end + end + end + + defp process_map_keys(bdd) do + # If the map type does not exist, returns :badmap + # If the map type is empty_map(), returns none() + + # Gets the nonempty maps from the TDD + dnf = map_bdd_to_dnf(bdd) + + if dnf == [] do + :badmap + else + # Check if any line in the DNF represents an open map, and compute the union of domain keys types. + {has_open_map, domain_keys_types} = + Enum.reduce_while(dnf, {false, none()}, fn {tag_or_domains, _fields, _negs}, + {_flag, acc} -> + case tag_or_domains do + :open -> + # A negation cannot make an open map closed without cancelling it completely, which is filtered by `map_bdd_to_dnf/1`. + {:halt, {true, acc}} + + domains = %{} -> + new_acc = + Enum.reduce(domains, acc, fn {domain_key, value}, acc -> + if not subtype?(value, not_set()) do + union(domain_key_to_type(domain_key), acc) + else + acc + end + end) + + # It's likely that we saturate the domain keys up to literal term(), so we can check for it. + if new_acc == unfolded_term() do + {:halt, {true, acc}} + else + {:cont, {false, new_acc}} + end + + _ -> + {:cont, {false, acc}} + end + end) + + if has_open_map do + term() + else + # Get all possible atom keys from the DNF + all_atom_keys = map_fetch_all_key_names(dnf) + + # Filter atom keys that are actually present (non-empty after negations) + present_atom_keys = + all_atom_keys + |> :sets.to_list() + |> Enum.filter(fn atom_key -> + {_optional?, value_type} = map_dnf_fetch_static(dnf, atom_key) + not empty?(value_type) + end) + + atom_keys_type = + if Enum.empty?(present_atom_keys) do + none() + else + atom(present_atom_keys) + end + + union(atom_keys_type, domain_keys_types) + end + end + end + + defp process_map_values(bdd) do + # First check if the map type itself is empty (impossible) + # If map_empty? returns true, the map type is impossible, so return :badmap + # If it returns false, at least one valid map exists, so process normally + if map_empty?(bdd) do + :badmap + else + dnf = map_bdd_to_dnf(bdd) + + # Check if any line in the DNF represents an open map + # An open map is either :open tag, or has all domain keys (which only happens + # when open_map is used with domain specifications) + {has_all_values, domain_values} = + Enum.reduce_while(dnf, {false, none()}, fn {tag_or_domains, _fields, _negs}, + {_flag, acc} -> + case tag_or_domains do + :open -> + # A negation cannot make an open map closed without cancelling it completely, which is filtered by `map_bdd_to_dnf/1`. + {:halt, {true, acc}} + + domains = %{} -> + # A negation cannot remove a domain without cancelling it completely, which is filtered by `map_bdd_to_dnf/1`. + new_acc = + Enum.reduce(domains, acc, fn {_domain_key, domain_value}, acc -> + union(domain_value, acc) + end) + |> remove_optional_static() + + # Short-circuit if we saturate the domain values up to term() + if new_acc == unfolded_term() do + {:halt, {true, acc}} + else + {:cont, {false, new_acc}} + end + + _ -> + {:cont, {false, acc}} + end + end) + + if has_all_values do + term() + else + # Get all possible atom keys from the DNF + all_atom_keys = map_fetch_all_key_names(dnf) + + # For each atom key, get its value type using map_dnf_fetch_static (handles negations) + atom_key_values = + all_atom_keys + |> :sets.to_list() + |> Enum.reduce(none(), fn atom_key, acc -> + {_optional?, value_type} = map_dnf_fetch_static(dnf, atom_key) + union(value_type, acc) + end) + + union(atom_key_values, domain_values) + end + end + end + + # Convert a domain_key tuple to the actual type + defp domain_key_to_type({:domain_key, :binary}), do: binary() + defp domain_key_to_type({:domain_key, :empty_list}), do: empty_list() + defp domain_key_to_type({:domain_key, :integer}), do: integer() + defp domain_key_to_type({:domain_key, :float}), do: float() + defp domain_key_to_type({:domain_key, :pid}), do: pid() + defp domain_key_to_type({:domain_key, :port}), do: port() + defp domain_key_to_type({:domain_key, :reference}), do: reference() + defp domain_key_to_type({:domain_key, :fun}), do: fun() + defp domain_key_to_type({:domain_key, :atom}), do: atom() + defp domain_key_to_type({:domain_key, :tuple}), do: tuple() + defp domain_key_to_type({:domain_key, :map}), do: open_map() + defp domain_key_to_type({:domain_key, :list}), do: non_empty_list(term(), term()) + @doc """ Fetches the type of the value returned by accessing `key` on `map` with the assumption that the descr is exclusively a map (or dynamic). @@ -3927,8 +4162,8 @@ defmodule Module.Types.Descr do here_branch ++ later_branches end - # No more negative elements to process: there is no “all-equal” branch to add, - # because we’re constructing {t} ant not {u}, which must differ somewhere. + # No more negative elements to process: there is no "all-equal" branch to add, + # because we're constructing {t} ant not {u}, which must differ somewhere. defp tuple_elim_content(_acc, _tag, _elements, []) do [] end diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 49925d9e8b..791fae3d5b 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1509,6 +1509,150 @@ defmodule Module.Types.DescrTest do |> equal?(integer()) end + test "map_values" do + assert map_values(:term) == :badmap + assert map_values(integer()) == :badmap + assert map_values(union(open_map(), integer())) == :badmap + assert map_values(none()) == :badmap + assert map_values(empty_map()) == none() + assert map_values(open_map()) == term() + assert map_values(closed_map(a: integer())) == integer() + assert map_values(closed_map(a: integer(), b: atom())) == union(integer(), atom()) + + # Returns :badbap if the map type is empty + assert open_map() + |> difference(open_map(a: if_set(term()), c: if_set(term()))) + |> map_values() == :badmap + + assert map_values(union(closed_map(a: float()), closed_map(b: pid()))) == + union(float(), pid()) + + assert map_values(difference(closed_map(a: number(), b: atom()), closed_map(a: float()))) == + union(number(), atom()) + + # A negation may remove a key entirely; in that case, its value should not appear + assert closed_map(a: if_set(integer()), b: atom()) + |> difference(closed_map(a: integer(), b: term())) + |> equal?(closed_map(b: atom())) + + assert closed_map(a: if_set(integer()), b: atom()) + |> difference(closed_map(a: integer(), b: term())) + |> map_values() == atom() + + # Test with domain keys + assert map_values(closed_map([{domain_key(:integer), binary()}])) == binary() + assert map_values(closed_map([{domain_key(:atom), integer()}])) == integer() + + # Test with both atom keys and domain keys + map_with_both = + closed_map([ + {:a, atom([:ok])}, + {:b, float()}, + {domain_key(:integer), binary()}, + {domain_key(:tuple), pid()} + ]) + + assert map_values(map_with_both) == + union(atom([:ok]), union(float(), union(binary(), pid()))) + + # Test open maps with domain keys + assert map_values(open_map([{domain_key(:integer), binary()}])) == term() + + # Test dynamic maps + assert map_values(dynamic(open_map())) == dynamic() + assert map_values(dynamic(closed_map(a: integer()))) == dynamic(integer()) + + assert map_values(union(dynamic(closed_map(a: integer())), closed_map(b: atom()))) == + union(dynamic(integer()), atom()) + + # A static integer is refused. + assert map_values(union(dynamic(open_map()), integer())) == :badmap + + # A dynamic integer, if there are some map types, is accepted (and ignored). + assert map_values(dynamic(union(integer(), closed_map(a: atom())))) == dynamic(atom()) + + # Test with if_set (optional keys) + assert map_values(closed_map(a: if_set(integer()))) == integer() + + # Complex difference cases + assert map_values(open_map() |> difference(negation(closed_map(a: integer())))) == integer() + + assert closed_map([{:a, float()}, {domain_key(:integer), integer()}]) + |> difference(negation(closed_map(a: term()))) + |> map_values() == float() + end + + test "map_keys" do + assert map_keys(:term) == :badmap + assert map_keys(integer()) == :badmap + assert map_keys(union(open_map(), integer())) == :badmap + assert map_keys(none()) == :badmap + + # A non existent map type is refused. + assert open_map() + |> difference(open_map(a: if_set(term()), c: if_set(term()))) + |> map_keys() == :badmap + + assert map_keys(empty_map()) == none() + assert map_keys(open_map()) == term() + assert map_keys(closed_map(a: integer())) == atom([:a]) + assert map_keys(closed_map(a: integer(), b: atom())) == atom([:a, :b]) + assert map_keys(union(closed_map(a: float()), closed_map(b: pid()))) == atom([:a, :b]) + + # Test with domain keys + assert map_keys(closed_map([{domain_key(:integer), binary()}])) == integer() + assert map_keys(closed_map([{domain_key(:tuple), binary()}])) == tuple() + + # Test with both atom keys and domain keys + map_with_both = + closed_map([ + {:a, atom([:ok])}, + {:b, float()}, + {domain_key(:integer), binary()}, + {domain_key(:tuple), pid()} + ]) + + assert map_keys(map_with_both) == + union(atom([:a, :b]), union(integer(), tuple())) + + # Test open maps - should return term() for keys + assert map_keys(open_map()) == term() + assert map_keys(open_map([{domain_key(:integer), binary()}])) == term() + assert map_keys(open_map(a: integer())) == term() + + # Test with multiple domain keys + all_domains = + closed_map([ + {domain_key(:integer), atom([:int])}, + {domain_key(:float), atom([:float])}, + {domain_key(:atom), binary()}, + {domain_key(:binary), integer()}, + {domain_key(:tuple), float()} + ]) + + assert map_keys(all_domains) == + union(integer(), union(float(), union(atom(), union(binary(), tuple())))) + + # Test dynamic maps + assert map_keys(dynamic(open_map())) == dynamic() + assert map_keys(dynamic(closed_map(a: integer()))) == dynamic(atom([:a])) + + assert map_keys(union(dynamic(closed_map(a: integer())), closed_map(b: atom()))) == + union(dynamic(atom([:a])), atom([:b])) + + # A static integer is refused. + assert map_keys(union(dynamic(open_map()), integer())) == :badmap + + # Test with negations + assert map_keys(difference(closed_map(a: integer(), b: atom()), closed_map(a: integer()))) == + atom([:a, :b]) + + # If a key is removed entirely by a negation, it should not appear in the result + assert closed_map(a: if_set(integer()), b: atom()) + |> difference(closed_map(a: integer(), b: term())) + |> map_keys() == atom([:b]) + end + test "domain_to_args" do # take complex tuples, normalize them, and check if they are still equal complex_tuples = [