Skip to content

Commit d3cef1f

Browse files
authored
Type checking of protocols in for-comprehensions (#14124)
1 parent 4787116 commit d3cef1f

File tree

9 files changed

+214
-56
lines changed

9 files changed

+214
-56
lines changed

lib/elixir/lib/calendar/date.ex

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,8 @@ defmodule Date do
188188
end
189189

190190
def utc_today(calendar) do
191-
calendar
192-
|> DateTime.utc_now()
193-
|> DateTime.to_date()
191+
%{year: year, month: month, day: day} = DateTime.utc_now(calendar)
192+
%Date{year: year, month: month, day: day, calendar: calendar}
194193
end
195194

196195
@doc """

lib/elixir/lib/module/types/apply.ex

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -811,14 +811,12 @@ defmodule Module.Types.Apply do
811811
end
812812

813813
def format_diagnostic({:badremote, mfac, expr, args_types, domain, clauses, context}) do
814-
traces = collect_traces(expr, context)
815814
{mod, fun, arity, converter} = mfac
816815
meta = elem(expr, 1)
817816

818-
# Protocol errors can be very verbose, so we collapse structs
819-
{banner, hints, opts} =
820-
cond do
821-
meta[:from_interpolation] ->
817+
{banner, hints, traces} =
818+
case Keyword.get(meta, :type_check) do
819+
:interpolation ->
822820
{_, _, [arg]} = expr
823821

824822
{"""
@@ -827,34 +825,59 @@ defmodule Module.Types.Apply do
827825
#{expr_to_string(arg) |> indent(4)}
828826
829827
it has type:
830-
""", [:interpolation], [collapse_structs: true]}
828+
""", [:interpolation], collect_traces(expr, context)}
831829

832-
Code.ensure_loaded?(mod) and
833-
Keyword.has_key?(mod.module_info(:attributes), :__protocol__) ->
834-
{nil, [{:protocol, mod}], [collapse_structs: true]}
830+
:generator ->
831+
{:<-, _, [_, arg]} = expr
835832

836-
true ->
837-
{nil, [], []}
838-
end
833+
{"""
834+
incompatible value given to for-comprehension:
839835
840-
explanation =
841-
empty_arg_reason(converter.(args_types)) ||
842-
"""
843-
but expected one of:
844-
#{clauses_args_to_quoted_string(clauses, converter, opts)}
845-
"""
836+
#{expr_to_string(expr) |> indent(4)}
846837
847-
mfa_or_fa = if mod, do: Exception.format_mfa(mod, fun, arity), else: "#{fun}/#{arity}"
838+
it has type:
839+
""", [:generator], collect_traces(arg, context)}
848840

849-
banner =
850-
banner ||
851-
"""
852-
incompatible types given to #{mfa_or_fa}:
841+
:into ->
842+
{"""
843+
incompatible value given to :into option in for-comprehension:
853844
854-
#{expr_to_string(expr) |> indent(4)}
845+
into: #{expr_to_string(expr) |> indent(4)}
855846
856-
given types:
857-
"""
847+
it has type:
848+
""", [:into], collect_traces(expr, context)}
849+
850+
_ ->
851+
mfa_or_fa = if mod, do: Exception.format_mfa(mod, fun, arity), else: "#{fun}/#{arity}"
852+
853+
{"""
854+
incompatible types given to #{mfa_or_fa}:
855+
856+
#{expr_to_string(expr) |> indent(4)}
857+
858+
given types:
859+
""", [], collect_traces(expr, context)}
860+
end
861+
862+
explanation =
863+
cond do
864+
reason = empty_arg_reason(converter.(args_types)) ->
865+
reason
866+
867+
Code.ensure_loaded?(mod) and
868+
Keyword.has_key?(mod.module_info(:attributes), :__protocol__) ->
869+
# Protocol errors can be very verbose, so we collapse structs
870+
"""
871+
but expected a type that implements the #{inspect(mod)} protocol, it must be one of:
872+
#{clauses_args_to_quoted_string(clauses, converter, collapse_structs: true)}
873+
"""
874+
875+
true ->
876+
"""
877+
but expected one of:
878+
#{clauses_args_to_quoted_string(clauses, converter, [])}
879+
"""
880+
end
858881

859882
%{
860883
details: %{typing_traces: traces},

lib/elixir/lib/module/types/descr.ex

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ defmodule Module.Types.Descr do
3131
@map_empty [{:closed, %{}, []}]
3232

3333
@none %{}
34-
@empty_list %{bitmap: @bit_empty_list}
35-
@not_non_empty_list %{bitmap: @bit_top, atom: @atom_top, tuple: @tuple_top, map: @map_top}
3634
@term %{
3735
bitmap: @bit_top,
3836
atom: @atom_top,
3937
tuple: @tuple_top,
4038
map: @map_top,
4139
list: @non_empty_list_top
4240
}
41+
@empty_list %{bitmap: @bit_empty_list}
42+
@not_non_empty_list Map.delete(@term, :list)
4343

4444
@empty_intersection [0, @none]
4545
@empty_difference [0, []]
@@ -98,6 +98,7 @@ defmodule Module.Types.Descr do
9898
@not_set %{optional: 1}
9999
@term_or_optional Map.put(@term, :optional, 1)
100100
@term_or_dynamic_optional Map.put(@term, :dynamic, %{optional: 1})
101+
@not_atom_or_optional Map.delete(@term_or_optional, :atom)
101102

102103
def not_set(), do: @not_set
103104
def if_set(:term), do: term_or_optional()
@@ -1751,10 +1752,32 @@ defmodule Module.Types.Descr do
17511752
end
17521753

17531754
# Two maps are fusible if they differ in at most one element.
1755+
defp non_fusible_maps?({_, fields1, []}, {_, fields2, []})
1756+
when map_size(fields1) > map_size(fields2) do
1757+
not fusible_maps?(Map.to_list(fields2), fields1, 0)
1758+
end
1759+
17541760
defp non_fusible_maps?({_, fields1, []}, {_, fields2, []}) do
1755-
Enum.count_until(fields1, fn {key, value} -> Map.fetch!(fields2, key) != value end, 2) > 1
1761+
not fusible_maps?(Map.to_list(fields1), fields2, 0)
1762+
end
1763+
1764+
defp fusible_maps?([{:__struct__, value} | rest], fields, count) do
1765+
case Map.fetch!(fields, :__struct__) do
1766+
^value -> fusible_maps?(rest, fields, count)
1767+
_ -> false
1768+
end
17561769
end
17571770

1771+
defp fusible_maps?([{key, value} | rest], fields, count) do
1772+
case Map.fetch!(fields, key) do
1773+
^value -> fusible_maps?(rest, fields, count)
1774+
_ when count == 1 -> false
1775+
_ when count == 0 -> fusible_maps?(rest, fields, count + 1)
1776+
end
1777+
end
1778+
1779+
defp fusible_maps?([], _fields, _count), do: true
1780+
17581781
defp map_non_negated_fuse_pair({tag, fields1, []}, {_, fields2, []}) do
17591782
fields =
17601783
symmetrical_merge(fields1, fields2, fn _k, v1, v2 ->
@@ -1818,6 +1841,11 @@ defmodule Module.Types.Descr do
18181841
{:empty_map, [], []}
18191842
end
18201843

1844+
def map_literal_to_quoted({:open, %{__struct__: @not_atom_or_optional} = fields}, _opts)
1845+
when map_size(fields) == 1 do
1846+
{:non_struct_map, [], []}
1847+
end
1848+
18211849
def map_literal_to_quoted({tag, fields}, opts) do
18221850
case tag do
18231851
:closed ->

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

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -338,10 +338,10 @@ defmodule Module.Types.Expr do
338338
end
339339

340340
# TODO: for pat <- expr do expr end
341-
def of_expr({:for, _meta, [_ | _] = args}, stack, context) do
341+
def of_expr({:for, meta, [_ | _] = args}, stack, context) do
342342
{clauses, [[{:do, block} | opts]]} = Enum.split(args, -1)
343343
context = Enum.reduce(clauses, context, &for_clause(&1, stack, &2))
344-
context = Enum.reduce(opts, context, &for_option(&1, stack, &2))
344+
context = Enum.reduce(opts, context, &for_option(&1, meta, stack, &2))
345345

346346
if Keyword.has_key?(opts, :reduce) do
347347
{_, context} = of_clauses(block, [dynamic()], :for_reduce, stack, {none(), context})
@@ -471,13 +471,17 @@ defmodule Module.Types.Expr do
471471

472472
## Comprehensions
473473

474-
defp for_clause({:<-, _, [left, right]} = expr, stack, context) do
474+
defp for_clause({:<-, meta, [left, right]}, stack, context) do
475+
expr = {:<-, [type_check: :generator] ++ meta, [left, right]}
475476
{pattern, guards} = extract_head([left])
476-
{_, context} = of_expr(right, stack, context)
477+
{type, context} = of_expr(right, stack, context)
477478

478479
{_type, context} =
479480
Pattern.of_match(pattern, guards, dynamic(), expr, :for, stack, context)
480481

482+
{_type, context} =
483+
Apply.remote(Enumerable, :count, [right], [type], expr, stack, context)
484+
481485
context
482486
end
483487

@@ -500,17 +504,34 @@ defmodule Module.Types.Expr do
500504
context
501505
end
502506

503-
defp for_option({:into, expr}, stack, context) do
504-
{_type, context} = of_expr(expr, stack, context)
507+
defp for_option({:into, expr}, _meta, _stack, context) when is_list(expr) or is_binary(expr) do
508+
context
509+
end
510+
511+
defp for_option({:into, expr}, meta, stack, context) do
512+
{type, context} = of_expr(expr, stack, context)
513+
514+
meta =
515+
case expr do
516+
{_, meta, _} -> meta
517+
_ -> meta
518+
end
519+
520+
wrapped_expr = {:__block__, [type_check: :into] ++ meta, [expr]}
521+
522+
{_type, context} =
523+
Apply.remote(Collectable, :into, [expr], [type], wrapped_expr, stack, context)
524+
505525
context
506526
end
507527

508-
defp for_option({:reduce, expr}, stack, context) do
528+
defp for_option({:reduce, expr}, _meta, stack, context) do
509529
{_type, context} = of_expr(expr, stack, context)
510530
context
511531
end
512532

513-
defp for_option({:uniq, _}, _stack, context) do
533+
defp for_option({:uniq, _}, _meta, _stack, context) do
534+
# This option is verified to be a boolean at compile-time
514535
context
515536
end
516537

lib/elixir/lib/module/types/helpers.ex

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,24 @@ defmodule Module.Types.Helpers do
8585
:interpolation ->
8686
"""
8787
88-
#{hint()} string interpolation in Elixir uses the String.Chars protocol to \
88+
#{hint()} string interpolation uses the String.Chars protocol to \
8989
convert a data structure into a string. Either convert the data type into a \
9090
string upfront or implement the protocol accordingly
9191
"""
9292

93-
{:protocol, protocol} ->
93+
:generator ->
9494
"""
9595
96-
#{hint()} #{inspect(protocol)} is a protocol in Elixir. Either make sure you \
97-
give valid data types as arguments or implement the protocol accordingly
96+
#{hint()} for-comprehensions use the Enumerable protocol to traverse \
97+
data structures. Either convert the data type into a list (or another Enumerable) \
98+
or implement the protocol accordingly
99+
"""
100+
101+
:into ->
102+
"""
103+
104+
#{hint()} the :into option in for-comprehensions use the Collectable protocol to \
105+
build its result. Either pass a valid data type or implement the protocol accordingly
98106
"""
99107

100108
:anonymous_rescue ->

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ defmodule Module.Types.Of do
9696
{Function, fun()},
9797
{Integer, integer()},
9898
{List, list(term())},
99-
{Map, open_map(__struct__: not_set())},
99+
{Map, open_map(__struct__: if_set(negation(atom())))},
100100
{Port, port()},
101101
{PID, pid()},
102102
{Reference, reference()},
@@ -339,7 +339,7 @@ defmodule Module.Types.Of do
339339
{{:., _, [String.Chars, :to_string]} = dot, meta, [arg]},
340340
{:binary, _, nil}
341341
) do
342-
{dot, [from_interpolation: true] ++ meta, [arg]}
342+
{dot, [type_check: :interpolation] ++ meta, [arg]}
343343
end
344344

345345
defp annotate_interpolation(left, _right) do

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,6 +1494,15 @@ defmodule Module.Types.DescrTest do
14941494
assert closed_map(__struct__: atom([Decimal]), coef: term(), exp: term(), sign: integer())
14951495
|> to_quoted_string(collapse_structs: true) ==
14961496
"%Decimal{sign: integer()}"
1497+
1498+
# Does not fuse structs
1499+
assert union(closed_map(__struct__: atom([Foo])), closed_map(__struct__: atom([Bar])))
1500+
|> to_quoted_string() ==
1501+
"%{__struct__: Bar} or %{__struct__: Foo}"
1502+
1503+
# Properly format non_struct_map
1504+
assert open_map(__struct__: if_set(negation(atom()))) |> to_quoted_string() ==
1505+
"non_struct_map()"
14971506
end
14981507
end
14991508

0 commit comments

Comments
 (0)