Skip to content
Merged
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
35 changes: 27 additions & 8 deletions lib/elixir/lib/module/types/descr.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3766,7 +3766,7 @@ defmodule Module.Types.Descr do
# If any of required or optional domains are satisfied, then we compute the
# initial return type. `map_update_keys_static` will then union into the
# computed type below, using the original bdd/dnf, not the one with updated domains.
descr = map_update_put_domains(bdd, domains, type_fun)
descr = map_update_put_domains(bdd, domains, type_fun, force?)
{remove_optional(value), descr, errors, true}
else
{remove_optional(value), none(), errors, false}
Expand Down Expand Up @@ -3919,31 +3919,42 @@ defmodule Module.Types.Descr do
:found_key -> true
end

# For each domain key, check if it exists in the map DNF and classify it
# as valid (matched) or invalid (missing). Accumulates the value type if require_type? is set.
# Returns {found?, valid_domains, invalid_domains, accumulated_value_type},
# where found? tracks whether at least one domain key was matched in the map.
defp map_update_get_domains(dnf, domain_keys, acc, require_type?, any_atom_key) do
Enum.reduce(domain_keys, {false, [], [], acc}, fn domain_key, {found?, valid, invalid, acc} ->
# Get the value type for this domain key, excluding optional entries
value = map_get_domain_no_optional(dnf, domain_key, none())

cond do
# Atom domains are special: we also check for individually named atom keys
domain_key == :atom ->
atom_acc = any_atom_key.()

cond do
# Domain has a direct match: valid, union both atom keys and domain value
not empty?(value) ->
acc = if require_type?, do: union(union(atom_acc, acc), value), else: acc
{true, [:atom | valid], invalid, acc}

# No direct match, but individual atom keys exist: found but domain is invalid
not empty?(atom_acc) ->
acc = if require_type?, do: union(atom_acc, acc), else: acc
{true, valid, [:atom | invalid], acc}

# No match at all
true ->
{found?, valid, [:atom | invalid], acc}
end

# Non-atom domain key has a match: mark as valid
not empty?(value) ->
acc = if require_type?, do: union(acc, value), else: acc
{true, [domain_key | valid], invalid, acc}

# Non-atom domain key not found: mark as invalid
true ->
{found?, valid, [domain_key | invalid], acc}
end
Expand Down Expand Up @@ -3973,24 +3984,29 @@ defmodule Module.Types.Descr do
# But that would not be helpful, as we can't distinguish between these two
# in Elixir code. It only makes sense to build the union for domain keys
# that do not exist.
defp map_update_put_domains(bdd, [], _type_fun), do: %{map: bdd}
defp map_update_put_domains(bdd, [], _type_fun, _force?), do: %{map: bdd}

defp map_update_put_domains(bdd, domain_keys, type_fun) do
defp map_update_put_domains(bdd, domain_keys, type_fun, force?) do
bdd =
bdd_map(bdd, fn {tag, fields} ->
{map_update_put_domain(tag, domain_keys, type_fun), fields}
{map_update_put_domain(tag, domain_keys, type_fun, force?), fields}
end)

%{map: bdd}
end

defp map_update_put_domain(tag_or_domains, domain_keys, type_fun) do
defp map_update_put_domain(tag_or_domains, domain_keys, type_fun, force?) do
case tag_or_domains do
:open ->
:open

:closed ->
fields_from_keys(domain_keys, if_set(type_fun.(true, none())))
# Non-forced updates must not invoke the callback on absent branches:
# the callback may itself typecheck a function application, and
# applying it to `none()` will raise undue warnings.
if force?,
do: fields_from_keys(domain_keys, if_set(type_fun.(true, none()))),
else: :closed

# Note: domain_keys may contain duplicates, so we cannot
# do a side-by-side traversal here.
Expand All @@ -4001,7 +4017,10 @@ defmodule Module.Types.Descr do
fields_store(domain_key, union(value, type_fun.(true, remove_optional(value))), acc)

:error ->
fields_store(domain_key, if_set(type_fun.(true, none())), acc)
# Likewise, only forced updates may synthesize missing domain keys.
if force?,
do: fields_store(domain_key, if_set(type_fun.(true, none())), acc),
else: acc
end
end)
end
Expand Down Expand Up @@ -4078,7 +4097,7 @@ defmodule Module.Types.Descr do
descr =
case required_domains ++ optional_domains do
[] -> none()
domains -> map_update_put_domains(bdd, domains, type_fun)
domains -> map_update_put_domains(bdd, domains, type_fun, true)
end

dnf = map_bdd_to_dnf_with_empty(bdd)
Expand Down
24 changes: 24 additions & 0 deletions lib/elixir/test/elixir/module/types/descr_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2275,6 +2275,30 @@ defmodule Module.Types.DescrTest do
|> map_update(atom([:b]), integer(), true, true) == {none(), none(), []}
end

test "with non-empty open maps does not call the callback with none from absent branches" do
# This is a test of the map_update_fun/5 with forced?: false parameter.
# We check that it does not call its typed_fun argument with `none()`
# due to the key being absent in the map.

type = dynamic(difference(open_map(), empty_map()))
ref = make_ref()

fun = fn _optional?, value ->
send(self(), {ref, value})
value
end

_ = map_update_fun(type, binary(), fun, false, false)

messages = Process.info(self(), :messages) |> elem(1)

# Check that the callback was not invoked with `none()`
refute Enum.any?(messages, fn
{seen_ref, value} when seen_ref == ref -> empty?(value)
_ -> false
end)
end

test "with dynamic atom keys" do
assert map_update(closed_map(key: atom([:value])), dynamic(), atom([:new_value])) ==
{atom([:value]), closed_map(key: atom([:value, :new_value])), []}
Expand Down
Loading