diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 3170cb96ff..37d4cdee14 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -447,6 +447,9 @@ defmodule Module.Types.Apply do :badtuple -> remote_error(:erlang, :element, [integer(), tuple_type], expr, stack, context) + :badindex when index < 1 -> + remote_error({:negindex, index - 1}, :erlang, :element, 2, expr, stack, context) + :badindex -> remote_error({:badindex, index, tuple_type}, :erlang, :element, 2, expr, stack, context) end @@ -473,12 +476,39 @@ defmodule Module.Types.Apply do args_types = [integer(), tuple_type, elem_type] remote_error(:erlang, :insert_element, args_types, expr, stack, context) + :badindex when index < 1 -> + remote_error({:negindex, index - 1}, :erlang, :insert_element, 3, expr, stack, context) + :badindex -> error = {:badindex, index - 1, tuple_type} remote_error(error, :erlang, :insert_element, 3, expr, stack, context) end end + defp do_remote(:erlang, :setelement, [index, tuple, elem], _, expr, stack, context, of_fun) + when is_integer(index) do + tuple_type = open_tuple(List.duplicate(term(), max(index, 1))) + + {tuple_type, context} = of_fun.(tuple, tuple_type, expr, stack, context) + {elem_type, context} = of_fun.(elem, term(), expr, stack, context) + + case tuple_replace_at(tuple_type, index - 1, elem_type) do + value_type when is_descr(value_type) -> + {return(value_type, [tuple_type, elem_type], stack), context} + + :badtuple -> + args_types = [integer(), tuple_type, elem_type] + remote_error(:erlang, :setelement, args_types, expr, stack, context) + + :badindex when index < 1 -> + remote_error({:negindex, index - 1}, :erlang, :setelement, 3, expr, stack, context) + + :badindex -> + error = {:badindex, index, tuple_type} + remote_error(error, :erlang, :setelement, 3, expr, stack, context) + end + end + defp do_remote(:erlang, :delete_element, [index, tuple], _, expr, stack, context, of_fun) when is_integer(index) do tuple_type = open_tuple(List.duplicate(term(), max(index, 1))) @@ -491,6 +521,9 @@ defmodule Module.Types.Apply do :badtuple -> remote_error(:erlang, :delete_element, [integer(), tuple_type], expr, stack, context) + :badindex when index < 1 -> + remote_error({:negindex, index - 1}, :erlang, :delete_element, 2, expr, stack, context) + :badindex -> error = {:badindex, index, tuple_type} remote_error(error, :erlang, :delete_element, 2, expr, stack, context) @@ -1808,6 +1841,29 @@ defmodule Module.Types.Apply do } end + def format_diagnostic({{:negindex, index}, mfac, expr, context}) do + traces = collect_traces(expr, context) + {mod, fun, arity, _converter} = mfac + mfa = Exception.format_mfa(mod, fun, arity) + + %{ + details: %{typing_traces: traces}, + message: + IO.iodata_to_binary([ + """ + expected a non-negative integer as index in #{mfa}: + + #{expr_to_string(expr) |> indent(4)} + + got the index: + + #{index} + """, + format_traces(traces) + ]) + } + end + def format_diagnostic({{:badkeydomain, map, key, error}, mfac, expr, context}) do {mod, fun, arity, _converter} = mfac mfa = Exception.format_mfa(mod, fun, arity) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index bbef71f22a..ae368b238b 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -5850,8 +5850,16 @@ defmodule Module.Types.Descr do def tuple_insert_at(descr, index, type) when is_integer(index) and index >= 0 do case :maps.take(:dynamic, unfold(type)) do - :error -> tuple_insert_at_checked(descr, index, type) - {dynamic, _static} -> dynamic(tuple_insert_at_checked(descr, index, dynamic)) + :error -> + tuple_insert_at_checked(descr, index, type) + + {dynamic_type, _static} -> + # Errors about the tuple/index do not become "dynamic errors" when the + # inserted value happens to be dynamic — propagate them as-is. + case tuple_insert_at_checked(descr, index, dynamic_type) do + atom when atom in [:badtuple, :badindex] -> atom + result -> dynamic(result) + end end end @@ -5919,6 +5927,97 @@ defmodule Module.Types.Descr do end) end + @doc """ + Replace an element in the tuple at the given (0-based) index. + + It returns the same as `tuple_fetch/2`. + """ + def tuple_replace_at(:term, _key, _type), do: :badtuple + + def tuple_replace_at(descr, index, type) when is_integer(index) and index >= 0 do + case :maps.take(:dynamic, unfold(type)) do + :error -> + tuple_replace_at_checked(descr, index, type) + + {dynamic_type, _static} -> + # Errors about the tuple/index do not become "dynamic errors" when the + # replacement value happens to be dynamic — propagate them as-is. + case tuple_replace_at_checked(descr, index, dynamic_type) do + atom when atom in [:badtuple, :badindex] -> atom + result -> dynamic(result) + end + end + end + + def tuple_replace_at(_, _, _), do: :badindex + + defp tuple_replace_at_checked(descr, index, type) do + case :maps.take(:dynamic, descr) do + :error -> + # Note: the empty type is not a valid input + is_proper_tuple? = descr_key?(descr, :tuple) and non_empty_tuple_only?(descr) + is_proper_size? = tuple_of_size_at_least_static?(descr, index + 1) + + cond do + is_proper_tuple? and is_proper_size? -> tuple_replace_static(descr, index, type) + is_proper_tuple? -> :badindex + true -> :badtuple + end + + {dynamic, static} -> + is_proper_tuple? = descr_key?(dynamic, :tuple) and tuple_only?(static) + is_proper_size? = tuple_of_size_at_least_static?(static, index + 1) + + cond do + is_proper_tuple? and is_proper_size? -> + static_result = tuple_replace_static(static, index, type) + + # Prune for dynamic values that make the operation succeed. + dynamic_input = intersection(dynamic, tuple_of_size_at_least(index + 1)) + + if empty?(dynamic_input) and empty?(static) do + :badindex + else + dynamic_result = tuple_replace_static(dynamic_input, index, type) + union(dynamic(dynamic_result), static_result) + end + + # Highlight the case where the issue is an index out of range from the tuple + is_proper_tuple? -> + :badindex + + true -> + :badtuple + end + end + end + + defp tuple_replace_static(descr, _, _) when descr == @none, do: none() + + defp tuple_replace_static(descr, index, type) do + # Unlike insert/delete, replace is not injective at the replaced index, so + # transformed negative leaves would discard valid outputs. We eliminate + # negations first by converting to a positive-only DNF and replacing in + # each disjunct. + Map.update!(descr, :tuple, fn bdd -> + tuple_bdd_to_dnf_no_negations(bdd) + |> Enum.reduce(:bdd_bot, fn {tag, elements}, acc -> + # If the tuple is open, then we want List.replace_at to update the correct + # index, which requires filling the tuple with `term()` values first. + # Closed tuples of an incorrect size will have been cancelled by the + # earlier intersection with `tuple_of_size_at_least`. + elements = + if tag == :open and length(elements) < index + 1 do + tuple_fill(elements, index + 1) + else + elements + end + + bdd_union(acc, tuple_new(tag, List.replace_at(elements, index, type))) + end) + end) + end + defp tuple_of_size_at_least(n) when is_integer(n) and n >= 0 do %{tuple: tuple_new(:open, List.duplicate(term(), n))} end diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 53c49536b7..5e72142ca3 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1808,6 +1808,25 @@ defmodule Module.Types.DescrTest do assert dynamic(union(tuple(), integer())) |> tuple_delete_at(1) |> equal?(dynamic(tuple_of_size_at_least(1))) + + # Deleting beyond a dynamic fixed-size tuple must fail with :badindex. + # In particular, deleting index N from a dynamic N-tuple must NOT + # silently return the original tuple type (off-by-one pruning bug). + assert tuple_delete_at(dynamic(tuple([atom([:ok]), term()])), 2) == :badindex + assert tuple_delete_at(dynamic(tuple([atom([:a]), atom([:b])])), 2) == :badindex + assert tuple_delete_at(dynamic(tuple([atom([:ok])])), 1) == :badindex + + # Deletion is not injective at the deleted position; the negative + # constraint at that position must not be preserved. + assert difference(tuple([atom(), atom()]), tuple([atom([:a]), term()])) + |> tuple_delete_at(0) + |> equal?(tuple([atom()])) + + # Negative constraints at positions OTHER than the deleted one are + # preserved (after shifting). + assert difference(tuple([atom(), atom()]), tuple([atom([:a]), term()])) + |> tuple_delete_at(1) + |> equal?(difference(tuple([atom()]), tuple([atom([:a])]))) end test "tuple_insert_at" do @@ -1881,6 +1900,126 @@ defmodule Module.Types.DescrTest do assert dynamic(union(tuple(), integer())) |> tuple_insert_at(1, boolean()) |> equal?(dynamic(open_tuple([term(), boolean()]))) + + # Errors must propagate even when the inserted value is dynamic + assert tuple_insert_at(integer(), 0, dynamic()) == :badtuple + assert tuple_insert_at(term(), 0, dynamic()) == :badtuple + assert tuple_insert_at(tuple([atom([:ok])]), 2, dynamic()) == :badindex + assert tuple_insert_at(tuple([atom([:ok])]), -1, dynamic()) == :badindex + + # Out-of-bounds insertions into a dynamic fixed-size tuple must fail + # with :badindex (rather than silently producing dynamic(none())). + assert tuple_insert_at(dynamic(tuple([atom([:ok]), term()])), 3, binary()) == + :badindex + + assert tuple_insert_at(dynamic(tuple([atom([:ok])])), 2, binary()) == :badindex + + # Even at index 0 (where the size constraint is vacuous) the dynamic + # upper bound must still be intersected with the tuple domain so that + # non-tuple alternatives are pruned. `Tuple.insert_at/3` always returns + # a tuple, so an integer alternative cannot survive the operation. + assert tuple_insert_at(dynamic(term()), 0, boolean()) == + dynamic(open_tuple([boolean()])) + + assert tuple_insert_at(dynamic(union(tuple(), integer())), 0, boolean()) == + dynamic(open_tuple([boolean()])) + end + + test "tuple_replace_at" do + assert tuple_replace_at(tuple([integer(), atom()]), 2, boolean()) == :badindex + assert tuple_replace_at(tuple([integer(), atom()]), -1, boolean()) == :badindex + assert tuple_replace_at(empty_tuple(), 0, boolean()) == :badindex + assert tuple_replace_at(integer(), 0, boolean()) == :badtuple + assert tuple_replace_at(term(), 0, boolean()) == :badtuple + assert tuple_replace_at(tuple([none()]), 0, boolean()) == :badtuple + + # Out-of-bounds in a union + assert union(tuple([integer(), atom()]), tuple([float()])) + |> tuple_replace_at(1, boolean()) == :badindex + + # Test replacing an element in a closed tuple + assert tuple_replace_at(tuple([integer(), atom(), boolean()]), 1, float()) == + tuple([integer(), float(), boolean()]) + + # Test replacing the first element of a closed tuple + assert tuple_replace_at(tuple([integer(), atom()]), 0, boolean()) == + tuple([boolean(), atom()]) + + # Test replacing the last element of a closed tuple + assert tuple_replace_at(tuple([integer(), atom()]), 1, boolean()) == + tuple([integer(), boolean()]) + + # Test replacing in an open tuple + assert tuple_replace_at(open_tuple([integer(), atom(), boolean()]), 1, float()) == + open_tuple([integer(), float(), boolean()]) + + # Test replacing with a dynamic type + assert tuple_replace_at(tuple([integer(), atom()]), 1, dynamic()) == + dynamic(tuple([integer(), term()])) + + # Test replacing in a dynamic tuple + assert tuple_replace_at(dynamic(tuple([integer(), atom()])), 1, boolean()) == + dynamic(tuple([integer(), boolean()])) + + # Test replacing in a union of tuples + assert tuple_replace_at(union(tuple([integer()]), tuple([atom()])), 0, boolean()) == + tuple([boolean()]) + + # Test replacing in an intersection of tuples + assert intersection(tuple([integer(), atom()]), tuple([term(), boolean()])) + |> tuple_replace_at(1, float()) == tuple([integer(), float()]) + + # Test replacing in a difference of tuples + assert difference(tuple([integer(), atom(), boolean()]), tuple([term(), term()])) + |> tuple_replace_at(1, float()) + |> equal?(tuple([integer(), float(), boolean()])) + + # Replacing in a difference where the negation actually constrains the + # positive (not just by arity). The replaced position drops its negative + # constraint, the other positions keep theirs. + assert difference(tuple([atom(), atom()]), tuple([atom([:a]), term()])) + |> tuple_replace_at(0, boolean()) + |> equal?(tuple([boolean(), atom()])) + + assert difference(tuple([atom(), atom()]), tuple([atom([:a]), term()])) + |> tuple_replace_at(1, boolean()) + |> equal?(difference(tuple([atom(), boolean()]), tuple([atom([:a]), boolean()]))) + + # Errors must propagate even when the replacement value is dynamic + assert tuple_replace_at(integer(), 0, dynamic()) == :badtuple + assert tuple_replace_at(term(), 0, dynamic()) == :badtuple + assert tuple_replace_at(tuple([atom([:ok])]), 1, dynamic()) == :badindex + assert tuple_replace_at(empty_tuple(), 0, dynamic()) == :badindex + + # Out-of-bounds writes to a dynamic fixed-size tuple must fail with :badindex + assert tuple_replace_at(dynamic(tuple([atom([:ok]), term()])), 2, binary()) == + :badindex + + assert tuple_replace_at(dynamic(tuple([atom([:ok])])), 1, binary()) == :badindex + + # Test replacing in a complex union involving dynamic + assert union(tuple([integer(), atom()]), dynamic(tuple([float(), binary()]))) + |> tuple_replace_at(1, boolean()) + |> equal?( + union( + tuple([integer(), boolean()]), + dynamic(tuple([float(), boolean()])) + ) + ) + + # Successfully replacing at position `index` in a tuple means that the dynamic + # values that succeed are intersected with tuples of size at least `index + 1` + assert dynamic(tuple()) + |> tuple_replace_at(0, boolean()) + |> equal?(dynamic(open_tuple([boolean()]))) + + assert dynamic(term()) + |> tuple_replace_at(0, boolean()) + |> equal?(dynamic(open_tuple([boolean()]))) + + assert dynamic(union(tuple(), integer())) + |> tuple_replace_at(1, boolean()) + |> equal?(dynamic(open_tuple([term(), boolean()]))) end test "tuple_values" do diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index dc6831f1b5..224eb865eb 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -813,6 +813,17 @@ defmodule Module.Types.ExprTest do {:ok, integer()} """ + + assert typeerror!(elem({1, 2}, -1)) == + ~l""" + expected a non-negative integer as index in Kernel.elem/2: + + elem({1, 2}, -1) + + got the index: + + -1 + """ end test "Tuple.insert_at/3" do @@ -858,6 +869,34 @@ defmodule Module.Types.ExprTest do {:ok, integer()} """ + + assert typeerror!([x], Tuple.insert_at({:ok, x}, 3, "foo")) |> strip_ansi() == + ~l""" + expected a tuple with at least 3 elements in Tuple.insert_at/3: + + Tuple.insert_at({:ok, x}, 3, "foo") + + the given type does not have the given index: + + dynamic({:ok, term()}) + + where "x" was given the type: + + # type: dynamic() + # from: types_test.ex:LINE-1 + x + """ + + assert typeerror!(Tuple.insert_at({1, 2}, -1, :x)) == + ~l""" + expected a non-negative integer as index in Tuple.insert_at/3: + + Tuple.insert_at({1, 2}, -1, :x) + + got the index: + + -1 + """ end test "Tuple.delete_at/2" do @@ -897,6 +936,105 @@ defmodule Module.Types.ExprTest do {:ok, integer()} """ + + assert typeerror!([x], Tuple.delete_at({:ok, x}, 2)) |> strip_ansi() == + ~l""" + expected a tuple with at least 3 elements in Tuple.delete_at/2: + + Tuple.delete_at({:ok, x}, 2) + + the given type does not have the given index: + + dynamic({:ok, term()}) + + where "x" was given the type: + + # type: dynamic() + # from: types_test.ex:LINE-1 + x + """ + + assert typeerror!(Tuple.delete_at({1, 2}, -1)) == + ~l""" + expected a non-negative integer as index in Tuple.delete_at/2: + + Tuple.delete_at({1, 2}, -1) + + got the index: + + -1 + """ + end + + test "put_elem/3" do + assert typecheck!(put_elem({:ok, 123}, 0, "foo")) == tuple([binary(), integer()]) + assert typecheck!(put_elem({:ok, 123}, 1, "foo")) == tuple([atom([:ok]), binary()]) + + assert typecheck!([x], put_elem({:ok, x}, 0, "foo")) == + dynamic(tuple([binary(), term()])) + + assert typecheck!([x], put_elem({:ok, x}, 1, "foo")) == + dynamic(tuple([atom([:ok]), binary()])) + + assert typeerror!([<>], put_elem(x, 0, "foo")) |> strip_ansi() == + ~l""" + incompatible types given to Kernel.put_elem/3: + + put_elem(x, 0, "foo") + + given types: + + float(), integer(), binary() + + but expected one of: + + {...}, integer(), term() + + where "x" was given the type: + + # type: float() + # from: types_test.ex:LINE-1 + <> + """ + + assert typeerror!(put_elem({:ok, 123}, 2, "foo")) == + ~l""" + expected a tuple with at least 3 elements in Kernel.put_elem/3: + + put_elem({:ok, 123}, 2, "foo") + + the given type does not have the given index: + + {:ok, integer()} + """ + + assert typeerror!([x], put_elem({:ok, x}, 2, "foo")) |> strip_ansi() == + ~l""" + expected a tuple with at least 3 elements in Kernel.put_elem/3: + + put_elem({:ok, x}, 2, "foo") + + the given type does not have the given index: + + dynamic({:ok, term()}) + + where "x" was given the type: + + # type: dynamic() + # from: types_test.ex:LINE-1 + x + """ + + assert typeerror!(put_elem({1, 2}, -1, :x)) == + ~l""" + expected a non-negative integer as index in Kernel.put_elem/3: + + put_elem({1, 2}, -1, :x) + + got the index: + + -1 + """ end test "Tuple.duplicate/2" do