Skip to content
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

Implement type inferences from guard expressions #239

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
99 changes: 98 additions & 1 deletion lib/elixir_sense/core/metadata_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ defmodule ElixirSense.Core.MetadataBuilder do
|> find_vars(params)
|> merge_same_name_vars()

vars =
if options[:guards],
do: infer_type_from_guards(options[:guards], vars, state),
else: vars

{position, end_position} = extract_range(meta)

options = Keyword.put(options, :generated, state.generated)
Expand Down Expand Up @@ -555,7 +560,7 @@ defmodule ElixirSense.Core.MetadataBuilder do
)
when def_name in @defs do
ast_without_params = {def_name, meta, [{name, add_no_call(meta2), []}, guards, body]}
pre_func(ast_without_params, state, meta, name, params)
pre_func(ast_without_params, state, meta, name, params, guards: guards)
end

defp pre(
Expand Down Expand Up @@ -1663,6 +1668,98 @@ defmodule ElixirSense.Core.MetadataBuilder do
{ast, {vars, match_context}}
end

def infer_type_from_guards(guard_ast, vars, state) do
type_info = type_information_from_guards(guard_ast, state)

Enum.reduce(type_info, vars, fn {var, type}, acc ->
index = Enum.find_index(acc, &(&1.name == var))

if index,
do: List.update_at(acc, index, &Map.put(&1, :type, type)),
else: acc
end)
end

# A guard expression can be in either these form:
# :and :or
# / \ or / \ or guard_expr
# guard_expr guard_expr guard_expr guard_expr
#
# type information from :and subtrees are mergeable
# type information from :or subtrees are discarded
defp type_information_from_guards({:and, _, [guard_l, guard_r]}, state) do
Goose97 marked this conversation as resolved.
Show resolved Hide resolved
left = type_information_from_guards(guard_l, state)
right = type_information_from_guards(guard_r, state)

Keyword.merge(left, right, fn _k, v1, v2 ->
case {v1, v2} do
# func my_func(x) when is_map_key(x, :a) and is_map_key(x, :b)
{{:map, fields1, _}, {:map, fields2, _}} ->
{:map, Enum.uniq_by(fields1 ++ fields2, &elem(&1, 0)), nil}

# In case we can't merge, just pick one
_ ->
v1
end
end)
end

defp type_information_from_guards({:or, _, [_guard_l, _guard_r]}, _state), do: []
Goose97 marked this conversation as resolved.
Show resolved Hide resolved

defp type_information_from_guards(guard_ast, state) do
{_, acc} =
Macro.prewalk(guard_ast, [], fn
# Standalone variable: func my_func(x) when x
{var, _, nil} = node, acc ->
{node, [{var, :boolean} | acc]}

{guard_predicate, _, params} = node, acc ->
if type = guard_predicate_type(guard_predicate, params, state) do
[{var, _, nil} | _] = params
# If we found the predicate type, we can prematurely exit traversing the subtree
{[], [{var, type} | acc]}
else
{node, acc}
end

node, acc ->
{node, acc}
end)

acc
end

defp guard_predicate_type(p, _, _)
when p in [:is_number, :is_float, :is_integer, :round, :trunc, :div, :rem, :abs],
do: :number

defp guard_predicate_type(p, _, _) when p in [:is_binary, :binary_part], do: :binary

defp guard_predicate_type(p, _, _) when p in [:is_bitstring, :bit_size, :byte_size],
do: :bitstring

defp guard_predicate_type(p, _, _) when p in [:is_list, :hd, :tl, :length], do: :list
defp guard_predicate_type(p, _, _) when p in [:is_tuple, :tuple_size, :elem], do: :tuple
defp guard_predicate_type(:is_map, _, _), do: {:map, [], nil}

defp guard_predicate_type(:is_map_key, [_, key], state) do
case get_binding_type(state, key) do
{:atom, key} -> {:map, [{key, nil}], nil}
nil when is_binary(key) -> {:map, [{key, nil}], nil}
_ -> {:map, [], nil}
end
end

defp guard_predicate_type(:is_atom, _, _), do: :atom
defp guard_predicate_type(:is_boolean, _, _), do: :boolean

defp guard_predicate_type(:is_struct, [_, {:__aliases__, _, list}], state) do
{:struct, [], {:atom, expand_alias(state, list)}, nil}
end

defp guard_predicate_type(:is_struct, _, _), do: :struct
lukaszsamson marked this conversation as resolved.
Show resolved Hide resolved
defp guard_predicate_type(_, _, _), do: nil

# struct or struct update
def get_binding_type(
state,
Expand Down
100 changes: 100 additions & 0 deletions test/elixir_sense/core/metadata_builder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1477,6 +1477,106 @@ defmodule ElixirSense.Core.MetadataBuilderTest do
] = state |> get_line_vars(4)
end

describe "infer vars type information from guards" do
defp var_with_guards(guard) do
"""
defmodule MyModule do
def func(x) when #{guard} do
x
end
end
"""
|> string_to_state()
|> get_line_vars(3)
|> hd()
end

test "number guards" do
assert %VarInfo{name: :x, type: :number} = var_with_guards("is_number(x)")
assert %VarInfo{name: :x, type: :number} = var_with_guards("is_float(x)")
assert %VarInfo{name: :x, type: :number} = var_with_guards("is_integer(x)")
assert %VarInfo{name: :x, type: :number} = var_with_guards("round(x)")
assert %VarInfo{name: :x, type: :number} = var_with_guards("trunc(x)")
assert %VarInfo{name: :x, type: :number} = var_with_guards("div(x)")
assert %VarInfo{name: :x, type: :number} = var_with_guards("rem(x)")
assert %VarInfo{name: :x, type: :number} = var_with_guards("abs(x)")
end

test "binary guards" do
assert %VarInfo{name: :x, type: :binary} = var_with_guards("is_binary(x)")
assert %VarInfo{name: :x, type: :binary} = var_with_guards(~s/binary_part(x, 0, 1) == "a"/)
end

test "bitstring guards" do
assert %VarInfo{name: :x, type: :bitstring} = var_with_guards("is_bitstring(x)")
assert %VarInfo{name: :x, type: :bitstring} = var_with_guards("bit_size(x) == 1")
assert %VarInfo{name: :x, type: :bitstring} = var_with_guards("byte_size(x) == 1")
end

test "list guards" do
assert %VarInfo{name: :x, type: :list} = var_with_guards("is_list(x)")
assert %VarInfo{name: :x, type: :list} = var_with_guards("hd(x) == 1")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it be easy to get {:list, :integer} here? We'd need to parse {:==, _, _} expression

Copy link
Contributor Author

@Goose97 Goose97 Aug 30, 2023

Choose a reason for hiding this comment

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

I'm not sure if we should do this. List can have different type values, so situations like this can provide misleading type information:

list = [1, :atom, "binary"]
hd(list) == 1
# infer type of list as {:list, :integer} -> incorrect

Copy link
Collaborator

Choose a reason for hiding this comment

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

I made the assumption elsewhere just to simplify things. The type system here is used mostly for completions and I never aimed for perfectly expressing every type possible

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for explaining this. Fixed in fe7b2be

assert %VarInfo{name: :x, type: :list} = var_with_guards("tl(x) == 1")
Goose97 marked this conversation as resolved.
Show resolved Hide resolved
assert %VarInfo{name: :x, type: :list} = var_with_guards("length(x) == 1")
end

test "tuple guards" do
assert %VarInfo{name: :x, type: :tuple} = var_with_guards("is_tuple(x)")
assert %VarInfo{name: :x, type: :tuple} = var_with_guards("tuple_size(x) == 1")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Similarly with list example above it would be nice to get {:tuple, 1, [nil]} here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in fe7b2be

For unknown type tuple (i.e. when tuple_size(x) == 2), the type will be:

{:tuple, <size>, []}

assert %VarInfo{name: :x, type: :tuple} = var_with_guards("elem(x, 0) == 1")
end

test "atom guards" do
assert %VarInfo{name: :x, type: :atom} = var_with_guards("is_atom(x)")
end

test "boolean guards" do
assert %VarInfo{name: :x, type: :boolean} = var_with_guards("is_boolean(x)")
end

test "map guards" do
assert %VarInfo{name: :x, type: {:map, [], nil}} = var_with_guards("is_map(x)")
lukaszsamson marked this conversation as resolved.
Show resolved Hide resolved

assert %VarInfo{name: :x, type: {:map, [a: nil], nil}} =
var_with_guards("is_map_key(x, :a)")

assert %VarInfo{name: :x, type: {:map, [{"a", nil}], nil}} =
var_with_guards(~s/is_map_key(x, "a")/)
end

test "struct guards" do
assert %VarInfo{name: :x, type: :struct} = var_with_guards("is_struct(x)")
lukaszsamson marked this conversation as resolved.
Show resolved Hide resolved
Goose97 marked this conversation as resolved.
Show resolved Hide resolved

assert %VarInfo{name: :x, type: {:struct, [], {:atom, URI}, nil}} =
var_with_guards("is_struct(x, URI)")

assert %VarInfo{name: :x, type: {:struct, [], {:atom, URI}, nil}} =
"""
defmodule MyModule do
alias URI, as: MyURI

def func(x) when is_struct(x, MyURI) do
x
end
end
"""
|> string_to_state()
|> get_line_vars(5)
|> hd()
end

test "and combination predicate guards can be merge" do
assert %VarInfo{name: :x, type: :number} = var_with_guards("is_number(x) and x >= 1")

assert %VarInfo{name: :x, type: {:map, [a: nil, b: nil], nil}} =
var_with_guards("is_map_key(x, :a) and is_map_key(x, :b)")
end

test "or combination predicate guards can not be used" do
assert %VarInfo{name: :x, type: nil} = var_with_guards("is_number(x) or is_atom(x)")
end
end

test "aliases" do
state =
"""
Expand Down