Add completion for struct keys (#793)

* Add completion for struct keys

Largely ported from `IEx.Autocomplete`.

* Add test for __exception__ field in struct

* Fix exception test

* Fix exceptions assetion

* Create `:in_struct_field` identifier

Along with a refactor

* Fix typespecs for `:map_field`

* Address feedback

* Update lib/livebook/intellisense/identifier_matcher.ex

Co-authored-by: José Valim <jose.valim@gmail.com>

* Use markdown snippet for both docs

* Fix tests

Co-authored-by: José Valim <jose.valim@gmail.com>
This commit is contained in:
Max Veytsman 2021-12-12 15:19:17 -05:00 committed by GitHub
parent 9598fa6b34
commit 4aa5447e9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 164 additions and 13 deletions

View file

@ -138,11 +138,30 @@ defmodule Livebook.Intellisense do
defp format_completion_item({:map_field, name, _value}),
do: %{
label: name,
label: "#{name}",
kind: :field,
detail: "field",
documentation: nil,
insert_text: name
insert_text: "#{name}"
}
defp format_completion_item({:in_struct_field, struct, name, default}),
do: %{
label: "#{name}",
kind: :field,
detail: "#{inspect(struct)} struct field",
documentation:
join_with_divider([
code(name),
"""
**Default**
```
#{inspect(default, pretty: true, width: @line_length)}
```
"""
]),
insert_text: "#{name}: "
}
defp format_completion_item({:module, module, display_name, documentation}) do
@ -340,6 +359,19 @@ defmodule Livebook.Intellisense do
defp format_details_item({:map_field, name, _value}), do: code(name)
defp format_details_item({:in_struct_field, _struct, name, default}) do
join_with_divider([
code(name),
"""
**Default**
```
#{inspect(default, pretty: true, width: @line_length)}
```
"""
])
end
defp format_details_item({:module, _module, display_name, documentation}) do
join_with_divider([
code(display_name),

View file

@ -21,6 +21,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
@type identifier_item ::
{:variable, name(), value()}
| {:map_field, name(), value()}
| {:in_struct_field, module(), name(), default :: value()}
| {:module, module(), display_name(), Docs.documentation()}
| {:function, module(), name(), arity(), function_type(), display_name(),
Docs.documentation(), list(Docs.signature()), list(Docs.spec())}
@ -48,8 +49,16 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
list(identifier_item())
def completion_identifiers(hint, binding, env) do
context = Code.Fragment.cursor_context(hint)
ctx = %{binding: binding, env: env, matcher: @prefix_matcher}
context_to_matches(context, ctx, :completion)
ctx = %{
fragment: hint,
binding: binding,
env: env,
matcher: @prefix_matcher,
type: :completion
}
context_to_matches(context, ctx)
end
@doc """
@ -67,8 +76,17 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
def locate_identifier(line, column, binding, env) do
case Code.Fragment.surround_context(line, {1, column}) do
%{context: context, begin: {_, from}, end: {_, to}} ->
ctx = %{binding: binding, env: env, matcher: @exact_matcher}
matches = context_to_matches(context, ctx, :locate)
fragment = String.slice(line, 0, to - 1)
ctx = %{
fragment: fragment,
binding: binding,
env: env,
matcher: @exact_matcher,
type: :locate
}
matches = context_to_matches(context, ctx)
%{matches: matches, range: %{from: from, to: to}}
:none ->
@ -79,7 +97,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
# Takes a context returned from Code.Fragment.cursor_context
# or Code.Fragment.surround_context and looks up matching
# identifier items
defp context_to_matches(context, ctx, type) do
defp context_to_matches(context, ctx) do
case context do
{:alias, alias} ->
match_alias(List.to_string(alias), ctx, false)
@ -100,13 +118,13 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
match_default(ctx)
{:local_or_var, local_or_var} ->
match_local_or_var(List.to_string(local_or_var), ctx)
match_in_struct_fields_or_local_or_var(List.to_string(local_or_var), ctx)
{:local_arity, local} ->
match_local(List.to_string(local), %{ctx | matcher: @exact_matcher})
{:local_call, local} ->
case type do
case ctx.type do
:completion -> match_default(ctx)
:locate -> match_local(List.to_string(local), %{ctx | matcher: @exact_matcher})
end
@ -178,7 +196,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
end
defp match_default(ctx) do
match_local_or_var("", ctx)
match_in_struct_fields_or_local_or_var("", ctx)
end
defp match_alias(hint, ctx, nested?) do
@ -195,18 +213,73 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
defp match_struct(hint, ctx) do
for {:module, module, name, documentation} <- match_alias(hint, ctx, true),
has_struct?(module),
not is_exception?(module),
do: {:module, module, name, documentation}
end
defp has_struct?(mod) do
Code.ensure_loaded?(mod) and function_exported?(mod, :__struct__, 1) and
not function_exported?(mod, :exception, 1)
Code.ensure_loaded?(mod) and function_exported?(mod, :__struct__, 1)
end
defp is_exception?(mod) do
Code.ensure_loaded?(mod) and function_exported?(mod, :exception, 1)
end
defp match_module_member(mod, hint, ctx) do
match_module_function(mod, hint, ctx) ++ match_module_type(mod, hint, ctx)
end
defp match_in_struct_fields_or_local_or_var(hint, ctx) do
case expand_struct_fields(ctx) do
{:ok, struct, fields} ->
for {field, default} <- fields,
name = Atom.to_string(field),
ctx.matcher.(name, hint),
do: {:in_struct_field, struct, field, default}
_ ->
match_local_or_var(hint, ctx)
end
end
defp expand_struct_fields(ctx) do
with {:ok, quoted} <- Code.Fragment.container_cursor_to_quoted(ctx.fragment),
{aliases, pairs} <- find_struct_fields(quoted) do
mod_name = Enum.join(aliases, ".")
mod = expand_alias(mod_name, ctx)
fields =
if has_struct?(mod) do
# Remove the keys that have already been filled, and internal keys
Map.from_struct(mod.__struct__)
|> Map.drop(Keyword.keys(pairs))
|> Map.reject(fn {key, _} ->
key
|> Atom.to_string()
|> String.starts_with?("_")
end)
else
%{}
end
{:ok, mod, fields}
end
end
defp find_struct_fields(ast) do
ast
|> Macro.prewalker()
|> Enum.find_value(fn node ->
with {:%, _, [{:__aliases__, _, aliases}, {:%{}, _, pairs}]} <- node,
{pairs, [{:__cursor__, _, []}]} <- Enum.split(pairs, -1),
true <- Keyword.keyword?(pairs) do
{aliases, pairs}
else
_ -> nil
end
end)
end
defp match_local_or_var(hint, ctx) do
match_local(hint, ctx) ++ match_variable(hint, ctx)
end
@ -238,7 +311,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
is_atom(key),
name = Atom.to_string(key),
ctx.matcher.(name, hint),
do: {:map_field, name, value}
do: {:map_field, key, value}
end
defp match_sigil(hint, ctx) do

View file

@ -1053,6 +1053,52 @@ defmodule Livebook.IntellisenseTest do
] = Intellisense.get_completion_items("struct.my", binding, env)
end
test "completion for struct keys inside struct" do
{binding, env} = eval(do: nil)
assert [
%{
label: "my_val",
kind: :field,
detail: "Livebook.IntellisenseTest.MyStruct struct field",
documentation: "```\nmy_val\n```\n\n---\n\n**Default**\n\n```\nnil\n```\n",
insert_text: "my_val: "
}
] =
Intellisense.get_completion_items(
"%Livebook.IntellisenseTest.MyStruct{my",
binding,
env
)
end
test "completion for struct keys inside struct removes filled keys" do
{binding, env} =
eval do
struct = %Livebook.IntellisenseTest.MyStruct{}
end
assert [] =
Intellisense.get_completion_items(
"%Livebook.IntellisenseTest.MyStruct{my_val: 123, ",
binding,
env
)
end
test "completion for struct keys inside struct ignores `__exception__`" do
{binding, env} = eval(do: nil)
completions =
Intellisense.get_completion_items(
"%ArgumentError{",
binding,
env
)
refute Enum.find(completions, &match?(%{label: "__exception__"}, &1))
end
test "ignore invalid Elixir module literals" do
{binding, env} = eval(do: nil)