Improve intellisense to handle structs and sigils (#513)

* Update Code.Fragment backport

* Support structs completion and show module subtype

* Support sigil completion

* Update changelog

* Don't show completion items for hidden modules

* Update Code.Fragment backport
This commit is contained in:
Jonatan Kłosko 2021-08-23 10:42:18 +02:00 committed by GitHub
parent ab30f84548
commit acd100f3d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 378 additions and 76 deletions

View file

@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Added support for configuring file systems using env variables ([#498](https://github.com/livebook-dev/livebook/pull/498))
- Added a keyboard shortcut for triggering on-hover docs ([#508](https://github.com/livebook-dev/livebook/pull/508))
### Changed
- Improved intellisense to handle structs and sigils ([#513](https://github.com/livebook-dev/livebook/pull/513))
### Fixed
- Improved Markdown and math integration by migrating to remark ([#495](https://github.com/livebook-dev/livebook/pull/495))

View file

@ -3,6 +3,7 @@ import EditorClient from "./live_editor/editor_client";
import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter";
import HookServerAdapter from "./live_editor/hook_server_adapter";
import RemoteUser from "./live_editor/remote_user";
import { replacedSuffixLength } from "../highlight/text_utils";
/**
* Mounts cell source editor with real-time collaboration mechanism.
@ -242,7 +243,24 @@ class LiveEditor {
hint: lineUntilCursor,
})
.then((response) => {
const suggestions = completionItemsToSuggestions(response.items);
const suggestions = completionItemsToSuggestions(response.items).map(
(suggestion) => {
const replaceLength = replacedSuffixLength(
lineUntilCursor,
suggestion.insertText
);
const range = new monaco.Range(
position.lineNumber,
position.column - replaceLength,
position.lineNumber,
position.column
);
return { ...suggestion, range };
}
);
return { suggestions };
})
.catch(() => null);
@ -377,6 +395,10 @@ function parseItemKind(kind) {
return monaco.languages.CompletionItemKind.Function;
case "module":
return monaco.languages.CompletionItemKind.Module;
case "struct":
return monaco.languages.CompletionItemKind.Struct;
case "interface":
return monaco.languages.CompletionItemKind.Interface;
case "type":
return monaco.languages.CompletionItemKind.Class;
case "variable":

View file

@ -0,0 +1,13 @@
/**
* Returns length of suffix in `string` that should be replaced
* with `newSuffix` to avoid duplication.
*/
export function replacedSuffixLength(string, newSuffix) {
let suffix = newSuffix;
while (!string.endsWith(suffix)) {
suffix = suffix.slice(0, -1);
}
return suffix.length;
}

View file

@ -0,0 +1,10 @@
import { replacedSuffixLength } from "../../js/highlight/text_utils";
test("replacedSuffixLength", () => {
expect(replacedSuffixLength("to_string(", "")).toEqual(0);
expect(replacedSuffixLength("to_string(", "length")).toEqual(0);
expect(replacedSuffixLength("length", "length")).toEqual(6);
expect(replacedSuffixLength("x = ~", "~r")).toEqual(1);
expect(replacedSuffixLength("Enum.ma", "map")).toEqual(2);
expect(replacedSuffixLength("Enum.ma", "map_reduce")).toEqual(2);
});

View file

@ -66,10 +66,14 @@ defmodule Livebook.Intellisense do
list(Livebook.Runtime.completion_item())
def get_completion_items(hint, binding, env) do
IdentifierMatcher.completion_identifiers(hint, binding, env)
|> Enum.filter(&include_in_completion?/1)
|> Enum.map(&format_completion_item/1)
|> Enum.sort_by(&completion_item_priority/1)
end
defp include_in_completion?({:module, _module, _name, :hidden}), do: false
defp include_in_completion?(_), do: true
defp format_completion_item({:variable, name, value}),
do: %{
label: name,
@ -88,14 +92,28 @@ defmodule Livebook.Intellisense do
insert_text: name
}
defp format_completion_item({:module, name, doc_content}),
do: %{
defp format_completion_item({:module, module, name, doc_content}) do
subtype = get_module_subtype(module)
kind =
case subtype do
:protocol -> :interface
:exception -> :struct
:struct -> :struct
:behaviour -> :interface
_ -> :module
end
detail = Atom.to_string(subtype || :module)
%{
label: name,
kind: :module,
detail: "module",
kind: kind,
detail: detail,
documentation: format_doc_content(doc_content, :short),
insert_text: String.trim_leading(name, ":")
}
end
defp format_completion_item({:function, module, name, arity, doc_content, signatures, spec}),
do: %{
@ -132,7 +150,7 @@ defmodule Livebook.Intellisense do
{completion_item_kind_priority(completion_item.kind), completion_item.label}
end
@ordered_kinds [:field, :variable, :module, :function, :type]
@ordered_kinds [:field, :variable, :module, :struct, :interface, :function, :type]
defp completion_item_kind_priority(kind) when kind in @ordered_kinds do
Enum.find_index(@ordered_kinds, &(&1 == kind))
@ -172,7 +190,7 @@ defmodule Livebook.Intellisense do
])
end
defp format_details_item({:module, name, doc_content}) do
defp format_details_item({:module, _module, name, doc_content}) do
join_with_divider([
code(name),
format_doc_content(doc_content, :all)
@ -201,6 +219,33 @@ defmodule Livebook.Intellisense do
])
end
defp get_module_subtype(module) do
cond do
module_has_function?(module, :__protocol__, 1) ->
:protocol
module_has_function?(module, :__impl__, 1) ->
:implementation
module_has_function?(module, :__struct__, 0) ->
if module_has_function?(module, :exception, 1) do
:exception
else
:struct
end
module_has_function?(module, :behaviour_info, 1) ->
:behaviour
true ->
nil
end
end
defp module_has_function?(module, func, arity) do
Code.ensure_loaded?(module) and function_exported?(module, func, arity)
end
# Formatting helpers
defp join_with_divider(strings), do: join_with(strings, "\n\n---\n\n")
@ -276,6 +321,10 @@ defmodule Livebook.Intellisense do
"No documentation available"
end
defp format_doc_content(:hidden, _variant) do
"This is a private API"
end
defp format_doc_content({"text/markdown", markdown}, :short) do
# Extract just the first paragraph
markdown

View file

@ -19,14 +19,14 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
@type identifier_item ::
{:variable, name(), value()}
| {:map_field, name(), value()}
| {:module, name(), doc_content()}
| {:module, module(), name(), doc_content()}
| {:function, module(), name(), arity(), doc_content(), list(signature()), spec()}
| {:type, module(), name(), arity(), doc_content()}
| {:module_attribute, name(), doc_content()}
@type name :: String.t()
@type value :: term()
@type doc_content :: {format :: String.t(), content :: String.t()} | nil
@type doc_content :: {format :: String.t(), content :: String.t()} | :hidden | nil
@type signature :: String.t()
@type spec :: tuple() | nil
@ -80,7 +80,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
defp context_to_matches(context, ctx, type) do
case context do
{:alias, alias} ->
match_alias(List.to_string(alias), ctx)
match_alias(List.to_string(alias), ctx, false)
{:unquoted_atom, unquoted_atom} ->
match_erlang_module(List.to_string(unquoted_atom), ctx)
@ -121,6 +121,15 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{:module_attribute, attribute} ->
match_module_attribute(List.to_string(attribute), ctx)
{:sigil, []} ->
match_sigil("", ctx) ++ match_local("~", ctx)
{:sigil, sigil} ->
match_sigil(List.to_string(sigil), ctx)
{:struct, struct} ->
match_struct(List.to_string(struct), ctx)
# :none
_ ->
[]
@ -130,7 +139,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
defp match_dot(path, hint, ctx) do
case expand_dot_path(path, ctx) do
{:ok, mod} when is_atom(mod) and hint == "" ->
match_module_member(mod, hint, ctx) ++ match_module(mod, hint, ctx)
match_module_member(mod, hint, ctx) ++ match_module(mod, hint, false, ctx)
{:ok, mod} when is_atom(mod) ->
match_module_member(mod, hint, ctx)
@ -170,17 +179,28 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
match_local_or_var("", ctx)
end
defp match_alias(hint, ctx) do
defp match_alias(hint, ctx, nested?) do
case split_at_last_occurrence(hint, ".") do
{hint, ""} ->
match_elixir_root_module(hint, ctx) ++ match_env_alias(hint, ctx)
:error ->
match_elixir_root_module(hint, nested?, ctx) ++ match_env_alias(hint, ctx)
{alias, hint} ->
{:ok, alias, hint} ->
mod = expand_alias(alias, ctx)
match_module(mod, hint, ctx)
match_module(mod, hint, nested?, ctx)
end
end
defp match_struct(hint, ctx) do
for {:module, module, name, doc_content} <- match_alias(hint, ctx, true),
has_struct?(module),
do: {:module, module, name, doc_content}
end
defp has_struct?(mod) do
Code.ensure_loaded?(mod) and function_exported?(mod, :__struct__, 1) and
not 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
@ -219,11 +239,18 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
do: {:map_field, name, value}
end
defp match_sigil(hint, ctx) do
for {:function, module, "sigil_" <> sigil_name, arity, doc_content, signatures, spec} <-
match_local("sigil_", %{ctx | matcher: @prefix_matcher}),
ctx.matcher.(sigil_name, hint),
do: {:function, module, "~" <> sigil_name, arity, doc_content, signatures, spec}
end
defp match_erlang_module(hint, ctx) do
for mod <- get_matching_modules(hint, ctx),
usable_as_unquoted_module?(mod),
name = ":" <> Atom.to_string(mod),
do: {:module, name, get_module_doc_content(mod)}
do: {:module, mod, name, get_module_doc_content(mod)}
end
# Converts alias string to module atom with regard to the given env
@ -241,15 +268,15 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
for {alias, mod} <- ctx.env.aliases,
[name] = Module.split(alias),
ctx.matcher.(name, hint),
do: {:module, name, get_module_doc_content(mod)}
do: {:module, mod, name, get_module_doc_content(mod)}
end
defp match_module(base_mod, hint, ctx) do
defp match_module(base_mod, hint, nested?, ctx) do
# Note: we specifically don't want further completion
# if `base_mod` is an Erlang module.
if base_mod == Elixir or elixir_module?(base_mod) do
match_elixir_module(base_mod, hint, ctx)
match_elixir_module(base_mod, hint, nested?, ctx)
else
[]
end
@ -259,19 +286,19 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
mod |> Atom.to_string() |> String.starts_with?("Elixir.")
end
defp match_elixir_root_module(hint, ctx) do
items = match_elixir_module(Elixir, hint, ctx)
defp match_elixir_root_module(hint, nested?, ctx) do
items = match_elixir_module(Elixir, hint, nested?, ctx)
# `Elixir` is not a existing module name, but `Elixir.Enum` is,
# so if the user types `Eli` the completion should include `Elixir`.
if ctx.matcher.("Elixir", hint) do
[{:module, "Elixir", nil} | items]
[{:module, Elixir, "Elixir", nil} | items]
else
items
end
end
defp match_elixir_module(base_mod, hint, ctx) do
defp match_elixir_module(base_mod, hint, nested?, ctx) do
# Note: `base_mod` may be `Elixir`, even though it's not a valid module
match_prefix = "#{base_mod}.#{hint}"
@ -280,15 +307,17 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
for mod <- get_matching_modules(match_prefix, ctx),
parts = Module.split(mod),
length(parts) >= depth,
name = Enum.at(parts, depth - 1),
{parent_mod_parts, name_parts} = Enum.split(parts, depth - 1),
name_parts = if(nested?, do: name_parts, else: [hd(name_parts)]),
name = Enum.join(name_parts, "."),
# Note: module can be defined dynamically and its name
# may not be a valid alias (e.g. :"Elixir.My.module").
# That's why we explicitly check if the name part makes
# for a alias piece.
# for a valid alias piece.
valid_alias_piece?("." <> name),
mod = parts |> Enum.take(depth) |> Module.concat(),
mod = Module.concat(parent_mod_parts ++ name_parts),
uniq: true,
do: {:module, name, get_module_doc_content(mod)}
do: {:module, mod, name, get_module_doc_content(mod)}
end
defp valid_alias_piece?(<<?., char, rest::binary>>) when char in ?A..?Z,
@ -396,6 +425,9 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{:docs_v1, _, _, format, %{"en" => docstring}, _, _} ->
{format, docstring}
{:docs_v1, _, _, _, :hidden, _, _} ->
:hidden
_ ->
nil
end
@ -420,6 +452,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
defp doc_signatures(_), do: []
defp doc_content({_, _, _, %{"en" => docstr}, _}, format), do: {format, docstr}
defp doc_content({_, _, _, :hidden, _}, _format), do: :hidden
defp doc_content(_doc, _format), do: nil
defp exports(mod) do
@ -466,12 +499,12 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
defp split_at_last_occurrence(string, pattern) do
case :binary.matches(string, pattern) do
[] ->
{string, ""}
:error
parts ->
{start, _} = List.last(parts)
size = byte_size(string)
{binary_part(string, 0, start), binary_part(string, start + 1, size - start - 1)}
{:ok, binary_part(string, 0, start), binary_part(string, start + 1, size - start - 1)}
end
end
@ -561,33 +594,37 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
* `{:local_call, charlist}` - the context is a local (import or local)
call, such as `hello_world(` and `hello_world `
* `{:module_attribute, charlist}` - the context is a module attribute, such
as `@hello_wor`
* `{:module_attribute, charlist}` - the context is a module attribute,
such as `@hello_wor`
* `{:operator, charlist}` (since v1.13.0) - the context is an operator,
such as `+` or `==`. Note textual operators, such as `when` do not
appear as operators but rather as `:local_or_var`. `@` is never an
`:operator` and always a `:module_attribute`
* `{:operator, charlist}` - the context is an operator, such as `+` or
`==`. Note textual operators, such as `when` do not appear as operators
but rather as `:local_or_var`. `@` is never an `:operator` and always a
`:module_attribute`
* `{:operator_arity, charlist}` (since v1.13.0) - the context is an
operator arity, which is an operator followed by /, such as `+/`,
`not/` or `when/`
* `{:operator_arity, charlist}` - the context is an operator arity, which
is an operator followed by /, such as `+/`, `not/` or `when/`
* `{:operator_call, charlist}` (since v1.13.0) - the context is an
operator call, which is an operator followed by space, such as
`left + `, `not ` or `x when `
* `{:operator_call, charlist}` - the context is an operator call, which is
an operator followed by space, such as `left + `, `not ` or `x when `
* `:none` - no context possible
* `{:sigil, charlist}` - the context is a sigil. It may be either the beginning
of a sigil, such as `~` or `~s`, or an operator starting with `~`, such as
`~>` and `~>>`
* `{:struct, charlist}` - the context is a struct, such as `%`, `%UR` or `%URI`
* `{:unquoted_atom, charlist}` - the context is an unquoted atom. This
can be any atom or an atom representing a module
## Limitations
* The current algorithm only considers the last line of the input
* Context does not yet track strings and sigils
* Arguments of functions calls are not currently recognized
The current algorithm only considers the last line of the input. This means
it will also show suggestions inside strings, heredocs, etc, which is
intentional as it helps with doctests, references, and more. Other functions
may be added in the future that consider the tree-structure of the code.
"""
@doc since: "1.13.0"
@spec cursor_context(List.Chars.t(), keyword()) ::
@ -604,6 +641,8 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
| {:operator_arity, charlist}
| {:operator_call, charlist}
| :none
| {:sigil, charlist}
| {:struct, charlist}
| {:unquoted_atom, charlist}
when inside_dot:
{:alias, charlist}
@ -653,11 +692,13 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
@non_starter_punctuation ')]}"\'.$'
@space '\t\s'
@trailing_identifier '?!'
@tilde_op_prefix '<=~'
@non_identifier @trailing_identifier ++
@operators ++ @starter_punctuation ++ @non_starter_punctuation ++ @space
@textual_operators ~w(when not and or in)c
@incomplete_operators ~w(^^ ~~ ~)c
defp codepoint_cursor_context(reverse, _opts) do
{stripped, spaces} = strip_spaces(reverse, 0)
@ -665,6 +706,10 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
case stripped do
# It is empty
[] -> {:expr, 0}
# Structs
[?%, ?:, ?: | _] -> {{:struct, ''}, 1}
[?%, ?: | _] -> {{:unquoted_atom, '%'}, 2}
[?% | _] -> {{:struct, ''}, 1}
# Token/AST only operators
[?>, ?= | rest] when rest == [] or hd(rest) != ?: -> {:expr, 0}
[?>, ?- | rest] when rest == [] or hd(rest) != ?: -> {:expr, 0}
@ -728,6 +773,9 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{:module_attribute, acc, count} ->
{{:module_attribute, acc}, count}
{:sigil, acc, count} ->
{{:sigil, acc}, count}
{:unquoted_atom, acc, count} ->
{{:unquoted_atom, acc}, count}
@ -736,6 +784,9 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{'.' ++ rest, count} when rest == [] or hd(rest) != ?. ->
nested_alias(rest, count + 1, acc)
{'%' ++ _, count} ->
{{:struct, acc}, count + 1}
_ ->
{{:alias, acc}, count}
end
@ -775,6 +826,12 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
end
end
defp rest_identifier([?~ | rest], count, [letter])
when (letter in ?A..?Z or letter in ?a..?z) and
(rest == [] or hd(rest) not in @tilde_op_prefix) do
{:sigil, [letter], count + 1}
end
defp rest_identifier([?: | rest], count, acc) when rest == [] or hd(rest) != ?: do
case String.Tokenizer.tokenize(acc) do
{_, _, [], _, _, _} -> {:unquoted_atom, acc, count + 1}
@ -816,6 +873,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{rest, count} = strip_spaces(rest, count)
case identifier_to_cursor_context(rest, count, true) do
{{:struct, prev}, count} -> {{:struct, prev ++ '.' ++ acc}, count}
{{:alias, prev}, count} -> {{:alias, prev ++ '.' ++ acc}, count}
_ -> {:none, 0}
end
@ -830,6 +888,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{{:alias, _} = prev, count} -> {{:dot, prev, acc}, count}
{{:dot, _, _} = prev, count} -> {{:dot, prev, acc}, count}
{{:module_attribute, _} = prev, count} -> {{:dot, prev, acc}, count}
{{:struct, acc}, count} -> {{:struct, acc ++ '.'}, count}
{_, _} -> {:none, 0}
end
end
@ -838,7 +897,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
operator(rest, count + 1, [h | acc], call_op?)
end
defp operator(rest, count, acc, call_op?) when acc in ~w(^^ ~~ ~)c do
defp operator(rest, count, acc, call_op?) when acc in @incomplete_operators do
{rest, dot_count} = strip_spaces(rest, count)
cond do
@ -848,11 +907,21 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
match?([?. | rest] when rest == [] or hd(rest) != ?., rest) ->
dot(tl(rest), dot_count + 1, acc)
acc == '~' ->
{{:sigil, ''}, count}
true ->
{{:operator, acc}, count}
end
end
# If we are opening a sigil, ignore the operator.
defp operator([letter, ?~ | rest], _count, [op], _call_op?)
when op in '<|/' and (letter in ?A..?Z or letter in ?a..?z) and
(rest == [] or hd(rest) not in @tilde_op_prefix) do
{:none, 0}
end
defp operator(rest, count, acc, _call_op?) do
case elixir_tokenizer_tokenize(acc, 1, 1, []) do
{:ok, _, [{:atom, _, _}]} ->
@ -912,11 +981,10 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
The returned map contains the column the expression starts and the
first column after the expression ends.
This function builds on top of `cursor_context/2`. Therefore
it also provides a best-effort detection and may not be accurate
under all circumstances. See the "Return values" section for more
information on the available contexts as well as the "Limitations"
section.
Similar to `cursor_context/2`, this function also provides a best-effort
detection and may not be accurate under all circumstances. See the
"Return values" and "Limitations" section under `cursor_context/2` for
more information.
## Examples
@ -925,19 +993,22 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
## Differences to `cursor_context/2`
In contrast to `cursor_context/2`, `surround_context/3` does not
return `dot_call`/`dot_arity` nor `operator_call`/`operator_arity`
contexts because they should behave the same as `dot` and `operator`
respectively in complete expressions.
Because `surround_context/3` deals with complete code, it has some
difference to `cursor_context/2`:
On the other hand, it does make a distinction between `local_call`/
`local_arity` to `local_or_var`, since the latter can be a local or
variable.
* `dot_call`/`dot_arity` and `operator_call`/`operator_arity`
are collapsed into `dot` and `operator` contexts respectively
as they are not meaningful distinction between them
Also note that `@` when not followed by any identifier is returned
as `{:operator, '@'}`, while it is a `{:module_attribute, ''}` in
`cursor_context/3`. Once again, this happens because `surround_context/3`
assumes the expression is complete, while `cursor_context/2` does not.
* On the other hand, this function still makes a distinction between
`local_call`/`local_arity` and `local_or_var`, since the latter can
be a local or variable
* `@` when not followed by any identifier is returned as `{:operator, '@'}`
(in contrast to `{:module_attribute, ''}` in `cursor_context/2`
* This function never returns empty sigils `{:sigil, ''}` or empty structs
`{:struct, ''}` as context
"""
@doc since: "1.13.0"
@spec surround_context(List.Chars.t(), position(), keyword()) ::
@ -992,6 +1063,9 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
reversed = reversed_post ++ reversed_pre
case codepoint_cursor_context(reversed, opts) do
{{:struct, acc}, offset} ->
build_surround({:struct, acc}, reversed, line, offset)
{{:alias, acc}, offset} ->
build_surround({:alias, acc}, reversed, line, offset)
@ -1016,6 +1090,9 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{{:module_attribute, acc}, offset} ->
build_surround({:module_attribute, acc}, reversed, line, offset)
{{:sigil, acc}, offset} ->
build_surround({:sigil, acc}, reversed, line, offset)
{{:unquoted_atom, acc}, offset} ->
build_surround({:unquoted_atom, acc}, reversed, line, offset)
@ -1030,6 +1107,9 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{{:alias, acc}, offset} ->
build_surround({:alias, acc}, reversed, line, offset)
{{:struct, acc}, offset} ->
build_surround({:struct, acc}, reversed, line, offset)
_ ->
:none
end
@ -1041,13 +1121,16 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{[], _rest} ->
:none
{reversed_post, _rest} ->
{reversed_post, rest} ->
reversed = reversed_post ++ reversed_pre
case codepoint_cursor_context(reversed, opts) do
{{:operator, acc}, offset} ->
{{:operator, acc}, offset} when acc not in @incomplete_operators ->
build_surround({:operator, acc}, reversed, line, offset)
{{:sigil, ''}, offset} when hd(rest) in ?A..?Z or hd(rest) in ?a..?z ->
build_surround({:sigil, [hd(rest)]}, [hd(rest) | reversed], line, offset + 1)
{{:dot, _, [_ | _]} = dot, offset} ->
build_surround(dot, reversed, line, offset)
@ -1106,7 +1189,11 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{[?: | reversed_pre], post}
end
# Dot handling
defp adjust_position(reversed_pre, [?% | post]) do
adjust_position([?% | reversed_pre], post)
end
# Dot/struct handling
defp adjust_position(reversed_pre, post) do
case move_spaces(post, reversed_pre) do
# If we are between spaces and a dot, move past the dot
@ -1121,6 +1208,16 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
{post, reversed_pre} = move_spaces(post, reversed_pre)
{reversed_pre, post}
# If there is a % to our left, make sure to move to the first character
{[?% | _], _} ->
case move_spaces(post, reversed_pre) do
{[h | _] = post, reversed_pre} when h in ?A..?Z ->
{reversed_pre, post}
_ ->
{reversed_pre, post}
end
_ ->
{reversed_pre, post}
end

View file

@ -69,7 +69,8 @@ defprotocol Livebook.Runtime do
insert_text: String.t()
}
@type completion_item_kind :: :function | :module | :type | :variable | :field
@type completion_item_kind ::
:function | :module | :struct | :interface | :type | :variable | :field
@typedoc """
Looks up more details about an identifier found in `column` in `line`.

View file

@ -138,8 +138,8 @@ defmodule Livebook.IntellisenseTest do
},
%{
label: "Enumerable",
kind: :module,
detail: "module",
kind: :interface,
detail: "protocol",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
@ -148,12 +148,34 @@ defmodule Livebook.IntellisenseTest do
assert [
%{
label: "Enumerable",
kind: :module,
detail: "module",
kind: :interface,
detail: "protocol",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
] = Intellisense.get_completion_items("Enumera", binding, env)
assert [
%{
label: "RuntimeError",
kind: :struct,
detail: "exception",
documentation: "No documentation available",
insert_text: "RuntimeError"
}
] = Intellisense.get_completion_items("RuntimeE", binding, env)
end
test "Elixir struct completion lists nested options" do
{binding, env} = eval(do: nil)
assert %{
label: "File.Stat",
kind: :struct,
detail: "struct",
documentation: "A struct that holds file information.",
insert_text: "File.Stat"
} in Intellisense.get_completion_items("%Fi", binding, env)
end
test "Elixir type completion" do
@ -187,14 +209,14 @@ defmodule Livebook.IntellisenseTest do
] = Intellisense.get_completion_items(":file.nam", binding, env)
end
test "Elixir completion with self" do
test "Elixir module completion with self" do
{binding, env} = eval(do: nil)
assert [
%{
label: "Enumerable",
kind: :module,
detail: "module",
kind: :interface,
detail: "protocol",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
@ -222,14 +244,51 @@ defmodule Livebook.IntellisenseTest do
assert [] = Intellisense.get_completion_items("x.Foo.get_by", binding, env)
end
test "Elixir private module no completion" do
{binding, env} = eval(do: nil)
assert [] =
Intellisense.get_completion_items(
"Livebook.TestModules.Hidd",
binding,
env
)
end
test "Elixir private module members completion" do
{binding, env} = eval(do: nil)
assert [
%{
detail: "Livebook.TestModules.Hidden.hidden()",
documentation: "This is a private API",
insert_text: "hidden",
kind: :function,
label: "hidden/0"
},
%{
detail: "Livebook.TestModules.Hidden.visible()",
documentation: "No documentation available",
insert_text: "visible",
kind: :function,
label: "visible/0"
}
] =
Intellisense.get_completion_items(
"Livebook.TestModules.Hidden.",
binding,
env
)
end
test "Elixir root submodule completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "Access",
kind: :module,
detail: "module",
kind: :interface,
detail: "behaviour",
documentation: "Key-based access to data structures.",
insert_text: "Access"
}
@ -276,6 +335,45 @@ defmodule Livebook.IntellisenseTest do
] = Intellisense.get_completion_items("System.ve", binding, env)
end
test "Elixir sigil completion" do
{binding, env} = eval(do: nil)
regex_item = %{
label: "~r/2",
kind: :function,
detail: "Kernel.sigil_r(term, modifiers)",
documentation: "Handles the sigil `~r` for regular expressions.",
insert_text: "~r"
}
assert regex_item in Intellisense.get_completion_items("~", binding, env)
assert [^regex_item] = Intellisense.get_completion_items("~r", binding, env)
end
test "Elixir sigil-like operators" do
{binding, env} =
eval do
import Bitwise
end
bitwise_not_item = %{
label: "~~~/1",
kind: :function,
detail: "~~~expr",
documentation: """
Bitwise NOT unary operator.
```
@spec ~~~integer() :: integer()
```\
""",
insert_text: "~~~"
}
assert bitwise_not_item in Intellisense.get_completion_items("~", binding, env)
end
@tag :erl_docs
test "Erlang function completion" do
{binding, env} = eval(do: nil)

View file

@ -0,0 +1,8 @@
defmodule Livebook.TestModules.Hidden do
@moduledoc false
def visible, do: :ok
@doc false
def hidden, do: :ok
end