Skip to content

Commit 6fa3fe5

Browse files
committed
Consider keys may be overridden in a map
1 parent e650384 commit 6fa3fe5

File tree

3 files changed

+144
-27
lines changed

3 files changed

+144
-27
lines changed

lib/elixir/lib/module/types/expr.ex

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,32 @@ defmodule Module.Types.Expr do
118118
def of_expr({:%{}, meta, [{:|, _, [map, args]}]} = expr, stack, context) do
119119
{map_type, context} = of_expr(map, stack, context)
120120

121-
Of.permutate_map(args, stack, context, &of_expr/3, fn _closed?, pairs ->
122-
# TODO: If closed? is false, we need to open up the map
123-
Enum.reduce(pairs, map_type, fn {key, type}, acc ->
124-
case map_put(acc, key, type) do
125-
descr when is_descr(descr) -> descr
126-
:badmap -> throw({:badmap, map_type, expr, context})
127-
end
128-
end)
121+
Of.permutate_map(args, stack, context, &of_expr/3, fn fallback, keys, pairs ->
122+
# If there is no fallback (i.e. it is closed), we can update the existing map,
123+
# otherwise we only assert the existing keys.
124+
keys = if fallback == none(), do: keys, else: Enum.map(pairs, &elem(&1, 0)) ++ keys
125+
126+
# Assert the keys exist
127+
fallback =
128+
Enum.reduce(keys, fallback, fn key, acc ->
129+
case map_fetch(map_type, key) do
130+
{_, value_type} -> union(value_type, acc)
131+
:badkey -> throw({:badkey, map_type, key, expr, context})
132+
:badmap -> throw({:badmap, map_type, expr, context})
133+
end
134+
end)
135+
136+
if fallback == none() do
137+
Enum.reduce(pairs, map_type, fn {key, type}, acc ->
138+
case map_put(acc, key, type) do
139+
descr when is_descr(descr) -> descr
140+
:badmap -> throw({:badmap, map_type, expr, context})
141+
end
142+
end)
143+
else
144+
# TODO: Use the fallback type to actually indicate if open or closed.
145+
open_map(pairs)
146+
end
129147
end)
130148
catch
131149
error -> {error_type(), error(__MODULE__, error, meta, stack, context)}
@@ -547,6 +565,27 @@ defmodule Module.Types.Expr do
547565
}
548566
end
549567

568+
def format_diagnostic({:badkey, type, key, expr, context}) do
569+
traces = collect_traces(expr, context)
570+
571+
%{
572+
details: %{typing_traces: traces},
573+
message:
574+
IO.iodata_to_binary([
575+
"""
576+
expected a map with key #{inspect(key)} in map update syntax:
577+
578+
#{expr_to_string(expr) |> indent(4)}
579+
580+
but got type:
581+
582+
#{to_quoted_string(type) |> indent(4)}
583+
""",
584+
format_traces(traces)
585+
])
586+
}
587+
end
588+
550589
def format_diagnostic({:badbinary, type, expr, context}) do
551590
traces = collect_traces(expr, context)
552591

lib/elixir/lib/module/types/of.ex

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -108,58 +108,72 @@ defmodule Module.Types.Of do
108108
Builds a closed map.
109109
"""
110110
def closed_map(pairs, stack, context, of_fun) do
111-
permutate_map(pairs, stack, context, of_fun, fn closed?, pairs ->
112-
if closed?, do: closed_map(pairs), else: open_map(pairs)
111+
permutate_map(pairs, stack, context, of_fun, fn fallback, _keys, pairs ->
112+
# TODO: Use the fallback type to actually indicate if open or closed.
113+
if fallback == none(), do: closed_map(pairs), else: open_map(pairs)
113114
end)
114115
end
115116

116117
@doc """
117118
Builds permutation of maps according to the given keys.
118119
"""
119120
def permutate_map(pairs, stack, context, of_fun, of_map) do
120-
{closed?, single, multiple, context} =
121-
Enum.reduce(pairs, {true, [], [], context}, fn
122-
{key, value}, {closed?, single, multiple, context} ->
123-
{keys, context} = of_finite_key_type(key, stack, context, of_fun)
121+
{dynamic?, fallback, single, multiple, assert, context} =
122+
Enum.reduce(pairs, {false, none(), [], [], [], context}, fn
123+
{key, value}, {dynamic?, fallback, single, multiple, assert, context} ->
124+
{dynamic_key?, keys, context} = of_finite_key_type(key, stack, context, of_fun)
124125
{value_type, context} = of_fun.(value, stack, context)
126+
dynamic? = dynamic? or dynamic_key? or gradual?(value_type)
125127

126128
case keys do
127129
:none ->
128-
{false, single, multiple, context}
130+
fallback = union(fallback, value_type)
131+
132+
{fallback, assert} =
133+
Enum.reduce(single, {fallback, assert}, fn {key, type}, {fallback, assert} ->
134+
{union(fallback, type), [key | assert]}
135+
end)
136+
137+
{fallback, assert} =
138+
Enum.reduce(multiple, {fallback, assert}, fn {keys, type}, {fallback, assert} ->
139+
{union(fallback, type), keys ++ assert}
140+
end)
141+
142+
{dynamic?, fallback, [], [], assert, context}
129143

130144
[key] when multiple == [] ->
131-
{closed?, [{key, value_type} | single], multiple, context}
145+
{dynamic?, fallback, [{key, value_type} | single], multiple, assert, context}
132146

133147
keys ->
134-
{closed?, single, [{keys, value_type} | multiple], context}
148+
{dynamic?, fallback, single, [{keys, value_type} | multiple], assert, context}
135149
end
136150
end)
137151

138152
map =
139153
case Enum.reverse(multiple) do
140154
[] ->
141-
of_map.(closed?, Enum.reverse(single))
155+
of_map.(fallback, Enum.uniq(assert), Enum.reverse(single))
142156

143157
[{keys, type} | tail] ->
144158
for key <- keys, t <- cartesian_map(tail) do
145-
of_map.(closed?, Enum.reverse(single, [{key, type} | t]))
159+
of_map.(fallback, Enum.uniq(assert), Enum.reverse(single, [{key, type} | t]))
146160
end
147161
|> Enum.reduce(&union/2)
148162
end
149163

150-
{map, context}
164+
if dynamic?, do: {dynamic(map), context}, else: {map, context}
151165
end
152166

153167
defp of_finite_key_type(key, _stack, context, _of_fun) when is_atom(key) do
154-
{[key], context}
168+
{false, [key], context}
155169
end
156170

157171
defp of_finite_key_type(key, stack, context, of_fun) do
158172
{key_type, context} = of_fun.(key, stack, context)
159173

160174
case atom_fetch(key_type) do
161-
{:finite, list} -> {list, context}
162-
_ -> {:none, context}
175+
{:finite, list} -> {gradual?(key_type), list, context}
176+
_ -> {gradual?(key_type), :none, context}
163177
end
164178
end
165179

lib/elixir/test/elixir/module/types/expr_test.exs

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -443,10 +443,8 @@ defmodule Module.Types.ExprTest do
443443
end
444444

445445
describe "maps/structs" do
446-
test "creating maps" do
446+
test "creating closed maps" do
447447
assert typecheck!(%{foo: :bar}) == closed_map(foo: atom([:bar]))
448-
assert typecheck!(%{123 => 456}) == open_map()
449-
assert typecheck!(%{123 => 456, foo: :bar}) == open_map(foo: atom([:bar]))
450448
assert typecheck!([x], %{key: x}) == dynamic(closed_map(key: term()))
451449

452450
assert typecheck!(
@@ -457,6 +455,14 @@ defmodule Module.Types.ExprTest do
457455
) == closed_map(foo: atom([:second]))
458456
end
459457

458+
test "creating open maps" do
459+
assert typecheck!(%{123 => 456}) == open_map()
460+
# Since key cannot override :foo, we preserve it
461+
assert typecheck!([key], %{key => 456, foo: :bar}) == dynamic(open_map(foo: atom([:bar])))
462+
# Since key can override :foo, we do not preserve it
463+
assert typecheck!([key], %{:foo => :bar, key => :baz}) == dynamic(open_map())
464+
end
465+
460466
test "creating structs" do
461467
assert typecheck!(%Point{}) ==
462468
closed_map(
@@ -475,7 +481,7 @@ defmodule Module.Types.ExprTest do
475481
)
476482
end
477483

478-
test "updating maps" do
484+
test "updating to closed maps" do
479485
assert typecheck!([x], %{x | x: :zero}) ==
480486
dynamic(open_map(x: atom([:zero])))
481487

@@ -499,6 +505,64 @@ defmodule Module.Types.ExprTest do
499505
"""
500506
end
501507

508+
test "updating to open maps" do
509+
assert typecheck!(
510+
[key],
511+
(
512+
x = %{foo: :bar}
513+
%{x | key => :baz}
514+
)
515+
) == dynamic(open_map())
516+
517+
# Since key cannot override :foo, we preserve it
518+
assert typecheck!(
519+
[key],
520+
(
521+
x = %{foo: :bar}
522+
%{x | key => :baz, foo: :bat}
523+
)
524+
) == dynamic(open_map(foo: atom([:bat])))
525+
526+
# Since key can override :foo, we do not preserve it
527+
assert typecheck!(
528+
[key],
529+
(
530+
x = %{foo: :bar}
531+
%{x | :foo => :baz, key => :bat}
532+
)
533+
) == dynamic(open_map())
534+
535+
# The goal of this test is to verufy we assert keys,
536+
# even if they may be overridden later.
537+
assert typeerror!(
538+
[key],
539+
(
540+
x = %{key: :value}
541+
%{x | :foo => :baz, key => :bat}
542+
)
543+
) == ~l"""
544+
expected a map with key :foo in map update syntax:
545+
546+
%{x | :foo => :baz, key => :bat}
547+
548+
but got type:
549+
550+
%{key: :value}
551+
552+
where "key" was given the type:
553+
554+
# type: dynamic()
555+
# from: types_test.ex:538
556+
key
557+
558+
where "x" was given the type:
559+
560+
# type: %{key: :value}
561+
# from: types_test.ex:540
562+
x = %{key: :value}
563+
"""
564+
end
565+
502566
test "updating structs" do
503567
assert typecheck!([x], %Point{x | x: :zero}) ==
504568
dynamic(open_map(__struct__: atom([Point]), x: atom([:zero])))

0 commit comments

Comments
 (0)