diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 2c41ec14ef..922e898d8c 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -26,6 +26,23 @@ defmodule Module.Types.Descr do @bit_top (1 <<< 7) - 1 @bit_number @bit_integer ||| @bit_float + defmacrop domain_key(key), do: {:domain_key, key} + + @domain_key_types [ + {:domain_key, :binary}, + {:domain_key, :empty_list}, + {:domain_key, :integer}, + {:domain_key, :float}, + {:domain_key, :pid}, + {:domain_key, :port}, + {:domain_key, :reference}, + {:domain_key, :fun}, + {:domain_key, :atom}, + {:domain_key, :tuple}, + {:domain_key, :map}, + {:domain_key, :list} + ] + @fun_top :fun_top @atom_top {:negation, :sets.new(version: 2)} @map_top [{:open, %{}, []}] @@ -46,6 +63,11 @@ defmodule Module.Types.Descr do @not_non_empty_list Map.delete(@term, :list) @not_list Map.replace!(@not_non_empty_list, :bitmap, @bit_top - @bit_empty_list) + @not_set %{optional: 1} + @term_or_optional Map.put(@term, :optional, 1) + @term_or_dynamic_optional Map.put(@term, :dynamic, %{optional: 1}) + @not_atom_or_optional Map.delete(@term_or_optional, :atom) + @empty_intersection [0, @none, []] @empty_difference [0, []] @@ -66,7 +88,7 @@ defmodule Module.Types.Descr do def atom(as), do: %{atom: atom_new(as)} def atom(), do: %{atom: @atom_top} def binary(), do: %{bitmap: @bit_binary} - def closed_map(pairs), do: map_descr(:closed, pairs) + def closed_map(pairs), do: map_descr(:closed, pairs, @term_or_optional, false) def empty_list(), do: %{bitmap: @bit_empty_list} def empty_map(), do: %{map: @map_empty} def integer(), do: %{bitmap: @bit_integer} @@ -74,7 +96,8 @@ defmodule Module.Types.Descr do def list(type), do: list_descr(type, @empty_list, true) def non_empty_list(type, tail \\ @empty_list), do: list_descr(type, tail, false) def open_map(), do: %{map: @map_top} - def open_map(pairs), do: map_descr(:open, pairs) + def open_map(pairs), do: map_descr(:open, pairs, @term_or_optional, false) + def open_map(pairs, default), do: map_descr(:open, pairs, if_set(default), true) def open_tuple(elements, _fallback \\ term()), do: tuple_descr(:open, elements) def pid(), do: %{bitmap: @bit_pid} def port(), do: %{bitmap: @bit_port} @@ -230,15 +253,18 @@ defmodule Module.Types.Descr do # is equivalent to `%{:foo => integer() or not_set()}`. # # `not_set()` has no meaning outside of map types. - - @not_set %{optional: 1} - @term_or_optional Map.put(@term, :optional, 1) - @term_or_dynamic_optional Map.put(@term, :dynamic, %{optional: 1}) - @not_atom_or_optional Map.delete(@term_or_optional, :atom) - def not_set(), do: @not_set + def if_set(:term), do: term_or_optional() - def if_set(type), do: Map.put(type, :optional, 1) + + # If type contains a :dynamic part, :optional gets added there. + def if_set(type) do + case type do + %{dynamic: dyn} -> Map.put(%{type | dynamic: Map.put(dyn, :optional, 1)}, :optional, 1) + _ -> Map.put(type, :optional, 1) + end + end + defp term_or_optional(), do: @term_or_optional @compile {:inline, @@ -526,6 +552,8 @@ defmodule Module.Types.Descr do 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) @@ -931,6 +959,7 @@ defmodule Module.Types.Descr do end end + defp atom_only?(:term), do: false defp atom_only?(descr), do: empty?(Map.delete(descr, :atom)) defp atom_new(as) when is_list(as), do: {:union, :sets.from_list(as, version: 2)} @@ -2168,34 +2197,78 @@ defmodule Module.Types.Descr do # is the union of `%{..., a: atom(), b: if_set(not integer())}` and # `%{..., a: if_set(not atom()), b: integer()}`. For maps with more keys, # each key in a negated literal may create a new union when eliminated. + # + # Instead of a tag :open or :closed, we can also use a map od domains which + # specifies for each defined key domain (@domain_key_types) the type associated with + # those keys. + # + # For instance, the type `%{atom() => if_set(integer())}` is the type of maps where atom keys + # map to integers, without any non-atom keys. It is represented using the map literal + # `{%{atom: if_set(integer())}, [], []}`, with no defined keys or negations. + # + # The type `%{..., atom() => integer()}` represents maps with atom keys bound to integers, + # and other keys bound to any type. It will be represented using a map domain that maps + # atom to `if_set(integer())`, and every other domain key to `term_or_optional()`. + + defp map_descr(tag, pairs, default, force?) do + {fields, domains, dynamic?} = map_descr_pairs(pairs, [], %{}, false) + + map_new = + if domains != %{} or force? do + domains = + if tag == :open do + Enum.reduce(@domain_key_types, domains, &Map.put_new(&2, &1, default)) + else + domains + end - defp map_descr(tag, fields) do - case map_descr_pairs(fields, [], false) do - {fields, true} -> - %{dynamic: %{map: map_new(tag, fields |> Enum.reverse() |> :maps.from_list())}} + map_new(domains, fields) + else + map_new(tag, fields) + end - {_, false} -> - %{map: map_new(tag, :maps.from_list(fields))} + case dynamic? do + true -> %{dynamic: %{map: map_new}} + false -> %{map: map_new} end end - defp map_descr_pairs([{key, :term} | rest], acc, dynamic?) do - map_descr_pairs(rest, [{key, :term} | acc], dynamic?) + # TOD: Double check if we indeed want the union here + defp map_put_domain(domain, key, value) do + Map.update(domain, key, if_set(value), &union(&1, value)) end - defp map_descr_pairs([{key, value} | rest], acc, dynamic?) do - case :maps.take(:dynamic, value) do - :error -> map_descr_pairs(rest, [{key, value} | acc], dynamic?) - {dynamic, _static} -> map_descr_pairs(rest, [{key, dynamic} | acc], true) + defp map_descr_pairs([{key, :term} | rest], fields, domain, dynamic?) do + case is_atom(key) do + true -> map_descr_pairs(rest, [{key, :term} | fields], domain, dynamic?) + false -> map_descr_pairs(rest, fields, map_put_domain(domain, key, :term), dynamic?) end end - defp map_descr_pairs([], acc, dynamic?) do - {acc, dynamic?} + defp map_descr_pairs([{key, value} | rest], fields, domain, dynamic?) do + {value, dynamic?} = + case :maps.take(:dynamic, value) do + :error -> {value, dynamic?} + {dynamic, _static} -> {dynamic, true} + end + + case is_atom(key) do + true -> map_descr_pairs(rest, [{key, value} | fields], domain, dynamic?) + false -> map_descr_pairs(rest, fields, map_put_domain(domain, key, value), dynamic?) + end + end + + defp map_descr_pairs([], fields, domain, dynamic?) do + {fields |> Enum.reverse() |> :maps.from_list(), domain, dynamic?} end - defp tag_to_type(:open), do: term_or_optional() - defp tag_to_type(:closed), do: not_set() + defp tuple_tag_to_type(:open), do: term_or_optional() + defp tuple_tag_to_type(:closed), do: not_set() + + # Gets the default type associated to atom keys in a map. + defp map_key_tag_to_type(:open), do: term_or_optional() + defp map_key_tag_to_type(:closed), do: not_set() + defp map_key_tag_to_type(domains = %{}), do: Map.get(domains, domain_key(:atom), not_set()) defguardp is_optional_static(map) when is_map(map) and is_map_key(map, :optional) @@ -2376,6 +2449,58 @@ defmodule Module.Types.Descr do :maps.iterator(open) |> :maps.next() |> map_literal_intersection_loop(closed) end + # At least one tag is a tag-domain pair. + defp map_literal_intersection(tag_or_domains1, map1, tag_or_domains2, map2) 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 + default1 = map_key_tag_to_type(tag_or_domains1) + 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) + + # 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) + # 2. If key is only in map1, compute non empty intersection with atom2 + # 3. If key is only in map2, compute non empty intersection with atom1 + # We do that by computing intersection on all key labels in both map1 and map2, + # using default values when a key is not present. + {tag_or_domains, + symmetrical_merge(map1, default1, map2, default2, fn _key, v1, v2 -> + non_empty_intersection!(v1, v2) + end)} + 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 + new_domains = + for {domain_key(_) = domain_key, type1} <- domains1, reduce: %{} do + acc_domains -> + case domains2 do + %{^domain_key => type2} -> + inter = intersection(type1, type2) + + if empty_or_optional?(inter) do + acc_domains + else + Map.put(acc_domains, domain_key, inter) + end + + _ -> + acc_domains + end + end + + # If the explicit domains are empty, use simple atom tags + if map_size(new_domains) == 0, do: :closed, else: new_domains + end + defp map_literal_intersection_loop(:none, acc), do: {:closed, acc} defp map_literal_intersection_loop({key, type1, iterator}, acc) do @@ -2404,7 +2529,7 @@ defmodule Module.Types.Descr do {:open, fields2, []}, dnf1 when map_size(fields2) == 1 -> Enum.reduce(dnf1, [], fn {tag1, fields1, negs1}, acc -> {key, value, _rest} = :maps.next(:maps.iterator(fields2)) - t_diff = difference(Map.get(fields1, key, tag_to_type(tag1)), value) + t_diff = difference(Map.get(fields1, key, map_key_tag_to_type(tag1)), value) if empty?(t_diff) do acc @@ -2476,11 +2601,9 @@ defmodule Module.Types.Descr do # Optimization: if the key does not exist in the map, avoid building # if_set/not_set pairs and return the popped value directly. - defp map_fetch_static(%{map: [{tag, fields, []}]}, key) when not is_map_key(fields, key) do - case tag do - :open -> {true, term()} - :closed -> {true, none()} - end + defp map_fetch_static(%{map: [{tag_or_domains, fields, []}]}, key) + when not is_map_key(fields, key) do + map_key_tag_to_type(tag_or_domains) |> pop_optional_static() end # Takes a map dnf and returns the union of types it can take for a given key. @@ -2488,15 +2611,13 @@ defmodule Module.Types.Descr do defp map_fetch_static(%{map: dnf}, key) do dnf |> Enum.reduce(none(), fn - # Optimization: if there are no negatives, - # we can return the value directly. + # Optimization: if there are no negatives and key exists, return its value {_tag, %{^key => value}, []}, acc -> value |> union(acc) - # Optimization: if there are no negatives - # and the key does not exist, return the default one. + # Optimization: if there are no negatives and the key does not exist, return the default one. {tag, %{}, []}, acc -> - tag_to_type(tag) |> union(acc) + map_key_tag_to_type(tag) |> union(acc) {tag, fields, negs}, acc -> {fst, snd} = map_pop_key(tag, fields, key) @@ -2552,6 +2673,152 @@ defmodule Module.Types.Descr do end end + @doc """ + Refreshes the type of map after assuming some type was given to a key of a given type. + Assuming that the descr is exclusively a map (or dynamic). + """ + def map_refresh(:term, _key, _type), do: :badmap + + def map_refresh(descr, key_descr, type) do + {dynamic_descr, static_descr} = Map.pop(descr, :dynamic) + key_descr = unfold(key_descr) + type = unfold(type) + + cond do + # Either 1) static part is a map, or 2) static part is empty and dynamic part contains maps + not map_only?(static_descr) -> + :badmap + + empty?(static_descr) and not (not is_nil(dynamic_descr) and descr_key?(dynamic_descr, :map)) -> + :badmap + + # Either of those three types could be dynamic. + not (not is_nil(dynamic_descr) or Map.has_key?(key_descr, :dynamic) or + Map.has_key?(type, :dynamic)) -> + map_refresh_static(descr, key_descr, type) + + true -> + # If one of those is dynamic, we just compute the union + {descr_dynamic, descr_static} = Map.pop(descr, :dynamic, descr) + {key_dynamic, key_static} = Map.pop(key_descr, :dynamic, key_descr) + {type_dynamic, type_static} = Map.pop(type, :dynamic, type) + + with {:ok, new_static} <- map_refresh_static(descr_static, key_static, type_static), + {:ok, new_dynamic} <- map_refresh_static(descr_dynamic, key_dynamic, type_dynamic) do + {:ok, union(new_static, dynamic(new_dynamic))} + end + end + end + + def map_refresh_static(%{map: _} = descr, key_descr = %{}, type) do + # Check if descr is a valid map, + case atom_fetch(key_descr) do + # If the key_descr is a singleton, we directly put the type into the map. + {:finite, [single_key]} -> + map_put(descr, single_key, type) + + # In this case, we iterate on key_descr to add type to each key type it covers. + # Since we do not know which key will be used, we do the union with previous types. + _ -> + new_descr = + key_descr + |> covered_key_types() + |> Enum.reduce(descr, fn + {:atom, atom_key}, acc -> + map_refresh_atom(acc, atom_key, type) + + domain_key, acc -> + map_refresh_domain(acc, domain_key, type) + end) + + {:ok, new_descr} + end + end + + def map_refresh_static(:term, _key_descr, _type), do: {:ok, open_map()} + def map_refresh_static(_, _, _), do: {:ok, none()} + + @doc """ + Updates a key in a map type by fetching its current type, unioning it with a + `new_additional_type`, and then putting the resulting union type back. + + Returns: + - `{:ok, new_map_descr}`: If successful. + - `:badmap`: If the input `descr` is not a valid map type. + - `:badkey`: If the key is considered invalid during the take operation (e.g., + an optional key that resolves to an empty type). + """ + def map_refresh_key(descr, key, new_additional_type) when is_atom(key) do + case map_fetch(descr, key) do + :badmap -> + :badmap + + # Key is not present: we just add the new one and make it optional. + :badkey -> + with {:ok, descr} <- map_put(descr, key, if_set(new_additional_type)) do + descr + end + + {_optional?, current_key_type} -> + type_to_put = union(current_key_type, new_additional_type) + + case map_fetch_and_put(descr, key, type_to_put) do + {_taken_type, new_map_descr} -> new_map_descr + # Propagates :badmap or :badkey from map_fetch_and_put + error -> error + end + end + end + + def map_refresh_domain(%{map: [{tag, fields, []}]}, domain, type) do + %{map: [{map_refresh_tag(tag, domain, type), fields, []}]} + end + + def map_refresh_domain(%{map: dnf}, domain, type) do + Enum.map(dnf, fn + {tag, fields, []} -> + {map_refresh_tag(tag, domain, type), fields, []} + + {tag, fields, negs} -> + # For negations, we count on the idea that a negation will not remove any + # type from a domain unless it completely cancels out the type. + # So for any non-empty map dnf, we just update the domain with the new type, + # as well as its negations to keep them accurate. + {map_refresh_tag(tag, domain, type), fields, + Enum.map(negs, fn {neg_tag, neg_fields} -> + {map_refresh_tag(neg_tag, domain, type), neg_fields} + end)} + end) + end + + def map_refresh_atom(descr = %{map: dnf}, atom_key, type) do + case atom_key do + {:union, keys} -> + keys + |> :sets.to_list() + |> Enum.reduce(descr, fn key, acc -> map_refresh_key(acc, key, type) end) + + {:negation, keys} -> + # 1) Fetch all the possible keys in the dnf + # 2) Get them all, except the ones in neg_atoms + possible_keys = map_fetch_all_key_names(dnf) + considered_keys = :sets.subtract(possible_keys, keys) + + considered_keys + |> :sets.to_list() + |> Enum.reduce(descr, fn key, acc -> map_refresh_key(acc, key, type) end) + |> map_refresh_domain(domain_key(:atom), type) + end + end + + def map_refresh_tag(tag_or_domains, domain_key, type) do + case tag_or_domains do + :open -> :open + :closed -> %{domain_key => if_set(type)} + domains = %{} -> Map.update(domains, domain_key, if_set(type), &union(&1, type)) + end + end + defp map_put_shared(descr, key, type) do with {nil, descr} <- map_take(descr, key, nil, &map_put_static(&1, key, type)) do {:ok, descr} @@ -2584,6 +2851,249 @@ defmodule Module.Types.Descr do end end + @doc """ + Computes the union of types for keys matching `key_type` within the `map_type`. + + This generalizes `map_fetch/2` (which operates on a single literal key) to + work with a key type (e.g., `atom()`, `integer()`, `:a or :b`). It's based + on the map-selection operator t.[t'] described in Section 4.2 of "Typing Records, + Maps, and Structs" (Castagna et al., ICFP 2023). + + ## Return Values + + The function returns a tuple indicating the outcome and the resulting type union: + + * `{:ok, type}`: Standard success. `type` is the resulting union of types + found for the matching keys. This covers two sub-cases: + * **Keys definitely exist:** If `disjoint?(type, not_set())` is true, + all keys matching `key_type` are guaranteed to exist. + * **Keys may exist:** If `type` includes `not_set()`, some keys + matching `key_type` might exist (contributing their types) while + others might be absent (contributing `not_set()`). + + * `{:ok_absent, type}`: Success, but the resulting `type` is `none()` or a + subtype of `not_set()`. This indicates that no key matching `key_type` + can exist with a value other than `not_set()`. The caller may wish to + issue a warning, as this often implies selecting a field that is + effectively undefined. + + # TODO: implement/decide if worth it (it's from the paper) + * `{:ok_spillover, type}`: Success, and `type` is the resulting union. + However, this indicates that the `key_type` included keys not explicitly + covered by the `map_type`'s fields or domain specifications. The + projection relied on the map's default behavior (e.g., the `term()` + value type for unspecified keys in an open map). The caller may wish to + issue a warning, as this could conceal issues like selecting keys + not intended by the map's definition. + + * `:badmap`: The input `map_type` was invalid (e.g., not a map type or + a dynamic type wrapping a map type). + + * `:badkeytype`: The input `key_type` was invalid (e.g., not a subtype + of the allowed key types like `atom()`, `integer()`, etc.). + """ + def map_get(:term, _key_descr), do: :badmap + + def map_get(%{} = descr, key_descr) do + case :maps.take(:dynamic, descr) do + :error -> + if descr_key?(descr, :map) and map_only?(descr) do + {optional?, type_selected} = map_get_static(descr, key_descr) |> pop_optional_static() + + cond do + empty?(type_selected) -> {:ok_absent, atom([nil])} + optional? -> {:ok, nil_or_type(type_selected)} + true -> {:ok_present, type_selected} + end + else + :badmap + end + + {dynamic, static} -> + if descr_key?(dynamic, :map) and map_only?(static) do + {optional_dynamic?, dynamic_type} = + map_get_static(dynamic, key_descr) |> pop_optional_static() + + {optional_static?, static_type} = + map_get_static(static, key_descr) |> pop_optional_static() + + type_selected = union(dynamic(dynamic_type), static_type) + + cond do + empty?(type_selected) -> {:ok_absent, atom([nil])} + optional_dynamic? or optional_static? -> {:ok, nil_or_type(type_selected)} + true -> {:ok_present, type_selected} + end + else + :badmap + end + end + end + + # Returns the list of key types that are covered by the key_descr. + # E.g., for `{atom([:ok]), term} or integer()` it returns `[:tuple, :integer]`. + # We treat bitmap types as a separate key type. + defp covered_key_types(:term), do: @domain_key_types + + defp covered_key_types(key_descr) do + for {type_kind, type} <- key_descr, reduce: [] do + acc -> + cond do + type_kind == :atom -> [{:atom, type} | acc] + type_kind == :bitmap -> bitmap_to_domain_keys(type) ++ acc + not empty?(%{type_kind => type}) -> [domain_key(type_kind) | acc] + true -> acc + end + end + end + + # TODO: Optimize this + defp bitmap_to_domain_keys(bitmap) do + [ + if((bitmap &&& @bit_binary) != 0, do: domain_key(:binary)), + if((bitmap &&& @bit_empty_list) != 0, do: domain_key(:empty_list)), + if((bitmap &&& @bit_integer) != 0, do: domain_key(:integer)), + if((bitmap &&& @bit_float) != 0, do: domain_key(:float)), + if((bitmap &&& @bit_pid) != 0, do: domain_key(:pid)), + if((bitmap &&& @bit_port) != 0, do: domain_key(:port)), + if((bitmap &&& @bit_reference) != 0, do: domain_key(:reference)) + ] + |> Enum.reject(&is_nil/1) + end + + def nil_or_type(type), do: union(type, atom([nil])) + + defp unfold_domains(:closed), do: %{} + + defp unfold_domains(:open), + do: Map.new(@domain_key_types, fn domain_key -> {domain_key, @term_or_optional} end) + + defp unfold_domains(domains = %{}), do: domains + + def map_get_static(%{map: [{tag_or_domains, fields, []}]}, key_descr) do + # For each non-empty kind of type in the key_descr, we add the corresponding key domain in a union. + domains = unfold_domains(tag_or_domains) + + key_descr + |> covered_key_types() + |> Enum.reduce(none(), fn + # Note: we could stop if we reach term_or_optional() + {:atom, atom_type}, acc -> map_get_atom([{domains, fields, []}], atom_type) |> union(acc) + key_type, acc -> Map.get(domains, key_type, not_set()) |> union(acc) + end) + end + + def map_get_static(%{map: dnf}, key_descr) do + key_descr + |> covered_key_types() + |> Enum.reduce(none(), fn + {:atom, atom_type}, acc -> + map_get_atom(dnf, atom_type) |> union(acc) + + domain_key, acc -> + map_get_domain(dnf, domain_key) |> union(acc) + end) + end + + def map_get_static(%{}, _key), do: not_set() + def map_get_static(:term, _key), do: term_or_optional() + + # Given a map dnf return the union of types for a given atom type. Handles two cases: + # 1. A union of atoms (e.g., `{:union, atoms}`): + # - Iterates through each atom in the union. + # - Fetches the type for each atom and combines them into a union. + # + # 2. A negation of atoms (e.g., `{:negation, atoms}`): + # - Fetches all possible keys in the map's DNF. + # - Excludes the negated atoms from the considered keys. + # - Includes the domain of all atoms in the map's DNF. + # + # Example: + # Fetching a key of type `atom() and not (:a)` from a map of type + # `%{a: atom(), b: float(), atom() => pid()}` + # would return either `nil` or `float()` (key `:b`) or `pid()` (key `atom()`), but not `atom()` (key `:a`). + def map_get_atom(dnf, atom_type) do + case atom_type do + {:union, atoms} -> + atoms + |> :sets.to_list() + |> Enum.reduce(none(), fn atom, acc -> + {static_optional?, type} = map_fetch_static(%{map: dnf}, atom) + + if static_optional? do + union(type, acc) |> nil_or_type() |> if_set() + else + union(type, acc) + end + end) + + {:negation, atoms} -> + # 1) Fetch all the possible keys in the dnf + # 2) Get them all, except the ones in neg_atoms + possible_keys = map_fetch_all_key_names(dnf) + considered_keys = :sets.subtract(possible_keys, atoms) + + considered_keys + |> :sets.to_list() + |> Enum.reduce(none(), fn atom, acc -> + {static_optional?, type} = map_fetch_static(%{map: dnf}, atom) + + if static_optional? do + union(type, acc) |> nil_or_type() |> if_set() + else + union(type, acc) + end + end) + |> union(map_get_domain(dnf, domain_key(:atom))) + end + end + + # Fetch all present keys in a map dnf (including negated ones). + defp map_fetch_all_key_names(dnf) do + dnf + |> Enum.reduce(:sets.new(version: 2), fn {_tag, fields, negs}, acc -> + keys = :sets.from_list(Map.keys(fields)) + + # Add all the negative keys + # Example: %{...} and not %{a: not_set()} makes key :a present in the map + Enum.reduce(negs, keys, fn {_tag, neg_fields}, acc -> + :sets.from_list(Map.keys(neg_fields)) |> :sets.union(acc) + end) + |> :sets.union(acc) + end) + end + + # Take a map dnf and return the union of types for the given key domain. + def map_get_domain(dnf, domain_key(_) = domain_key) do + dnf + |> Enum.reduce(none(), fn + {tag, _fields, []}, acc when is_atom(tag) -> + map_key_tag_to_type(tag) |> union(acc) + + # Optimization: if there are no negatives and domains exists, return its value + {%{^domain_key => value}, _fields, []}, acc -> + value |> union(acc) + + # Optimization: if there are no negatives and the key does not exist, return the default type. + {domains = %{}, _fields, []}, acc -> + map_key_tag_to_type(domains) |> union(acc) + + {tag_or_domains, fields, negs}, acc -> + {fst, snd} = map_pop_domain(tag_or_domains, fields, domain_key) + + case map_split_negative_domain(negs, domain_key) do + :empty -> + acc + + negative -> + negative + |> pair_make_disjoint() + |> pair_eliminate_negations_fst(fst, snd) + |> union(acc) + end + end) + end + @doc """ Removes a key from a map type and return its type. @@ -2694,53 +3204,102 @@ defmodule Module.Types.Descr do defp map_empty?(:open, fs, [{:closed, _} | negs]), do: map_empty?(:open, fs, negs) defp map_empty?(tag, fields, [{neg_tag, neg_fields} | negs]) do - (Enum.all?(neg_fields, fn {neg_key, neg_type} -> - cond do - # Keys that are present in the negative map, but not in the positive one - is_map_key(fields, neg_key) -> - true - - # The key is not shared between positive and negative maps, - # if the negative type is optional, then there may be a value in common - tag == :closed -> - is_optional_static(neg_type) - - # There may be value in common - tag == :open -> - diff = difference(term_or_optional(), neg_type) - empty?(diff) or map_empty?(tag, Map.put(fields, neg_key, diff), negs) - end - end) and - Enum.all?(fields, fn {key, type} -> - case neg_fields do - %{^key => neg_type} -> - diff = difference(type, neg_type) - empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs) - - %{} -> - cond do - neg_tag == :open -> - true - - neg_tag == :closed and not is_optional_static(type) -> - false - - true -> - # an absent key in a open negative map can be ignored - diff = difference(type, tag_to_type(neg_tag)) - empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs) - end + if map_check_domain_keys(tag, neg_tag) do + atom_default = map_key_tag_to_type(tag) + neg_atom_default = map_key_tag_to_type(neg_tag) + + (Enum.all?(neg_fields, fn {neg_key, neg_type} -> + cond do + # Ignore keys present in both maps; will be handled below + is_map_key(fields, neg_key) -> + true + + # The key is not shared between positive and negative maps, + # if the negative type is optional, then there may be a value in common + tag == :closed -> + is_optional_static(neg_type) + + # There may be value in common + tag == :open -> + diff = difference(term_or_optional(), neg_type) + empty?(diff) or map_empty?(tag, Map.put(fields, neg_key, diff), negs) + + true -> + diff = difference(atom_default, neg_type) + empty?(diff) or map_empty?(tag, Map.put(fields, neg_key, diff), negs) end - end)) or map_empty?(tag, fields, negs) + end) and + Enum.all?(fields, fn {key, type} -> + case neg_fields do + %{^key => neg_type} -> + diff = difference(type, neg_type) + empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs) + + %{} -> + cond do + neg_tag == :open -> + true + + neg_tag == :closed and not is_optional_static(type) -> + false + + true -> + # an absent key in a open negative map can be ignored + diff = difference(type, neg_atom_default) + empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs) + end + end + end)) or map_empty?(tag, fields, negs) + else + map_empty?(tag, fields, negs) + end + 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 + + # 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 + map_size(neg_domains) == length(@domain_key_types) and + Enum.all?(neg_domains, fn {domain_key(_), type} -> subtype?(term_or_optional(), type) 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?(pos_domains, fn {domain_key(_), type} -> empty_or_optional?(type) end) + end + + # Component-wise comparison of domains + defp map_check_domain_keys(pos_domains, neg_domains) do + Enum.all?(pos_domains, fn {domain_key(_) = domain_key, type} -> + subtype?(type, Map.get(neg_domains, domain_key, not_set())) + end) end defp map_pop_key(tag, fields, key) do case :maps.take(key, fields) do {value, fields} -> {value, %{map: map_new(tag, fields)}} - :error -> {tag_to_type(tag), %{map: map_new(tag, fields)}} + :error -> {map_key_tag_to_type(tag), %{map: map_new(tag, fields)}} end end + # Pop a domain type, e.g. popping integers from %{integer() => binary()} + # returns {if_set(binary()), %{integer() => if_set(binary()}} + # If the domain is not present, use the tag to type as default. + defp map_pop_domain(domains = %{}, fields, domain_key) do + case :maps.take(domain_key, domains) do + {value, domains} -> {if_set(value), %{map: map_new(domains, fields)}} + :error -> {map_key_tag_to_type(domains), %{map: map_new(domains, fields)}} + end + end + + # Atom case + defp map_pop_domain(tag, fields, _domain_key), + do: {map_key_tag_to_type(tag), %{map: map_new(tag, fields)}} + defp map_split_negative(negs, key) do Enum.reduce_while(negs, [], fn # A negation with an open map means the whole thing is empty. @@ -2749,6 +3308,13 @@ defmodule Module.Types.Descr do end) end + defp map_split_negative_domain(negs, domain_key) do + Enum.reduce_while(negs, [], fn + {:open, fields}, _acc when map_size(fields) == 0 -> {:halt, :empty} + {tag, fields}, neg_acc -> {:cont, [map_pop_domain(tag, fields, domain_key) | neg_acc]} + end) + end + # Use heuristics to normalize a map dnf for pretty printing. defp map_normalize(dnfs) do for dnf <- dnfs, not map_empty?([dnf]) do @@ -2867,11 +3433,27 @@ defmodule Module.Types.Descr do {:map, [], []} end + def map_literal_to_quoted({domains = %{}, fields}, _opts) + when map_size(domains) == 0 and map_size(fields) == 0 do + {:empty_map, [], []} + end + def map_literal_to_quoted({:open, %{__struct__: @not_atom_or_optional} = fields}, _opts) when map_size(fields) == 1 do {:non_struct_map, [], []} end + def map_literal_to_quoted({domains = %{}, fields}, opts) do + domain_fields = + for {domain_key(domain_type), value_type} <- domains do + key = {:string, [], ["#{domain_type}() => "]} + {key, to_quoted(value_type, opts)} + end + + regular_fields_quoted = map_fields_to_quoted(:closed, Enum.sort(fields), opts) + {:%{}, [], domain_fields ++ regular_fields_quoted} + end + def map_literal_to_quoted({tag, fields}, opts) do case tag do :closed -> @@ -3355,7 +3937,7 @@ defmodule Module.Types.Descr do defp tuple_get(dnf, index) do Enum.reduce(dnf, none(), fn - {tag, elements}, acc -> Enum.at(elements, index, tag_to_type(tag)) |> union(acc) + {tag, elements}, acc -> Enum.at(elements, index, tuple_tag_to_type(tag)) |> union(acc) end) end @@ -3653,9 +4235,9 @@ defmodule Module.Types.Descr do ## Map helpers + # Erlang maps:merge_with/3 has to preserve the order in combiner. + # We don't care about the order, so we have a faster implementation. defp symmetrical_merge(left, right, fun) do - # Erlang maps:merge_with/3 has to preserve the order in combiner. - # We don't care about the order, so we have a faster implementation. if map_size(left) > map_size(right) do iterator_merge(:maps.next(:maps.iterator(right)), left, fun) else @@ -3675,9 +4257,44 @@ defmodule Module.Types.Descr do defp iterator_merge(:none, map, _fun), do: map + # Perform a symmetrical merge with default values + defp symmetrical_merge(left, left_default, right, right_default, fun) do + iterator = :maps.next(:maps.iterator(left)) + iterator_merge_left(iterator, left_default, right, right_default, %{}, fun) + end + + defp iterator_merge_left({key, v1, iterator}, v1_default, map, v2_default, acc, fun) do + value = + case map do + %{^key => v2} -> fun.(key, v1, v2) + %{} -> fun.(key, v1, v2_default) + end + + acc = Map.put(acc, key, value) + iterator_merge_left(:maps.next(iterator), v1_default, map, v2_default, acc, fun) + end + + defp iterator_merge_left(:none, v1_default, map, _v2_default, acc, fun) do + iterator_merge_right(:maps.next(:maps.iterator(map)), v1_default, acc, fun) + end + + defp iterator_merge_right({key, v2, iterator}, v1_default, acc, fun) do + acc = + case acc do + %{^key => _} -> acc + %{} -> Map.put(acc, key, fun.(key, v1_default, v2)) + end + + iterator_merge_right(:maps.next(iterator), v1_default, acc, fun) + end + + defp iterator_merge_right(:none, _v1_default, acc, _fun) do + acc + end + + # Erlang maps:intersect_with/3 has to preserve the order in combiner. + # We don't care about the order, so we have a faster implementation. defp symmetrical_intersection(left, right, fun) do - # Erlang maps:intersect_with/3 has to preserve the order in combiner. - # We don't care about the order, so we have a faster implementation. if map_size(left) > map_size(right) do iterator_intersection(:maps.next(:maps.iterator(right)), left, [], fun) else diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index e7d199396e..a30367cdc6 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -16,6 +16,15 @@ defmodule Module.Types.DescrTest do import Module.Types.Descr, except: [fun: 1] + defmacrop domain_key(key), do: {:domain_key, key} + + defp number(), do: union(integer(), float()) + defp empty_tuple(), do: tuple([]) + defp tuple_of_size_at_least(n) when is_integer(n), do: open_tuple(List.duplicate(term(), n)) + defp tuple_of_size(n) when is_integer(n) and n >= 0, do: tuple(List.duplicate(term(), n)) + defp list(elem_type, tail_type), do: union(empty_list(), non_empty_list(elem_type, tail_type)) + defp map_with_default(descr), do: open_map([], if_set(descr)) + describe "union" do test "bitmap" do assert union(integer(), float()) == union(float(), integer()) @@ -101,8 +110,28 @@ defmodule Module.Types.DescrTest do a_integer_open = open_map(a: integer()) assert equal?(union(closed_map(a: integer()), a_integer_open), a_integer_open) - assert difference(open_map(a: integer()), closed_map(b: boolean())) - |> equal?(open_map(a: integer())) + # Domain key types + atom_to_atom = open_map([{domain_key(:atom), atom()}]) + atom_to_integer = open_map([{domain_key(:atom), integer()}]) + + # Test union identity and different type maps + assert union(atom_to_atom, atom_to_atom) == atom_to_atom + + # Test subtype relationships with domain key maps + refute open_map([{domain_key(:atom), union(atom(), integer())}]) + |> subtype?(union(atom_to_atom, atom_to_integer)) + + assert union(atom_to_atom, atom_to_integer) + |> subtype?(open_map([{domain_key(:atom), union(atom(), integer())}])) + + # Test unions with empty and open maps + assert union(empty_map(), open_map([{domain_key(:integer), atom()}])) + |> equal?(open_map([{domain_key(:integer), atom()}])) + + assert union(open_map(), open_map([{domain_key(:integer), atom()}])) == open_map() + + # Test union of open map and map with domain key + assert union(open_map(), open_map([{domain_key(:integer), atom()}])) == open_map() end test "list" do @@ -263,7 +292,64 @@ defmodule Module.Types.DescrTest do assert empty?(intersection(closed_map(a: integer()), closed_map(a: atom()))) end - defp number(), do: union(integer(), float()) + test "map with domain keys" do + # %{..., int => t1, atom => t2} and %{int => t3} + # intersection is %{int => t1 and t3, atom => none} + map1 = open_map([{domain_key(:integer), integer()}, {domain_key(:atom), atom()}]) + map2 = closed_map([{domain_key(:integer), number()}]) + + intersection = intersection(map1, map2) + + expected = + closed_map([{domain_key(:integer), integer()}, {domain_key(:atom), none()}]) + + assert equal?(intersection, expected) + + # %{..., int => t1, atom => t2} and %{int => t3, pid => t4} + # intersection is %{int =>t1 and t3, atom => none, pid => t4} + map1 = open_map([{domain_key(:integer), integer()}, {domain_key(:atom), atom()}]) + map2 = closed_map([{domain_key(:integer), float()}, {domain_key(:pid), binary()}]) + + intersection = intersection(map1, map2) + + expected = + closed_map([ + {domain_key(:integer), intersection(integer(), float())}, + {domain_key(:atom), none()}, + {domain_key(:pid), binary()} + ]) + + assert equal?(intersection, expected) + + # %{..., int => t1, string => t3} and %{int => t4} + # intersection is %{int => t1 and t4, string => none} + map1 = open_map([{domain_key(:integer), integer()}, {domain_key(:binary), binary()}]) + map2 = closed_map([{domain_key(:integer), float()}]) + + intersection = intersection(map1, map2) + + assert equal?( + intersection, + closed_map([ + {domain_key(:integer), intersection(integer(), float())}, + {domain_key(:binary), none()} + ]) + ) + + assert subtype?(empty_map(), closed_map([{domain_key(:integer), atom()}])) + + t1 = closed_map([{domain_key(:integer), atom()}]) + t2 = closed_map([{domain_key(:integer), binary()}]) + + assert equal?(intersection(t1, t2), empty_map()) + + t1 = closed_map([{domain_key(:integer), atom()}]) + t2 = closed_map([{domain_key(:atom), term()}]) + + # their intersection is the empty map + refute empty?(intersection(t1, t2)) + assert equal?(intersection(t1, t2), empty_map()) + end test "list" do assert intersection(list(term()), list(term())) == list(term()) @@ -376,10 +462,6 @@ defmodule Module.Types.DescrTest do assert empty?(difference(dynamic(integer()), integer())) end - defp empty_tuple(), do: tuple([]) - defp tuple_of_size_at_least(n) when is_integer(n), do: open_tuple(List.duplicate(term(), n)) - defp tuple_of_size(n) when is_integer(n) and n >= 0, do: tuple(List.duplicate(term(), n)) - test "tuple" do assert empty?(difference(open_tuple([atom()]), open_tuple([term()]))) refute empty?(difference(tuple(), empty_tuple())) @@ -449,9 +531,57 @@ defmodule Module.Types.DescrTest do |> equal?(open_map(a: atom())) refute empty?(difference(open_map(), empty_map())) + + assert difference(open_map(a: integer()), closed_map(b: boolean())) + |> equal?(open_map(a: integer())) end - defp list(elem_type, tail_type), do: union(empty_list(), non_empty_list(elem_type, tail_type)) + test "map with domain keys" do + # Non-overlapping domain keys + t1 = closed_map([{domain_key(:integer), atom()}]) + t2 = closed_map([{domain_key(:atom), binary()}]) + assert equal?(difference(t1, t2) |> union(empty_map()), t1) + assert empty?(difference(t1, t1)) + + # %{atom() => t1} and not %{atom() => t2} is not %{atom() => t1 and not t2} + t3 = closed_map([{domain_key(:integer), atom()}]) + t4 = closed_map([{domain_key(:integer), atom([:ok])}]) + assert subtype?(difference(t3, t4), t3) + + refute difference(t3, t4) + |> equal?(closed_map([{domain_key(:integer), difference(atom(), atom([:ok]))}])) + + # Difference with a non-domain key map + t5 = closed_map([{domain_key(:integer), union(atom(), integer())}]) + t6 = closed_map(a: atom()) + assert equal?(difference(t5, t6), t5) + + # Removing atom keys from a map with defined atom keys + a_number = closed_map(a: number()) + a_number_and_pids = closed_map([{:a, number()}, {domain_key(:atom), pid()}]) + atom_to_float = closed_map([{domain_key(:atom), float()}]) + atom_to_term = closed_map([{domain_key(:atom), term()}]) + atom_to_pid = closed_map([{domain_key(:atom), pid()}]) + t_diff = difference(a_number, atom_to_float) + + # Removing atom keys that map to float, make the :a key point to integer only. + assert map_fetch(t_diff, :a) == {false, integer()} + # %{a => number, atom => pid} and not %{atom => float} gives numbers on :a + assert map_fetch(difference(a_number_and_pids, atom_to_float), :a) == {false, number()} + + assert map_fetch(t_diff, :foo) == :badkey + + assert subtype?(a_number, atom_to_term) + refute subtype?(a_number, atom_to_float) + + # Removing all atom keys from map %{:a => type} means there is nothing left. + assert empty?(difference(a_number, atom_to_term)) + refute empty?(intersection(atom_to_term, a_number)) + assert empty?(intersection(atom_to_pid, a_number)) + + # (%{:a => number} and not %{:a => float}) is %{:a => integer} + assert equal?(difference(a_number, atom_to_float), closed_map(a: integer())) + end test "list" do # Basic list type differences @@ -556,6 +686,16 @@ defmodule Module.Types.DescrTest do assert dynamic(open_map(a: union(integer(), binary()))) == open_map(a: dynamic(integer()) |> union(binary())) + + # For domains too + t1 = dynamic(open_map([{domain_key(:integer), integer()}])) + t2 = open_map([{domain_key(:integer), dynamic(integer())}]) + assert t1 == t2 + + # if_set on dynamic fields also must work + t1 = dynamic(open_map(a: if_set(integer()))) + t2 = open_map(a: if_set(dynamic(integer()))) + assert t1 == t2 end end @@ -612,6 +752,27 @@ defmodule Module.Types.DescrTest do assert subtype?(closed_map(a: integer()), closed_map(a: if_set(integer()))) refute subtype?(closed_map(a: if_set(term())), closed_map(a: term())) assert subtype?(closed_map(a: term()), closed_map(a: if_set(term()))) + + # With domains + t1 = closed_map([{domain_key(:integer), number()}]) + t2 = closed_map([{domain_key(:integer), integer()}]) + + assert subtype?(t2, t1) + + t1_minus_t2 = difference(t1, t2) + refute empty?(t1_minus_t2) + + assert subtype?(map_with_default(number()), open_map()) + t = difference(open_map(), map_with_default(number())) + refute empty?(t) + refute subtype?(open_map(), map_with_default(number())) + assert subtype?(map_with_default(integer()), map_with_default(number())) + refute subtype?(map_with_default(float()), map_with_default(atom())) + + assert equal?( + intersection(map_with_default(number()), map_with_default(float())), + map_with_default(float()) + ) end test "optional" do @@ -1423,7 +1584,6 @@ defmodule Module.Types.DescrTest do test "map_fetch with dynamic" do assert map_fetch(dynamic(), :a) == {true, dynamic()} - assert map_fetch(union(dynamic(), integer()), :a) == :badmap assert map_fetch(union(dynamic(open_map(a: integer())), integer()), :a) == :badmap assert map_fetch(union(dynamic(integer()), integer()), :a) == :badmap @@ -1440,6 +1600,104 @@ defmodule Module.Types.DescrTest do |> map_fetch(:a) == {false, union(dynamic(atom()), integer())} end + test "map_fetch with domain keys" do + integer_to_atom = open_map([{domain_key(:integer), atom()}]) + assert map_fetch(integer_to_atom, :foo) == :badkey + + # the key :a is for sure of type pid and exists in type + # %{atom() => pid()} and not %{:a => not_set()} + t1 = closed_map([{domain_key(:atom), pid()}]) + t2 = closed_map(a: not_set()) + t3 = open_map(a: not_set()) + + # Indeed, t2 is equivalent to the empty map + assert map_fetch(difference(t1, t2), :a) == :badkey + assert map_fetch(difference(t1, t3), :a) == {false, pid()} + + t4 = closed_map([{domain_key(:pid), atom()}]) + assert map_fetch(difference(t1, t4) |> difference(t3), :a) == {false, pid()} + + assert map_fetch(closed_map([{domain_key(:atom), pid()}]), :a) == :badkey + + assert map_fetch(dynamic(closed_map([{domain_key(:atom), pid()}])), :a) == + {true, dynamic(pid())} + + assert closed_map([{domain_key(:atom), number()}]) + |> difference(open_map(a: if_set(integer()))) + |> map_fetch(:a) == {false, float()} + + assert closed_map([{domain_key(:atom), number()}]) + |> difference(closed_map(b: if_set(integer()))) + |> map_fetch(:a) == :badkey + end + + test "map_get with domain keys" do + assert map_get(term(), term()) == :badmap + + map_type = closed_map([{domain_key(:tuple), binary()}]) + assert map_get(map_type, tuple()) == {:ok, nil_or_type(binary())} + + # Type with all domain types + # %{:bar => :ok, integer() => :int, float() => :float, atom() => binary(), binary() => integer(), tuple() => float(), map() => pid(), reference() => port(), pid() => boolean()} + all_domains = + closed_map([ + {:bar, atom([:ok])}, + {domain_key(:integer), atom([:int])}, + {domain_key(:float), atom([:float])}, + {domain_key(:atom), binary()}, + {domain_key(:binary), integer()}, + {domain_key(:tuple), float()}, + {domain_key(:map), pid()}, + {domain_key(:reference), port()}, + {domain_key(:pid), reference()}, + {domain_key(:port), boolean()} + ]) + + assert map_get(all_domains, atom([:bar])) == {:ok_present, atom([:ok])} + + assert map_get(all_domains, integer()) == {:ok, atom([:int]) |> nil_or_type()} + assert map_get(all_domains, number()) == {:ok, atom([:int, :float]) |> nil_or_type()} + + assert map_get(all_domains, empty_list()) == {:ok_absent, atom([nil])} + assert map_get(all_domains, atom([:foo])) == {:ok, binary() |> nil_or_type()} + assert map_get(all_domains, binary()) == {:ok, integer() |> nil_or_type()} + assert map_get(all_domains, tuple([integer(), atom()])) == {:ok, nil_or_type(float())} + assert map_get(all_domains, empty_map()) == {:ok, pid() |> nil_or_type()} + + # Union + assert map_get(all_domains, union(tuple(), empty_map())) == + {:ok, union(float(), pid() |> nil_or_type())} + + # Removing all maps with tuple keys + t_no_tuple = difference(all_domains, closed_map([{domain_key(:tuple), float()}])) + t_really_no_tuple = difference(all_domains, open_map([{domain_key(:tuple), float()}])) + assert subtype?(all_domains, open_map()) + # It's only closed maps, so it should not change + assert map_get(t_no_tuple, tuple()) == {:ok, float() |> nil_or_type()} + # This time we actually removed all tuple to float keys + assert map_get(t_really_no_tuple, tuple()) == {:ok_absent, atom([nil])} + + t1 = closed_map([{domain_key(:tuple), integer()}]) + t2 = closed_map([{domain_key(:tuple), float()}]) + t3 = union(t1, t2) + assert map_get(t3, tuple()) == {:ok, number() |> nil_or_type()} + end + + test "map_get with dynamic" do + {_answer, type_selected} = map_get(dynamic(), term()) + assert equal?(type_selected, dynamic() |> nil_or_type()) + end + + test "map_get with atom fall back" do + map = closed_map([{:a, atom([:a])}, {:b, atom([:b])}, {domain_key(:atom), pid()}]) + assert map_get(map, atom([:a, :b])) == {:ok_present, atom([:a, :b])} + assert map_get(map, atom([:a, :c])) == {:ok, union(atom([:a]), pid() |> nil_or_type())} + assert map_get(map, atom() |> difference(atom([:a, :b]))) == {:ok, pid() |> nil_or_type()} + + assert map_get(map, atom() |> difference(atom([:a]))) == + {:ok, union(atom([:b]), pid() |> nil_or_type())} + end + test "map_delete" do assert map_delete(term(), :a) == :badmap assert map_delete(integer(), :a) == :badmap @@ -1478,7 +1736,10 @@ defmodule Module.Types.DescrTest do # Deleting from a difference of maps {:ok, type} = - map_delete(difference(closed_map(a: integer(), b: atom()), closed_map(a: integer())), :b) + map_delete( + difference(closed_map(a: integer(), b: atom()), closed_map(a: integer())), + :b + ) assert equal?(type, closed_map(a: integer())) @@ -1486,6 +1747,12 @@ defmodule Module.Types.DescrTest do assert equal?(type, open_map(a: not_set())) end + test "map_delete with atom fallback" do + assert closed_map([{:a, integer()}, {:b, atom()}, {domain_key(:atom), pid()}]) + |> map_delete(:a) == + {:ok, closed_map([{:a, not_set()}, {:b, atom()}, {domain_key(:atom), pid()}])} + end + test "map_take" do assert map_take(term(), :a) == :badmap assert map_take(integer(), :a) == :badmap @@ -1573,11 +1840,15 @@ defmodule Module.Types.DescrTest do assert equal?( type, - union(closed_map(a: integer(), c: boolean()), closed_map(b: atom(), c: boolean())) + union( + closed_map(a: integer(), c: boolean()), + closed_map(b: atom(), c: boolean()) + ) ) # Put a key-value pair in a dynamic map - assert map_put(dynamic(open_map()), :a, integer()) == {:ok, dynamic(open_map(a: integer()))} + assert map_put(dynamic(open_map()), :a, integer()) == + {:ok, dynamic(open_map(a: integer()))} # Put a key-value pair in an intersection of maps {:ok, type} = @@ -1602,6 +1873,64 @@ defmodule Module.Types.DescrTest do {false, type} = map_fetch(map, :a) assert equal?(type, atom()) end + + test "map_put with domain keys" do + # Using a literal key or an expression of that singleton key is the same + assert map_refresh(empty_map(), atom([:a]), integer()) == {:ok, closed_map(a: integer())} + + # Several keys + assert map_refresh(empty_map(), atom([:a, :b]), integer()) == + {:ok, closed_map(a: if_set(integer()), b: if_set(integer()))} + + assert map_refresh(empty_map(), integer(), integer()) == + {:ok, closed_map([{domain_key(:integer), integer()}])} + + assert map_refresh(closed_map([{domain_key(:integer), integer()}]), integer(), float()) == + {:ok, closed_map([{domain_key(:integer), number()}])} + + assert map_refresh(open_map(), integer(), integer()) == {:ok, open_map()} + + # TODO: Revisit this + # {:ok, type} = map_refresh(empty_map(), integer(), dynamic()) + # assert equal?(type, dynamic(closed_map([{domain_key(:integer), term()}]))) + + # Adding a key of type float to a dynamic only guarantees that we have a map + # as we cannot express "has at least one key of type float => float" + {:ok, type} = map_refresh(dynamic(), float(), float()) + assert equal?(type, dynamic(open_map())) + + assert closed_map([{domain_key(:integer), integer()}]) + |> difference(open_map()) + |> empty?() + + assert closed_map([{domain_key(:integer), integer()}]) + |> difference(open_map()) + |> map_refresh(integer(), float()) == :badmap + + assert map_refresh(empty_map(), number(), float()) == + {:ok, + closed_map([ + {domain_key(:integer), float()}, + {domain_key(:float), float()} + ])} + + # Tricky cases with atoms: + # We add one atom fields that maps to an integer, which is not :a. So we do not touch + # :a, add integer to :b, and add a domain field. + assert map_refresh( + closed_map(a: pid(), b: pid()), + atom() |> difference(atom([:a])), + integer() + ) == + {:ok, + closed_map([ + {:a, pid()}, + {:b, union(pid(), integer())}, + {domain_key(:atom), integer()} + ])} + + assert map_refresh(empty_map(), term(), integer()) == {:ok, map_with_default(integer())} + end end describe "disjoint" do @@ -1707,7 +2036,8 @@ defmodule Module.Types.DescrTest do "empty_list() or non_empty_list(float() or integer(), pid())" # Merge last element types - assert union(list(atom([:ok]), integer()), list(atom([:ok]), float())) |> to_quoted_string() == + assert union(list(atom([:ok]), integer()), list(atom([:ok]), float())) + |> to_quoted_string() == "empty_list() or non_empty_list(:ok, float() or integer())" assert union(dynamic(list(integer(), float())), dynamic(list(integer(), pid()))) @@ -1805,7 +2135,12 @@ defmodule Module.Types.DescrTest do ) decimal_int = - closed_map(__struct__: atom([Decimal]), coef: integer(), exp: integer(), sign: integer()) + closed_map( + __struct__: atom([Decimal]), + coef: integer(), + exp: integer(), + sign: integer() + ) assert atom([:error]) |> union( @@ -1953,9 +2288,15 @@ defmodule Module.Types.DescrTest do "%{..., a: float() or integer()}" # Fusing complex nested maps with unions - assert closed_map(status: atom([:ok]), data: closed_map(value: term(), count: empty_list())) + assert closed_map( + status: atom([:ok]), + data: closed_map(value: term(), count: empty_list()) + ) |> union( - closed_map(status: atom([:ok]), data: closed_map(value: term(), count: open_map())) + closed_map( + status: atom([:ok]), + data: closed_map(value: term(), count: open_map()) + ) ) |> union(closed_map(status: atom([:error]), reason: atom([:timeout]))) |> union(closed_map(status: atom([:error]), reason: atom([:crash]))) @@ -1979,7 +2320,10 @@ defmodule Module.Types.DescrTest do "%{data: %{x: float() or integer(), y: atom()}, meta: map()}" # Test complex combinations - assert intersection(open_map(a: number(), b: atom()), open_map(a: integer(), c: boolean())) + assert intersection( + open_map(a: number(), b: atom()), + open_map(a: integer(), c: boolean()) + ) |> union(difference(open_map(x: atom()), open_map(x: boolean()))) |> to_quoted_string() == "%{..., a: integer(), b: atom(), c: boolean()} or %{..., x: atom() and not boolean()}"