Skip to content

Type inference for structs and type checking for dot/remote #13518

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 30, 2024
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
5 changes: 3 additions & 2 deletions lib/elixir/lib/kernel/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,11 @@ defmodule Kernel.Utils do

case enforce_keys -- :maps.keys(struct) do
[] ->
# The __struct__ field is used for expansion and for loading remote structs
# The __struct__ attribute is public and it is used for expansion
# and for loading remote structs.
:ets.insert(set, {:__struct__, struct, nil, []})

# Store all field metadata to go into __info__(:struct)
# The complete metadata goes into __info__(:struct)
mapper = fn {key, val} ->
%{field: key, default: val, required: :lists.member(key, enforce_keys)}
end
Expand Down
147 changes: 123 additions & 24 deletions lib/elixir/lib/module/types/descr.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ defmodule Module.Types.Descr do
def integer(), do: %{bitmap: @bit_integer}
def float(), do: %{bitmap: @bit_float}
def fun(), do: %{bitmap: @bit_fun}
def open_map(pairs), do: %{map: [{:open, Map.new(pairs), []}]}
def closed_map(pairs), do: %{map: [{:closed, Map.new(pairs), []}]}
def open_map(pairs), do: %{map: map_new(:open, Map.new(pairs))}
def closed_map(pairs), do: %{map: map_new(:closed, Map.new(pairs))}
def open_map(), do: %{map: @map_top}
def empty_map(), do: %{map: @map_empty}
def non_empty_list(), do: %{bitmap: @bit_non_empty_list}
Expand Down Expand Up @@ -416,6 +416,54 @@ defmodule Module.Types.Descr do
# an empty list of atoms. It is simplified to `0` in set operations, and the key
# is removed from the map.

@doc """
Returns a set of all known atoms.

Returns `{:ok, known_set}` if it is an atom, `:error` otherwise.
Notice `known_set` may be empty and still return positive, due to
negations.
"""
def atom_fetch(%{} = descr) do
case :maps.take(:dynamic, descr) do
:error ->
if atom_only?(descr) do
case descr do
%{atom: {:union, set}} -> {:ok, :sets.to_list(set)}
%{atom: {:negation, _}} -> {:ok, []}
%{} -> :error
end
else
:error
end

{dynamic, static} ->
if atom_only?(static) do
case {dynamic, static} do
{%{atom: {:union, d}}, %{atom: {:union, s}}} ->
{:ok, :sets.to_list(:sets.union(d, s))}

{_, %{atom: {:negation, _}}} ->
{:ok, []}

{%{atom: {:negation, _}}, _} ->
{:ok, []}

{%{atom: {:union, d}}, %{}} ->
{:ok, :sets.to_list(d)}

{%{}, %{atom: {:union, s}}} ->
{:ok, :sets.to_list(s)}

{%{}, %{}} ->
:error
end
else
:error
end
end
end

defp atom_only?(descr), do: empty?(Map.delete(descr, :atom))
defp atom_new(as) when is_list(as), do: {:union, :sets.from_list(as, version: 2)}

defp atom_intersection({tag1, s1}, {tag2, s2}) do
Expand Down Expand Up @@ -447,18 +495,35 @@ defmodule Module.Types.Descr do
if tag == :union and :sets.size(s) == 0, do: 0, else: {tag, s}
end

defp literal(lit), do: {:__block__, [], [lit]}
defp literal_to_quoted(lit) do
if is_atom(lit) and Macro.classify_atom(lit) == :alias do
segments =
case Atom.to_string(lit) do
"Elixir" ->
[:"Elixir"]

"Elixir." <> segments ->
segments
|> String.split(".")
|> Enum.map(&String.to_atom/1)
end

{:__aliases__, [], segments}
else
{:__block__, [], [lit]}
end
end

defp atom_to_quoted({:union, a}) do
if :sets.is_subset(@boolset, a) do
:sets.subtract(a, @boolset)
|> :sets.to_list()
|> Enum.sort()
|> Enum.reduce({:boolean, [], []}, &{:or, [], [&2, literal(&1)]})
|> Enum.reduce({:boolean, [], []}, &{:or, [], [&2, literal_to_quoted(&1)]})
else
:sets.to_list(a)
|> Enum.sort()
|> Enum.map(&literal/1)
|> Enum.map(&literal_to_quoted/1)
|> Enum.reduce(&{:or, [], [&2, &1]})
end
|> List.wrap()
Expand Down Expand Up @@ -584,31 +649,43 @@ defmodule Module.Types.Descr do
defp map_tag_to_type(:open), do: term_or_not_set()
defp map_tag_to_type(:closed), do: not_set()

defp map_descr(tag, fields), do: %{map: [{tag, fields, []}]}
defp map_new(tag, fields), do: [{tag, fields, []}]
defp map_descr(tag, fields), do: %{map: map_new(tag, fields)}

@doc """
Gets the type of the value returned by accessing `key` on `map`.
Gets the type of the value returned by accessing `key` on `map`
with the assumption that the descr is exclusively a map.

It returns a two element tuple. The first element says if the type
is optional or not, the second element is the type. In static mode,
we likely want to raise if `map.field` (or pattern matching?) is
called on an optional key.
It returns a two element tuple or `:error`. The first element says
if the type is optional or not, the second element is the type.
In static mode, we likely want to raise if `map.field`
(or pattern matching?) is called on an optional key.
"""
def map_get(%{} = descr, key) do
def map_fetch(%{} = descr, key) do
case :maps.take(:dynamic, descr) do
:error ->
map_get_static(descr, key)
if is_map_key(descr, :map) and map_only?(descr) do
map_fetch_static(descr, key)
else
:error
end

{dynamic, static} ->
{dynamic_optional?, dynamic_type} = map_get_static(dynamic, key)
{static_optional?, static_type} = map_get_static(static, key)
if (is_map_key(dynamic, :map) or is_map_key(static, :map)) and map_only?(static) do
{dynamic_optional?, dynamic_type} = map_fetch_static(dynamic, key)
{static_optional?, static_type} = map_fetch_static(static, key)

{dynamic_optional? or static_optional?,
union(intersection(dynamic(), dynamic_type), static_type)}
{dynamic_optional? or static_optional?,
union(intersection(dynamic(), dynamic_type), static_type)}
else
:error
end
end
end

defp map_get_static(descr, key) when is_atom(key) do
defp map_only?(descr), do: empty?(Map.delete(descr, :map))

defp map_fetch_static(descr, key) when is_atom(key) do
case descr do
%{map: map} ->
map_split_on_key(map, key)
Expand Down Expand Up @@ -832,18 +909,40 @@ defmodule Module.Types.Descr do

def map_literal_to_quoted({tag, fields}) do
case tag do
:closed -> {:%{}, [], map_fields_to_quoted(tag, fields)}
:open -> {:%{}, [], [{:..., [], nil} | map_fields_to_quoted(tag, fields)]}
:closed ->
with %{__struct__: struct_descr} <- fields,
{:ok, [struct]} <- atom_fetch(struct_descr) do
{:%, [],
[
literal_to_quoted(struct),
{:%{}, [], map_fields_to_quoted(tag, Map.delete(fields, :__struct__))}
]}
else
_ -> {:%{}, [], map_fields_to_quoted(tag, fields)}
end

:open ->
{:%{}, [], [{:..., [], nil} | map_fields_to_quoted(tag, fields)]}
end
end

defp map_fields_to_quoted(tag, map) do
for {key, type} <- Enum.sort(map),
sorted = Enum.sort(map)
keyword? = Inspect.List.keyword?(sorted)

for {key, type} <- sorted,
not (tag == :open and optional?(type) and term?(type)) do
key =
if keyword? do
{:__block__, [format: :keyword], [key]}
else
literal_to_quoted(key)
end

cond do
not optional?(type) -> {literal(key), to_quoted(type)}
empty?(type) -> {literal(key), {:not_set, [], []}}
true -> {literal(key), {:if_set, [], [to_quoted(type)]}}
not optional?(type) -> {key, to_quoted(type)}
empty?(type) -> {key, {:not_set, [], []}}
true -> {key, {:if_set, [], [to_quoted(type)]}}
end
end
end
Expand Down
56 changes: 26 additions & 30 deletions lib/elixir/lib/module/types/expr.ex
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,16 @@ defmodule Module.Types.Expr do
end
end

# TODO: %Struct{map | ...}
def of_expr(
{:%, meta, [module, {:%{}, _, [{:|, _, [_, _]}]} = update]},
{:%, _, [module, {:%{}, _, [{:|, _, [map, args]}]}]} = expr,
stack,
context
) do
with {:ok, _, context} <- Of.struct(module, meta, stack, context),
{:ok, _, context} <- of_expr(update, stack, context) do
{:ok, open_map(), context}
with {:ok, type, context} <- Of.struct(expr, module, args, true, stack, context, &of_expr/3),
{:ok, _, context} <- of_expr(map, stack, context) do
# TODO: We need to validate that map is actually compatible with struct.
# Perhaps a simple validation over the struct name?
{:ok, type, context}
end
end

Expand All @@ -130,12 +131,8 @@ defmodule Module.Types.Expr do
Of.closed_map(args, stack, context, &of_expr/3)
end

# TODO: %Struct{...}
def of_expr({:%, meta1, [module, {:%{}, _meta2, args}]}, stack, context) do
with {:ok, _, context} <- Of.struct(module, meta1, stack, context),
{:ok, _, context} <- Of.open_map(args, stack, context, &of_expr/3) do
{:ok, open_map(), context}
end
def of_expr({:%, _, [module, {:%{}, _, args}]} = expr, stack, context) do
Of.struct(expr, module, args, false, stack, context, &of_expr/3)
end

# ()
Expand Down Expand Up @@ -278,40 +275,39 @@ defmodule Module.Types.Expr do
end
end

# TODO: expr.key_or_fun
def of_expr({{:., _meta1, [expr1, _key_or_fun]}, meta2, []}, stack, context)
when not is_atom(expr1) do
if Keyword.get(meta2, :no_parens, false) do
with {:ok, _, context} <- of_expr(expr1, stack, context) do
{:ok, dynamic(), context}
end
else
with {:ok, _, context} <- of_expr(expr1, stack, context) do
def of_expr({{:., _, [callee, key_or_fun]}, meta, []} = expr, stack, context)
when not is_atom(callee) and is_atom(key_or_fun) do
with {:ok, type, context} <- of_expr(callee, stack, context) do
if Keyword.get(meta, :no_parens, false) do
Of.map_fetch(expr, type, key_or_fun, stack, context)
else
{_mods, context} = Of.remote(expr, type, key_or_fun, 0, [:dot], meta, stack, context)
# TODO: Return the proper type
{:ok, dynamic(), context}
end
end
end

# TODO: expr.fun(arg)
def of_expr({{:., _meta1, [expr1, fun]}, meta2, args}, stack, context) do
context = Of.remote(expr1, fun, length(args), meta2, stack, context)

with {:ok, _expr_type, context} <- of_expr(expr1, stack, context),
{:ok, _arg_types, context} <-
map_reduce_ok(args, context, &of_expr(&1, stack, &2)) do
def of_expr({{:., _, [remote, fun]}, meta, args} = expr, stack, context) do
with {:ok, remote_type, context} <- of_expr(remote, stack, context),
{:ok, _arg_types, context} <- map_reduce_ok(args, context, &of_expr(&1, stack, &2)) do
{_mods, context} = Of.remote(expr, remote_type, fun, length(args), [], meta, stack, context)
{:ok, dynamic(), context}
end
end

# TODO: &Foo.bar/1
def of_expr(
{:&, _, [{:/, _, [{{:., _, [module, fun]}, meta, []}, arity]}]},
{:&, _, [{:/, _, [{{:., _, [remote, fun]}, meta, []}, arity]}]} = expr,
stack,
context
)
when is_atom(module) and is_atom(fun) do
context = Of.remote(module, fun, arity, meta, stack, context)
{:ok, dynamic(), context}
when is_atom(fun) and is_integer(arity) do
with {:ok, remote_type, context} <- of_expr(remote, stack, context) do
{_mods, context} = Of.remote(expr, remote_type, fun, arity, [], meta, stack, context)
{:ok, dynamic(), context}
end
end

# &foo/1
Expand Down
7 changes: 7 additions & 0 deletions lib/elixir/lib/module/types/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ defmodule Module.Types.Helpers do
is an integer. Pass a modifier, such as <<expr::float>> or <<expr::binary>>, \
to change the default behavior.
"""

:dot ->
"""

#{hint()} "var.field" (without parentheses) means "var" is a map() while \
"var.fun()" (with parentheses) means "var" is an atom()
"""
end)
end

Expand Down
Loading
Loading