From f397cbae4ef9a223a95536712d587f93a8944471 Mon Sep 17 00:00:00 2001 From: gldubc Date: Fri, 17 May 2024 18:00:15 +0200 Subject: [PATCH 1/7] Set-theoretic tuple types using lists --- lib/elixir/lib/module/types/descr.ex | 340 +++++++++++++++++- .../test/elixir/module/types/descr_test.exs | 200 +++++++++++ 2 files changed, 527 insertions(+), 13 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index b046507a5c1..2716c76df4d 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -23,15 +23,15 @@ defmodule Module.Types.Descr do @bit_reference 1 <<< 6 @bit_non_empty_list 1 <<< 7 - @bit_tuple 1 <<< 8 - @bit_fun 1 <<< 9 - @bit_top (1 <<< 10) - 1 + @bit_fun 1 <<< 8 + @bit_top (1 <<< 9) - 1 @bit_list @bit_empty_list ||| @bit_non_empty_list @bit_number @bit_integer ||| @bit_float - @bit_optional 1 <<< 10 + @bit_optional 1 <<< 9 @atom_top {:negation, :sets.new(version: 2)} + @tuple_top [{:open, [], []}] @map_top [{:open, %{}, []}] @map_empty [{:closed, %{}, []}] @none %{} @@ -44,7 +44,7 @@ defmodule Module.Types.Descr do defp unfold(:term), do: unfolded_term() defp unfold(other), do: other - defp unfolded_term, do: %{bitmap: @bit_top, atom: @atom_top, map: @map_top} + defp unfolded_term, do: %{bitmap: @bit_top, atom: @atom_top, tuple: @tuple_top, map: @map_top} def atom(as), do: %{atom: atom_new(as)} def atom(), do: %{atom: @atom_top} @@ -62,7 +62,10 @@ defmodule Module.Types.Descr do def pid(), do: %{bitmap: @bit_pid} def port(), do: %{bitmap: @bit_port} def reference(), do: %{bitmap: @bit_reference} - def tuple(), do: %{bitmap: @bit_tuple} + def open_tuple(elements), do: %{tuple: tuple_new(:open, elements)} + def tuple(elements), do: %{tuple: tuple_new(:closed, elements)} + def empty_tuple(), do: tuple([]) + def tuple(), do: %{tuple: @tuple_top} @boolset :sets.from_list([true, false], version: 2) def boolean(), do: %{atom: {:union, @boolset}} @@ -80,7 +83,12 @@ defmodule Module.Types.Descr do # `not_set()` has no meaning outside of map types. @not_set %{bitmap: @bit_optional} - @term_or_optional %{bitmap: @bit_top ||| @bit_optional, atom: @atom_top, map: @map_top} + @term_or_optional %{ + bitmap: @bit_top ||| @bit_optional, + atom: @atom_top, + tuple: @tuple_top, + map: @map_top + } def not_set(), do: @not_set defp term_or_optional(), do: @term_or_optional @@ -149,6 +157,7 @@ defmodule Module.Types.Descr do defp union(:atom, v1, v2), do: atom_union(v1, v2) defp union(:dynamic, v1, v2), do: dynamic_union(v1, v2) defp union(:map, v1, v2), do: map_union(v1, v2) + defp union(:tuple, v1, v2), do: tuple_union(v1, v2) @doc """ Computes the intersection of two descrs. @@ -182,6 +191,7 @@ defmodule Module.Types.Descr do defp intersection(:atom, v1, v2), do: atom_intersection(v1, v2) defp intersection(:dynamic, v1, v2), do: dynamic_intersection(v1, v2) defp intersection(:map, v1, v2), do: map_intersection(v1, v2) + defp intersection(:tuple, v1, v2), do: tuple_intersection(v1, v2) @doc """ Computes the difference between two types. @@ -218,6 +228,7 @@ defmodule Module.Types.Descr do defp difference(:atom, v1, v2), do: atom_difference(v1, v2) defp difference(:dynamic, v1, v2), do: dynamic_difference(v1, v2) defp difference(:map, v1, v2), do: map_difference(v1, v2) + defp difference(:tuple, v1, v2), do: tuple_difference(v1, v2) @doc """ Compute the negation of a type. @@ -245,6 +256,7 @@ defmodule Module.Types.Descr do descr -> not Map.has_key?(descr, :bitmap) and not Map.has_key?(descr, :atom) and + (not Map.has_key?(descr, :tuple) or tuple_empty?(descr.tuple)) and (not Map.has_key?(descr, :map) or map_empty?(descr.map)) end end @@ -268,6 +280,7 @@ defmodule Module.Types.Descr do defp to_quoted(:atom, val), do: atom_to_quoted(val) defp to_quoted(:dynamic, descr), do: dynamic_to_quoted(descr) defp to_quoted(:map, dnf), do: map_to_quoted(dnf) + defp to_quoted(:tuple, dnf), do: tuple_to_quoted(dnf) @doc """ Converts a descr to its quoted string representation. @@ -416,7 +429,6 @@ defmodule Module.Types.Descr do port: @bit_port, reference: @bit_reference, non_empty_list: @bit_non_empty_list, - tuple: @bit_tuple, fun: @bit_fun ] @@ -698,8 +710,8 @@ defmodule Module.Types.Descr do {acc, dynamic?} end - defp map_tag_to_type(:open), do: term_or_optional() - defp map_tag_to_type(:closed), do: not_set() + defp tag_to_type(:open), do: term_or_optional() + defp tag_to_type(:closed), do: not_set() defp map_new(tag, fields = %{}), do: [{tag, fields, []}] @@ -926,7 +938,7 @@ defmodule Module.Types.Descr do false else # an absent key in a open negative map can be ignored - diff = difference(type, map_tag_to_type(neg_tag)) + diff = difference(type, tag_to_type(neg_tag)) empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs) end end @@ -945,7 +957,7 @@ defmodule Module.Types.Descr do # Optimization: if there are no negatives # and the key does not exist, return the default one. {tag, %{}, []} -> - [map_tag_to_type(tag)] + [tag_to_type(tag)] {tag, fields, negs} -> {fst, snd} = map_pop_key(tag, fields, key) @@ -960,7 +972,7 @@ defmodule Module.Types.Descr do defp map_pop_key(tag, fields, key) do case :maps.take(key, fields) do {value, fields} -> {value, %{map: map_new(tag, fields)}} - :error -> {map_tag_to_type(tag), %{map: map_new(tag, fields)}} + :error -> {tag_to_type(tag), %{map: map_new(tag, fields)}} end end @@ -1066,6 +1078,308 @@ defmodule Module.Types.Descr do end end + ## Tuple + + # Represents tuple types in two forms: + # 1. Closed tuples: Fixed-length tuples with specific element types + # Example: {integer(), atom()} + # 2. Open tuples: Variable-length tuples with a minimum set of element types + # Example: {atom(), boolean(), ...} + # + # Internal representation: + # - Closed tuple: {:closed, [element_type, ...]} + # - Open tuple: {:open, [element_type, ...]} + # + # Examples: + # - {integer(), atom()} is encoded as {:closed, [integer(), atom()]} + # - {atom(), boolean(), ...} is encoded as {:open, [atom(), boolean()]} + + defp tuple_new(tag, elements), do: [{tag, elements, []}] + + defp tuple_intersection(dnf1, dnf2) do + for {tag1, elements1, negs1} <- dnf1, + {tag2, elements2, negs2} <- dnf2, + reduce: [] do + acc -> + try do + {tag, fields} = tuple_literal_intersection(tag1, elements1, tag2, elements2) + [{tag, fields, negs1 ++ negs2} | acc] + catch + :empty -> acc + end + end + |> case do + [] -> 0 + acc -> acc + end + end + + defp tuple_literal_intersection(tag1, elements1, tag2, elements2) do + n = length(elements1) + m = length(elements2) + + cond do + (tag1 == :closed and n < m) or (tag2 == :closed and n > m) -> throw(:empty) + tag1 == :open and tag2 == :open -> {:open, zip_intersection(elements1, elements2, [])} + true -> {:closed, zip_intersection(elements1, elements2, [])} + end + end + + # Intersects two lists of types, and _appends_ the extra elements to the result. + defp zip_intersection([], types2, acc), do: Enum.reverse(acc, types2) + defp zip_intersection(types1, [], acc), do: Enum.reverse(acc, types1) + + defp zip_intersection([type1 | rest1], [type2 | rest2], acc) do + zip_intersection(rest1, rest2, [non_empty_intersection!(type1, type2) | acc]) + end + + defp tuple_difference(dnf1, dnf2) do + Enum.reduce(dnf2, dnf1, fn {tag2, elements2, negs2}, dnf1 -> + Enum.reduce(dnf1, [], fn {tag1, elements1, negs1}, acc -> + acc = [{tag1, elements1, [{tag2, elements2} | negs1]}] ++ acc + + Enum.reduce(negs2, acc, fn {neg_tag2, neg_elements2}, acc -> + try do + {tag, fields} = tuple_literal_intersection(tag1, elements1, neg_tag2, neg_elements2) + [{tag, fields, negs1}] ++ acc + catch + :empty -> acc + end + end) + end) + end) + |> case do + [] -> 0 + acc -> acc + end + end + + defp tuple_union(left, right), do: left ++ right + + defp tuple_to_quoted(dnf) do + dnf + |> tuple_normalize() + |> Enum.map(&tuple_each_to_quoted/1) + |> case do + [] -> [] + dnf -> Enum.reduce(dnf, &{:or, [], [&2, &1]}) |> List.wrap() + end + end + + defp tuple_each_to_quoted({tag, positive_map, negative_maps}) do + case negative_maps do + [] -> + tuple_literal_to_quoted({tag, positive_map}) + + _ -> + negative_maps + |> Enum.map(&tuple_literal_to_quoted/1) + |> Enum.reduce(&{:or, [], [&2, &1]}) + |> Kernel.then( + &{:and, [], [tuple_literal_to_quoted({tag, positive_map}), {:not, [], [&1]}]} + ) + end + end + + defp tuple_literal_to_quoted({:closed, []}), do: {:{}, [], []} + + defp tuple_literal_to_quoted({tag, elements}) do + case tag do + :closed -> {:{}, [], Enum.map(elements, &to_quoted/1)} + :open -> {:{}, [], Enum.map(elements, &to_quoted/1) ++ [{:..., [], nil}]} + end + end + + # Pads a list of elements with term(). + defp tuple_fill(elements, length) when length(elements) > length do + raise ArgumentError, "tuple_fill: elements are longer than the desired length" + end + + defp tuple_fill(elements, desired_length) do + elements ++ List.duplicate(term(), desired_length - length(elements)) + end + + # Check if a tuple represented in DNF is empty + defp tuple_empty?(dnf) do + Enum.all?(dnf, fn {tag, pos, negs} -> tuple_empty?(tag, pos, negs) end) + end + + # No negations, so not empty + defp tuple_empty?(_, _, []), do: false + # Open empty negation makes it empty + defp tuple_empty?(_, _, [{:open, []} | _]), do: true + # Open positive can't be emptied by a single closed negative + defp tuple_empty?(:open, _, [{:closed, _}]), do: false + + defp tuple_empty?(tag, elements, [{neg_tag, neg_elements} | negs]) do + n = length(elements) + m = length(neg_elements) + + # Scenarios where the difference is guaranteed to be empty: + # 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_empty?(tag, elements, negs) + else + check_elements([], tag, elements, neg_elements, negs) and + check_compatibility(n, m, tag, elements, neg_tag, negs) + end + end + + # Recursively check elements for emptiness + defp check_elements(_, _, _, [], _), do: true + + defp check_elements(acc, tag, elements, [neg_type | neg_elements], negs) do + {ty, elements} = List.pop_at(elements, 0, term()) + diff = difference(ty, neg_type) + + (empty?(diff) or tuple_empty?(tag, Enum.reverse(acc, [diff | elements]), negs)) and + check_elements([ty | acc], tag, elements, neg_elements, negs) + 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 check_compatibility(n, m, tag, elements, neg_tag, negs) 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_empty?(:closed, tuple_fill(elements, &1), negs)) and + (neg_tag == :open or tuple_empty?(:open, tuple_fill(elements, m + 1), negs))) + end + + @doc """ + Fetches the type of the value returned by accessing `index` on `tuple` + with the assumption that the descr is exclusively a tuple (or dynamic). + + Returns one of: + - `{false, type}` if the element is always accessible and has the given `type`. + - `{true, type}` if the element may exist and has the given `type`. + - `:badindex` if the index is never accessible in the tuple type. + - `:badtuple` if the descr is not a tuple type. + + ## Examples + + iex> tuple_fetch(tuple([integer(), atom()]), 0) + {false, integer()} + + iex> tuple_fetch(tuple([integer(), atom()]), 2) + :badindex + + iex> tuple_fetch(union(tuple([integer()]), tuple([integer(), atom()])), 1) + {true, atom()} + + iex> tuple_fetch(dynamic(), 0) + {true, dynamic()} + + iex> tuple_fetch(integer(), 0) + :badtuple + + """ + def tuple_fetch(_, index) when index < 0, do: :badindex + def tuple_fetch(:term, _key), do: :badtuple + + def tuple_fetch(%{} = descr, key) do + case :maps.take(:dynamic, descr) do + :error -> + if is_map_key(descr, :tuple) and tuple_only?(descr) do + {static_optional?, static_type} = + tuple_fetch_static(descr, key) |> pop_optional_static() + + cond do + empty?(static_type) -> :badindex + static_optional? -> {true, static_type} + true -> {false, static_type} + end + else + :badtuple + end + + {:term, static} -> + if tuple_only?(static) do + static_type = tuple_fetch_static(static, key) + {true, union(dynamic(), static_type)} + else + :badtuple + end + + {%{map: {:open, [], []}}, static} when static == @none -> + {true, dynamic()} + + {dynamic, static} -> + if is_map_key(dynamic, :tuple) and tuple_only?(static) do + {dynamic_optional?, dynamic_type} = + tuple_fetch_static(dynamic, key) |> pop_optional_static() + + {static_optional?, static_type} = + tuple_fetch_static(static, key) |> pop_optional_static() + + if empty?(dynamic_type) do + :badindex + else + {static_optional? or dynamic_optional?, union(dynamic(dynamic_type), static_type)} + end + else + :badtuple + end + end + end + + defp tuple_only?(descr), do: empty?(Map.delete(descr, :tuple)) + + defp tuple_fetch_static(descr, index) when is_integer(index) do + case descr do + %{tuple: tuple} -> Enum.reduce(tuple_split_on_index(tuple, index), none(), &union/2) + %{} -> none() + end + end + + defp tuple_split_on_index(dnf, index) do + Enum.flat_map(dnf, fn + {tag, elements, []} -> + [Enum.at(elements, index) || tag_to_type(tag)] + + {tag, elements, negs} -> + {fst, snd} = tuple_pop_index(tag, elements, index) + + case tuple_split_negative(negs, index) do + :empty -> [] + negative -> negative |> pair_make_disjoint() |> pair_eliminate_negations(fst, snd) + end + end) + end + + defp tuple_pop_index(tag, elements, index) do + case List.pop_at(elements, index) do + {nil, _} -> {tag_to_type(tag), %{tuple: [{tag, elements, []}]}} + {type, rest} -> {type, %{tuple: [{tag, rest, []}]}} + end + end + + defp tuple_split_negative(negs, index) do + Enum.reduce_while(negs, [], fn + {:open, []}, _acc -> {:halt, :empty} + {tag, elements}, acc -> {:cont, [tuple_pop_index(tag, elements, index) | acc]} + end) + end + + # Use heuristics to normalize a tuple dnf for pretty printing. + defp tuple_normalize(dnf) do + dnf + |> Enum.reject(&tuple_empty?([&1])) + |> Enum.map(fn {tag, fields, negs} -> + {tag, fields, Enum.reject(negs, &tuple_empty_negation?(tag, fields, &1))} + end) + end + + # Remove useless negations, which denote tuples of incompatible sizes. + defp tuple_empty_negation?(tag, elements, {neg_tag, neg_elements}) do + n = length(elements) + m = length(neg_elements) + + (tag == :closed and n < m) or (neg_tag == :closed and n > m) + end + ## Pairs # # To simplify disjunctive normal forms of e.g., map types, it is useful to diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 98acbc79822..6c8458b7284 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -56,6 +56,27 @@ defmodule Module.Types.DescrTest do assert equal?(union(intersection(dynamic(), atom()), atom()), atom()) end + test "tuple" do + assert equal?(union(tuple(), tuple()), tuple()) + + t = tuple([integer(), atom()]) + assert equal?(union(t, t), t) + + assert union(tuple([integer(), atom()]), tuple([float(), atom()])) + |> equal?(tuple([union(integer(), float()), atom()])) + + assert union(tuple([integer(), atom()]), tuple([integer(), binary()])) + |> equal?(tuple([integer(), union(atom(), binary())])) + + assert open_tuple([atom()]) + |> union(tuple([atom(), integer()])) + |> equal?(open_tuple([atom()])) + + assert tuple([union(integer(), atom())]) + |> difference(open_tuple([atom()])) + |> equal?(tuple([integer()])) + end + test "map" do assert equal?(union(open_map(), open_map()), open_map()) assert equal?(union(closed_map(a: integer()), open_map()), open_map()) @@ -101,6 +122,16 @@ defmodule Module.Types.DescrTest do assert empty?(intersection(intersection(dynamic(), atom()), integer())) end + test "tuple" do + assert empty?(intersection(open_tuple([atom()]), open_tuple([integer()]))) + + assert intersection(open_tuple([atom()]), tuple([term(), integer()])) + |> equal?(tuple([atom(), integer()])) + + assert intersection(tuple([term(), integer()]), tuple([atom(), term()])) + |> equal?(tuple([atom(), integer()])) + end + test "map" do assert intersection(open_map(), open_map()) == open_map() assert equal?(intersection(closed_map(a: integer()), open_map()), closed_map(a: integer())) @@ -151,6 +182,61 @@ defmodule Module.Types.DescrTest do assert empty?(difference(none(), dynamic())) end + 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), do: tuple(List.duplicate(term(), n)) + + test "tuple" do + assert empty?(difference(open_tuple([atom()]), open_tuple([term()]))) + refute empty?(difference(tuple(), empty_tuple())) + refute tuple_of_size_at_least(2) |> difference(tuple_of_size(2)) |> empty?() + assert tuple_of_size_at_least(2) |> difference(tuple_of_size_at_least(1)) |> empty?() + assert tuple_of_size_at_least(3) |> difference(tuple_of_size_at_least(3)) |> empty?() + refute tuple_of_size_at_least(2) |> difference(tuple_of_size_at_least(3)) |> empty?() + refute tuple([term(), term()]) |> difference(tuple([atom(), term()])) |> empty?() + refute tuple([term(), term()]) |> difference(tuple([atom()])) |> empty?() + assert tuple([term(), term()]) |> difference(tuple([term(), term()])) |> empty?() + + # {term(), term(), ...} and not ({term(), term(), term(), ...} or {term(), term()}) + assert tuple_of_size_at_least(2) + |> difference(tuple_of_size(2)) + |> difference(tuple_of_size_at_least(3)) + |> empty?() + + assert tuple([term(), term()]) + |> difference(tuple([atom()])) + |> difference(open_tuple([term()])) + |> difference(empty_tuple()) + |> empty?() + + refute difference(tuple(), empty_tuple()) + |> difference(open_tuple([term(), term()])) + |> empty? + + assert difference(open_tuple([term()]), open_tuple([term(), term()])) + |> difference(tuple([term()])) + |> empty?() + + assert open_tuple([atom()]) + |> difference(tuple([integer(), integer()])) + |> equal?(open_tuple([atom()])) + + assert tuple([union(atom(), integer()), term()]) + |> difference(open_tuple([atom(), term()])) + |> equal?(tuple([integer(), term()])) + + assert tuple([union(atom(), integer()), term()]) + |> difference(open_tuple([atom(), term()])) + |> difference(open_tuple([integer(), term()])) + |> empty?() + + assert tuple([term(), union(atom(), integer()), term()]) + |> difference(open_tuple([term(), integer()])) + |> equal?(tuple([term(), atom(), term()])) + + assert difference(tuple(), open_tuple([term(), term()])) + |> equal?(union(tuple([term()]), tuple([]))) + end + test "map" do assert empty?(difference(open_map(), open_map())) assert empty?(difference(open_map(), term())) @@ -166,6 +252,8 @@ defmodule Module.Types.DescrTest do assert difference(open_map(a: atom()), closed_map(b: integer())) |> equal?(open_map(a: atom())) + + refute empty?(difference(open_map(), empty_map())) end end @@ -204,6 +292,20 @@ defmodule Module.Types.DescrTest do assert subtype?(integer(), union(dynamic(), integer())) end + test "tuple" do + assert subtype?(empty_tuple(), tuple()) + assert subtype?(tuple([integer(), atom()]), tuple()) + refute subtype?(empty_tuple(), open_tuple([term()])) + assert subtype?(tuple([integer(), atom()]), tuple([term(), term()])) + refute subtype?(tuple([integer(), atom()]), tuple([integer(), integer()])) + refute subtype?(tuple([integer(), atom()]), tuple([atom(), atom()])) + + assert subtype?(tuple([integer(), atom()]), open_tuple([integer(), atom()])) + refute subtype?(tuple([term()]), open_tuple([term(), term()])) + refute subtype?(tuple([integer(), atom()]), open_tuple([integer(), integer()])) + refute subtype?(open_tuple([integer(), atom()]), tuple([integer(), integer()])) + end + test "map" do assert subtype?(open_map(), term()) assert subtype?(closed_map(a: integer()), open_map()) @@ -244,6 +346,10 @@ defmodule Module.Types.DescrTest do assert compatible?(integer(), dynamic()) end + test "tuple" do + assert compatible?(dynamic(tuple()), tuple([integer(), atom()])) + end + test "map" do assert compatible?(closed_map(a: integer()), open_map()) assert compatible?(intersection(dynamic(), open_map()), closed_map(a: integer())) @@ -251,6 +357,25 @@ defmodule Module.Types.DescrTest do end describe "empty" do + test "tuple" do + assert intersection(tuple([integer(), atom()]), open_tuple([atom()])) |> empty? + refute open_tuple([integer(), integer()]) |> difference(empty_tuple()) |> empty? + refute open_tuple([integer(), integer()]) |> difference(open_tuple([atom()])) |> empty? + refute open_tuple([term()]) |> difference(tuple([term()])) |> empty? + assert difference(tuple(), empty_tuple()) |> difference(open_tuple([term()])) |> empty? + assert difference(tuple(), open_tuple([term()])) |> difference(empty_tuple()) |> empty? + + refute open_tuple([term()]) + |> difference(tuple([term()])) + |> difference(tuple([term()])) + |> empty? + + assert tuple([integer(), union(integer(), atom())]) + |> difference(tuple([integer(), integer()])) + |> difference(tuple([integer(), atom()])) + |> empty? + end + test "map" do assert intersection(closed_map(b: atom()), open_map(a: integer())) |> empty?() end @@ -292,6 +417,61 @@ defmodule Module.Types.DescrTest do assert atom_fetch(union(atom([:foo, :bar]), dynamic(term()))) == {:infinite, []} end + test "tuple_fetch" do + assert tuple_fetch(term(), 0) == :badtuple + assert tuple_fetch(integer(), 0) == :badtuple + assert tuple_fetch(tuple([integer(), atom()]), 0) == {false, integer()} + assert tuple_fetch(tuple([integer(), atom()]), 1) == {false, atom()} + assert tuple_fetch(tuple([integer(), atom()]), 2) == :badindex + assert tuple_fetch(tuple([integer(), atom()]), -1) == :badindex + assert tuple_fetch(empty_tuple(), 0) == :badindex + assert difference(tuple(), tuple()) |> tuple_fetch(0) == :badindex + assert tuple([atom()]) |> difference(empty_tuple()) |> tuple_fetch(0) == {false, atom()} + + assert difference(tuple([union(integer(), atom())]), open_tuple([atom()])) + |> tuple_fetch(0) == {false, integer()} + + assert tuple_fetch(union(tuple([integer(), atom()]), dynamic(open_tuple([atom()]))), 1) + |> Kernel.then(fn {opt, ty} -> opt and equal?(ty, union(atom(), dynamic())) end) + + assert tuple_fetch(union(tuple([integer()]), tuple([atom()])), 0) == + {false, union(integer(), atom())} + + assert tuple([integer(), atom(), union(atom(), integer())]) + |> difference(tuple([integer(), term(), atom()])) + |> tuple_fetch(2) == {false, integer()} + + assert tuple([integer(), atom(), union(union(atom(), integer()), list())]) + |> difference(tuple([integer(), term(), atom()])) + |> difference(open_tuple([term(), atom(), list()])) + |> tuple_fetch(2) == {false, integer()} + + assert tuple([integer(), atom(), integer()]) + |> difference(tuple([integer(), term(), integer()])) + |> tuple_fetch(1) == :badindex + + assert tuple([integer(), atom(), integer()]) + |> difference(tuple([integer(), term(), atom()])) + |> tuple_fetch(2) == {false, integer()} + + # difference with map_fetch: querying the index 0 of tuple() is not an error + assert tuple_fetch(tuple(), 0) + |> Kernel.then(fn {opt, type} -> opt and equal?(type, term()) end) + end + + test "tuple_fetch with dynamic" do + assert tuple_fetch(dynamic(), 0) == {true, dynamic()} + assert tuple_fetch(dynamic(empty_tuple()), 0) == :badindex + assert tuple_fetch(dynamic(tuple([integer(), atom()])), 2) == :badindex + assert tuple_fetch(union(dynamic(), integer()), 0) == :badtuple + + assert tuple_fetch(dynamic(tuple()), 0) + |> Kernel.then(fn {opt, type} -> opt and equal?(type, dynamic()) end) + + assert tuple_fetch(union(dynamic(), open_tuple([atom()])), 0) == + {true, union(atom(), dynamic())} + end + test "map_fetch" do assert map_fetch(term(), :a) == :badmap assert map_fetch(union(open_map(), integer()), :a) == :badmap @@ -379,6 +559,12 @@ defmodule Module.Types.DescrTest do assert union(dynamic(open_map(a: atom())), open_map(a: integer())) |> map_fetch(:a) == {false, union(dynamic(atom()), integer())} + + # this is bad + assert dynamic() |> map_fetch(:a) == {true, dynamic()} + assert union(dynamic(), integer()) |> map_fetch(:a) == {true, dynamic()} + assert union(dynamic(open_map(a: integer())), integer()) |> map_fetch(:a) == :badmap + assert union(dynamic(integer()), integer()) |> map_fetch(:a) == :badmap end end @@ -430,6 +616,20 @@ defmodule Module.Types.DescrTest do "dynamic(%{a: integer()})" end + test "tuples" do + assert tuple([integer(), atom()]) |> to_quoted_string() == "{integer(), atom()}" + assert open_tuple([integer(), atom()]) |> to_quoted_string() == "{integer(), atom(), ...}" + + assert union(tuple([integer(), atom()]), open_tuple([atom()])) |> to_quoted_string() == + "{integer(), atom()} or {atom(), ...}" + + assert difference(tuple([integer(), atom()]), open_tuple([atom()])) |> to_quoted_string() == + "{integer(), atom()} and not {atom(), ...}" + + assert tuple([closed_map(a: integer()), open_map()]) |> to_quoted_string() == + "{%{a: integer()}, %{...}}" + end + test "map" do assert empty_map() |> to_quoted_string() == "empty_map()" assert open_map() |> to_quoted_string() == "%{...}" From 38188215848f1a819bb2542e3e2d1116660bfb17 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc Date: Wed, 31 Jul 2024 22:43:57 +0200 Subject: [PATCH 2/7] Remove noise --- lib/elixir/test/elixir/module/types/descr_test.exs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 6c8458b7284..26202f6a112 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -559,12 +559,6 @@ defmodule Module.Types.DescrTest do assert union(dynamic(open_map(a: atom())), open_map(a: integer())) |> map_fetch(:a) == {false, union(dynamic(atom()), integer())} - - # this is bad - assert dynamic() |> map_fetch(:a) == {true, dynamic()} - assert union(dynamic(), integer()) |> map_fetch(:a) == {true, dynamic()} - assert union(dynamic(open_map(a: integer())), integer()) |> map_fetch(:a) == :badmap - assert union(dynamic(integer()), integer()) |> map_fetch(:a) == :badmap end end From 86b24b06f635cd7bddd8e5687a264ee85a908ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Thu, 1 Aug 2024 14:22:48 +0200 Subject: [PATCH 3/7] Fix bug in map_fetch --- lib/elixir/lib/module/types/descr.ex | 15 ++++++++++----- .../test/elixir/module/types/descr_test.exs | 4 ++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 2716c76df4d..27dc678af71 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -729,7 +729,7 @@ defmodule Module.Types.Descr do def map_fetch(%{} = descr, key) do case :maps.take(:dynamic, descr) do :error -> - if is_map_key(descr, :map) and map_only?(descr) do + if map_key?(descr) and map_only?(descr) do {static_optional?, static_type} = map_fetch_static(descr, key) if static_optional? or empty?(static_type) do @@ -741,11 +741,8 @@ defmodule Module.Types.Descr do :badmap end - {:term, _static} -> - {true, dynamic()} - {dynamic, static} -> - if is_map_key(dynamic, :map) and map_only?(static) do + if map_key?(dynamic) and map_only?(static) do {dynamic_optional?, dynamic_type} = map_fetch_static(dynamic, key) {static_optional?, static_type} = map_fetch_static(static, key) @@ -760,8 +757,16 @@ defmodule Module.Types.Descr do end end + # TODO: Refactor this into descr_key?/1 and and descr_only?/1 + defp map_key?(:term), do: true + defp map_key?(descr), do: is_map_key(descr, :map) + defp map_only?(descr), do: empty?(Map.delete(descr, :map)) + defp map_fetch_static(:term, _key) do + {true, term()} + end + defp map_fetch_static(descr, key) when is_atom(key) do case descr do # Optimization: if the key does not exist in the map, diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 26202f6a112..c39e4877b51 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -549,6 +549,10 @@ 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 + assert intersection(dynamic(), open_map(a: integer())) |> map_fetch(:a) == {false, intersection(integer(), dynamic())} From fc4c9988effa96a407680a7e0ed369f06179127e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Thu, 1 Aug 2024 14:29:57 +0200 Subject: [PATCH 4/7] Unify map/tuple logic --- lib/elixir/lib/module/types/descr.ex | 48 ++++++++++++---------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 27dc678af71..44de3c4e418 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -106,6 +106,9 @@ defmodule Module.Types.Descr do defguardp is_optional_static(map) when is_map(map) and is_map_key(map, :bitmap) and (map.bitmap &&& @bit_optional) != 0 + defp descr_key?(:term, _key), do: true + defp descr_key?(descr, key), do: is_map_key(descr, key) + ## Set operations def term_type?(:term), do: true @@ -729,7 +732,7 @@ defmodule Module.Types.Descr do def map_fetch(%{} = descr, key) do case :maps.take(:dynamic, descr) do :error -> - if map_key?(descr) and map_only?(descr) do + if descr_key?(descr, :map) and map_only?(descr) do {static_optional?, static_type} = map_fetch_static(descr, key) if static_optional? or empty?(static_type) do @@ -742,7 +745,7 @@ defmodule Module.Types.Descr do end {dynamic, static} -> - if map_key?(dynamic) and map_only?(static) do + if descr_key?(dynamic, :map) and map_only?(static) do {dynamic_optional?, dynamic_type} = map_fetch_static(dynamic, key) {static_optional?, static_type} = map_fetch_static(static, key) @@ -757,10 +760,6 @@ defmodule Module.Types.Descr do end end - # TODO: Refactor this into descr_key?/1 and and descr_only?/1 - defp map_key?(:term), do: true - defp map_key?(descr), do: is_map_key(descr, :map) - defp map_only?(descr), do: empty?(Map.delete(descr, :map)) defp map_fetch_static(:term, _key) do @@ -1287,9 +1286,8 @@ defmodule Module.Types.Descr do def tuple_fetch(%{} = descr, key) do case :maps.take(:dynamic, descr) do :error -> - if is_map_key(descr, :tuple) and tuple_only?(descr) do - {static_optional?, static_type} = - tuple_fetch_static(descr, key) |> pop_optional_static() + if descr_key?(descr, :tuple) and tuple_only?(descr) do + {static_optional?, static_type} = tuple_fetch_static(descr, key) cond do empty?(static_type) -> :badindex @@ -1300,24 +1298,10 @@ defmodule Module.Types.Descr do :badtuple end - {:term, static} -> - if tuple_only?(static) do - static_type = tuple_fetch_static(static, key) - {true, union(dynamic(), static_type)} - else - :badtuple - end - - {%{map: {:open, [], []}}, static} when static == @none -> - {true, dynamic()} - {dynamic, static} -> - if is_map_key(dynamic, :tuple) and tuple_only?(static) do - {dynamic_optional?, dynamic_type} = - tuple_fetch_static(dynamic, key) |> pop_optional_static() - - {static_optional?, static_type} = - tuple_fetch_static(static, key) |> pop_optional_static() + if descr_key?(dynamic, :tuple) and tuple_only?(static) do + {dynamic_optional?, dynamic_type} = tuple_fetch_static(dynamic, key) + {static_optional?, static_type} = tuple_fetch_static(static, key) if empty?(dynamic_type) do :badindex @@ -1334,8 +1318,16 @@ defmodule Module.Types.Descr do defp tuple_fetch_static(descr, index) when is_integer(index) do case descr do - %{tuple: tuple} -> Enum.reduce(tuple_split_on_index(tuple, index), none(), &union/2) - %{} -> none() + :term -> + {true, term()} + + %{tuple: tuple} -> + tuple_split_on_index(tuple, index) + |> Enum.reduce(none(), &union/2) + |> pop_optional_static() + + %{} -> + {false, none()} end end From aab5a3afd7393e2dd93d46f14c2e57a36c2d6c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Thu, 1 Aug 2024 14:48:44 +0200 Subject: [PATCH 5/7] Document badindex decision on tuple_fetch --- lib/elixir/lib/module/types/descr.ex | 22 +++++++++++++++---- .../test/elixir/module/types/descr_test.exs | 4 +--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 44de3c4e418..cc44e3bacc0 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -1289,10 +1289,24 @@ defmodule Module.Types.Descr do if descr_key?(descr, :tuple) and tuple_only?(descr) do {static_optional?, static_type} = tuple_fetch_static(descr, key) - cond do - empty?(static_type) -> :badindex - static_optional? -> {true, static_type} - true -> {false, static_type} + # If I access a static tuple at a "open position", we have two options: + # + # 1. Do not allow the access and return :badindex, + # you must use dynamic for these cases (this is what we chose for maps) + # + # 2. Allow the access and return the static type + # + # The trouble with allowing the access is that it is a potential runtime + # error not being caught by the type system. + # + # Furthermore, our choice here, needs to be consistent with elem/put_elem + # when the index is the `integer()` type. If we choose to return `:badindex`, + # then all elem/put_elem with an `integer()` and the tuple is not dynamic + # should also be a static typing error. We chose to go with 1. + if static_optional? or empty?(static_type) do + :badindex + else + {false, static_type} end else :badtuple diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index c39e4877b51..9ed8e52b181 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -454,9 +454,7 @@ defmodule Module.Types.DescrTest do |> difference(tuple([integer(), term(), atom()])) |> tuple_fetch(2) == {false, integer()} - # difference with map_fetch: querying the index 0 of tuple() is not an error - assert tuple_fetch(tuple(), 0) - |> Kernel.then(fn {opt, type} -> opt and equal?(type, term()) end) + assert tuple_fetch(tuple(), 0) == :badindex end test "tuple_fetch with dynamic" do From f425c1e3e5d713111b1915fc5d793647dd3e4f3f Mon Sep 17 00:00:00 2001 From: Guillaume Duboc Date: Thu, 1 Aug 2024 16:37:06 +0200 Subject: [PATCH 6/7] Resolve comments --- lib/elixir/lib/module/types/descr.ex | 31 +++++++++---------- .../test/elixir/module/types/descr_test.exs | 1 + 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index cc44e3bacc0..b79e49c731e 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -64,7 +64,6 @@ defmodule Module.Types.Descr do def reference(), do: %{bitmap: @bit_reference} def open_tuple(elements), do: %{tuple: tuple_new(:open, elements)} def tuple(elements), do: %{tuple: tuple_new(:closed, elements)} - def empty_tuple(), do: tuple([]) def tuple(), do: %{tuple: @tuple_top} @boolset :sets.from_list([true, false], version: 2) @@ -1195,12 +1194,13 @@ defmodule Module.Types.Descr do end # Pads a list of elements with term(). - defp tuple_fill(elements, length) when length(elements) > length do - raise ArgumentError, "tuple_fill: elements are longer than the desired length" - end - defp tuple_fill(elements, desired_length) do - elements ++ List.duplicate(term(), desired_length - length(elements)) + pad_length = desired_length - length(elements) + if pad_length < 0 do + raise ArgumentError, "tuple_fill: elements are longer than the desired length" + else + elements ++ List.duplicate(term(), pad_length) + end end # Check if a tuple represented in DNF is empty @@ -1225,26 +1225,26 @@ defmodule Module.Types.Descr do if (tag == :closed and n < m) or (neg_tag == :closed and n > m) do tuple_empty?(tag, elements, negs) else - check_elements([], tag, elements, neg_elements, negs) and - check_compatibility(n, m, tag, elements, neg_tag, negs) + tuple_elements([], tag, elements, neg_elements, negs) and + tuple_compatibility(n, m, tag, elements, neg_tag, negs) end end # Recursively check elements for emptiness - defp check_elements(_, _, _, [], _), do: true + defp tuple_elements(_, _, _, [], _), do: true - defp check_elements(acc, tag, elements, [neg_type | neg_elements], negs) do + defp tuple_elements(acc, tag, elements, [neg_type | neg_elements], negs) do {ty, elements} = List.pop_at(elements, 0, term()) diff = difference(ty, neg_type) (empty?(diff) or tuple_empty?(tag, Enum.reverse(acc, [diff | elements]), negs)) and - check_elements([ty | acc], tag, elements, neg_elements, negs) + tuple_elements([ty | acc], tag, elements, neg_elements, negs) 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 check_compatibility(n, m, tag, elements, neg_tag, negs) do + defp tuple_compatibility(n, m, tag, elements, neg_tag, negs) 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 @@ -1267,8 +1267,7 @@ defmodule Module.Types.Descr do iex> tuple_fetch(tuple([integer(), atom()]), 0) {false, integer()} - iex> tuple_fetch(tuple([integer(), atom()]), 2) - :badindex + :badindex iex> tuple_fetch(union(tuple([integer()]), tuple([integer(), atom()])), 1) {true, atom()} @@ -1277,7 +1276,7 @@ defmodule Module.Types.Descr do {true, dynamic()} iex> tuple_fetch(integer(), 0) - :badtuple + :badtuple """ def tuple_fetch(_, index) when index < 0, do: :badindex @@ -1348,7 +1347,7 @@ defmodule Module.Types.Descr do defp tuple_split_on_index(dnf, index) do Enum.flat_map(dnf, fn {tag, elements, []} -> - [Enum.at(elements, index) || tag_to_type(tag)] + [Enum.at(elements, index, tag_to_type(tag))] {tag, elements, negs} -> {fst, snd} = tuple_pop_index(tag, elements, index) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 9ed8e52b181..86a2151d681 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -182,6 +182,7 @@ defmodule Module.Types.DescrTest do assert empty?(difference(none(), dynamic())) 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), do: tuple(List.duplicate(term(), n)) From 28f7b4a0913eac5e8dcb0379e430bf2bbad7bab1 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc Date: Thu, 1 Aug 2024 16:55:53 +0200 Subject: [PATCH 7/7] Opti on tuple_normalize --- lib/elixir/lib/module/types/descr.ex | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index b79e49c731e..0e8e51d74d7 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -1196,6 +1196,7 @@ defmodule Module.Types.Descr do # Pads a list of elements with term(). defp tuple_fill(elements, desired_length) do pad_length = desired_length - length(elements) + if pad_length < 0 do raise ArgumentError, "tuple_fill: elements are longer than the desired length" else @@ -1375,18 +1376,16 @@ defmodule Module.Types.Descr do # Use heuristics to normalize a tuple dnf for pretty printing. defp tuple_normalize(dnf) do - dnf - |> Enum.reject(&tuple_empty?([&1])) - |> Enum.map(fn {tag, fields, negs} -> - {tag, fields, Enum.reject(negs, &tuple_empty_negation?(tag, fields, &1))} - end) + for {tag, elements, negs} <- dnf, + not tuple_empty?([{tag, elements, negs}]) do + n = length(elements) + {tag, elements, Enum.reject(negs, &tuple_empty_negation?(tag, n, &1))} + end end # Remove useless negations, which denote tuples of incompatible sizes. - defp tuple_empty_negation?(tag, elements, {neg_tag, neg_elements}) do - n = length(elements) + defp tuple_empty_negation?(tag, n, {neg_tag, neg_elements}) do m = length(neg_elements) - (tag == :closed and n < m) or (neg_tag == :closed and n > m) end