mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-01-27 01:08:29 +08:00
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:
parent
ab30f84548
commit
acd100f3d3
9 changed files with 378 additions and 76 deletions
|
@ -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))
|
||||
|
|
|
@ -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":
|
||||
|
|
13
assets/js/highlight/text_utils.js
Normal file
13
assets/js/highlight/text_utils.js
Normal 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;
|
||||
}
|
10
assets/test/lib/text_utils.test.js
Normal file
10
assets/test/lib/text_utils.test.js
Normal 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);
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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)
|
||||
|
|
8
test/support/test_modules/hidden.ex
Normal file
8
test/support/test_modules/hidden.ex
Normal file
|
@ -0,0 +1,8 @@
|
|||
defmodule Livebook.TestModules.Hidden do
|
||||
@moduledoc false
|
||||
|
||||
def visible, do: :ok
|
||||
|
||||
@doc false
|
||||
def hidden, do: :ok
|
||||
end
|
Loading…
Reference in a new issue