diff --git a/lib/livebook/intellisense/elixir.ex b/lib/livebook/intellisense/elixir.ex index f1ee0b1cd04..931ac4c5a56 100644 --- a/lib/livebook/intellisense/elixir.ex +++ b/lib/livebook/intellisense/elixir.ex @@ -40,19 +40,24 @@ defmodule Livebook.Intellisense.Elixir do end defp handle_completion(hint, context, node) do + Intellisense.Elixir.IdentifierMatcher.completion_identifiers(hint, context, node) + |> format_completion_identifiers(extra_completion_items(hint)) + end + + def format_completion_identifiers(completions, extra \\ []) do items = - Intellisense.Elixir.IdentifierMatcher.completion_identifiers(hint, context, node) + completions |> Enum.filter(&include_in_completion?/1) |> Enum.map(&format_completion_item/1) - |> Enum.concat(extra_completion_items(hint)) + |> Enum.concat(extra) |> Enum.sort_by(&completion_item_priority/1) %{items: items} end - defp include_in_completion?(%{kind: :module, documentation: :hidden}), do: false - defp include_in_completion?(%{kind: :function, documentation: :hidden}), do: false - defp include_in_completion?(_), do: true + def include_in_completion?(%{kind: :module, documentation: :hidden}), do: false + def include_in_completion?(%{kind: :function, documentation: :hidden}), do: false + def include_in_completion?(_), do: true defp format_completion_item(%{kind: :variable, name: name}), do: %{ @@ -133,6 +138,7 @@ defmodule Livebook.Intellisense.Elixir do } end + #TODO : module and signature is important in display of completion docs, name and displayname isnt in happy flow, if no signatures name is also needed defp format_completion_item(%{ kind: :function, module: module, @@ -192,14 +198,41 @@ defmodule Livebook.Intellisense.Elixir do insert_text: cond do arity == 0 -> "#{Atom.to_string(name)}()" + # true -> "#{Atom.to_string(name)}(${})" end } + + # Note: array_needed is a boolean to know if '[]' should be put inside atrribute, + # as in -export([]). It is also a way to differentiate erlang's atributes from elixir's. + defp format_completion_item(%{ + kind: :module_attribute, + name: name, + documentation: documentation, + array_needed: array_needed + }), + do: %{ + label: Atom.to_string(name), + kind: :variable, + documentation: + join_with_newlines([ + Intellisense.Elixir.Docs.format_documentation(documentation, :short), + "(module attribute)" + ]), + # A snippet with cursor in parentheses + insert_text: + if array_needed do + "#{name}([${}])." + else + "#{name}(${})." + end + } + defp format_completion_item(%{ kind: :module_attribute, name: name, - documentation: documentation + documentation: documentation, }), do: %{ label: Atom.to_string(name), @@ -212,6 +245,7 @@ defmodule Livebook.Intellisense.Elixir do insert_text: Atom.to_string(name) } + defp format_completion_item(%{kind: :bitstring_modifier, name: name, arity: arity}) do insert_text = if arity == 0 do @@ -228,7 +262,7 @@ defmodule Livebook.Intellisense.Elixir do } end - defp keyword_macro?(name) do + def keyword_macro?(name) do def? = name |> Atom.to_string() |> String.starts_with?("def") def? or @@ -256,7 +290,7 @@ defmodule Livebook.Intellisense.Elixir do ] end - defp env_macro?(name) do + def env_macro?(name) do name in [:__ENV__, :__MODULE__, :__DIR__, :__STACKTRACE__, :__CALLER__] end @@ -309,7 +343,7 @@ defmodule Livebook.Intellisense.Elixir do :bitstring_option ] - defp completion_item_priority(%{kind: :struct} = completion_item) do + def completion_item_priority(%{kind: :struct} = completion_item) do if completion_item.documentation =~ "(exception)" do {length(@ordered_kinds), completion_item.label} else @@ -317,7 +351,7 @@ defmodule Livebook.Intellisense.Elixir do end end - defp completion_item_priority(completion_item) do + def completion_item_priority(completion_item) do {completion_item_kind_priority(completion_item.kind), completion_item.label} end @@ -338,22 +372,21 @@ defmodule Livebook.Intellisense.Elixir do contents = Enum.map(matches, &format_details_item/1) definition = get_definition_location(hd(matches), context) - %{range: range, contents: contents, definition: definition} end end - defp include_in_details?(%{kind: :function, from_default: true}), do: false - defp include_in_details?(%{kind: :bitstring_modifier}), do: false - defp include_in_details?(_), do: true + def include_in_details?(%{kind: :function, from_default: true}), do: false + def include_in_details?(%{kind: :bitstring_modifier}), do: false + def include_in_details?(_), do: true - defp format_details_item(%{kind: :variable, name: name}), do: code(name) + def format_details_item(%{kind: :variable, name: name}), do: code(name) - defp format_details_item(%{kind: :map_field, name: name}), do: code(name) + def format_details_item(%{kind: :map_field, name: name}), do: code(name) - defp format_details_item(%{kind: :in_map_field, name: name}), do: code(name) + def format_details_item(%{kind: :in_map_field, name: name}), do: code(name) - defp format_details_item(%{kind: :in_struct_field, name: name, default: default}) do + def format_details_item(%{kind: :in_struct_field, name: name, default: default}) do join_with_divider([ code(name), """ @@ -366,7 +399,8 @@ defmodule Livebook.Intellisense.Elixir do ]) end - defp format_details_item(%{kind: :module, module: module, documentation: documentation}) do + #TODO: module formatting should handle erlang modules too, module is of type :math and should be math + def format_details_item(%{kind: :module, module: module, documentation: documentation}) do join_with_divider([ code(inspect(module)), format_docs_link(module), @@ -374,7 +408,8 @@ defmodule Livebook.Intellisense.Elixir do ]) end - defp format_details_item(%{ + #TODO: format_signatures and format_specs needs to be reworked for erlang functions + def format_details_item(%{ kind: :function, module: module, name: name, @@ -396,7 +431,7 @@ defmodule Livebook.Intellisense.Elixir do ]) end - defp format_details_item(%{ + def format_details_item(%{ kind: :type, module: module, name: name, @@ -412,38 +447,38 @@ defmodule Livebook.Intellisense.Elixir do ]) end - defp format_details_item(%{kind: :module_attribute, name: name, documentation: documentation}) do + def format_details_item(%{kind: :module_attribute, name: name, documentation: documentation}) do join_with_divider([ code("@#{name}"), Intellisense.Elixir.Docs.format_documentation(documentation, :all) ]) end - defp get_definition_location(%{kind: :module, module: module}, context) do + def get_definition_location(%{kind: :module, module: module}, context) do get_definition_location(module, context, {:module, module}) end - defp get_definition_location( + def get_definition_location( %{kind: :function, module: module, name: name, arity: arity}, context ) do get_definition_location(module, context, {:function, name, arity}) end - defp get_definition_location(%{kind: :type, module: module, name: name, arity: arity}, context) do + def get_definition_location(%{kind: :type, module: module, name: name, arity: arity}, context) do get_definition_location(module, context, {:type, name, arity}) end - defp get_definition_location(_idenfitier, _context), do: nil + def get_definition_location(_idenfitier, _context), do: nil - defp get_definition_location(module, context, identifier) do + def get_definition_location(module, context, identifier) do if context.ebin_path do path = Path.join(context.ebin_path, "#{module}.beam") with true <- File.exists?(path), {:ok, line} <- - Intellisense.Elixir.Docs.locate_definition(String.to_charlist(path), identifier) do - file = module.module_info(:compile)[:source] + Intellisense.Elixir.Docs.locate_definition(String.to_charlist(path), identifier), + {:ok, file} <- Keyword.fetch(module.module_info(:compile), :source) do %{file: to_string(file), line: line} else _otherwise -> nil @@ -470,7 +505,8 @@ defmodule Livebook.Intellisense.Elixir do end end - defp format_signature_item({_name, signature, _documentation, _specs}), + # FIXME: This is public + def format_signature_item({_name, signature, _documentation, _specs}), do: %{ signature: signature, arguments: arguments_from_signature(signature) @@ -485,22 +521,22 @@ defmodule Livebook.Intellisense.Elixir do # Formatting helpers - defp join_with_divider(strings), do: join_with(strings, "\n\n---\n\n") + def join_with_divider(strings), do: join_with(strings, "\n\n---\n\n") - defp join_with_newlines(strings), do: join_with(strings, "\n\n") + def join_with_newlines(strings), do: join_with(strings, "\n\n") - defp join_with_middle_dot(strings), do: join_with(strings, " · ") + def join_with_middle_dot(strings), do: join_with(strings, " · ") - defp join_with(strings, joiner) do + def join_with(strings, joiner) do case Enum.reject(strings, &is_nil/1) do [] -> nil parts -> Enum.join(parts, joiner) end end - defp code(nil), do: nil + def code(nil), do: nil - defp code(code) do + def code(code) do """ ``` #{code} @@ -508,7 +544,7 @@ defmodule Livebook.Intellisense.Elixir do """ end - defp format_docs_link(module, function_or_type \\ nil) do + def format_docs_link(module, function_or_type \\ nil) do app = Application.get_application(module) module_name = module_name(module) @@ -558,6 +594,7 @@ defmodule Livebook.Intellisense.Elixir do signature_fallback(module, name, arity) end + #TODO: this should be reimplemented for erlang, module is of type :module, and . is used not : defp format_signatures(signatures, module, _name, _arity) do signatures_string = Enum.join(signatures, "\n") @@ -583,15 +620,15 @@ defmodule Livebook.Intellisense.Elixir do "#{inspect(module)}.#{name}(#{args})" end - defp format_meta(:deprecated, %{deprecated: deprecated}) do + def format_meta(:deprecated, %{deprecated: deprecated}) do "**Deprecated**. " <> deprecated end - defp format_meta(:since, %{since: since}) do + def format_meta(:since, %{since: since}) do "Since " <> since end - defp format_meta(_, _), do: nil + def format_meta(_, _), do: nil defp format_specs([], _name, _line_length), do: nil diff --git a/lib/livebook/intellisense/elixir/docs.ex b/lib/livebook/intellisense/elixir/docs.ex index 780609771c9..938ecb3dcb0 100644 --- a/lib/livebook/intellisense/elixir/docs.ex +++ b/lib/livebook/intellisense/elixir/docs.ex @@ -189,11 +189,15 @@ defmodule Livebook.Intellisense.Elixir.Docs do def locate_definition(path, identifier) def locate_definition(path, {:module, module}) do - with {:ok, {:raw_abstract_v1, annotations}} <- beam_lib_chunks(path, :abstract_code) do - {:attribute, anno, :module, ^module} = - Enum.find(annotations, &match?({:attribute, _, :module, _}, &1)) + case beam_lib_chunks(path, :abstract_code) do + {:ok, {:raw_abstract_v1, annotations}} -> + {:attribute, anno, :module, ^module} = + Enum.find(annotations, &match?({:attribute, _, :module, _}, &1)) - {:ok, :erl_anno.line(anno)} + {:ok, :erl_anno.line(anno)} + + _ -> + :error end end @@ -201,12 +205,32 @@ defmodule Livebook.Intellisense.Elixir.Docs do with {:ok, {:debug_info_v1, _, {:elixir_v1, meta, _}}} <- beam_lib_chunks(path, :debug_info), {_pair, _kind, kw, _body} <- keyfind(meta.definitions, {name, arity}) do Keyword.fetch(kw, :line) + else + _ -> locate_erlang_function(path, name, arity) end end def locate_definition(path, {:type, name, arity}) do - with {:ok, {:raw_abstract_v1, annotations}} <- beam_lib_chunks(path, :abstract_code) do - fetch_type_line(annotations, name, arity) + case beam_lib_chunks(path, :abstract_code) do + {:ok, {:raw_abstract_v1, annotations}} -> + fetch_type_line(annotations, name, arity) + + _ -> + :error + end + end + + defp locate_erlang_function(path, name, arity) do + with {:ok, {:raw_abstract_v1, annotations}} <- + beam_lib_chunks(path, :abstract_code), + line when is_integer(line) <- + Enum.find_value(annotations, fn + {:function, anno, ^name, ^arity, _} -> :erl_anno.line(anno) + _ -> nil + end) do + {:ok, line} + else + _ -> :error end end diff --git a/lib/livebook/intellisense/elixir/identifier_matcher.ex b/lib/livebook/intellisense/elixir/identifier_matcher.ex index 592d35ad3c7..9d6fdf6cdfc 100644 --- a/lib/livebook/intellisense/elixir/identifier_matcher.ex +++ b/lib/livebook/intellisense/elixir/identifier_matcher.ex @@ -65,7 +65,8 @@ defmodule Livebook.Intellisense.Elixir.IdentifierMatcher do | %{ kind: :module_attribute, name: name(), - documentation: Docs.documentation() + documentation: Docs.documentation(), + language: name() } | %{ kind: :bitstring_modifier, @@ -99,6 +100,7 @@ defmodule Livebook.Intellisense.Elixir.IdentifierMatcher do @alias_only_atoms ~w(alias import require)a @alias_only_charlists ~w(alias import require)c + @spec clear_all_loaded(any()) :: boolean() @doc """ Clears all loaded entries stored for node. """ @@ -359,7 +361,7 @@ defmodule Livebook.Intellisense.Elixir.IdentifierMatcher do Code.ensure_loaded?(mod) and function_exported?(mod, :exception, 1) end - defp match_module_member(mod, hint, ctx) do + def match_module_member(mod, hint, ctx) do match_module_function(mod, hint, ctx) ++ match_module_type(mod, hint, ctx) end @@ -494,7 +496,8 @@ defmodule Livebook.Intellisense.Elixir.IdentifierMatcher do imports ++ special_forms end - defp match_variable(hint, ctx) do + # FIXME: THIS IS PUBLIC + def match_variable(hint, ctx) do for {var, nil} <- Macro.Env.vars(ctx.intellisense_context.env), name = Atom.to_string(var), ctx.matcher.(name, hint), @@ -517,7 +520,7 @@ defmodule Livebook.Intellisense.Elixir.IdentifierMatcher do do: %{item | display_name: "~" <> sigil_name} end - defp match_erlang_module(hint, ctx) do + def match_erlang_module(hint, ctx) do for mod <- get_matching_modules(hint, ctx), usable_as_unquoted_module?(mod), name = ":" <> Atom.to_string(mod), @@ -671,7 +674,8 @@ defmodule Livebook.Intellisense.Elixir.IdentifierMatcher do do: module end - defp match_module_function(mod, hint, ctx, funs \\ nil) do + # FIXME: THIS IS PUBLIC + def match_module_function(mod, hint, ctx, funs \\ nil) do if ensure_loaded?(mod, ctx.node) do funs = funs || exports(mod, ctx.node) diff --git a/lib/livebook/intellisense/elixir/signature_matcher.ex b/lib/livebook/intellisense/elixir/signature_matcher.ex index 6f981b0cbab..c4a58cd55c4 100644 --- a/lib/livebook/intellisense/elixir/signature_matcher.ex +++ b/lib/livebook/intellisense/elixir/signature_matcher.ex @@ -50,7 +50,8 @@ defmodule Livebook.Intellisense.Elixir.SignatureMatcher do end end - defp signature_infos_for_members(mod, funs, active_argument, node) do + # FIXME: This is public + def signature_infos_for_members(mod, funs, active_argument, node) do infos = Livebook.Intellisense.Elixir.Docs.lookup_module_members(mod, funs, node, kinds: [:function, :macro] diff --git a/lib/livebook/intellisense/erlang.ex b/lib/livebook/intellisense/erlang.ex index 849b0ff7ec0..12e7f599aea 100644 --- a/lib/livebook/intellisense/erlang.ex +++ b/lib/livebook/intellisense/erlang.ex @@ -1,4 +1,5 @@ defmodule Livebook.Intellisense.Erlang do + alias Livebook.Intellisense @behaviour Intellisense @@ -9,30 +10,411 @@ defmodule Livebook.Intellisense.Erlang do nil end - def handle_request({:completion, hint}, context, _node) do - handle_completion(hint, context) + def handle_request({:completion, hint}, context, node) do + handle_completion(hint, context, node) end - def handle_request({:details, line, column}, context, _node) do - handle_details(line, column, context) + def handle_request({:details, line, column}, context, node) do + handle_details(line, column, context, node) end - def handle_request({:signature, hint}, context, _node) do - handle_signature(hint, context) + def handle_request({:signature, hint}, context, node) do + handle_signature(hint, context, node) end - defp handle_completion(_hint, _context) do - # TODO: implement. See t:Livebook.Runtime.completion_response/0 for return type. - nil + defp handle_completion(hint, context, node) do + Intellisense.Erlang.IdentifierMatcher.completion_identifiers(hint, context, node) + |> format_completion_identifiers(extra_completion_items(hint)) end - defp handle_details(_line, _column, _context) do - # TODO: implement. See t:Livebook.Runtime.details_response/0 for return type. - nil + def format_completion_identifiers(completions, extra \\ []) do + items = + completions + |> Enum.filter(&Intellisense.Elixir.include_in_completion?/1) + |> Enum.map(&format_completion_item/1) + |> Enum.concat(extra) + |> Enum.sort_by(&Intellisense.Elixir.completion_item_priority/1) + + %{items: items} end - defp handle_signature(_hint, _context) do - # TODO: implement. See t:Livebook.Runtime.signature_response/0 for return type. - nil + defp format_completion_item(%{kind: :variable, name: name}), + do: %{ + label: Atom.to_string(name), + kind: :variable, + documentation: "(variable)", + insert_text: Atom.to_string(name) + } + + defp format_completion_item(%{ + kind: :module, + module: module, + display_name: display_name, + documentation: documentation + }) do + subtype = Intellisense.Elixir.Docs.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: display_name, + kind: kind, + documentation: + Intellisense.Elixir.join_with_newlines([ + Intellisense.Elixir.Docs.format_documentation(documentation, :short), + "(#{detail})" + ]), + insert_text: String.trim_leading(display_name, ":") + } + end + + defp format_completion_item(%{ + kind: :function, + module: module, + name: name, + arity: arity, + type: type, + display_name: display_name, + documentation: documentation, + signatures: signatures + }), + do: %{ + label: "#{display_name}/#{arity}", + kind: :function, + documentation: + Intellisense.Elixir.join_with_newlines([ + Intellisense.Elixir.Docs.format_documentation(documentation, :short), + Intellisense.Elixir.code(format_signatures(signatures, module, name, arity)) + ]), + insert_text: + cond do + type == :macro and Intellisense.Elixir.keyword_macro?(name) -> + "#{display_name} " + + type == :macro and Intellisense.Elixir.env_macro?(name) -> + display_name + + String.starts_with?(display_name, "~") -> + display_name + + Macro.operator?(name, arity) -> + display_name + + arity == 0 -> + "#{display_name}()" + + true -> + # A snippet with cursor in parentheses + "#{display_name}(${})" + end + } + + defp format_completion_item(%{ + kind: :type, + name: name, + arity: arity, + documentation: documentation, + type_spec: type_spec + }), + do: %{ + label: "#{name}/#{arity}", + kind: :type, + documentation: + Intellisense.Elixir.join_with_newlines([ + Intellisense.Elixir.Docs.format_documentation(documentation, :short), + format_type_spec(type_spec) |> Intellisense.Elixir.code() + ]), + insert_text: + cond do + arity == 0 -> "#{Atom.to_string(name)}()" + # + true -> "#{Atom.to_string(name)}(${})" + end + } + + + # Note: array_needed is a boolean to know if '[]' should be put inside atrribute, + # as in -export([]). It is also a way to differentiate erlang's atributes from elixir's. + defp format_completion_item(%{ + kind: :module_attribute, + name: name, + documentation: documentation, + array_needed: array_needed + }), + do: %{ + label: Atom.to_string(name), + kind: :variable, + documentation: + Intellisense.Elixir.join_with_newlines([ + Intellisense.Elixir.Docs.format_documentation(documentation, :short), + "(module attribute)" + ]), + # A snippet with cursor in parentheses + insert_text: + if array_needed do + "#{name}([${}])." + else + "#{name}(${})." + end + } + + defp format_completion_item(%{ + kind: :module_attribute, + name: name, + documentation: documentation, + }), + do: %{ + label: Atom.to_string(name), + kind: :variable, + documentation: + Intellisense.Elixir.join_with_newlines([ + Intellisense.Elixir.Docs.format_documentation(documentation, :short), + "(module attribute)" + ]), + insert_text: Atom.to_string(name) + } + + defp format_completion_item(%{kind: :bitstring_modifier, name: name, arity: arity}) do + insert_text = + if arity == 0 do + Atom.to_string(name) + else + "#{name}(${})" + end + + %{ + label: Atom.to_string(name), + kind: :type, + documentation: "(bitstring option)", + insert_text: insert_text + } + end + + defp handle_details(line, column, context, node) do + %{matches: matches, range: range} = + Intellisense.Erlang.IdentifierMatcher.locate_identifier(line, column, context, node) + + case Enum.filter(matches, &Intellisense.Elixir.include_in_details?/1) do + [] -> + nil + + matches -> + matches = Enum.sort_by(matches, & &1[:arity], :asc) + contents = Enum.map(matches, &format_details_item/1) + + definition = Intellisense.Elixir.get_definition_location(hd(matches), context) + %{range: range, contents: contents, definition: definition} + end + end + + def format_details_item(%{kind: :module, module: module, documentation: documentation}) do + Intellisense.Elixir.join_with_divider([ + Intellisense.Elixir.code(Atom.to_string(module)), + Intellisense.Elixir.format_docs_link(module), + Intellisense.Elixir.Docs.format_documentation(documentation, :all) + ]) + end + + def format_details_item(%{ + kind: :function, + module: module, + name: name, + arity: arity, + documentation: documentation, + signatures: signatures, + specs: specs, + meta: meta + }) do + Intellisense.Elixir.join_with_divider([ + format_signatures(signatures, module, name, arity) |> Intellisense.Elixir.code(), + Intellisense.Elixir.join_with_middle_dot([ + Intellisense.Elixir.format_docs_link(module, {:function, name, arity}), + Intellisense.Elixir.format_meta(:since, meta) + ]), + Intellisense.Elixir.format_meta(:deprecated, meta), + format_specs(specs, name, arity) |> Intellisense.Elixir.code(), + Intellisense.Elixir.Docs.format_documentation(documentation, :all) + ]) + end + + def format_details_item(%{ + kind: :type, + module: module, + name: name, + arity: arity, + documentation: documentation, + type_spec: type_spec + }) do + Intellisense.Elixir.join_with_divider([ + format_type_signature(type_spec, module, name, arity) |> Intellisense.Elixir.code(), + Intellisense.Elixir.format_docs_link(module, {:type, name, arity}), + format_type_spec(type_spec) |> Intellisense.Elixir.code(), + Intellisense.Elixir.Docs.format_documentation(documentation, :all) + ]) + end + + defp handle_signature(hint, context, node) do + case Intellisense.Erlang.SignatureMatcher.get_matching_signatures(hint, context, node) do + {:ok, [], _active_argument} -> + nil + {:ok, signature_infos, active_argument} -> + %{ + active_argument: active_argument, + items: + signature_infos + |> Enum.map(&Intellisense.Elixir.format_signature_item/1) + |> Enum.uniq() + } + :error -> + nil + end + end + + defp format_signatures([], module, name, arity) do + signature_fallback(module, name, arity) + end + + defp format_signatures(signatures, module, _name, _arity) do + signatures_string = Enum.join(signatures, "\n") + module_string = format_signature_module(module) + + module_string <> signatures_string + end + + defp format_type_signature(nil, module, name, arity) do + signature_fallback(module, name, arity) + end + + defp format_type_signature({_type_kind, type}, module, _name, _arity) do + {:"::", _env, [lhs, _rhs]} = Code.Typespec.type_to_quoted(type) + + {name, meta, args} = lhs + + capitalized_args = Enum.map(args, fn + {var_name, var_meta, context} when is_atom(var_name) -> + string_name = Atom.to_string(var_name) + {first, rest} = String.split_at(string_name, 1) + + new_name = + (String.upcase(first) <> rest) + |> String.to_atom() + + {new_name, var_meta, context} + + other -> other + end) + + new_lhs = {name, meta, capitalized_args} + + module_string = format_signature_module(module) + + module_string <> Macro.to_string(new_lhs) + end + + defp signature_fallback(module, name, arity) do + args = Enum.map_join(1..arity//1, ", ", fn n -> "Arg#{n}" end) + "#{module}:#{name}(#{args})" + end + + defp format_signature_module(module) do + if module == :erlang do + "" + else + "#{module}:" + end + end + + defp format_specs([], _name, _arity), do: nil + + defp format_specs(specs, name, arity) do + erl_attribute = {:attribute, 1, :spec, {{name, arity}, specs}} + format_spec(erl_attribute) + + rescue + _ -> nil + end + + defp format_type_spec({type_kind, type}) do + erl_attribute = {:attribute, 1, type_kind, type} + format_spec(erl_attribute) + + rescue + _ -> nil + end + + defp format_type_spec(_), do: nil + + defp format_spec(ast) do + {:attribute, _, type, _} = ast + + offset = byte_size(Atom.to_string(type)) + 2 + + options = [linewidth: 98 + offset] + + :erl_pp.attribute(ast, options) + |> IO.chardata_to_string() + |> String.trim() + end + + @keywords [ + {"true", "(boolean)"}, + {"false", "(boolean)"}, + + {"begin", "(block operator)"}, + {"case", "(case operator)"}, + {"fun", "(anonymous function operator)"}, + {"if", "(if operator)"}, + {"when", "(guard operator)"}, + + {"after", "(after operator)"}, + {"catch", "(catch operator)"}, + {"receive", "(receive operator)"}, + {"try", "(try operator)"}, + + {"and", "(logical AND operator)"}, + {"andalso", "(short-circuit logical AND operator)"}, + {"band", "(bitwise AND operator)"}, + + {"not", "(logical NOT operator)"}, + {"bnot", "(bitwise NOT operator)"}, + + {"or", "(logical OR operator)"}, + {"orelse", "(short-circuit logical OR operator)"}, + {"bor", "(bitwise OR operator)"}, + + {"div", "(integer division operator)"}, + {"rem", "(integer remainder operator)"}, + {"bxor", "(bitwise XOR operator)"}, + {"bsl", "(bitshift left operator)"}, + {"bsr", "(bitshift right operator)"}, + {"xor", "(logical XOR operator)"}, + ] + + defp extra_completion_items(hint) do + items = Enum.map(@keywords, + fn {keyword, desc} -> %{ + label: keyword, + kind: :keyword, + documentation: desc, + insert_text: keyword, + } end + ) + + last_word = hint |> String.split(~r/\s/) |> List.last() + + if last_word == "" do + [] + else + Enum.filter(items, &String.starts_with?(&1.label, last_word)) + end end end diff --git a/lib/livebook/intellisense/erlang/identifier_matcher.ex b/lib/livebook/intellisense/erlang/identifier_matcher.ex new file mode 100644 index 00000000000..e71c13b1b9a --- /dev/null +++ b/lib/livebook/intellisense/erlang/identifier_matcher.ex @@ -0,0 +1,294 @@ +defmodule Livebook.Intellisense.Erlang.IdentifierMatcher do + alias Livebook.Intellisense + + @type identifier_item :: + %{ + kind: :variable, + name: name() + } + | %{ + kind: :module, + module: module(), + display_name: display_name(), + } + | %{ + kind: :function, + module: module(), + name: name(), + arity: arity(), + display_name: display_name(), + } + | %{ + kind: :keyword, + name: name(), + } + | %{ + kind: :bitstring_modifier, + name: name(), + arity: integer() + } + | %{ + kind: :module_attribute, + name: name(), + documentation: Docs.documentation(), + array_needed: boolean() + } + + @type name :: atom() + @type display_name :: String.t() + + @exact_matcher &Kernel.==/2 + @prefix_matcher &String.starts_with?/2 + + @bitstring_modifiers [ + :big, + :binary, + :bits, + :bitstring, + :bytes, + :integer, + :float, + :little, + :native, + :signed, + :unit, + :unsigned, + :utf8, + :utf16, + :utf32, + ] + + @reserved_attributes [ + {:module, %{doc: ""}, false}, + {:export, %{doc: ""}, true}, + {:import, %{doc: ""}, true}, + {:moduledoc, %{doc: ""}, false}, + {:compile, %{doc: ""}, true}, + {:vsn, %{doc: ""}, false}, + {:on_load, %{doc: ""}, false}, + {:nifs, %{doc: ""}, true}, + {:behaviour, %{doc: ""}, false}, + {:callback, %{doc: ""}, true}, + {:record, %{doc: ""}, false}, + {:include, %{doc: ""}, false}, + {:define, %{doc: ""}, false}, + {:file, %{doc: ""}, false}, + {:type, %{doc: ""}, false}, + {:spec, %{doc: ""}, false}, + {:doc, %{doc: ""}, false}, + {:feature, %{doc: ""}, false}, + ] + + def completion_identifiers(hint, intellisense_context, node) do + context = cursor_context(hint) + + ctx = %{ + fragment: hint, + intellisense_context: intellisense_context, + matcher: @prefix_matcher, + type: :completion, + node: node, + } + + context_to_matches(context, ctx) + end + + @doc """ + Extracts information about an identifier found in `column` in + `line`. + + The function returns range of columns where the identifier + is located and a list of matching identifier items. + """ + @spec locate_identifier(String.t(), pos_integer(), Intellisense.context(), node()) :: + %{ + matches: list(identifier_item()), + range: nil | %{from: pos_integer(), to: pos_integer()} + } + def locate_identifier(line, column, intellisense_context, node) do + case surround_context(line, column) do + %{context: context, begin: from, end: to} -> + fragment = String.slice(line, 0, to - 1) + + ctx = %{ + fragment: fragment, + intellisense_context: intellisense_context, + matcher: @exact_matcher, + type: :locate, + node: node + } + + matches = context_to_matches(context, ctx) + %{matches: matches, range: %{from: from, to: to}} + + :none -> + %{matches: [], range: nil} + end + end + + defp context_to_matches(context, ctx) do + case context do + {:mod_member, mod, member} -> + Intellisense.Elixir.IdentifierMatcher.match_module_member(mod, Atom.to_string(member), ctx) + {:pre_directive, directive} -> + match_module_attribute(directive, ctx) + {:atom, atom} -> + match_atom(Atom.to_string(atom), ctx) + {:var, var} -> + match_var(var, ctx) + {:bitstring_modifier, hint, existing} -> + for modifier <- @bitstring_modifiers, + @prefix_matcher.(Atom.to_string(modifier), Atom.to_string(hint)), + modifier not in existing, + do: %{kind: :bitstring_modifier, name: modifier, arity: 0} + + # :none + _ -> + [] + end + end + + defp cursor_context(hint) do + case :erl_scan.string(String.to_charlist(hint)) do + {:error, _, _} -> + :none + {:ok, tokens, _} -> + match_tokens_to_context(Enum.reverse(tokens)) + end + end + + defp match_tokens_to_context(tokens) do + case tokens do + [{:atom, _, member}, {:":", _}, {:atom, _, mod} | _] -> {:mod_member, mod, member} + [ {:":", _}, {:atom, _, mod} | _] -> {:mod_member, mod, :""} + + [{:atom, _, _field}, {:".", _}, {:atom, _, _record}, {:"#", _} | _] -> :none + [{:".", _}, {:atom, _, _record}, {:"#", _} | _] -> :none + [{:atom, _, _record}, {:"#", _} | _] -> :none + [{:"#", _} | _] -> :none + + [{:atom, _, macro}, {:"?", _} | _] -> {:macro, macro} + [{:var, _, macro}, {:"?", _} | _] -> {:macro, macro} + + [{:atom, _, directive}, {:"-", _}, {:".", _}] -> {:pre_directive, directive} + [{:atom, _, directive}, {:"-", _} ] -> {:pre_directive, directive} + + [{:atom, _, mod}, {:"-", _} | _] -> match_maybe_bitstring_mod(mod, tokens) + [{:atom, _, mod}, {:"/", _} | _] -> match_maybe_bitstring_mod(mod, tokens) + + [{:atom, _, atom} | _] -> {:atom, atom} + + [{:var, _, var} | _] -> {:var, var} + + [] -> :none + _ -> :expr + end + end + + defp match_module_attribute(directive, ctx) do + for {attribute, info, array_needed} <- @reserved_attributes, + ctx.matcher.(Atom.to_string(attribute), Atom.to_string(directive)), + do: %{ + kind: :module_attribute, + name: attribute, + documentation: {"text/markdown", info.doc}, + array_needed: array_needed, + } + end + + defp match_atom(hint, ctx) do + (Intellisense.Elixir.IdentifierMatcher.match_erlang_module(hint, ctx) ++ + Intellisense.Elixir.IdentifierMatcher.match_module_member(:erlang, hint, ctx)) + |> Enum.map(fn + %{display_name: name} = item when is_binary(name) -> + %{item | display_name: String.trim_leading(name, ":")} + item -> + item + end) + end + + defp surround_context(line, column) do + case :erl_scan.string(String.to_charlist(line)) do + {:error, _, _} -> + :none + {:ok, tokens, _} -> + before_cursor = split_tokens_at_column(tokens, column) + match_tokens_to_context_with_columns(before_cursor) + end + end + + defp split_tokens_at_column(tokens, column) do + {_, taken} = + Enum.reduce_while(tokens, {0, []}, fn token, {pos, acc} -> + text = token_to_text(token) + start_col = pos + 1 + + if start_col <= column do + new_pos = pos + String.length(text) + {:cont, {new_pos, [{token, start_col, new_pos + 1}| acc]}} + else + {:halt, {pos, acc}} + end + end) + taken + end + + defp token_to_text({_, _, val}), do: to_string(val) + defp token_to_text({val, _}), do: to_string(val) + + defp match_tokens_to_context_with_columns(tokens) do + case tokens do + [{{:atom, _, member}, _, to}, {{:":", _}, _, _}, {{:atom, _, mod}, from, _} | _] -> + %{context: {:mod_member, mod, member}, begin: from, end: to} + + [{{:atom, _, atom}, from, to} | _] -> + %{context: {:atom, atom}, begin: from, end: to} + + _ -> :none + end + end + + defp match_var(hint, ctx) do + hint + |> Livebook.Runtime.Evaluator.erlang_to_elixir_var + |> to_string + |> Intellisense.Elixir.IdentifierMatcher.match_variable(ctx) + |> Enum.map(&%{&1 | name: Livebook.Runtime.Evaluator.elixir_to_erlang_var(&1[:name])}) + end + + defp match_maybe_bitstring_mod(hint, tokens) do + if in_bitstring?(tokens) do + existing = bitstring_mods(tokens) |> Enum.drop(1) + + case List.last(existing) do + {:err} -> {:atom, hint} + _ -> {:bitstring_modifier, hint, existing} + end + else + {:atom, hint} + end + end + + defp bitstring_mods(tokens) do + case tokens do + # the unit modifier takes an argument, we skip it here + [{:integer, _, _}, {:":", _}, {:atom, _, :unit} = head | tail] -> + bitstring_mods([head | tail]) + + [{:atom, _, mod}, {:"-", _} | tail] -> [mod | bitstring_mods(tail)] + [{:atom, _, mod}, {:"/", _} | _] -> [mod] + + _ -> [{:err}] + end + end + + defp in_bitstring?(tokens, depth \\ 0) do + case tokens do + [] -> false + [{:"<<", _} | _] when depth == 0 -> true + [{:"<<", _} | tail] -> in_bitstring?(tail, depth - 1) + [{:">>", _} | tail] -> in_bitstring?(tail, depth + 1) + [_ | tail] -> in_bitstring?(tail, depth) + end + end +end diff --git a/lib/livebook/intellisense/erlang/signature_matcher.ex b/lib/livebook/intellisense/erlang/signature_matcher.ex new file mode 100644 index 00000000000..34953c0adf4 --- /dev/null +++ b/lib/livebook/intellisense/erlang/signature_matcher.ex @@ -0,0 +1,94 @@ +defmodule Livebook.Intellisense.Erlang.SignatureMatcher do + alias Livebook.Intellisense + + @type signature_info :: {name :: atom(), Docs.signature(), Docs.documentation(), Docs.spec()} + + + @spec get_matching_signatures(String.t(), Livebook.Intellisense.context(), node()) :: + {:ok, list(signature_info()), active_argument :: non_neg_integer()} | :error + def get_matching_signatures(hint, _intellisense_context, node) do + case call_target_and_argument(hint) do + {:ok, {:remote, mod, name}, active_argument} -> + signature_infos = + Intellisense.Elixir.SignatureMatcher.signature_infos_for_members( + mod, + [{name, :any}], + active_argument, + node + ) + + {:ok, signature_infos, active_argument} + + {:ok, {:local, name}, active_argument} -> + signature_infos = + Intellisense.Elixir.SignatureMatcher.signature_infos_for_members( + :erlang, + [{name, :any}], + active_argument, + node + ) + + {:ok, signature_infos, active_argument} + + _ -> + :error + end + end + + defp call_target_and_argument(hint) do + with {:ok, ast} <- parse_last_call(hint) do + [call_head | _] = ast + case call_head do + {:call, _, {:remote, _, {:atom, _, mod}, {:atom, _, name}}, args} -> + {:ok, {:remote, mod, name}, length(args) - 1} + {:call, _, {:atom, _, name}, args} -> + {:ok, {:local, name}, length(args) - 1} + _ -> :error + end + else + _ -> :error + end + end + + defp parse_last_call(hint) do + case :erl_scan.string(String.to_charlist(hint)) do + {:ok, tokens, _} -> + tokens + |> Enum.reverse + |> filter_last_call + |> Enum.concat([{:atom, 1, :__context__}, {:")", 1}, {:dot, 1}]) + |> :erl_parse.parse_exprs + error -> + error + end + end + + defp filter_last_call(tokens) do + filter_last_call(tokens, :left_bracket) + end + defp filter_last_call([{:"(", 1} | tokens], :left_bracket) do + filter_last_call(tokens, :function_name) ++ [{:"(", 1}] + end + defp filter_last_call([{:")", 1} | tokens], :left_bracket) do + filter_last_call(tokens, :right_bracket) ++ [{:")", 1}] + end + defp filter_last_call([tok | tokens], :left_bracket) do + filter_last_call(tokens, :left_bracket) ++ [tok] + end + defp filter_last_call([{:"(", 1} | tokens], :right_bracket) do + filter_last_call(tokens, :left_bracket) ++ [{:"(", 1}] + end + defp filter_last_call([tok | tokens], :right_bracket) do + filter_last_call(tokens, :right_bracket) ++ [tok] + end + defp filter_last_call([{:atom, 1, fun}, {:":", 1}, {:atom, 1, mod} | _], :function_name) do + [{:atom, 1, mod}, {:":", 1}, {:atom, 1, fun}] + end + defp filter_last_call([{:atom, 1, fun} | _], :function_name) do + [{:atom, 1, fun}] + end + defp filter_last_call(_, _) do + [] + end + +end diff --git a/lib/livebook/runtime/erl_dist.ex b/lib/livebook/runtime/erl_dist.ex index 3e08bdfc518..25a2da11cb3 100644 --- a/lib/livebook/runtime/erl_dist.ex +++ b/lib/livebook/runtime/erl_dist.ex @@ -36,6 +36,8 @@ defmodule Livebook.Runtime.ErlDist do Livebook.Intellisense.Elixir.IdentifierMatcher, Livebook.Intellisense.Elixir.SignatureMatcher, Livebook.Intellisense.Erlang, + Livebook.Intellisense.Erlang.IdentifierMatcher, + Livebook.Intellisense.Erlang.SignatureMatcher, Livebook.Runtime.ErlDist, Livebook.Runtime.ErlDist.NodeManager, Livebook.Runtime.ErlDist.RuntimeServer, diff --git a/lib/livebook/runtime/evaluator.ex b/lib/livebook/runtime/evaluator.ex index ad8b4ec4f33..fd97138c974 100644 --- a/lib/livebook/runtime/evaluator.ex +++ b/lib/livebook/runtime/evaluator.ex @@ -751,7 +751,7 @@ defmodule Livebook.Runtime.Evaluator do try do {:ok, forms} = :epp.parse_file(filename, source_name: String.to_charlist(env.file)) - case :compile.forms(forms) do + case :compile.forms(forms, [:debug_info, source: String.to_charlist(env.file)]) do {:ok, module, binary} -> file = if ebin_path = ebin_path() do @@ -885,14 +885,15 @@ defmodule Livebook.Runtime.Evaluator do end end - defp elixir_to_erlang_var(name) do + #FIXME: THIS IS PUBLIC NOW + def elixir_to_erlang_var(name) do name |> :erlang.atom_to_binary() |> toggle_var_case() |> :erlang.binary_to_atom() end - defp erlang_to_elixir_var(name) do + def erlang_to_elixir_var(name) do name |> :erlang.atom_to_binary() |> toggle_var_case() diff --git a/test/livebook/intellisense/erlang_test.exs b/test/livebook/intellisense/erlang_test.exs new file mode 100644 index 00000000000..1a73ae6336b --- /dev/null +++ b/test/livebook/intellisense/erlang_test.exs @@ -0,0 +1 @@ +#TODO: this: