diff --git a/assets/js/hooks/cell_editor/live_editor.js b/assets/js/hooks/cell_editor/live_editor.js index 00f4b7826..f3c61aa46 100644 --- a/assets/js/hooks/cell_editor/live_editor.js +++ b/assets/js/hooks/cell_editor/live_editor.js @@ -506,7 +506,9 @@ export default class LiveEditor { const settings = settingsStore.get(); // Trigger completion implicitly only for identifiers and members - const triggerBeforeCursor = context.matchBefore(/[\w?!.]$/); + const triggerBeforeCursor = context.matchBefore( + this.getTriggerBeforeCursorRegex(), + ); if (!triggerBeforeCursor && !context.explicit) { return null; @@ -546,6 +548,13 @@ export default class LiveEditor { .catch(() => null); } + /** Get the regex for the trigger before cursor */ + getTriggerBeforeCursorRegex() { + if (this.language === "elixir") return /[\w?!.]$/; + if (this.language === "erlang") return /[\w:]$/; + return /[\w.]$/; + } + /** @private */ getCompletionHint(context) { // By default we only send the current line content until cursor diff --git a/lib/livebook/intellisense.ex b/lib/livebook/intellisense.ex index b1aba292f..7d12d52a3 100644 --- a/lib/livebook/intellisense.ex +++ b/lib/livebook/intellisense.ex @@ -1,17 +1,12 @@ defmodule Livebook.Intellisense do - # This module provides intellisense related operations suitable for - # integration with a text editor. + # Language-specific intellisense that is used by the code editor. # - # In a way, this provides the very basic features of a language - # server that Livebook uses. + # This module defines a behaviour and dispatches the intellisense + # implementation to the appropriate language-specific module. alias Livebook.Intellisense alias Livebook.Runtime - # Configures width used for inspect and specs formatting. - @line_length 45 - @extended_line_length 80 - @typedoc """ Evaluation state to consider for intellisense. @@ -24,6 +19,35 @@ defmodule Livebook.Intellisense do map_binding: (Code.binding() -> any()) } + @doc """ + Language-specific implementation of `t:Runtime.intellisense_request/1`. + """ + @callback handle_request( + request :: Runtime.intellisense_request(), + context :: context(), + node :: node() + ) :: Runtime.intellisense_response() + + @doc """ + Resolves an intellisense request as defined in + `t:Runtime.intellisense_request/1`. + """ + @spec handle_request( + Runtime.language(), + Runtime.intellisense_request(), + context(), + node() + ) :: Runtime.intellisense_response() + def handle_request(language, request, context, node) do + if impl = impl_for_language(language) do + apply(impl, :handle_request, [request, context, node]) + end + end + + defp impl_for_language(:elixir), do: Intellisense.Elixir + defp impl_for_language(:erlang), do: Intellisense.Erlang + defp impl_for_language(_other), do: nil + @doc """ Adjusts the system for more accurate intellisense. """ @@ -57,896 +81,6 @@ defmodule Livebook.Intellisense do """ @spec clear_cache(node()) :: :ok def clear_cache(node) do - Intellisense.IdentifierMatcher.clear_all_loaded(node) - end - - @doc """ - Resolves an intellisense request as defined by `Runtime`. - - In practice this function simply dispatches the request to one of - the other public functions in this module. - """ - @spec handle_request( - Runtime.intellisense_request(), - context(), - node() - ) :: Runtime.intellisense_response() - def handle_request(request, context, node) - - def handle_request({:completion, hint}, context, node) do - items = get_completion_items(hint, context, node) - %{items: items} - end - - def handle_request({:details, line, column}, context, node) do - get_details(line, column, context, node) - end - - def handle_request({:signature, hint}, context, node) do - get_signature_items(hint, context, node) - end - - def handle_request({:format, code}, _context, _node) do - format_code(code) - end - - @doc """ - Formats Elixir code. - """ - @spec format_code(String.t()) :: Runtime.format_response() - def format_code(code) do - try do - formatted = - code - |> Code.format_string!() - |> IO.iodata_to_binary() - - %{code: formatted, code_markers: []} - rescue - error in [SyntaxError, TokenMissingError, MismatchedDelimiterError] -> - code_marker = %{line: error.line, description: error.description, severity: :error} - %{code: nil, code_markers: [code_marker]} - end - end - - @doc """ - Returns information about signatures matching the given `hint`. - """ - @spec get_signature_items(String.t(), context(), node()) :: Runtime.signature_response() | nil - def get_signature_items(hint, context, node) do - case Intellisense.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(&format_signature_item/1) - |> Enum.uniq() - } - - :error -> - nil - end - end - - defp format_signature_item({_name, signature, _documentation, _specs}), - do: %{ - signature: signature, - arguments: arguments_from_signature(signature) - } - - defp arguments_from_signature(signature) do - signature - |> Code.string_to_quoted!() - |> elem(2) - |> Enum.map(&Macro.to_string/1) - end - - @doc """ - Returns a list of completion suggestions for the given `hint`. - """ - @spec get_completion_items(String.t(), context(), node()) :: list(Runtime.completion_item()) - def get_completion_items(hint, context, node) do - Intellisense.IdentifierMatcher.completion_identifiers(hint, context, node) - |> Enum.filter(&include_in_completion?/1) - |> Enum.map(&format_completion_item/1) - |> Enum.concat(extra_completion_items(hint)) - |> Enum.sort_by(&completion_item_priority/1) - 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 - - 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: :map_field, name: name}), - do: %{ - label: Atom.to_string(name), - kind: :field, - documentation: "(field)", - insert_text: Atom.to_string(name) - } - - defp format_completion_item(%{kind: :in_map_field, name: name}), - do: %{ - label: Atom.to_string(name), - kind: :field, - documentation: "(field)", - insert_text: "#{name}: " - } - - defp format_completion_item(%{ - kind: :in_struct_field, - struct: struct, - name: name, - default: default - }), - do: %{ - label: Atom.to_string(name), - kind: :field, - documentation: - join_with_divider([ - """ - `%#{inspect(struct)}{}` struct field. - - **Default** - - ``` - #{inspect(default, pretty: true, width: @line_length)} - ```\ - """ - ]), - insert_text: "#{name}: " - } - - defp format_completion_item(%{ - kind: :module, - module: module, - display_name: display_name, - documentation: documentation - }) do - subtype = Intellisense.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: - join_with_newlines([ - 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: - join_with_newlines([ - format_documentation(documentation, :short), - code(format_signatures(signatures, module, name, arity)) - ]), - insert_text: - cond do - type == :macro and keyword_macro?(name) -> - "#{display_name} " - - type == :macro and 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: - join_with_newlines([ - format_documentation(documentation, :short), - format_type_spec(type_spec, @line_length) |> code() - ]), - insert_text: - cond do - arity == 0 -> "#{Atom.to_string(name)}()" - true -> "#{Atom.to_string(name)}(${})" - end - } - - defp format_completion_item(%{ - kind: :module_attribute, - name: name, - documentation: documentation - }), - do: %{ - label: Atom.to_string(name), - kind: :variable, - documentation: - join_with_newlines([ - 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 keyword_macro?(name) do - def? = name |> Atom.to_string() |> String.starts_with?("def") - - def? or - name in [ - # Special forms - :alias, - :case, - :cond, - :for, - :fn, - :import, - :quote, - :receive, - :require, - :try, - :with, - - # Kernel - :destructure, - :raise, - :reraise, - :if, - :unless, - :use - ] - end - - defp env_macro?(name) do - name in [:__ENV__, :__MODULE__, :__DIR__, :__STACKTRACE__, :__CALLER__] - end - - defp extra_completion_items(hint) do - items = [ - %{ - label: "true", - kind: :keyword, - documentation: "(boolean)", - insert_text: "true" - }, - %{ - label: "false", - kind: :keyword, - documentation: "(boolean)", - insert_text: "false" - }, - %{ - label: "nil", - kind: :keyword, - documentation: "(special atom)", - insert_text: "nil" - }, - %{ - label: "when", - kind: :keyword, - documentation: "(guard operator)", - insert_text: "when" - } - ] - - 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 - - @ordered_kinds [ - :keyword, - :field, - :variable, - :module, - :struct, - :interface, - :function, - :type, - :bitstring_option - ] - - defp completion_item_priority(%{kind: :struct} = completion_item) do - if completion_item.documentation =~ "(exception)" do - {length(@ordered_kinds), completion_item.label} - else - {completion_item_kind_priority(completion_item.kind), completion_item.label} - end - end - - defp completion_item_priority(completion_item) do - {completion_item_kind_priority(completion_item.kind), completion_item.label} - end - - defp completion_item_kind_priority(kind) when kind in @ordered_kinds do - Enum.find_index(@ordered_kinds, &(&1 == kind)) - end - - @doc """ - Returns detailed information about an identifier located - in `column` in `line`. - """ - @spec get_details(String.t(), pos_integer(), context(), node()) :: - Runtime.details_response() | nil - def get_details(line, column, context, node) do - %{matches: matches, range: range} = - Intellisense.IdentifierMatcher.locate_identifier(line, column, context, node) - - case Enum.filter(matches, &include_in_details?/1) do - [] -> - nil - - matches -> - matches = Enum.sort_by(matches, & &1[:arity], :asc) - 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 - - defp format_details_item(%{kind: :variable, name: name}), do: code(name) - - defp format_details_item(%{kind: :map_field, name: name}), do: code(name) - - defp 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 - join_with_divider([ - code(name), - """ - **Default** - - ``` - #{inspect(default, pretty: true, width: @line_length)} - ```\ - """ - ]) - end - - defp format_details_item(%{kind: :module, module: module, documentation: documentation}) do - join_with_divider([ - code(inspect(module)), - format_docs_link(module), - format_documentation(documentation, :all) - ]) - end - - defp format_details_item(%{ - kind: :function, - module: module, - name: name, - arity: arity, - documentation: documentation, - signatures: signatures, - specs: specs, - meta: meta - }) do - join_with_divider([ - format_signatures(signatures, module, name, arity) |> code(), - join_with_middle_dot([ - format_docs_link(module, {:function, name, arity}), - format_meta(:since, meta) - ]), - format_meta(:deprecated, meta), - format_specs(specs, name, @extended_line_length) |> code(), - format_documentation(documentation, :all) - ]) - end - - defp format_details_item(%{ - kind: :type, - module: module, - name: name, - arity: arity, - documentation: documentation, - type_spec: type_spec - }) do - join_with_divider([ - format_type_signature(type_spec, module, name, arity) |> code(), - format_docs_link(module, {:type, name, arity}), - format_type_spec(type_spec, @extended_line_length) |> code(), - format_documentation(documentation, :all) - ]) - end - - defp format_details_item(%{kind: :module_attribute, name: name, documentation: documentation}) do - join_with_divider([ - code("@#{name}"), - format_documentation(documentation, :all) - ]) - end - - defp get_definition_location(%{kind: :module, module: module}, context) do - get_definition_location(module, context, {:module, module}) - end - - defp 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 - get_definition_location(module, context, {:type, name, arity}) - end - - defp get_definition_location(_idenfitier, _context), do: nil - - defp 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.Docs.locate_definition(String.to_charlist(path), identifier) do - file = module.module_info(:compile)[:source] - %{file: to_string(file), line: line} - else - _otherwise -> nil - end - end - end - - # Formatting helpers - - defp join_with_divider(strings), do: join_with(strings, "\n\n---\n\n") - - defp join_with_newlines(strings), do: join_with(strings, "\n\n") - - defp join_with_middle_dot(strings), do: join_with(strings, " · ") - - defp 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 - - defp code(code) do - """ - ``` - #{code} - ```\ - """ - end - - defp format_docs_link(module, function_or_type \\ nil) do - app = Application.get_application(module) - module_name = module_name(module) - - is_otp? = - case :code.which(module) do - :preloaded -> true - [_ | _] = path -> List.starts_with?(path, :code.lib_dir()) - _ -> false - end - - cond do - is_otp? -> - hash = - case function_or_type do - {:function, function, arity} -> "##{function}-#{arity}" - {:type, type, _arity} -> "#type-#{type}" - nil -> "" - end - - url = "https://www.erlang.org/doc/man/#{module_name}.html#{hash}" - "[View on Erlang Docs](#{url})" - - vsn = app && Application.spec(app, :vsn) -> - hash = - case function_or_type do - {:function, function, arity} -> "##{function}/#{arity}" - {:type, type, arity} -> "#t:#{type}/#{arity}" - nil -> "" - end - - url = "https://hexdocs.pm/#{app}/#{vsn}/#{module_name}.html#{hash}" - "[View on Hexdocs](#{url})" - - true -> - 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") - - # Don't add module prefix to operator signatures - if :binary.match(signatures_string, ["(", "/"]) != :nomatch do - inspect(module) <> "." <> signatures_string - else - signatures_string - end - 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) - inspect(module) <> "." <> Macro.to_string(lhs) - end - - defp signature_fallback(module, name, arity) do - args = Enum.map_join(1..arity//1, ", ", fn n -> "arg#{n}" end) - "#{inspect(module)}.#{name}(#{args})" - end - - defp format_meta(:deprecated, %{deprecated: deprecated}) do - "**Deprecated**. " <> deprecated - end - - defp format_meta(:since, %{since: since}) do - "Since " <> since - end - - defp format_meta(_, _), do: nil - - defp format_specs([], _name, _line_length), do: nil - - defp format_specs(specs, name, line_length) do - spec_lines = - Enum.map(specs, fn spec -> - code = Code.Typespec.spec_to_quoted(name, spec) |> Macro.to_string() - ["@spec ", code] - end) - - specs_code = - spec_lines - |> Enum.intersperse("\n") - |> IO.iodata_to_binary() - - try do - Code.format_string!(specs_code, line_length: line_length) - rescue - _ -> specs_code - end - end - - defp format_type_spec({type_kind, type}, line_length) when type_kind in [:type, :opaque] do - ast = {:"::", _env, [lhs, _rhs]} = Code.Typespec.type_to_quoted(type) - - type_string = - case type_kind do - :type -> ast - :opaque -> lhs - end - |> Macro.to_string() - - type_spec_code = "@#{type_kind} #{type_string}" - - try do - Code.format_string!(type_spec_code, line_length: line_length) - rescue - _ -> type_spec_code - end - end - - defp format_type_spec(_, _line_length), do: nil - - defp format_documentation(doc, variant) - - defp format_documentation(nil, _variant) do - "No documentation available" - end - - defp format_documentation(:hidden, _variant) do - "This is a private API" - end - - defp format_documentation({"text/markdown", markdown}, :short) do - # Extract just the first paragraph - markdown - |> String.split("\n\n") - |> hd() - |> String.trim() - end - - defp format_documentation({"application/erlang+html", erlang_html}, :short) do - # Extract just the first paragraph - erlang_html - |> Enum.find(&match?({:p, _, _}, &1)) - |> case do - nil -> nil - paragraph -> erlang_html_to_md([paragraph]) - end - end - - defp format_documentation({"text/markdown", markdown}, :all) do - markdown - end - - defp format_documentation({"application/erlang+html", erlang_html}, :all) do - erlang_html_to_md(erlang_html) - end - - defp format_documentation({format, _content}, _variant) do - raise "unknown documentation format #{inspect(format)}" - end - - # Erlang HTML AST - # See https://erlang.org/doc/apps/erl_docgen/doc_storage.html#erlang-documentation-format - - def erlang_html_to_md(ast) do - build_md([], ast) - |> IO.iodata_to_binary() - |> String.trim() - end - - defp build_md(iodata, ast) - - defp build_md(iodata, []), do: iodata - - defp build_md(iodata, [string | ast]) when is_binary(string) do - string |> append_inline(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{tag, _, content} | ast]) when tag in [:em, :i] do - render_emphasis(content) |> append_inline(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{tag, _, content} | ast]) when tag in [:strong, :b] do - render_strong(content) |> append_inline(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{:code, _, content} | ast]) do - render_code_inline(content) |> append_inline(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{:a, attrs, content} | ast]) do - render_link(content, attrs) |> append_inline(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{:br, _, []} | ast]) do - render_line_break() |> append_inline(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{tag, _, content} | ast]) when tag in [:p, :div] do - render_paragraph(content) |> append_block(iodata) |> build_md(ast) - end - - @headings ~w(h1 h2 h3 h4 h5 h6)a - - defp build_md(iodata, [{tag, _, content} | ast]) when tag in @headings do - n = 1 + Enum.find_index(@headings, &(&1 == tag)) - render_heading(n, content) |> append_block(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{:pre, _, [{:code, _, [content]}]} | ast]) do - render_code_block(content, "erlang") |> append_block(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{:div, [{:class, class} | _], content} | ast]) do - type = class |> to_string() |> String.upcase() - - render_blockquote([{:p, [], [{:strong, [], [type]}]} | content]) - |> append_block(iodata) - |> build_md(ast) - end - - defp build_md(iodata, [{:ul, [{:class, "types"} | _], content} | ast]) do - render_types_list(content) |> append_block(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{:ul, _, content} | ast]) do - render_unordered_list(content) |> append_block(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{:ol, _, content} | ast]) do - render_ordered_list(content) |> append_block(iodata) |> build_md(ast) - end - - defp build_md(iodata, [{:dl, _, content} | ast]) do - render_description_list(content) |> append_block(iodata) |> build_md(ast) - end - - defp append_inline(md, iodata), do: [iodata, md] - defp append_block(md, iodata), do: [iodata, "\n", md, "\n"] - - # Renderers - - defp render_emphasis(content) do - ["*", build_md([], content), "*"] - end - - defp render_strong(content) do - ["**", build_md([], content), "**"] - end - - defp render_code_inline(content) do - ["`", build_md([], content), "`"] - end - - defp render_link(content, attrs) do - caption = build_md([], content) - - if href = attrs[:href] do - ["[", caption, "](", href, ")"] - else - caption - end - end - - defp render_line_break(), do: "\\\n" - - defp render_paragraph(content), do: erlang_html_to_md(content) - - defp render_heading(n, content) do - title = build_md([], content) - [String.duplicate("#", n), " ", title] - end - - defp render_code_block(content, language) do - ["```", language, "\n", content, "\n```"] - end - - defp render_blockquote(content) do - inner = erlang_html_to_md(content) - - inner - |> String.split("\n") - |> Enum.map_intersperse("\n", &["> ", &1]) - end - - defp render_unordered_list(content) do - marker_fun = fn _index -> "* " end - render_list(content, marker_fun, " ") - end - - defp render_ordered_list(content) do - marker_fun = fn index -> "#{index + 1}. " end - render_list(content, marker_fun, " ") - end - - defp render_list(items, marker_fun, indent) do - spaced? = spaced_list_items?(items) - item_separator = if(spaced?, do: "\n\n", else: "\n") - - items - |> Enum.map(fn {:li, _, content} -> erlang_html_to_md(content) end) - |> Enum.with_index() - |> Enum.map(fn {inner, index} -> - [first_line | lines] = String.split(inner, "\n") - - first_line = marker_fun.(index) <> first_line - - lines = - Enum.map(lines, fn - "" -> "" - line -> indent <> line - end) - - Enum.intersperse([first_line | lines], "\n") - end) - |> Enum.intersperse(item_separator) - end - - defp spaced_list_items?([{:li, _, [{:p, _, _content} | _]} | _items]), do: true - defp spaced_list_items?([_ | items]), do: spaced_list_items?(items) - defp spaced_list_items?([]), do: false - - defp render_description_list(content) do - # Rewrite description list as an unordered list with pseudo heading - content - |> Enum.chunk_every(2) - |> Enum.map(fn [{:dt, _, dt}, {:dd, _, dd}] -> - {:li, [], [{:p, [], [{:strong, [], dt}]}, {:p, [], dd}]} - end) - |> render_unordered_list() - end - - defp render_types_list(content) do - content - |> group_type_list_items([]) - |> render_unordered_list() - end - - defp group_type_list_items([], acc), do: Enum.reverse(acc) - - defp group_type_list_items([{:li, [{:name, _type_name}], []} | items], acc) do - group_type_list_items(items, acc) - end - - defp group_type_list_items([{:li, [{:class, "type"}], content} | items], acc) do - group_type_list_items(items, [{:li, [], [{:code, [], content}]} | acc]) - end - - defp group_type_list_items( - [{:li, [{:class, "description"}], content} | items], - [{:li, [], prev_content} | acc] - ) do - group_type_list_items(items, [{:li, [], prev_content ++ [{:p, [], content}]} | acc]) - end - - defp module_name(module) do - case Atom.to_string(module) do - "Elixir." <> name -> name - name -> name - end + Intellisense.Elixir.IdentifierMatcher.clear_all_loaded(node) end end diff --git a/lib/livebook/intellisense/elixir.ex b/lib/livebook/intellisense/elixir.ex new file mode 100644 index 000000000..f1ee0b1cd --- /dev/null +++ b/lib/livebook/intellisense/elixir.ex @@ -0,0 +1,637 @@ +defmodule Livebook.Intellisense.Elixir do + alias Livebook.Intellisense + + @behaviour Intellisense + + # Configures width used for inspect and specs formatting. + @line_length 45 + @extended_line_length 80 + + @impl true + def handle_request({:format, code}, _context, _node) do + handle_format(code) + end + + 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, node) + end + + def handle_request({:signature, hint}, context, node) do + handle_signature(hint, context, node) + end + + defp handle_format(code) do + try do + formatted = + code + |> Code.format_string!() + |> IO.iodata_to_binary() + + %{code: formatted, code_markers: []} + rescue + error in [SyntaxError, TokenMissingError, MismatchedDelimiterError] -> + code_marker = %{line: error.line, description: error.description, severity: :error} + %{code: nil, code_markers: [code_marker]} + end + end + + defp handle_completion(hint, context, node) do + items = + Intellisense.Elixir.IdentifierMatcher.completion_identifiers(hint, context, node) + |> Enum.filter(&include_in_completion?/1) + |> Enum.map(&format_completion_item/1) + |> Enum.concat(extra_completion_items(hint)) + |> 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 + + 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: :map_field, name: name}), + do: %{ + label: Atom.to_string(name), + kind: :field, + documentation: "(field)", + insert_text: Atom.to_string(name) + } + + defp format_completion_item(%{kind: :in_map_field, name: name}), + do: %{ + label: Atom.to_string(name), + kind: :field, + documentation: "(field)", + insert_text: "#{name}: " + } + + defp format_completion_item(%{ + kind: :in_struct_field, + struct: struct, + name: name, + default: default + }), + do: %{ + label: Atom.to_string(name), + kind: :field, + documentation: + join_with_divider([ + """ + `%#{inspect(struct)}{}` struct field. + + **Default** + + ``` + #{inspect(default, pretty: true, width: @line_length)} + ```\ + """ + ]), + insert_text: "#{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: + 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: + join_with_newlines([ + Intellisense.Elixir.Docs.format_documentation(documentation, :short), + code(format_signatures(signatures, module, name, arity)) + ]), + insert_text: + cond do + type == :macro and keyword_macro?(name) -> + "#{display_name} " + + type == :macro and 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: + join_with_newlines([ + Intellisense.Elixir.Docs.format_documentation(documentation, :short), + format_type_spec(type_spec, @line_length) |> code() + ]), + insert_text: + cond do + arity == 0 -> "#{Atom.to_string(name)}()" + true -> "#{Atom.to_string(name)}(${})" + end + } + + defp format_completion_item(%{ + kind: :module_attribute, + name: name, + documentation: documentation + }), + do: %{ + label: Atom.to_string(name), + kind: :variable, + documentation: + 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 keyword_macro?(name) do + def? = name |> Atom.to_string() |> String.starts_with?("def") + + def? or + name in [ + # Special forms + :alias, + :case, + :cond, + :for, + :fn, + :import, + :quote, + :receive, + :require, + :try, + :with, + + # Kernel + :destructure, + :raise, + :reraise, + :if, + :unless, + :use + ] + end + + defp env_macro?(name) do + name in [:__ENV__, :__MODULE__, :__DIR__, :__STACKTRACE__, :__CALLER__] + end + + defp extra_completion_items(hint) do + items = [ + %{ + label: "true", + kind: :keyword, + documentation: "(boolean)", + insert_text: "true" + }, + %{ + label: "false", + kind: :keyword, + documentation: "(boolean)", + insert_text: "false" + }, + %{ + label: "nil", + kind: :keyword, + documentation: "(special atom)", + insert_text: "nil" + }, + %{ + label: "when", + kind: :keyword, + documentation: "(guard operator)", + insert_text: "when" + } + ] + + 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 + + @ordered_kinds [ + :keyword, + :field, + :variable, + :module, + :struct, + :interface, + :function, + :type, + :bitstring_option + ] + + defp completion_item_priority(%{kind: :struct} = completion_item) do + if completion_item.documentation =~ "(exception)" do + {length(@ordered_kinds), completion_item.label} + else + {completion_item_kind_priority(completion_item.kind), completion_item.label} + end + end + + defp completion_item_priority(completion_item) do + {completion_item_kind_priority(completion_item.kind), completion_item.label} + end + + defp completion_item_kind_priority(kind) when kind in @ordered_kinds do + Enum.find_index(@ordered_kinds, &(&1 == kind)) + end + + defp handle_details(line, column, context, node) do + %{matches: matches, range: range} = + Intellisense.Elixir.IdentifierMatcher.locate_identifier(line, column, context, node) + + case Enum.filter(matches, &include_in_details?/1) do + [] -> + nil + + matches -> + matches = Enum.sort_by(matches, & &1[:arity], :asc) + 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 + + defp format_details_item(%{kind: :variable, name: name}), do: code(name) + + defp format_details_item(%{kind: :map_field, name: name}), do: code(name) + + defp 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 + join_with_divider([ + code(name), + """ + **Default** + + ``` + #{inspect(default, pretty: true, width: @line_length)} + ```\ + """ + ]) + end + + defp format_details_item(%{kind: :module, module: module, documentation: documentation}) do + join_with_divider([ + code(inspect(module)), + format_docs_link(module), + Intellisense.Elixir.Docs.format_documentation(documentation, :all) + ]) + end + + defp format_details_item(%{ + kind: :function, + module: module, + name: name, + arity: arity, + documentation: documentation, + signatures: signatures, + specs: specs, + meta: meta + }) do + join_with_divider([ + format_signatures(signatures, module, name, arity) |> code(), + join_with_middle_dot([ + format_docs_link(module, {:function, name, arity}), + format_meta(:since, meta) + ]), + format_meta(:deprecated, meta), + format_specs(specs, name, @extended_line_length) |> code(), + Intellisense.Elixir.Docs.format_documentation(documentation, :all) + ]) + end + + defp format_details_item(%{ + kind: :type, + module: module, + name: name, + arity: arity, + documentation: documentation, + type_spec: type_spec + }) do + join_with_divider([ + format_type_signature(type_spec, module, name, arity) |> code(), + format_docs_link(module, {:type, name, arity}), + format_type_spec(type_spec, @extended_line_length) |> code(), + Intellisense.Elixir.Docs.format_documentation(documentation, :all) + ]) + end + + defp 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 + get_definition_location(module, context, {:module, module}) + end + + defp 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 + get_definition_location(module, context, {:type, name, arity}) + end + + defp get_definition_location(_idenfitier, _context), do: nil + + defp 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] + %{file: to_string(file), line: line} + else + _otherwise -> nil + end + end + end + + defp handle_signature(hint, context, node) do + case Intellisense.Elixir.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(&format_signature_item/1) + |> Enum.uniq() + } + + :error -> + nil + end + end + + defp format_signature_item({_name, signature, _documentation, _specs}), + do: %{ + signature: signature, + arguments: arguments_from_signature(signature) + } + + defp arguments_from_signature(signature) do + signature + |> Code.string_to_quoted!() + |> elem(2) + |> Enum.map(&Macro.to_string/1) + end + + # Formatting helpers + + defp join_with_divider(strings), do: join_with(strings, "\n\n---\n\n") + + defp join_with_newlines(strings), do: join_with(strings, "\n\n") + + defp join_with_middle_dot(strings), do: join_with(strings, " · ") + + defp 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 + + defp code(code) do + """ + ``` + #{code} + ```\ + """ + end + + defp format_docs_link(module, function_or_type \\ nil) do + app = Application.get_application(module) + module_name = module_name(module) + + is_otp? = + case :code.which(module) do + :preloaded -> true + [_ | _] = path -> List.starts_with?(path, :code.lib_dir()) + _ -> false + end + + cond do + is_otp? -> + hash = + case function_or_type do + {:function, function, arity} -> "##{function}-#{arity}" + {:type, type, _arity} -> "#type-#{type}" + nil -> "" + end + + url = "https://www.erlang.org/doc/man/#{module_name}.html#{hash}" + "[View on Erlang Docs](#{url})" + + vsn = app && Application.spec(app, :vsn) -> + hash = + case function_or_type do + {:function, function, arity} -> "##{function}/#{arity}" + {:type, type, arity} -> "#t:#{type}/#{arity}" + nil -> "" + end + + url = "https://hexdocs.pm/#{app}/#{vsn}/#{module_name}.html#{hash}" + "[View on Hexdocs](#{url})" + + true -> + nil + end + end + + defp module_name(module) do + case Atom.to_string(module) do + "Elixir." <> name -> name + name -> name + 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") + + # Don't add module prefix to operator signatures + if :binary.match(signatures_string, ["(", "/"]) != :nomatch do + inspect(module) <> "." <> signatures_string + else + signatures_string + end + 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) + inspect(module) <> "." <> Macro.to_string(lhs) + end + + defp signature_fallback(module, name, arity) do + args = Enum.map_join(1..arity//1, ", ", fn n -> "arg#{n}" end) + "#{inspect(module)}.#{name}(#{args})" + end + + defp format_meta(:deprecated, %{deprecated: deprecated}) do + "**Deprecated**. " <> deprecated + end + + defp format_meta(:since, %{since: since}) do + "Since " <> since + end + + defp format_meta(_, _), do: nil + + defp format_specs([], _name, _line_length), do: nil + + defp format_specs(specs, name, line_length) do + spec_lines = + Enum.map(specs, fn spec -> + code = Code.Typespec.spec_to_quoted(name, spec) |> Macro.to_string() + ["@spec ", code] + end) + + specs_code = + spec_lines + |> Enum.intersperse("\n") + |> IO.iodata_to_binary() + + try do + Code.format_string!(specs_code, line_length: line_length) + rescue + _ -> specs_code + end + end + + defp format_type_spec({type_kind, type}, line_length) when type_kind in [:type, :opaque] do + ast = {:"::", _env, [lhs, _rhs]} = Code.Typespec.type_to_quoted(type) + + type_string = + case type_kind do + :type -> ast + :opaque -> lhs + end + |> Macro.to_string() + + type_spec_code = "@#{type_kind} #{type_string}" + + try do + Code.format_string!(type_spec_code, line_length: line_length) + rescue + _ -> type_spec_code + end + end + + defp format_type_spec(_, _line_length), do: nil +end diff --git a/lib/livebook/intellisense/docs.ex b/lib/livebook/intellisense/elixir/docs.ex similarity index 50% rename from lib/livebook/intellisense/docs.ex rename to lib/livebook/intellisense/elixir/docs.ex index 01915a2c0..780609771 100644 --- a/lib/livebook/intellisense/docs.ex +++ b/lib/livebook/intellisense/elixir/docs.ex @@ -1,4 +1,4 @@ -defmodule Livebook.Intellisense.Docs do +defmodule Livebook.Intellisense.Elixir.Docs do # This module is responsible for extracting and normalizing # information like documentation, signatures and specs. # @@ -230,4 +230,245 @@ defmodule Livebook.Intellisense.Docs do defp keyfind(list, key) do List.keyfind(list, key, 0) || :error end + + @doc """ + Formats the given documentation content as Markdown. + + The `variant` argument can be either `:all` to return the full content, + or `:short` to return only the first paragraph. + """ + @spec format_documentation(documentation(), :all | :short) :: String.t() + def format_documentation(doc, variant) + + def format_documentation(nil, _variant) do + "No documentation available" + end + + def format_documentation(:hidden, _variant) do + "This is a private API" + end + + def format_documentation({"text/markdown", markdown}, :short) do + # Extract just the first paragraph + markdown + |> String.split("\n\n") + |> hd() + |> String.trim() + end + + def format_documentation({"application/erlang+html", erlang_html}, :short) do + # Extract just the first paragraph + erlang_html + |> Enum.find(&match?({:p, _, _}, &1)) + |> case do + nil -> nil + paragraph -> erlang_html_to_md([paragraph]) + end + end + + def format_documentation({"text/markdown", markdown}, :all) do + markdown + end + + def format_documentation({"application/erlang+html", erlang_html}, :all) do + erlang_html_to_md(erlang_html) + end + + def format_documentation({format, _content}, _variant) do + raise "unknown documentation format #{inspect(format)}" + end + + # Erlang HTML AST + # See https://erlang.org/doc/apps/erl_docgen/doc_storage.html#erlang-documentation-format + + defp erlang_html_to_md(ast) do + build_md([], ast) + |> IO.iodata_to_binary() + |> String.trim() + end + + defp build_md(iodata, ast) + + defp build_md(iodata, []), do: iodata + + defp build_md(iodata, [string | ast]) when is_binary(string) do + string |> append_inline(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{tag, _, content} | ast]) when tag in [:em, :i] do + render_emphasis(content) |> append_inline(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{tag, _, content} | ast]) when tag in [:strong, :b] do + render_strong(content) |> append_inline(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{:code, _, content} | ast]) do + render_code_inline(content) |> append_inline(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{:a, attrs, content} | ast]) do + render_link(content, attrs) |> append_inline(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{:br, _, []} | ast]) do + render_line_break() |> append_inline(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{tag, _, content} | ast]) when tag in [:p, :div] do + render_paragraph(content) |> append_block(iodata) |> build_md(ast) + end + + @headings ~w(h1 h2 h3 h4 h5 h6)a + + defp build_md(iodata, [{tag, _, content} | ast]) when tag in @headings do + n = 1 + Enum.find_index(@headings, &(&1 == tag)) + render_heading(n, content) |> append_block(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{:pre, _, [{:code, _, [content]}]} | ast]) do + render_code_block(content, "erlang") |> append_block(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{:div, [{:class, class} | _], content} | ast]) do + type = class |> to_string() |> String.upcase() + + render_blockquote([{:p, [], [{:strong, [], [type]}]} | content]) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{:ul, [{:class, "types"} | _], content} | ast]) do + render_types_list(content) |> append_block(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{:ul, _, content} | ast]) do + render_unordered_list(content) |> append_block(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{:ol, _, content} | ast]) do + render_ordered_list(content) |> append_block(iodata) |> build_md(ast) + end + + defp build_md(iodata, [{:dl, _, content} | ast]) do + render_description_list(content) |> append_block(iodata) |> build_md(ast) + end + + defp append_inline(md, iodata), do: [iodata, md] + defp append_block(md, iodata), do: [iodata, "\n", md, "\n"] + + # Renderers + + defp render_emphasis(content) do + ["*", build_md([], content), "*"] + end + + defp render_strong(content) do + ["**", build_md([], content), "**"] + end + + defp render_code_inline(content) do + ["`", build_md([], content), "`"] + end + + defp render_link(content, attrs) do + caption = build_md([], content) + + if href = attrs[:href] do + ["[", caption, "](", href, ")"] + else + caption + end + end + + defp render_line_break(), do: "\\\n" + + defp render_paragraph(content), do: erlang_html_to_md(content) + + defp render_heading(n, content) do + title = build_md([], content) + [String.duplicate("#", n), " ", title] + end + + defp render_code_block(content, language) do + ["```", language, "\n", content, "\n```"] + end + + defp render_blockquote(content) do + inner = erlang_html_to_md(content) + + inner + |> String.split("\n") + |> Enum.map_intersperse("\n", &["> ", &1]) + end + + defp render_unordered_list(content) do + marker_fun = fn _index -> "* " end + render_list(content, marker_fun, " ") + end + + defp render_ordered_list(content) do + marker_fun = fn index -> "#{index + 1}. " end + render_list(content, marker_fun, " ") + end + + defp render_list(items, marker_fun, indent) do + spaced? = spaced_list_items?(items) + item_separator = if(spaced?, do: "\n\n", else: "\n") + + items + |> Enum.map(fn {:li, _, content} -> erlang_html_to_md(content) end) + |> Enum.with_index() + |> Enum.map(fn {inner, index} -> + [first_line | lines] = String.split(inner, "\n") + + first_line = marker_fun.(index) <> first_line + + lines = + Enum.map(lines, fn + "" -> "" + line -> indent <> line + end) + + Enum.intersperse([first_line | lines], "\n") + end) + |> Enum.intersperse(item_separator) + end + + defp spaced_list_items?([{:li, _, [{:p, _, _content} | _]} | _items]), do: true + defp spaced_list_items?([_ | items]), do: spaced_list_items?(items) + defp spaced_list_items?([]), do: false + + defp render_description_list(content) do + # Rewrite description list as an unordered list with pseudo heading + content + |> Enum.chunk_every(2) + |> Enum.map(fn [{:dt, _, dt}, {:dd, _, dd}] -> + {:li, [], [{:p, [], [{:strong, [], dt}]}, {:p, [], dd}]} + end) + |> render_unordered_list() + end + + defp render_types_list(content) do + content + |> group_type_list_items([]) + |> render_unordered_list() + end + + defp group_type_list_items([], acc), do: Enum.reverse(acc) + + defp group_type_list_items([{:li, [{:name, _type_name}], []} | items], acc) do + group_type_list_items(items, acc) + end + + defp group_type_list_items([{:li, [{:class, "type"}], content} | items], acc) do + group_type_list_items(items, [{:li, [], [{:code, [], content}]} | acc]) + end + + defp group_type_list_items( + [{:li, [{:class, "description"}], content} | items], + [{:li, [], prev_content} | acc] + ) do + group_type_list_items(items, [{:li, [], prev_content ++ [{:p, [], content}]} | acc]) + end end diff --git a/lib/livebook/intellisense/identifier_matcher.ex b/lib/livebook/intellisense/elixir/identifier_matcher.ex similarity index 97% rename from lib/livebook/intellisense/identifier_matcher.ex rename to lib/livebook/intellisense/elixir/identifier_matcher.ex index 1589819ab..592d35ad3 100644 --- a/lib/livebook/intellisense/identifier_matcher.ex +++ b/lib/livebook/intellisense/elixir/identifier_matcher.ex @@ -1,4 +1,4 @@ -defmodule Livebook.Intellisense.IdentifierMatcher do +defmodule Livebook.Intellisense.Elixir.IdentifierMatcher do # This module allows for extracting information about identifiers # based on code and runtime information (binding, environment). # @@ -11,7 +11,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do # Server. alias Livebook.Intellisense - alias Livebook.Intellisense.Docs + alias Livebook.Intellisense.Elixir.Docs @typedoc """ A single identifier together with relevant information. @@ -525,7 +525,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do kind: :module, module: mod, display_name: name, - documentation: Intellisense.Docs.get_module_documentation(mod, ctx.node) + documentation: Intellisense.Elixir.Docs.get_module_documentation(mod, ctx.node) } end @@ -549,7 +549,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do kind: :module, module: mod, display_name: name, - documentation: Intellisense.Docs.get_module_documentation(mod, ctx.node) + documentation: Intellisense.Elixir.Docs.get_module_documentation(mod, ctx.node) } end @@ -603,7 +603,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do kind: :module, module: mod, display_name: name, - documentation: Intellisense.Docs.get_module_documentation(mod, ctx.node) + documentation: Intellisense.Elixir.Docs.get_module_documentation(mod, ctx.node) } end @@ -682,7 +682,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do end) doc_items = - Intellisense.Docs.lookup_module_members( + Intellisense.Elixir.Docs.lookup_module_members( mod, Enum.map(matching_funs, &Tuple.delete_at(&1, 2)), ctx.node, @@ -755,7 +755,9 @@ defmodule Livebook.Intellisense.IdentifierMatcher do end) doc_items = - Intellisense.Docs.lookup_module_members(mod, matching_types, ctx.node, kinds: [:type]) + Intellisense.Elixir.Docs.lookup_module_members(mod, matching_types, ctx.node, + kinds: [:type] + ) Enum.map(matching_types, fn {name, arity} -> doc_item = diff --git a/lib/livebook/intellisense/signature_matcher.ex b/lib/livebook/intellisense/elixir/signature_matcher.ex similarity index 97% rename from lib/livebook/intellisense/signature_matcher.ex rename to lib/livebook/intellisense/elixir/signature_matcher.ex index 562898290..6f981b0cb 100644 --- a/lib/livebook/intellisense/signature_matcher.ex +++ b/lib/livebook/intellisense/elixir/signature_matcher.ex @@ -1,8 +1,8 @@ -defmodule Livebook.Intellisense.SignatureMatcher do +defmodule Livebook.Intellisense.Elixir.SignatureMatcher do # This module allows for extracting information about function # signatures matching an incomplete call. - alias Livebook.Intellisense.Docs + alias Livebook.Intellisense.Elixir.Docs @type signature_info :: {name :: atom(), Docs.signature(), Docs.documentation(), Docs.spec()} @@ -52,7 +52,7 @@ defmodule Livebook.Intellisense.SignatureMatcher do defp signature_infos_for_members(mod, funs, active_argument, node) do infos = - Livebook.Intellisense.Docs.lookup_module_members(mod, funs, node, + 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 new file mode 100644 index 000000000..849b0ff7e --- /dev/null +++ b/lib/livebook/intellisense/erlang.ex @@ -0,0 +1,38 @@ +defmodule Livebook.Intellisense.Erlang do + alias Livebook.Intellisense + + @behaviour Intellisense + + @impl true + def handle_request({:format, _code}, _context, _node) do + # Not supported. + nil + end + + def handle_request({:completion, hint}, context, _node) do + handle_completion(hint, context) + end + + def handle_request({:details, line, column}, context, _node) do + handle_details(line, column, context) + end + + def handle_request({:signature, hint}, context, _node) do + handle_signature(hint, context) + end + + defp handle_completion(_hint, _context) do + # TODO: implement. See t:Livebook.Runtime.completion_response/0 for return type. + nil + end + + defp handle_details(_line, _column, _context) do + # TODO: implement. See t:Livebook.Runtime.details_response/0 for return type. + nil + end + + defp handle_signature(_hint, _context) do + # TODO: implement. See t:Livebook.Runtime.signature_response/0 for return type. + nil + end +end diff --git a/lib/livebook/notebook/cell/code.ex b/lib/livebook/notebook/cell/code.ex index c1c392637..5f7bd6148 100644 --- a/lib/livebook/notebook/cell/code.ex +++ b/lib/livebook/notebook/cell/code.ex @@ -41,7 +41,7 @@ defmodule Livebook.Notebook.Cell.Code do end @doc """ - Return the list of supported langauges for code cells. + Return the list of supported languages for code cells. """ @spec languages() :: list(%{name: String.t(), language: atom()}) def languages() do diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex index 7c55fbd9b..12e537012 100644 --- a/lib/livebook/runtime.ex +++ b/lib/livebook/runtime.ex @@ -969,11 +969,12 @@ defprotocol Livebook.Runtime do @spec handle_intellisense( t(), pid(), + language(), intellisense_request(), parent_locators(), {atom(), atom()} | nil ) :: reference() - def handle_intellisense(runtime, send_to, request, parent_locators, node) + def handle_intellisense(runtime, send_to, language, request, parent_locators, node) @doc """ Reads file at the given absolute path within the runtime file system. diff --git a/lib/livebook/runtime/attached.ex b/lib/livebook/runtime/attached.ex index e834a449f..5a50e25a4 100644 --- a/lib/livebook/runtime/attached.ex +++ b/lib/livebook/runtime/attached.ex @@ -159,8 +159,15 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Attached do RuntimeServer.drop_container(runtime.server_pid, container_ref) end - def handle_intellisense(runtime, send_to, request, parent_locators, node) do - RuntimeServer.handle_intellisense(runtime.server_pid, send_to, request, parent_locators, node) + def handle_intellisense(runtime, send_to, language, request, parent_locators, node) do + RuntimeServer.handle_intellisense( + runtime.server_pid, + send_to, + language, + request, + parent_locators, + node + ) end def read_file(runtime, path) do diff --git a/lib/livebook/runtime/embedded.ex b/lib/livebook/runtime/embedded.ex index 872824a85..1c3052dc3 100644 --- a/lib/livebook/runtime/embedded.ex +++ b/lib/livebook/runtime/embedded.ex @@ -90,8 +90,15 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Embedded do RuntimeServer.drop_container(runtime.server_pid, container_ref) end - def handle_intellisense(runtime, send_to, request, parent_locators, node) do - RuntimeServer.handle_intellisense(runtime.server_pid, send_to, request, parent_locators, node) + def handle_intellisense(runtime, send_to, language, request, parent_locators, node) do + RuntimeServer.handle_intellisense( + runtime.server_pid, + send_to, + language, + request, + parent_locators, + node + ) end def read_file(runtime, path) do diff --git a/lib/livebook/runtime/erl_dist.ex b/lib/livebook/runtime/erl_dist.ex index 0c59bd4bc..3e08bdfc5 100644 --- a/lib/livebook/runtime/erl_dist.ex +++ b/lib/livebook/runtime/erl_dist.ex @@ -31,9 +31,11 @@ defmodule Livebook.Runtime.ErlDist do Livebook.Runtime.Evaluator.Formatter, Livebook.Runtime.Evaluator.Doctests, Livebook.Intellisense, - Livebook.Intellisense.Docs, - Livebook.Intellisense.IdentifierMatcher, - Livebook.Intellisense.SignatureMatcher, + Livebook.Intellisense.Elixir, + Livebook.Intellisense.Elixir.Docs, + Livebook.Intellisense.Elixir.IdentifierMatcher, + Livebook.Intellisense.Elixir.SignatureMatcher, + Livebook.Intellisense.Erlang, Livebook.Runtime.ErlDist, Livebook.Runtime.ErlDist.NodeManager, Livebook.Runtime.ErlDist.RuntimeServer, diff --git a/lib/livebook/runtime/erl_dist/runtime_server.ex b/lib/livebook/runtime/erl_dist/runtime_server.ex index 14173bb3d..28bca9a28 100644 --- a/lib/livebook/runtime/erl_dist/runtime_server.ex +++ b/lib/livebook/runtime/erl_dist/runtime_server.ex @@ -125,13 +125,19 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do @spec handle_intellisense( pid(), pid(), + Runtime.language(), Runtime.intellisense_request(), Runtime.Runtime.parent_locators(), {atom(), atom()} | nil ) :: reference() - def handle_intellisense(pid, send_to, request, parent_locators, node) do + def handle_intellisense(pid, send_to, language, request, parent_locators, node) do ref = make_ref() - GenServer.cast(pid, {:handle_intellisense, send_to, ref, request, parent_locators, node}) + + GenServer.cast( + pid, + {:handle_intellisense, send_to, ref, language, request, parent_locators, node} + ) + ref end @@ -546,7 +552,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do end def handle_cast( - {:handle_intellisense, send_to, ref, request, parent_locators, node}, + {:handle_intellisense, send_to, ref, language, request, parent_locators, node}, state ) do {container_ref, parent_evaluation_refs} = @@ -577,7 +583,10 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do Task.Supervisor.start_child(state.task_supervisor, fn -> node = intellisense_node(node) - response = Livebook.Intellisense.handle_request(request, intellisense_context, node) + + response = + Livebook.Intellisense.handle_request(language, request, intellisense_context, node) + send(send_to, {:runtime_intellisense_response, ref, request, response}) end) diff --git a/lib/livebook/runtime/fly.ex b/lib/livebook/runtime/fly.ex index 7d16dd3b0..80d1b7dce 100644 --- a/lib/livebook/runtime/fly.ex +++ b/lib/livebook/runtime/fly.ex @@ -426,8 +426,15 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Fly do RuntimeServer.drop_container(runtime.server_pid, container_ref) end - def handle_intellisense(runtime, send_to, request, parent_locators, node) do - RuntimeServer.handle_intellisense(runtime.server_pid, send_to, request, parent_locators, node) + def handle_intellisense(runtime, send_to, language, request, parent_locators, node) do + RuntimeServer.handle_intellisense( + runtime.server_pid, + send_to, + language, + request, + parent_locators, + node + ) end def read_file(runtime, path) do diff --git a/lib/livebook/runtime/k8s.ex b/lib/livebook/runtime/k8s.ex index f7054a9eb..a09495acc 100644 --- a/lib/livebook/runtime/k8s.ex +++ b/lib/livebook/runtime/k8s.ex @@ -365,8 +365,15 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.K8s do RuntimeServer.drop_container(runtime.server_pid, container_ref) end - def handle_intellisense(runtime, send_to, request, parent_locators, node) do - RuntimeServer.handle_intellisense(runtime.server_pid, send_to, request, parent_locators, node) + def handle_intellisense(runtime, send_to, language, request, parent_locators, node) do + RuntimeServer.handle_intellisense( + runtime.server_pid, + send_to, + language, + request, + parent_locators, + node + ) end def read_file(runtime, path) do diff --git a/lib/livebook/runtime/standalone.ex b/lib/livebook/runtime/standalone.ex index 424e64bf7..3333a1d0b 100644 --- a/lib/livebook/runtime/standalone.ex +++ b/lib/livebook/runtime/standalone.ex @@ -283,8 +283,15 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Standalone do RuntimeServer.drop_container(runtime.server_pid, container_ref) end - def handle_intellisense(runtime, send_to, request, parent_locators, node) do - RuntimeServer.handle_intellisense(runtime.server_pid, send_to, request, parent_locators, node) + def handle_intellisense(runtime, send_to, language, request, parent_locators, node) do + RuntimeServer.handle_intellisense( + runtime.server_pid, + send_to, + language, + request, + parent_locators, + node + ) end def read_file(runtime, path) do diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 9318ea4d8..e6320e19c 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -452,7 +452,7 @@ defmodule Livebook.Session do end @doc """ - Requests the given langauge to be enabled. + Requests the given language to be enabled. This inserts extra cells and adds dependencies if applicable. """ @@ -462,7 +462,7 @@ defmodule Livebook.Session do end @doc """ - Requests the given langauge to be disabled. + Requests the given language to be disabled. """ @spec disable_language(pid(), atom()) :: :ok def disable_language(pid, language) do diff --git a/lib/livebook_web/live/hub/teams/deployment_group_form_component.ex b/lib/livebook_web/live/hub/teams/deployment_group_form_component.ex index 02f625e73..4bc2cb64f 100644 --- a/lib/livebook_web/live/hub/teams/deployment_group_form_component.ex +++ b/lib/livebook_web/live/hub/teams/deployment_group_form_component.ex @@ -131,7 +131,10 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do ~H"""