From acd100f3d313d319e773181fa9610fdc40909637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Mon, 23 Aug 2021 10:42:18 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + assets/js/cell/live_editor.js | 24 +- assets/js/highlight/text_utils.js | 13 ++ assets/test/lib/text_utils.test.js | 10 + lib/livebook/intellisense.ex | 61 ++++- .../intellisense/identifier_matcher.ex | 215 +++++++++++++----- lib/livebook/runtime.ex | 3 +- test/livebook/intellisense_test.exs | 116 +++++++++- test/support/test_modules/hidden.ex | 8 + 9 files changed, 378 insertions(+), 76 deletions(-) create mode 100644 assets/js/highlight/text_utils.js create mode 100644 assets/test/lib/text_utils.test.js create mode 100644 test/support/test_modules/hidden.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b7c37ad7..de4bf6bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/assets/js/cell/live_editor.js b/assets/js/cell/live_editor.js index f6b8db42e..a0d767a44 100644 --- a/assets/js/cell/live_editor.js +++ b/assets/js/cell/live_editor.js @@ -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": diff --git a/assets/js/highlight/text_utils.js b/assets/js/highlight/text_utils.js new file mode 100644 index 000000000..67697b9ac --- /dev/null +++ b/assets/js/highlight/text_utils.js @@ -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; +} diff --git a/assets/test/lib/text_utils.test.js b/assets/test/lib/text_utils.test.js new file mode 100644 index 000000000..7ebc5039b --- /dev/null +++ b/assets/test/lib/text_utils.test.js @@ -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); +}); diff --git a/lib/livebook/intellisense.ex b/lib/livebook/intellisense.ex index 7b5ac2949..f3d1f1896 100644 --- a/lib/livebook/intellisense.ex +++ b/lib/livebook/intellisense.ex @@ -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 diff --git a/lib/livebook/intellisense/identifier_matcher.ex b/lib/livebook/intellisense/identifier_matcher.ex index 443a96d63..cd95ec06a 100644 --- a/lib/livebook/intellisense/identifier_matcher.ex +++ b/lib/livebook/intellisense/identifier_matcher.ex @@ -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?(<>) 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 diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex index c64ef3fa2..9f90f2462 100644 --- a/lib/livebook/runtime.ex +++ b/lib/livebook/runtime.ex @@ -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`. diff --git a/test/livebook/intellisense_test.exs b/test/livebook/intellisense_test.exs index 847ab1e79..3d8608f22 100644 --- a/test/livebook/intellisense_test.exs +++ b/test/livebook/intellisense_test.exs @@ -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) diff --git a/test/support/test_modules/hidden.ex b/test/support/test_modules/hidden.ex new file mode 100644 index 000000000..39172cf09 --- /dev/null +++ b/test/support/test_modules/hidden.ex @@ -0,0 +1,8 @@ +defmodule Livebook.TestModules.Hidden do + @moduledoc false + + def visible, do: :ok + + @doc false + def hidden, do: :ok +end