Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions lib/elixir/lib/module/types/apply.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)))
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
103 changes: 101 additions & 2 deletions lib/elixir/lib/module/types/descr.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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} ->
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if dropping the static part is right here. insert_element does drop it while delete_element preserves

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insert_element should be changed to preserve it, this is a loss of precision

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like something for a follow up PR

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can take care of it.

# 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
Expand Down
139 changes: 139 additions & 0 deletions lib/elixir/test/elixir/module/types/descr_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor Author

@lukaszsamson lukaszsamson May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests in 1819-1829 do not assert any new behaviour added in this PR but were failing pre #15376

Copy link
Copy Markdown
Member

@gldubc gldubc May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can get rid of them, this was tested in the PR.

# 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
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests in 1917-1925 do not assert any new behaviour added in this PR but were failing pre #15376

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also tested in the PR, can remove. :)

# 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
Expand Down
Loading