Show full documentation when hovering over an identifier (#453)

* Show full documentation when hovering over an identifier

* Remove leftover function

* Improve determining subject boundaries
This commit is contained in:
Jonatan Kłosko 2021-07-20 21:30:53 +02:00 committed by GitHub
parent 6276aafa72
commit ef06e49d18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1979 additions and 1345 deletions

View file

@ -1,12 +1,68 @@
/* Monaco overrides */
/* === Monaco overrides === */
/* Add some spacing to code snippets in completion suggestions */
div.suggest-details-container div.monaco-tokenized-source {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
/*
CSS normalization removes the default styles of HTML elements,
so we need to adjust styles of Monaco-rendered Markdown docs.
Also some spacing adjustments.
*/
.suggest-details .header p {
@apply pb-0 pt-3 !important;
}
/* Monaco cursor widget */
.monaco-hover p,
.suggest-details .docs p {
@apply my-2 !important;
}
.suggest-details h1,
.monaco-hover h1 {
@apply text-xl font-semibold mt-4 mb-2;
}
.suggest-details h2,
.monaco-hover h2 {
@apply text-lg font-medium mt-4 mb-2;
}
.suggest-details h3,
.monaco-hover h3 {
@apply font-medium mt-4 mb-2;
}
.suggest-details ul,
.monaco-hover ul {
@apply list-disc;
}
.suggest-details ol,
.monaco-hover ol {
@apply list-decimal;
}
.suggest-details hr,
.monaco-hover hr {
@apply my-2 !important;
}
.suggest-details blockquote,
.monaco-hover blockquote {
@apply border-l-4 border-gray-200 pl-4 py-0.5 my-2;
}
/* Add some spacing to code snippets in completion suggestions */
.suggest-details div.monaco-tokenized-source,
.monaco-hover div.monaco-tokenized-source {
@apply my-2;
}
/* Increase the hover box limits */
.monaco-hover-content {
max-width: 1000px !important;
max-height: 300px !important;
}
/* === Monaco cursor widget === */
.monaco-cursor-widget-container {
pointer-events: none;

View file

@ -22,8 +22,7 @@ class LiveEditor {
this.__mountEditor();
if (type === "elixir") {
this.__setupCompletion();
this.__setupFormatting();
this.__setupIntellisense();
}
const serverAdapter = new HookServerAdapter(hook, cellId);
@ -196,9 +195,17 @@ class LiveEditor {
});
}
__setupCompletion() {
/**
* Defines cell-specific providers for various editor features.
*/
__setupIntellisense() {
this.handlerByRef = {};
/**
* Completion happens asynchronously, the flow goes as follows:
* Intellisense requests such as completion or formatting are
* handled asynchronously by the runtime.
*
* As an example, let's go through the steps for completion:
*
* * the user opens the completion list, which triggers the global
* completion provider registered in `live_editor/monaco.js`
@ -210,101 +217,124 @@ class LiveEditor {
* * then `__getCompletionItems` sends a completion request to the LV process
* and gets a unique reference, under which it keeps completion callback
*
* * finally the hook receives the "completion_response" event with completion items,
* it looks up completion callback for the received reference and calls it
* with the received items list
* * finally the hook receives the "intellisense_response" event with completion
* response, it looks up completion callback for the received reference and calls
* it with the response, which finally returns the completion items to the editor
*/
const completionHandlerByRef = {};
this.editor.getModel().__getCompletionItems = (model, position) => {
const line = model.getLineContent(position.lineNumber);
const lineUntilCursor = line.slice(0, position.column - 1);
return new Promise((resolve, reject) => {
this.hook.pushEvent(
"completion_request",
{
hint: lineUntilCursor,
cell_id: this.cellId,
},
({ completion_ref: completionRef }) => {
if (completionRef) {
completionHandlerByRef[completionRef] = (items) => {
const suggestions = completionItemsToSuggestions(items);
resolve({ suggestions });
};
} else {
resolve({ suggestions: [] });
}
}
);
});
return this.__asyncIntellisenseRequest("completion", {
hint: lineUntilCursor,
})
.then((response) => {
const suggestions = completionItemsToSuggestions(response.items);
return { suggestions };
})
.catch(() => null);
};
this.hook.handleEvent(
"completion_response",
({ completion_ref: completionRef, items }) => {
const handler = completionHandlerByRef[completionRef];
this.editor.getModel().__getHover = (model, position) => {
const line = model.getLineContent(position.lineNumber);
const index = position.column - 1;
if (handler) {
handler(items);
delete completionHandlerByRef[completionRef];
}
}
);
}
return this.__asyncIntellisenseRequest("details", { line, index })
.then((response) => {
const contents = response.contents.map((content) => ({
value: content,
isTrusted: true,
}));
__setupFormatting() {
/**
* Similarly to completion, formatting is delegated to the function
* defined below, where we simply communicate with LV to get
* a formatted version of the current editor content.
*/
const range = new monaco.Range(
position.lineNumber,
response.range.from + 1,
position.lineNumber,
response.range.to + 1
);
return { contents, range };
})
.catch(() => null);
};
this.editor.getModel().__getDocumentFormattingEdits = (model) => {
const content = model.getValue();
return new Promise((resolve, reject) => {
this.hook.pushEvent(
"format_code",
{ code: content },
({ code: formatted }) => {
/**
* We use a single edit replacing the whole editor content,
* but the editor itself optimises this into a list of edits
* that produce minimal diff using the Myers string difference.
*
* References:
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L324
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/common/services/editorSimpleWorker.ts#L489
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/base/common/diff/diff.ts#L227-L231
*
* Eventually the editor will received the optimised list of edits,
* which we then convert to Delta and send to the server.
* Consequently, the Delta carries only the minimal formatting diff.
*
* Also, if edits are applied to the editor, either by typing
* or receiving remote changes, the formatting is cancelled.
* In other words the formatting changes are actually applied
* only if the editor stays intact.
*
* References:
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L313
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/browser/core/editorState.ts#L137
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L326
*/
return this.__asyncIntellisenseRequest("format", { code: content })
.then((response) => {
/**
* We use a single edit replacing the whole editor content,
* but the editor itself optimises this into a list of edits
* that produce minimal diff using the Myers string difference.
*
* References:
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L324
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/common/services/editorSimpleWorker.ts#L489
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/base/common/diff/diff.ts#L227-L231
*
* Eventually the editor will received the optimised list of edits,
* which we then convert to Delta and send to the server.
* Consequently, the Delta carries only the minimal formatting diff.
*
* Also, if edits are applied to the editor, either by typing
* or receiving remote changes, the formatting is cancelled.
* In other words the formatting changes are actually applied
* only if the editor stays intact.
*
* References:
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L313
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/browser/core/editorState.ts#L137
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L326
*/
const replaceEdit = {
range: model.getFullModelRange(),
text: formatted,
};
const replaceEdit = {
range: model.getFullModelRange(),
text: response.code,
};
resolve([replaceEdit]);
}
);
});
return [replaceEdit];
})
.catch(() => null);
};
this.hook.handleEvent("intellisense_response", ({ ref, response }) => {
const handler = this.handlerByRef[ref];
if (handler) {
handler(response);
delete this.handlerByRef[ref];
}
});
}
/**
* Pushes an intellisense request.
*
* The returned promise is either resolved with a valid
* response or rejected with null.
*/
__asyncIntellisenseRequest(type, props) {
return new Promise((resolve, reject) => {
this.hook.pushEvent(
"intellisense_request",
{ cell_id: this.cellId, type, ...props },
({ ref }) => {
if (ref) {
this.handlerByRef[ref] = (response) => {
if (response) {
resolve(response);
} else {
reject(null);
}
};
} else {
reject(null);
}
}
);
});
}
}

View file

@ -26,29 +26,43 @@ document.fonts.addEventListener("loadingdone", (event) => {
}
});
// Define custom completion provider.
// In our case the completion behaviour is cell-dependent,
// so we delegate the implementation to the appropriate cell.
// See cell/live_editor.js for more details.
/**
* Define custom providers for various editor features.
*
* In our case, each cell has its own editor and behaviour
* of requests like completion and hover are cell dependent.
* For this reason we delegate the implementation to the
* specific cell by using its text model object.
*
* See cell/live_editor.js for more details.
*/
monaco.languages.registerCompletionItemProvider("elixir", {
provideCompletionItems: (model, position) => {
provideCompletionItems: (model, position, context, token) => {
if (model.__getCompletionItems) {
return model.__getCompletionItems(model, position);
} else {
return [];
return null;
}
},
});
monaco.languages.registerHoverProvider("elixir", {
provideHover: (model, position, token) => {
if (model.__getHover) {
return model.__getHover(model, position);
} else {
return null;
}
},
});
// Define custom code formatting provider.
// Formatting is cell agnostic, but we still delegate
// to a cell specific implementation to communicate with LV.
monaco.languages.registerDocumentFormattingEditProvider("elixir", {
provideDocumentFormattingEdits: function (model, options, token) {
provideDocumentFormattingEdits: (model, options, token) => {
if (model.__getDocumentFormattingEdits) {
return model.__getDocumentFormattingEdits(model);
} else {
return [];
return null;
}
},
});

View file

@ -118,15 +118,21 @@ defmodule Livebook.Evaluator do
end
@doc """
Asynchronously finds completion items matching the given `hint` text.
Asynchronously handles the given intellisense request.
If `evaluation_ref` is given, its binding and environment are also
used for the completion. Response is sent to the `send_to` process
as `{:completion_response, ref, items}`.
used as context for the intellisense. Response is sent to the `send_to`
process as `{:intellisense_response, ref, response}`.
"""
@spec request_completion_items(t(), pid(), term(), String.t(), ref() | nil) :: :ok
def request_completion_items(evaluator, send_to, ref, hint, evaluation_ref \\ nil) do
GenServer.cast(evaluator, {:request_completion_items, send_to, ref, hint, evaluation_ref})
@spec handle_intellisense(
t(),
pid(),
term(),
Livebook.Runtime.intellisense_request(),
ref() | nil
) :: :ok
def handle_intellisense(evaluator, send_to, ref, request, evaluation_ref \\ nil) do
GenServer.cast(evaluator, {:handle_intellisense, send_to, ref, request, evaluation_ref})
end
## Callbacks
@ -206,10 +212,10 @@ defmodule Livebook.Evaluator do
{:noreply, state}
end
def handle_cast({:request_completion_items, send_to, ref, hint, evaluation_ref}, state) do
def handle_cast({:handle_intellisense, send_to, ref, request, evaluation_ref}, state) do
context = get_context(state, evaluation_ref)
items = Livebook.Completion.get_completion_items(hint, context.binding, context.env)
send(send_to, {:completion_response, ref, items})
response = Livebook.Intellisense.handle_request(request, context.binding, context.env)
send(send_to, {:intellisense_response, ref, response})
{:noreply, state}
end

View file

@ -0,0 +1,516 @@
defmodule Livebook.Intellisense do
@moduledoc false
# This module provides intellisense related operations
# suitable for integration with a text editor.
#
# In a way, this provides the very basic features of a
# language server that Livebook uses.
alias Livebook.Intellisense.Completion
# Configures width used for inspect and specs formatting.
@line_length 30
@extended_line_length 80
@doc """
Resolves an intellisense request as defined by `Livebook.Runtime`.
In practice this function simply dispatches the request to one of
the other public functions in this module.
"""
@spec handle_request(
Livebook.Runtime.intellisense_request(),
Code.binding(),
Macro.Env.t()
) :: Livebook.Runtime.intellisense_response()
def handle_request(request, env, binding)
def handle_request({:completion, hint}, binding, env) do
items = get_completion_items(hint, binding, env)
%{items: items}
end
def handle_request({:details, line, index}, binding, env) do
get_details(line, index, binding, env)
end
def handle_request({:format, code}, _binding, _env) do
case format_code(code) do
{:ok, code} -> %{code: code}
:error -> nil
end
end
@doc """
Formats Elixir code.
"""
@spec format_code(String.t()) :: {:ok, String.t()} | :error
def format_code(code) do
try do
formatted =
code
|> Code.format_string!()
|> IO.iodata_to_binary()
{:ok, formatted}
rescue
_ -> :error
end
end
@doc """
Returns a list of completion suggestions for the given `hint`.
"""
@spec get_completion_items(String.t(), Code.binding(), Macro.Env.t()) ::
list(Livebook.Runtime.completion_item())
def get_completion_items(hint, binding, env) do
Completion.get_completion_items(hint, binding, env)
|> Enum.map(&format_completion_item/1)
|> Enum.sort_by(&completion_item_priority/1)
end
defp format_completion_item({:variable, name, value}),
do: %{
label: name,
kind: :variable,
detail: "variable",
documentation: value_snippet(value, @line_length),
insert_text: name
}
defp format_completion_item({:map_field, name, value}),
do: %{
label: name,
kind: :field,
detail: "field",
documentation: value_snippet(value, @line_length),
insert_text: name
}
defp format_completion_item({:module, name, doc_content}),
do: %{
label: name,
kind: :module,
detail: "module",
documentation: format_doc_content(doc_content, :short),
insert_text: String.trim_leading(name, ":")
}
defp format_completion_item({:function, module, name, arity, doc_content, signatures, spec}),
do: %{
label: "#{name}/#{arity}",
kind: :function,
detail: format_signatures(signatures, module),
documentation:
join_with_newlines([
format_doc_content(doc_content, :short),
format_spec(spec, @line_length) |> code()
]),
insert_text: name
}
defp format_completion_item({:type, _module, name, arity, doc_content}),
do: %{
label: "#{name}/#{arity}",
kind: :type,
detail: "typespec",
documentation: format_doc_content(doc_content, :short),
insert_text: name
}
defp format_completion_item({:module_attribute, name, doc_content}),
do: %{
label: name,
kind: :variable,
detail: "module attribute",
documentation: format_doc_content(doc_content, :short),
insert_text: name
}
defp completion_item_priority(completion_item) do
{completion_item_kind_priority(completion_item.kind), completion_item.label}
end
@ordered_kinds [:field, :variable, :module, :function, :type]
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 identifier being
at `index` in `line`.
"""
@spec get_details(String.t(), non_neg_integer(), Code.binding(), Macro.Env.t()) ::
Livebook.Runtime.details() | nil
def get_details(line, index, binding, env) do
{from, to} = subject_range(line, index)
if from < to do
subject = binary_part(line, from, to - from)
Completion.get_completion_items(subject, binding, env, exact: true)
|> Enum.map(&format_details_item/1)
|> Enum.uniq()
|> case do
[] -> nil
contents -> %{range: %{from: from, to: to}, contents: contents}
end
else
nil
end
end
# Reference: https://github.com/elixir-lang/elixir/blob/d1223e11fda880d5646f6385b33684d1b2ec0b9c/lib/elixir/lib/code.ex#L341-L345
@operators '\\<>+-*/:=|&~^@%'
@non_closing_punctuation '.,([{;'
@closing_punctuation ')]}'
@space '\t\s'
@closing_identifier '?!'
@punctuation @non_closing_punctuation ++ @closing_punctuation
defp subject_range(line, index) do
{left, right} = String.split_at(line, index)
left =
left
|> String.to_charlist()
|> Enum.reverse()
|> consume_until(@space ++ @operators ++ (@punctuation -- '.') ++ @closing_identifier, ':@')
|> List.to_string()
right =
right
|> String.to_charlist()
|> consume_until(@space ++ @operators ++ @punctuation, @closing_identifier)
|> List.to_string()
{index - byte_size(left), index + byte_size(right)}
end
defp consume_until(acc \\ [], chars, stop, stop_include)
defp consume_until(acc, [], _, _), do: Enum.reverse(acc)
defp consume_until(acc, [char | chars], stop, stop_include) do
cond do
char in stop_include -> consume_until([char | acc], [], stop, stop_include)
char in stop -> consume_until(acc, [], stop, stop_include)
true -> consume_until([char | acc], chars, stop, stop_include)
end
end
defp format_details_item({:variable, name, value}) do
join_with_divider([
code(name),
value_snippet(value, @extended_line_length)
])
end
defp format_details_item({:map_field, _name, value}) do
join_with_divider([
value_snippet(value, @extended_line_length)
])
end
defp format_details_item({:module, name, doc_content}) do
join_with_divider([
code(name),
format_doc_content(doc_content, :all)
])
end
defp format_details_item({:function, module, _name, _arity, doc_content, signatures, spec}) do
join_with_divider([
format_signatures(signatures, module) |> code(),
format_spec(spec, @extended_line_length) |> code(),
format_doc_content(doc_content, :all)
])
end
defp format_details_item({:type, _module, name, _arity, doc_content}) do
join_with_divider([
code(name),
format_doc_content(doc_content, :all)
])
end
defp format_details_item({:module_attribute, name, doc_content}) do
join_with_divider([
code("@" <> name),
format_doc_content(doc_content, :all)
])
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(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 value_snippet(value, line_length) do
"""
```
#{inspect(value, pretty: true, width: line_length)}
```\
"""
end
defp format_signatures([], _module), do: nil
defp format_signatures(signatures, module) do
module_to_prefix(module) <> Enum.join(signatures, "\n")
end
defp module_to_prefix(mod) do
case Atom.to_string(mod) do
"Elixir." <> name -> name <> "."
name -> ":" <> name <> "."
end
end
defp format_spec(nil, _line_length), do: nil
defp format_spec({{name, _arity}, spec_ast_list}, line_length) do
spec_lines =
Enum.map(spec_ast_list, fn spec_ast ->
spec =
Code.Typespec.spec_to_quoted(name, spec_ast)
|> Macro.to_string()
|> Code.format_string!(line_length: line_length)
["@spec ", spec]
end)
spec_lines
|> Enum.intersperse("\n")
|> IO.iodata_to_binary()
end
defp format_doc_content(doc, variant)
defp format_doc_content(nil, _variant) do
"No documentation available"
end
defp format_doc_content({"text/markdown", markdown}, :short) do
# Extract just the first paragraph
markdown
|> String.split("\n\n")
|> hd()
|> String.trim()
end
defp format_doc_content({"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_doc_content({"text/markdown", markdown}, :all) do
markdown
end
defp format_doc_content({"application/erlang+html", erlang_html}, :all) do
erlang_html_to_md(erlang_html)
end
defp format_doc_content({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, [{:p, _, content} | ast]) 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
lines = Enum.map(content, fn {:li, _, line} -> line end)
render_code_block(lines, "erlang") |> 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(&("> " <> &1))
|> Enum.join("\n")
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
end

View file

@ -1,41 +1,48 @@
defmodule Livebook.Completion do
defmodule Livebook.Intellisense.Completion do
@moduledoc false
# This module provides basic intellisense completion
# suitable for text editors.
# This module provides basic completion based on code
# and runtime information (binding, environment).
#
# The implementation is based primarly on `IEx.Autocomplete`.
# It also takes insights from `ElixirSense.Providers.Suggestion.Complete`,
# which is a very extensive implementation used in the Elixir Language Server.
# which is a very extensive implementation used in the
# Elixir Language Server.
@type completion_item :: Livebook.Runtime.completion_item()
@type completion_item ::
{:variable, name(), value()}
| {:map_field, name(), value()}
| {:module, name(), doc_content()}
| {:function, module(), name(), arity(), doc_content(), list(signature()), spec()}
| {:type, module(), name(), arity(), doc_content()}
| {:module_attribute, name(), doc_content()}
# Configures width used for inspect and specs formatting.
@line_length 30
@type name :: String.t()
@type value :: term()
@type doc_content :: {format :: String.t(), content :: String.t()} | nil
@type signature :: String.t()
@type spec :: tuple() | nil
@doc """
Returns a list of completion suggestions for the given `hint`.
Returns a list of identifiers matching the given `hint`
together with relevant information.
Uses evaluation binding and environment to expand aliases,
Evaluation binding and environment is used to expand aliases,
imports, nested maps, etc.
`hint` may be a single token or line fragment like `if Enum.m`.
## Options
* `exact` - whether the hint must match exactly the given
identifier. Defaults to `false`, resulting in prefix matching.
"""
@spec get_completion_items(String.t(), Code.binding(), Macro.Env.t()) :: list(completion_item())
def get_completion_items(hint, binding, env) do
hint
|> complete(%{binding: binding, env: env})
|> Enum.sort_by(&completion_item_priority/1)
end
@spec get_completion_items(String.t(), Code.binding(), Macro.Env.t(), keyword()) ::
list(completion_item())
def get_completion_items(hint, binding, env, opts \\ []) do
matcher = if opts[:exact], do: &Kernel.==/2, else: &String.starts_with?/2
defp completion_item_priority(completion_item) do
{completion_item_kind_priority(completion_item.kind), completion_item.label}
end
@ordered_kinds [:field, :variable, :module, :function, :type]
defp completion_item_kind_priority(kind) when kind in @ordered_kinds do
Enum.find_index(@ordered_kinds, &(&1 == kind))
complete(hint, %{binding: binding, env: env, matcher: matcher})
end
defp complete(hint, ctx) do
@ -44,7 +51,7 @@ defmodule Livebook.Completion do
complete_alias(List.to_string(alias), ctx)
{:unquoted_atom, unquoted_atom} ->
complete_erlang_module(List.to_string(unquoted_atom))
complete_erlang_module(List.to_string(unquoted_atom), ctx)
{:dot, path, hint} ->
complete_dot(path, List.to_string(hint), ctx)
@ -68,7 +75,7 @@ defmodule Livebook.Completion do
complete_default(ctx)
{:module_attribute, attribute} ->
complete_module_attribute(List.to_string(attribute))
complete_module_attribute(List.to_string(attribute), ctx)
# :none
_ ->
@ -81,13 +88,13 @@ defmodule Livebook.Completion do
defp complete_dot(path, hint, ctx) do
case expand_dot_path(path, ctx) do
{:ok, mod} when is_atom(mod) and hint == "" ->
complete_module_member(mod, hint) ++ complete_module(mod, hint)
complete_module_member(mod, hint, ctx) ++ complete_module(mod, hint, ctx)
{:ok, mod} when is_atom(mod) ->
complete_module_member(mod, hint)
complete_module_member(mod, hint, ctx)
{:ok, map} when is_map(map) ->
complete_map_field(map, hint)
complete_map_field(map, hint, ctx)
_ ->
[]
@ -124,16 +131,16 @@ defmodule Livebook.Completion do
defp complete_alias(hint, ctx) do
case split_at_last_occurrence(hint, ".") do
{hint, ""} ->
complete_elixir_root_module(hint) ++ complete_env_alias(hint, ctx)
complete_elixir_root_module(hint, ctx) ++ complete_env_alias(hint, ctx)
{alias, hint} ->
mod = expand_alias(alias, ctx)
complete_module(mod, hint)
complete_module(mod, hint, ctx)
end
end
defp complete_module_member(mod, hint) do
complete_module_function(mod, hint) ++ complete_module_type(mod, hint)
defp complete_module_member(mod, hint, ctx) do
complete_module_function(mod, hint, ctx) ++ complete_module_type(mod, hint, ctx)
end
defp complete_local_or_var(hint, ctx) do
@ -145,10 +152,10 @@ defmodule Livebook.Completion do
ctx.env
|> imports_from_env()
|> Enum.flat_map(fn {mod, funs} ->
complete_module_function(mod, hint, funs)
complete_module_function(mod, hint, ctx, funs)
end)
special_forms = complete_module_function(Kernel.SpecialForms, hint)
special_forms = complete_module_function(Kernel.SpecialForms, hint, ctx)
imports ++ special_forms
end
@ -156,51 +163,24 @@ defmodule Livebook.Completion do
defp complete_variable(hint, ctx) do
for {key, value} <- ctx.binding,
name = Atom.to_string(key),
String.starts_with?(name, hint),
do: %{
label: name,
kind: :variable,
detail: "variable",
documentation: value_docstr(value),
insert_text: name
}
ctx.matcher.(name, hint),
do: {:variable, name, value}
end
defp complete_map_field(map, hint) do
defp complete_map_field(map, hint, ctx) do
# Note: we need Map.to_list/1 in case this is a struct
for {key, value} <- Map.to_list(map),
is_atom(key),
name = Atom.to_string(key),
String.starts_with?(name, hint),
do: %{
label: name,
kind: :field,
detail: "field",
documentation: value_docstr(value),
insert_text: name
}
ctx.matcher.(name, hint),
do: {:map_field, name, value}
end
defp value_docstr(value) do
"""
```
#{inspect(value, pretty: true, width: @line_length)}
```\
"""
end
defp complete_erlang_module(hint) do
for mod <- get_matching_modules(hint),
defp complete_erlang_module(hint, ctx) do
for mod <- get_matching_modules(hint, ctx),
usable_as_unquoted_module?(mod),
name = Atom.to_string(mod) do
%{
label: name,
kind: :module,
detail: "module",
documentation: mod |> get_module_doc_content() |> format_doc_content(),
insert_text: name
}
end
name = ":" <> Atom.to_string(mod),
do: {:module, name, get_module_doc_content(mod)}
end
# Converts alias string to module atom with regard to the given env
@ -217,23 +197,16 @@ defmodule Livebook.Completion do
defp complete_env_alias(hint, ctx) do
for {alias, mod} <- ctx.env.aliases,
[name] = Module.split(alias),
String.starts_with?(name, hint) do
%{
label: name,
kind: :module,
detail: "module",
documentation: mod |> get_module_doc_content() |> format_doc_content(),
insert_text: name
}
end
ctx.matcher.(name, hint),
do: {:module, name, get_module_doc_content(mod)}
end
defp complete_module(base_mod, hint) do
defp complete_module(base_mod, hint, ctx) do
# Note: we specifically don't want further completion
# if `base_mod` is an Erlang module.
if base_mod == Elixir or elixir_module?(base_mod) do
complete_elixir_module(base_mod, hint)
complete_elixir_module(base_mod, hint, ctx)
else
[]
end
@ -243,34 +216,25 @@ defmodule Livebook.Completion do
mod |> Atom.to_string() |> String.starts_with?("Elixir.")
end
defp complete_elixir_root_module(hint) do
items = complete_elixir_module(Elixir, hint)
defp complete_elixir_root_module(hint, ctx) do
items = complete_elixir_module(Elixir, hint, ctx)
# `Elixir` is not a existing module name, but `Elixir.Enum` is,
# so if the user types `Eli` the completion should include `Elixir`.
if String.starts_with?("Elixir", hint) do
[
%{
label: "Elixir",
kind: :module,
detail: "module",
documentation: nil,
insert_text: "Elixir"
}
| items
]
if ctx.matcher.("Elixir", hint) do
[{:module, "Elixir", nil} | items]
else
items
end
end
defp complete_elixir_module(base_mod, hint) do
defp complete_elixir_module(base_mod, hint, ctx) do
# Note: `base_mod` may be `Elixir`, even though it's not a valid module
match_prefix = "#{base_mod}.#{hint}"
depth = match_prefix |> Module.split() |> length()
for mod <- get_matching_modules(match_prefix),
for mod <- get_matching_modules(match_prefix, ctx),
parts = Module.split(mod),
length(parts) >= depth,
name = Enum.at(parts, depth - 1),
@ -281,13 +245,7 @@ defmodule Livebook.Completion do
valid_alias_piece?("." <> name),
mod = parts |> Enum.take(depth) |> Module.concat(),
uniq: true,
do: %{
label: name,
kind: :module,
detail: "module",
documentation: mod |> get_module_doc_content() |> format_doc_content(),
insert_text: name
}
do: {:module, name, get_module_doc_content(mod)}
end
defp valid_alias_piece?(<<?., char, rest::binary>>) when char in ?A..?Z,
@ -309,9 +267,9 @@ defmodule Livebook.Completion do
Code.Identifier.classify(mod) != :other
end
defp get_matching_modules(hint) do
defp get_matching_modules(hint, ctx) do
get_modules()
|> Enum.filter(&String.starts_with?(Atom.to_string(&1), hint))
|> Enum.filter(&ctx.matcher.(Atom.to_string(&1), hint))
|> Enum.uniq()
end
@ -340,7 +298,7 @@ defmodule Livebook.Completion do
:ets.match(:ac_tab, {{:loaded, :"$1"}, :_})
end
defp complete_module_function(mod, hint, funs \\ nil) do
defp complete_module_function(mod, hint, ctx, funs \\ nil) do
if ensure_loaded?(mod) do
{format, docs} = get_docs(mod, [:function, :macro])
specs = get_specs(mod)
@ -350,24 +308,17 @@ defmodule Livebook.Completion do
funs
|> Enum.filter(fn {name, _arity} ->
name = Atom.to_string(name)
String.starts_with?(name, hint)
ctx.matcher.(name, hint)
end)
|> Enum.map(fn {name, arity} ->
base_arity = Map.get(funs_with_base_arity, {name, arity}, arity)
doc = find_doc(docs, {name, base_arity})
spec = find_spec(specs, {name, base_arity})
docstr = doc |> doc_content(format) |> format_doc_content()
signatures = doc |> doc_signatures() |> format_signatures(mod)
spec = format_spec(spec)
doc_content = doc_content(doc, format)
signatures = doc_signatures(doc)
%{
label: "#{name}/#{arity}",
kind: :function,
detail: signatures,
documentation: documentation_join([docstr, spec]),
insert_text: Atom.to_string(name)
}
{:function, mod, Atom.to_string(name), arity, doc_content, signatures, spec}
end)
else
[]
@ -428,78 +379,6 @@ defmodule Livebook.Completion do
defp doc_content({_, _, _, %{"en" => docstr}, _}, format), do: {format, docstr}
defp doc_content(_doc, _format), do: nil
defp documentation_join(list) do
case Enum.reject(list, &is_nil/1) do
[] -> nil
parts -> Enum.join(parts, "\n\n")
end
end
defp format_signatures([], _mod), do: nil
defp format_signatures(signatures, mod) do
mod_to_prefix(mod) <> Enum.join(signatures, "\n")
end
defp mod_to_prefix(mod) do
case Atom.to_string(mod) do
"Elixir." <> name -> name <> "."
name -> name <> "."
end
end
defp format_doc_content(nil), do: nil
defp format_doc_content({"text/markdown", markdown}) do
# Extract just the first paragraph
markdown
|> String.split("\n\n")
|> hd()
|> String.trim()
end
defp format_doc_content({"application/erlang+html", erlang_html}) do
case erlang_html do
# Extract just the first paragraph
[{:p, _, inner} | _] ->
inner
|> text_from_erlang_html()
|> String.trim()
_ ->
nil
end
end
defp format_doc_content(_), do: nil
def text_from_erlang_html(ast) when is_list(ast) do
ast
|> Enum.map(&text_from_erlang_html/1)
|> Enum.join("")
end
def text_from_erlang_html(ast) when is_binary(ast), do: ast
def text_from_erlang_html({_, _, ast}), do: text_from_erlang_html(ast)
defp format_spec(nil), do: nil
defp format_spec({{name, _arity}, spec_ast_list}) do
spec_lines =
Enum.map(spec_ast_list, fn spec_ast ->
spec =
Code.Typespec.spec_to_quoted(name, spec_ast)
|> Macro.to_string()
|> Code.format_string!(line_length: @line_length)
["@spec ", spec]
end)
["```", spec_lines, "```"]
|> Enum.intersperse("\n")
|> IO.iodata_to_binary()
end
defp exports(mod) do
if Code.ensure_loaded?(mod) and function_exported?(mod, :__info__, 1) do
mod.__info__(:macros) ++ (mod.__info__(:functions) -- [__info__: 1])
@ -508,26 +387,20 @@ defmodule Livebook.Completion do
end
end
defp complete_module_type(mod, hint) do
defp complete_module_type(mod, hint, ctx) do
{format, docs} = get_docs(mod, [:type])
types = get_module_types(mod)
types
|> Enum.filter(fn {name, _arity} ->
name = Atom.to_string(name)
String.starts_with?(name, hint)
ctx.matcher.(name, hint)
end)
|> Enum.map(fn {name, arity} ->
doc = find_doc(docs, {name, arity})
docstr = doc |> doc_content(format) |> format_doc_content()
doc_content = doc_content(doc, format)
%{
label: "#{name}/#{arity}",
kind: :type,
detail: "typespec",
documentation: docstr,
insert_text: Atom.to_string(name)
}
{:type, mod, Atom.to_string(name), arity, doc_content}
end)
end
@ -559,17 +432,10 @@ defmodule Livebook.Completion do
end
end
defp complete_module_attribute(hint) do
defp complete_module_attribute(hint, ctx) do
for {attribute, info} <- Module.reserved_attributes(),
name = Atom.to_string(attribute),
String.starts_with?(name, hint) do
%{
label: name,
kind: :variable,
detail: "module attribute",
documentation: info.doc,
insert_text: name
}
end
ctx.matcher.(name, hint),
do: {:module_attribute, name, {"text/markdown", info.doc}}
end
end

View file

@ -31,8 +31,36 @@ defprotocol Livebook.Runtime do
@type locator :: {container_ref(), evaluation_ref() | nil}
@typedoc """
A single completion result.
Recognised intellisense requests.
"""
@type intellisense_request ::
completion_request()
| details_request()
| format_request()
@typedoc """
Expected intellisense responses.
Responding with `nil` indicates there is no relevant reply
and effectively aborts the request, so it's suitable for
error cases.
"""
@type intellisense_response ::
nil
| completion_response()
| details_response()
| format_response()
@typedoc """
Looks up a list of identifiers that are suitable code
completions for the given hint.
"""
@type completion_request :: {:completion, hint :: String.t()}
@type completion_response :: %{
items: list(completion_item())
}
@type completion_item :: %{
label: String.t(),
kind: completion_item_kind(),
@ -43,6 +71,28 @@ defprotocol Livebook.Runtime do
@type completion_item_kind :: :function | :module | :type | :variable | :field
@typedoc """
Looks up more details about an identifier found at `index` in `line`.
"""
@type details_request :: {:details, line :: String.t(), index :: non_neg_integer()}
@type details_response :: %{
range: %{
from: non_neg_integer(),
to: non_neg_integer()
},
contents: list(String.t())
}
@typedoc """
Formats the given code snippet.
"""
@type format_request :: {:format, code :: String.t()}
@type format_response :: %{
code: String.t()
}
@doc """
Sets the caller as runtime owner.
@ -127,19 +177,20 @@ defprotocol Livebook.Runtime do
def drop_container(runtime, container_ref)
@doc """
Asynchronously finds completion items matching the given `hint` text.
Asynchronously handles an intellisense request.
The given `locator` idenfities an evaluation, which bindings
and environment are used to provide a more relevant completion
results. If there's no appropriate evaluation, `nil` refs can
be provided.
This part of runtime functionality is used to provide
language and context specific intellisense features in
the text editor.
Completion response is sent to the `send_to` process as
`{:completion_response, ref, items}`, where `items` is a
list of `t:Livebook.Runtime.completion_item/0`.
The response is sent to the `send_to` process as
`{:intellisense_response, ref, response}`.
The given `locator` idenfities an evaluation that may be used
as context when resolving the request (if relevant).
"""
@spec request_completion_items(t(), pid(), term(), String.t(), locator()) :: :ok
def request_completion_items(runtime, send_to, completion_ref, hint, locator)
@spec handle_intellisense(t(), pid(), reference(), intellisense_request(), locator()) :: :ok
def handle_intellisense(runtime, send_to, ref, request, locator)
@doc """
Synchronously starts a runtime of the same type with the

View file

@ -61,14 +61,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Attached do
ErlDist.RuntimeServer.drop_container(runtime.server_pid, container_ref)
end
def request_completion_items(runtime, send_to, completion_ref, hint, locator) do
ErlDist.RuntimeServer.request_completion_items(
runtime.server_pid,
send_to,
completion_ref,
hint,
locator
)
def handle_intellisense(runtime, send_to, ref, request, locator) do
ErlDist.RuntimeServer.handle_intellisense(runtime.server_pid, send_to, ref, request, locator)
end
def duplicate(runtime) do

View file

@ -89,14 +89,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do
ErlDist.RuntimeServer.drop_container(runtime.server_pid, container_ref)
end
def request_completion_items(runtime, send_to, completion_ref, hint, locator) do
ErlDist.RuntimeServer.request_completion_items(
runtime.server_pid,
send_to,
completion_ref,
hint,
locator
)
def handle_intellisense(runtime, send_to, ref, request, locator) do
ErlDist.RuntimeServer.handle_intellisense(runtime.server_pid, send_to, ref, request, locator)
end
def duplicate(_runtime) do

View file

@ -63,14 +63,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Embedded do
ErlDist.RuntimeServer.drop_container(runtime.server_pid, container_ref)
end
def request_completion_items(runtime, send_to, completion_ref, hint, locator) do
ErlDist.RuntimeServer.request_completion_items(
runtime.server_pid,
send_to,
completion_ref,
hint,
locator
)
def handle_intellisense(runtime, send_to, ref, request, locator) do
ErlDist.RuntimeServer.handle_intellisense(runtime.server_pid, send_to, ref, request, locator)
end
def duplicate(_runtime) do

View file

@ -24,7 +24,8 @@ defmodule Livebook.Runtime.ErlDist do
Livebook.Evaluator,
Livebook.Evaluator.IOProxy,
Livebook.Evaluator.DefaultFormatter,
Livebook.Completion,
Livebook.Intellisense,
Livebook.Intellisense.Completion,
Livebook.Runtime.ErlDist,
Livebook.Runtime.ErlDist.NodeManager,
Livebook.Runtime.ErlDist.RuntimeServer,

View file

@ -78,19 +78,24 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
end
@doc """
Asynchronously sends completion request for the given
`hint` text.
Asynchronously sends an intellisense request to the server.
The completion request is forwarded to `Livebook.Evaluator`
process that belongs to the given container. If there's no
evaluator, there's also no binding and environment, so a
generic completion is handled by a temporary process.
Completions are forwarded to `Livebook.Evaluator` process
that belongs to the given container. If there's no evaluator,
there's also no binding and environment, so a generic
completion is handled by a temporary process.
See `Livebook.Runtime` for more details.
"""
@spec request_completion_items(pid(), pid(), term(), String.t(), Runtime.locator()) :: :ok
def request_completion_items(pid, send_to, completion_ref, hint, locator) do
GenServer.cast(pid, {:request_completion_items, send_to, completion_ref, hint, locator})
@spec handle_intellisense(
pid(),
pid(),
reference(),
Runtime.intellisense_request(),
Runtime.locator()
) :: :ok
def handle_intellisense(pid, send_to, ref, request, locator) do
GenServer.cast(pid, {:handle_intellisense, send_to, ref, request, locator})
end
@doc """
@ -206,19 +211,19 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
{:noreply, state}
end
def handle_cast(
{:request_completion_items, send_to, ref, hint, {container_ref, evaluation_ref}},
state
) do
if evaluator = Map.get(state.evaluators, container_ref) do
Evaluator.request_completion_items(evaluator, send_to, ref, hint, evaluation_ref)
def handle_cast({:handle_intellisense, send_to, ref, request, locator}, state) do
{container_ref, evaluation_ref} = locator
evaluator = state.evaluators[container_ref]
if evaluator != nil and elem(request, 0) not in [:format] do
Evaluator.handle_intellisense(evaluator, send_to, ref, request, evaluation_ref)
else
# Since there's no evaluator, we may as well get the completion items here.
# Handle the request in a temporary process using an empty evaluation context
Task.Supervisor.start_child(state.completion_supervisor, fn ->
binding = []
env = :elixir.env_for_eval([])
items = Livebook.Completion.get_completion_items(hint, binding, env)
send(send_to, {:completion_response, ref, items})
response = Livebook.Intellisense.handle_request(request, binding, env)
send(send_to, {:intellisense_response, ref, response})
end)
end

View file

@ -142,14 +142,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.MixStandalone do
ErlDist.RuntimeServer.drop_container(runtime.server_pid, container_ref)
end
def request_completion_items(runtime, send_to, completion_ref, hint, locator) do
ErlDist.RuntimeServer.request_completion_items(
runtime.server_pid,
send_to,
completion_ref,
hint,
locator
)
def handle_intellisense(runtime, send_to, ref, request, locator) do
ErlDist.RuntimeServer.handle_intellisense(runtime.server_pid, send_to, ref, request, locator)
end
def duplicate(runtime) do

View file

@ -613,23 +613,43 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("completion_request", %{"hint" => hint, "cell_id" => cell_id}, socket) do
def handle_event("intellisense_request", %{"cell_id" => cell_id} = params, socket) do
request =
case params do
%{"type" => "completion", "hint" => hint} ->
{:completion, hint}
%{"type" => "details", "line" => line, "index" => index} ->
{:details, line, index}
%{"type" => "format", "code" => code} ->
{:format, code}
end
data = socket.private.data
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id) do
if data.runtime do
completion_ref = make_ref()
ref = make_ref()
prev_locator = Session.find_prev_locator(data.notebook, cell, section)
Runtime.request_completion_items(data.runtime, self(), completion_ref, hint, prev_locator)
Runtime.handle_intellisense(data.runtime, self(), ref, request, prev_locator)
{:reply, %{"completion_ref" => inspect(completion_ref)}, socket}
{:reply, %{"ref" => inspect(ref)}, socket}
else
{:reply, %{"completion_ref" => nil},
put_flash(
socket,
:info,
"You need to start a runtime (or evaluate a cell) for accurate completion"
)}
info =
case params["type"] do
"completion" ->
"You need to start a runtime (or evaluate a cell) for code completion"
"format" ->
"You need to start a runtime (or evaluate a cell) to enable code formatting"
_ ->
nil
end
socket = if info, do: put_flash(socket, :info, info), else: socket
{:reply, %{"ref" => nil}, socket}
end
else
_ -> {:noreply, socket}
@ -724,9 +744,9 @@ defmodule LivebookWeb.SessionLive do
|> push_redirect(to: Routes.home_path(socket, :page))}
end
def handle_info({:completion_response, ref, items}, socket) do
payload = %{"completion_ref" => inspect(ref), "items" => items}
{:noreply, push_event(socket, "completion_response", payload)}
def handle_info({:intellisense_response, ref, response}, socket) do
payload = %{"ref" => inspect(ref), "response" => response}
{:noreply, push_event(socket, "intellisense_response", payload)}
end
def handle_info(

View file

@ -1,933 +0,0 @@
defmodule Livebook.CompletionTest.Utils do
@moduledoc false
@doc """
Returns `{binding, env}` resulting from evaluating
the given block of code in a fresh context.
"""
defmacro eval(do: block) do
binding = []
env = :elixir.env_for_eval([])
{_, binding, env} = :elixir.eval_quoted(block, binding, env)
quote do
{unquote(Macro.escape(binding)), unquote(Macro.escape(env))}
end
end
end
defmodule Livebook.CompletionTest do
use ExUnit.Case, async: true
import Livebook.CompletionTest.Utils
alias Livebook.Completion
test "completion when no hint given" do
{binding, env} = eval(do: nil)
length_item = %{
label: "length/1",
kind: :function,
detail: "Kernel.length(list)",
documentation: """
Returns the length of `list`.
```
@spec length(list()) ::
non_neg_integer()
```\
""",
insert_text: "length"
}
assert length_item in Completion.get_completion_items("", binding, env)
assert length_item in Completion.get_completion_items("Enum.map(list, ", binding, env)
end
@tag :erl_docs
test "Erlang module completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "zlib",
kind: :module,
detail: "module",
documentation:
"This module provides an API for the zlib library (www.zlib.net). It is used to compress and decompress data. The data format is described by RFC 1950, RFC 1951, and RFC 1952.",
insert_text: "zlib"
}
] = Completion.get_completion_items(":zl", binding, env)
end
test "Erlang module no completion" do
{binding, env} = eval(do: nil)
assert [] = Completion.get_completion_items(":unknown", binding, env)
end
test "Erlang module multiple values completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "user",
kind: :module,
detail: "module",
documentation: _user_doc,
insert_text: "user"
},
%{
label: "user_drv",
kind: :module,
detail: "module",
documentation: _user_drv_doc,
insert_text: "user_drv"
},
%{
label: "user_sup",
kind: :module,
detail: "module",
documentation: _user_sup_doc,
insert_text: "user_sup"
}
] = Completion.get_completion_items(":user", binding, env)
end
@tag :erl_docs
test "Erlang root completion" do
{binding, env} = eval(do: nil)
lists_item = %{
label: "lists",
kind: :module,
detail: "module",
documentation: "This module contains functions for list processing.",
insert_text: "lists"
}
assert lists_item in Completion.get_completion_items(":", binding, env)
assert lists_item in Completion.get_completion_items(" :", binding, env)
end
test "Elixir proxy" do
{binding, env} = eval(do: nil)
assert %{
label: "Elixir",
kind: :module,
detail: "module",
documentation: nil,
insert_text: "Elixir"
} in Completion.get_completion_items("E", binding, env)
end
test "Elixir module completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "Enum",
kind: :module,
detail: "module",
documentation: "Provides a set of algorithms to work with enumerables.",
insert_text: "Enum"
},
%{
label: "Enumerable",
kind: :module,
detail: "module",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
] = Completion.get_completion_items("En", binding, env)
assert [
%{
label: "Enumerable",
kind: :module,
detail: "module",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
] = Completion.get_completion_items("Enumera", binding, env)
end
test "Elixir type completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "from/0",
kind: :type,
detail: "typespec",
documentation: "Tuple describing the client of a call request.",
insert_text: "from"
}
] = Completion.get_completion_items("GenServer.fr", binding, env)
assert [
%{
label: "name/0",
kind: :type,
detail: "typespec",
documentation: _name_doc,
insert_text: "name"
},
%{
label: "name_all/0",
kind: :type,
detail: "typespec",
documentation: _name_all_doc,
insert_text: "name_all"
}
] = Completion.get_completion_items(":file.nam", binding, env)
end
test "Elixir completion with self" do
{binding, env} = eval(do: nil)
assert [
%{
label: "Enumerable",
kind: :module,
detail: "module",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
] = Completion.get_completion_items("Enumerable", binding, env)
end
test "Elixir completion on modules from load path" do
{binding, env} = eval(do: nil)
assert %{
label: "Jason",
kind: :module,
detail: "module",
documentation: "A blazing fast JSON parser and generator in pure Elixir.",
insert_text: "Jason"
} in Completion.get_completion_items("Jas", binding, env)
end
test "Elixir no completion" do
{binding, env} = eval(do: nil)
assert [] = Completion.get_completion_items(".", binding, env)
assert [] = Completion.get_completion_items("Xyz", binding, env)
assert [] = Completion.get_completion_items("x.Foo", binding, env)
assert [] = Completion.get_completion_items("x.Foo.get_by", binding, env)
end
test "Elixir root submodule completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "Access",
kind: :module,
detail: "module",
documentation: "Key-based access to data structures.",
insert_text: "Access"
}
] = Completion.get_completion_items("Elixir.Acce", binding, env)
end
test "Elixir submodule completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "ANSI",
kind: :module,
detail: "module",
documentation: "Functionality to render ANSI escape sequences.",
insert_text: "ANSI"
}
] = Completion.get_completion_items("IO.AN", binding, env)
end
test "Elixir submodule no completion" do
{binding, env} = eval(do: nil)
assert [] = Completion.get_completion_items("IEx.Xyz", binding, env)
end
test "Elixir function completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "version/0",
kind: :function,
detail: "System.version()",
documentation: """
Elixir version information.
```
@spec version() :: String.t()
```\
""",
insert_text: "version"
}
] = Completion.get_completion_items("System.ve", binding, env)
end
@tag :erl_docs
test "Erlang function completion" do
{binding, env} = eval(do: nil)
assert %{
label: "gzip/1",
kind: :function,
detail: "zlib.gzip/1",
documentation: """
Compresses data with gz headers and checksum.
```
@spec gzip(data) :: compressed
when data: iodata(),
compressed: binary()
```\
""",
insert_text: "gzip"
} in Completion.get_completion_items(":zlib.gz", binding, env)
end
test "function completion with arity" do
{binding, env} = eval(do: nil)
assert %{
label: "concat/1",
kind: :function,
detail: "Enum.concat(enumerables)",
documentation: """
Given an enumerable of enumerables, concatenates the `enumerables` into
a single list.
```
@spec concat(t()) :: t()
```\
""",
insert_text: "concat"
} in Completion.get_completion_items("Enum.concat/", binding, env)
end
test "function completion same name with different arities" do
{binding, env} = eval(do: nil)
assert [
%{
label: "concat/1",
kind: :function,
detail: "Enum.concat(enumerables)",
documentation: """
Given an enumerable of enumerables, concatenates the `enumerables` into
a single list.
```
@spec concat(t()) :: t()
```\
""",
insert_text: "concat"
},
%{
label: "concat/2",
kind: :function,
detail: "Enum.concat(left, right)",
documentation: """
Concatenates the enumerable on the `right` with the enumerable on the
`left`.
```
@spec concat(t(), t()) :: t()
```\
""",
insert_text: "concat"
}
] = Completion.get_completion_items("Enum.concat", binding, env)
end
test "function completion when has default args then documentation all arities have docs" do
{binding, env} = eval(do: nil)
assert [
%{
label: "join/1",
kind: :function,
detail: ~S{Enum.join(enumerable, joiner \\ "")},
documentation: """
Joins the given `enumerable` into a string using `joiner` as a
separator.
```
@spec join(t(), String.t()) ::
String.t()
```\
""",
insert_text: "join"
},
%{
label: "join/2",
kind: :function,
detail: ~S{Enum.join(enumerable, joiner \\ "")},
documentation: """
Joins the given `enumerable` into a string using `joiner` as a
separator.
```
@spec join(t(), String.t()) ::
String.t()
```\
""",
insert_text: "join"
}
] = Completion.get_completion_items("Enum.jo", binding, env)
end
test "function completion using a variable bound to a module" do
{binding, env} =
eval do
mod = System
end
assert [
%{
label: "version/0",
kind: :function,
detail: "System.version()",
documentation: """
Elixir version information.
```
@spec version() :: String.t()
```\
""",
insert_text: "version"
}
] = Completion.get_completion_items("mod.ve", binding, env)
end
test "map atom key completion" do
{binding, env} =
eval do
map = %{
foo: 1,
bar_1: ~r/pattern/,
bar_2: true
}
end
assert [
%{
label: "bar_1",
kind: :field,
detail: "field",
documentation: ~s{```\n~r/pattern/\n```},
insert_text: "bar_1"
},
%{
label: "bar_2",
kind: :field,
detail: "field",
documentation: ~s{```\ntrue\n```},
insert_text: "bar_2"
},
%{
label: "foo",
kind: :field,
detail: "field",
documentation: ~s{```\n1\n```},
insert_text: "foo"
}
] = Completion.get_completion_items("map.", binding, env)
assert [
%{
label: "foo",
kind: :field,
detail: "field",
documentation: ~s{```\n1\n```},
insert_text: "foo"
}
] = Completion.get_completion_items("map.f", binding, env)
end
test "nested map atom key completion" do
{binding, env} =
eval do
map = %{
nested: %{
deeply: %{
foo: 1,
bar_1: 23,
bar_2: 14,
mod: System
}
}
}
end
assert [
%{
label: "nested",
kind: :field,
detail: "field",
documentation: """
```
%{
deeply: %{
bar_1: 23,
bar_2: 14,
foo: 1,
mod: System
}
}
```\
""",
insert_text: "nested"
}
] = Completion.get_completion_items("map.nest", binding, env)
assert [
%{
label: "foo",
kind: :field,
detail: "field",
documentation: ~s{```\n1\n```},
insert_text: "foo"
}
] = Completion.get_completion_items("map.nested.deeply.f", binding, env)
assert [
%{
label: "version/0",
kind: :function,
detail: "System.version()",
documentation: """
Elixir version information.
```
@spec version() :: String.t()
```\
""",
insert_text: "version"
}
] = Completion.get_completion_items("map.nested.deeply.mod.ve", binding, env)
assert [] = Completion.get_completion_items("map.non.existent", binding, env)
end
test "map string key completion is not supported" do
{binding, env} =
eval do
map = %{"foo" => 1}
end
assert [] = Completion.get_completion_items("map.f", binding, env)
end
test "autocompletion off a bound variable only works for modules and maps" do
{binding, env} =
eval do
num = 5
map = %{nested: %{num: 23}}
end
assert [] = Completion.get_completion_items("num.print", binding, env)
assert [] = Completion.get_completion_items("map.nested.num.f", binding, env)
end
test "autocompletion using access syntax does is not supported" do
{binding, env} =
eval do
map = %{nested: %{deeply: %{num: 23}}}
end
assert [] = Completion.get_completion_items("map[:nested][:deeply].n", binding, env)
assert [] = Completion.get_completion_items("map[:nested].deeply.n", binding, env)
assert [] = Completion.get_completion_items("map.nested.[:deeply].n", binding, env)
end
test "macro completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "is_nil/1",
kind: :function,
detail: "Kernel.is_nil(term)",
documentation: "Returns `true` if `term` is `nil`, `false` otherwise.",
insert_text: "is_nil"
}
] = Completion.get_completion_items("Kernel.is_ni", binding, env)
end
test "special forms completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "quote/2",
kind: :function,
detail: "Kernel.SpecialForms.quote(opts, block)",
documentation: "Gets the representation of any expression.",
insert_text: "quote"
}
] = Completion.get_completion_items("quot", binding, env)
end
test "kernel import completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "put_in/2",
kind: :function,
detail: "Kernel.put_in(path, value)",
documentation: "Puts a value in a nested structure via the given `path`.",
insert_text: "put_in"
},
%{
label: "put_in/3",
kind: :function,
detail: "Kernel.put_in(data, keys, value)",
documentation: """
Puts a value in a nested structure.
```
@spec put_in(
Access.t(),
[term(), ...],
term()
) :: Access.t()
```\
""",
insert_text: "put_in"
}
] = Completion.get_completion_items("put_i", binding, env)
end
test "variable name completion" do
{binding, env} =
eval do
number = 3
numbats = ["numbat", "numbat"]
nothing = nil
end
assert [
%{
label: "numbats",
kind: :variable,
detail: "variable",
documentation: ~s{```\n["numbat", "numbat"]\n```},
insert_text: "numbats"
}
] = Completion.get_completion_items("numba", binding, env)
assert [
%{
label: "numbats",
kind: :variable,
detail: "variable",
documentation: ~s{```\n["numbat", "numbat"]\n```},
insert_text: "numbats"
},
%{
label: "number",
kind: :variable,
detail: "variable",
documentation: ~s{```\n3\n```},
insert_text: "number"
}
] = Completion.get_completion_items("num", binding, env)
assert [
%{
label: "nothing",
kind: :variable,
detail: "variable",
documentation: ~s{```\nnil\n```},
insert_text: "nothing"
},
%{label: "node/0"},
%{label: "node/1"},
%{label: "not/1"}
] = Completion.get_completion_items("no", binding, env)
end
test "completion of manually imported functions and macros" do
{binding, env} =
eval do
import Enum
import System, only: [version: 0]
import Protocol
end
assert [
%{label: "take/2"},
%{label: "take_every/2"},
%{label: "take_random/2"},
%{label: "take_while/2"}
] = Completion.get_completion_items("take", binding, env)
assert %{
label: "version/0",
kind: :function,
detail: "System.version()",
documentation: """
Elixir version information.
```
@spec version() :: String.t()
```\
""",
insert_text: "version"
} in Completion.get_completion_items("v", binding, env)
assert [
%{label: "derive/2"},
%{label: "derive/3"}
] = Completion.get_completion_items("der", binding, env)
end
test "ignores quoted variables when performing variable completion" do
{binding, env} =
eval do
quote do
var!(my_var_1, Elixir) = 1
end
my_var_2 = 2
end
assert [
%{label: "my_var_2"}
] = Completion.get_completion_items("my_var", binding, env)
end
test "completion inside expression" do
{binding, env} = eval(do: nil)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Completion.get_completion_items("1 En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Completion.get_completion_items("foo(En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Completion.get_completion_items("Test En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Completion.get_completion_items("foo(x,En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Completion.get_completion_items("[En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Completion.get_completion_items("{En", binding, env)
end
test "ampersand completion" do
{binding, env} = eval(do: nil)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Completion.get_completion_items("&En", binding, env)
assert [
%{label: "all?/1"},
%{label: "all?/2"}
] = Completion.get_completion_items("&Enum.al", binding, env)
assert [
%{label: "all?/1"},
%{label: "all?/2"}
] = Completion.get_completion_items("f = &Enum.al", binding, env)
end
test "negation operator completion" do
{binding, env} = eval(do: nil)
assert [
%{label: "is_binary/1"}
] = Completion.get_completion_items("!is_bin", binding, env)
end
test "pin operator completion" do
{binding, env} =
eval do
my_variable = 2
end
assert [
%{label: "my_variable"}
] = Completion.get_completion_items("^my_va", binding, env)
end
defmodule SublevelTest.LevelA.LevelB do
end
test "Elixir completion sublevel" do
{binding, env} = eval(do: nil)
assert [
%{label: "LevelA"}
] =
Completion.get_completion_items(
"Livebook.CompletionTest.SublevelTest.",
binding,
env
)
end
test "complete aliases of Elixir modules" do
{binding, env} =
eval do
alias List, as: MyList
end
assert [
%{label: "MyList"}
] = Completion.get_completion_items("MyL", binding, env)
assert [
%{label: "to_integer/1"},
%{label: "to_integer/2"}
] = Completion.get_completion_items("MyList.to_integ", binding, env)
end
@tag :erl_docs
test "complete aliases of Erlang modules" do
{binding, env} =
eval do
alias :lists, as: EList
end
assert [
%{label: "EList"}
] = Completion.get_completion_items("EL", binding, env)
assert [
%{label: "map/2"},
%{label: "mapfoldl/3"},
%{label: "mapfoldr/3"}
] = Completion.get_completion_items("EList.map", binding, env)
assert %{
label: "max/1",
kind: :function,
detail: "lists.max/1",
documentation: """
Returns the first element of List that compares greater than or equal to all other elements of List.
```
@spec max(list) :: max
when list: [t, ...],
max: t,
t: term()
```\
""",
insert_text: "max"
} in Completion.get_completion_items("EList.", binding, env)
assert [] = Completion.get_completion_items("EList.Invalid", binding, env)
end
test "completion for functions added when compiled module is reloaded" do
{binding, env} = eval(do: nil)
{:module, _, bytecode, _} =
defmodule Sample do
def foo(), do: 0
end
assert [
%{label: "foo/0"}
] = Completion.get_completion_items("Livebook.CompletionTest.Sample.foo", binding, env)
Code.compiler_options(ignore_module_conflict: true)
defmodule Sample do
def foo(), do: 0
def foobar(), do: 0
end
assert [
%{label: "foo/0"},
%{label: "foobar/0"}
] = Completion.get_completion_items("Livebook.CompletionTest.Sample.foo", binding, env)
after
Code.compiler_options(ignore_module_conflict: false)
:code.purge(Sample)
:code.delete(Sample)
end
defmodule MyStruct do
defstruct [:my_val]
end
test "completion for struct names" do
{binding, env} = eval(do: nil)
assert [
%{label: "MyStruct"}
] = Completion.get_completion_items("Livebook.CompletionTest.MyStr", binding, env)
end
test "completion for struct keys" do
{binding, env} =
eval do
struct = %Livebook.CompletionTest.MyStruct{}
end
assert [
%{label: "my_val"}
] = Completion.get_completion_items("struct.my", binding, env)
end
test "ignore invalid Elixir module literals" do
{binding, env} = eval(do: nil)
defmodule(:"Elixir.Livebook.CompletionTest.Unicodé", do: nil)
assert [] = Completion.get_completion_items("Livebook.CompletionTest.Unicod", binding, env)
after
:code.purge(:"Elixir.Livebook.CompletionTest.Unicodé")
:code.delete(:"Elixir.Livebook.CompletionTest.Unicodé")
end
test "known Elixir module attributes completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "moduledoc",
kind: :variable,
detail: "module attribute",
documentation: "Provides documentation for the current module.",
insert_text: "moduledoc"
}
] = Completion.get_completion_items("@modu", binding, env)
end
test "handles calls on module attribute" do
{binding, env} = eval(do: nil)
assert [] = Completion.get_completion_items("@attr.value", binding, env)
end
end

View file

@ -219,10 +219,11 @@ defmodule Livebook.EvaluatorTest do
end
end
describe "request_completion_items/5" do
describe "handle_intellisense/5 given completion request" do
test "sends completion response to the given process", %{evaluator: evaluator} do
Evaluator.request_completion_items(evaluator, self(), :comp_ref, "System.ver")
assert_receive {:completion_response, :comp_ref, [%{label: "version/0"}]}, 1_000
request = {:completion, "System.ver"}
Evaluator.handle_intellisense(evaluator, self(), :ref, request)
assert_receive {:intellisense_response, :ref, %{items: [%{label: "version/0"}]}}, 1_000
end
test "given evaluation reference uses its bindings and env", %{evaluator: evaluator} do
@ -234,12 +235,14 @@ defmodule Livebook.EvaluatorTest do
Evaluator.evaluate_code(evaluator, self(), code, :code_1)
assert_receive {:evaluation_response, :code_1, _, %{evaluation_time_ms: _time_ms}}
Evaluator.request_completion_items(evaluator, self(), :comp_ref, "num", :code_1)
assert_receive {:completion_response, :comp_ref, [%{label: "number"}]}, 1_000
request = {:completion, "num"}
Evaluator.handle_intellisense(evaluator, self(), :ref, request, :code_1)
assert_receive {:intellisense_response, :ref, %{items: [%{label: "number"}]}}, 1_000
Evaluator.request_completion_items(evaluator, self(), :comp_ref, "ANSI.brigh", :code_1)
request = {:completion, "ANSI.brigh"}
Evaluator.handle_intellisense(evaluator, self(), :ref, request, :code_1)
assert_receive {:completion_response, :comp_ref, [%{label: "bright/0"}]}, 1_000
assert_receive {:intellisense_response, :ref, %{items: [%{label: "bright/0"}]}}, 1_000
end
end

View file

@ -0,0 +1,995 @@
defmodule Livebook.IntellisenseTest.Utils do
@moduledoc false
@doc """
Returns `{binding, env}` resulting from evaluating
the given block of code in a fresh context.
"""
defmacro eval(do: block) do
binding = []
env = :elixir.env_for_eval([])
{_, binding, env} = :elixir.eval_quoted(block, binding, env)
quote do
{unquote(Macro.escape(binding)), unquote(Macro.escape(env))}
end
end
end
defmodule Livebook.IntellisenseTest do
use ExUnit.Case, async: true
import Livebook.IntellisenseTest.Utils
alias Livebook.Intellisense
describe "get_completion_items/3" do
test "completion when no hint given" do
{binding, env} = eval(do: nil)
length_item = %{
label: "length/1",
kind: :function,
detail: "Kernel.length(list)",
documentation: """
Returns the length of `list`.
```
@spec length(list()) ::
non_neg_integer()
```\
""",
insert_text: "length"
}
assert length_item in Intellisense.get_completion_items("", binding, env)
assert length_item in Intellisense.get_completion_items("Enum.map(list, ", binding, env)
end
@tag :erl_docs
test "Erlang module completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: ":zlib",
kind: :module,
detail: "module",
documentation:
"This module provides an API for the zlib library ([www.zlib.net](http://www.zlib.net)). It is used to compress and decompress data. The data format is described by [RFC 1950](https://www.ietf.org/rfc/rfc1950.txt), [RFC 1951](https://www.ietf.org/rfc/rfc1951.txt), and [RFC 1952](https://www.ietf.org/rfc/rfc1952.txt).",
insert_text: "zlib"
}
] = Intellisense.get_completion_items(":zl", binding, env)
end
test "Erlang module no completion" do
{binding, env} = eval(do: nil)
assert [] = Intellisense.get_completion_items(":unknown", binding, env)
end
test "Erlang module multiple values completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: ":user",
kind: :module,
detail: "module",
documentation: _user_doc,
insert_text: "user"
},
%{
label: ":user_drv",
kind: :module,
detail: "module",
documentation: _user_drv_doc,
insert_text: "user_drv"
},
%{
label: ":user_sup",
kind: :module,
detail: "module",
documentation: _user_sup_doc,
insert_text: "user_sup"
}
] = Intellisense.get_completion_items(":user", binding, env)
end
@tag :erl_docs
test "Erlang root completion" do
{binding, env} = eval(do: nil)
lists_item = %{
label: ":lists",
kind: :module,
detail: "module",
documentation: "This module contains functions for list processing.",
insert_text: "lists"
}
assert lists_item in Intellisense.get_completion_items(":", binding, env)
assert lists_item in Intellisense.get_completion_items(" :", binding, env)
end
test "Elixir proxy" do
{binding, env} = eval(do: nil)
assert %{
label: "Elixir",
kind: :module,
detail: "module",
documentation: "No documentation available",
insert_text: "Elixir"
} in Intellisense.get_completion_items("E", binding, env)
end
test "Elixir module completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "Enum",
kind: :module,
detail: "module",
documentation: "Provides a set of algorithms to work with enumerables.",
insert_text: "Enum"
},
%{
label: "Enumerable",
kind: :module,
detail: "module",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
] = Intellisense.get_completion_items("En", binding, env)
assert [
%{
label: "Enumerable",
kind: :module,
detail: "module",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
] = Intellisense.get_completion_items("Enumera", binding, env)
end
test "Elixir type completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "from/0",
kind: :type,
detail: "typespec",
documentation: "Tuple describing the client of a call request.",
insert_text: "from"
}
] = Intellisense.get_completion_items("GenServer.fr", binding, env)
assert [
%{
label: "name/0",
kind: :type,
detail: "typespec",
documentation: _name_doc,
insert_text: "name"
},
%{
label: "name_all/0",
kind: :type,
detail: "typespec",
documentation: _name_all_doc,
insert_text: "name_all"
}
] = Intellisense.get_completion_items(":file.nam", binding, env)
end
test "Elixir completion with self" do
{binding, env} = eval(do: nil)
assert [
%{
label: "Enumerable",
kind: :module,
detail: "module",
documentation: "Enumerable protocol used by `Enum` and `Stream` modules.",
insert_text: "Enumerable"
}
] = Intellisense.get_completion_items("Enumerable", binding, env)
end
test "Elixir completion on modules from load path" do
{binding, env} = eval(do: nil)
assert %{
label: "Jason",
kind: :module,
detail: "module",
documentation: "A blazing fast JSON parser and generator in pure Elixir.",
insert_text: "Jason"
} in Intellisense.get_completion_items("Jas", binding, env)
end
test "Elixir no completion" do
{binding, env} = eval(do: nil)
assert [] = Intellisense.get_completion_items(".", binding, env)
assert [] = Intellisense.get_completion_items("Xyz", binding, env)
assert [] = Intellisense.get_completion_items("x.Foo", binding, env)
assert [] = Intellisense.get_completion_items("x.Foo.get_by", binding, env)
end
test "Elixir root submodule completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "Access",
kind: :module,
detail: "module",
documentation: "Key-based access to data structures.",
insert_text: "Access"
}
] = Intellisense.get_completion_items("Elixir.Acce", binding, env)
end
test "Elixir submodule completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "ANSI",
kind: :module,
detail: "module",
documentation: "Functionality to render ANSI escape sequences.",
insert_text: "ANSI"
}
] = Intellisense.get_completion_items("IO.AN", binding, env)
end
test "Elixir submodule no completion" do
{binding, env} = eval(do: nil)
assert [] = Intellisense.get_completion_items("IEx.Xyz", binding, env)
end
test "Elixir function completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "version/0",
kind: :function,
detail: "System.version()",
documentation: """
Elixir version information.
```
@spec version() :: String.t()
```\
""",
insert_text: "version"
}
] = Intellisense.get_completion_items("System.ve", binding, env)
end
@tag :erl_docs
test "Erlang function completion" do
{binding, env} = eval(do: nil)
assert %{
label: "gzip/1",
kind: :function,
detail: ":zlib.gzip/1",
documentation: """
Compresses data with gz headers and checksum.
```
@spec gzip(data) :: compressed
when data: iodata(),
compressed: binary()
```\
""",
insert_text: "gzip"
} in Intellisense.get_completion_items(":zlib.gz", binding, env)
end
test "function completion with arity" do
{binding, env} = eval(do: nil)
assert %{
label: "concat/1",
kind: :function,
detail: "Enum.concat(enumerables)",
documentation: """
Given an enumerable of enumerables, concatenates the `enumerables` into
a single list.
```
@spec concat(t()) :: t()
```\
""",
insert_text: "concat"
} in Intellisense.get_completion_items("Enum.concat/", binding, env)
end
test "function completion same name with different arities" do
{binding, env} = eval(do: nil)
assert [
%{
label: "concat/1",
kind: :function,
detail: "Enum.concat(enumerables)",
documentation: """
Given an enumerable of enumerables, concatenates the `enumerables` into
a single list.
```
@spec concat(t()) :: t()
```\
""",
insert_text: "concat"
},
%{
label: "concat/2",
kind: :function,
detail: "Enum.concat(left, right)",
documentation: """
Concatenates the enumerable on the `right` with the enumerable on the
`left`.
```
@spec concat(t(), t()) :: t()
```\
""",
insert_text: "concat"
}
] = Intellisense.get_completion_items("Enum.concat", binding, env)
end
test "function completion when has default args then documentation all arities have docs" do
{binding, env} = eval(do: nil)
assert [
%{
label: "join/1",
kind: :function,
detail: ~S{Enum.join(enumerable, joiner \\ "")},
documentation: """
Joins the given `enumerable` into a string using `joiner` as a
separator.
```
@spec join(t(), String.t()) ::
String.t()
```\
""",
insert_text: "join"
},
%{
label: "join/2",
kind: :function,
detail: ~S{Enum.join(enumerable, joiner \\ "")},
documentation: """
Joins the given `enumerable` into a string using `joiner` as a
separator.
```
@spec join(t(), String.t()) ::
String.t()
```\
""",
insert_text: "join"
}
] = Intellisense.get_completion_items("Enum.jo", binding, env)
end
test "function completion using a variable bound to a module" do
{binding, env} =
eval do
mod = System
end
assert [
%{
label: "version/0",
kind: :function,
detail: "System.version()",
documentation: """
Elixir version information.
```
@spec version() :: String.t()
```\
""",
insert_text: "version"
}
] = Intellisense.get_completion_items("mod.ve", binding, env)
end
test "map atom key completion" do
{binding, env} =
eval do
map = %{
foo: 1,
bar_1: ~r/pattern/,
bar_2: true
}
end
assert [
%{
label: "bar_1",
kind: :field,
detail: "field",
documentation: ~s{```\n~r/pattern/\n```},
insert_text: "bar_1"
},
%{
label: "bar_2",
kind: :field,
detail: "field",
documentation: ~s{```\ntrue\n```},
insert_text: "bar_2"
},
%{
label: "foo",
kind: :field,
detail: "field",
documentation: ~s{```\n1\n```},
insert_text: "foo"
}
] = Intellisense.get_completion_items("map.", binding, env)
assert [
%{
label: "foo",
kind: :field,
detail: "field",
documentation: ~s{```\n1\n```},
insert_text: "foo"
}
] = Intellisense.get_completion_items("map.f", binding, env)
end
test "nested map atom key completion" do
{binding, env} =
eval do
map = %{
nested: %{
deeply: %{
foo: 1,
bar_1: 23,
bar_2: 14,
mod: System
}
}
}
end
assert [
%{
label: "nested",
kind: :field,
detail: "field",
documentation: """
```
%{
deeply: %{
bar_1: 23,
bar_2: 14,
foo: 1,
mod: System
}
}
```\
""",
insert_text: "nested"
}
] = Intellisense.get_completion_items("map.nest", binding, env)
assert [
%{
label: "foo",
kind: :field,
detail: "field",
documentation: ~s{```\n1\n```},
insert_text: "foo"
}
] = Intellisense.get_completion_items("map.nested.deeply.f", binding, env)
assert [
%{
label: "version/0",
kind: :function,
detail: "System.version()",
documentation: """
Elixir version information.
```
@spec version() :: String.t()
```\
""",
insert_text: "version"
}
] = Intellisense.get_completion_items("map.nested.deeply.mod.ve", binding, env)
assert [] = Intellisense.get_completion_items("map.non.existent", binding, env)
end
test "map string key completion is not supported" do
{binding, env} =
eval do
map = %{"foo" => 1}
end
assert [] = Intellisense.get_completion_items("map.f", binding, env)
end
test "autocompletion off a bound variable only works for modules and maps" do
{binding, env} =
eval do
num = 5
map = %{nested: %{num: 23}}
end
assert [] = Intellisense.get_completion_items("num.print", binding, env)
assert [] = Intellisense.get_completion_items("map.nested.num.f", binding, env)
end
test "autocompletion using access syntax does is not supported" do
{binding, env} =
eval do
map = %{nested: %{deeply: %{num: 23}}}
end
assert [] = Intellisense.get_completion_items("map[:nested][:deeply].n", binding, env)
assert [] = Intellisense.get_completion_items("map[:nested].deeply.n", binding, env)
assert [] = Intellisense.get_completion_items("map.nested.[:deeply].n", binding, env)
end
test "macro completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "is_nil/1",
kind: :function,
detail: "Kernel.is_nil(term)",
documentation: "Returns `true` if `term` is `nil`, `false` otherwise.",
insert_text: "is_nil"
}
] = Intellisense.get_completion_items("Kernel.is_ni", binding, env)
end
test "special forms completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "quote/2",
kind: :function,
detail: "Kernel.SpecialForms.quote(opts, block)",
documentation: "Gets the representation of any expression.",
insert_text: "quote"
}
] = Intellisense.get_completion_items("quot", binding, env)
end
test "kernel import completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "put_in/2",
kind: :function,
detail: "Kernel.put_in(path, value)",
documentation: "Puts a value in a nested structure via the given `path`.",
insert_text: "put_in"
},
%{
label: "put_in/3",
kind: :function,
detail: "Kernel.put_in(data, keys, value)",
documentation: """
Puts a value in a nested structure.
```
@spec put_in(
Access.t(),
[term(), ...],
term()
) :: Access.t()
```\
""",
insert_text: "put_in"
}
] = Intellisense.get_completion_items("put_i", binding, env)
end
test "variable name completion" do
{binding, env} =
eval do
number = 3
numbats = ["numbat", "numbat"]
nothing = nil
end
assert [
%{
label: "numbats",
kind: :variable,
detail: "variable",
documentation: ~s{```\n["numbat", "numbat"]\n```},
insert_text: "numbats"
}
] = Intellisense.get_completion_items("numba", binding, env)
assert [
%{
label: "numbats",
kind: :variable,
detail: "variable",
documentation: ~s{```\n["numbat", "numbat"]\n```},
insert_text: "numbats"
},
%{
label: "number",
kind: :variable,
detail: "variable",
documentation: ~s{```\n3\n```},
insert_text: "number"
}
] = Intellisense.get_completion_items("num", binding, env)
assert [
%{
label: "nothing",
kind: :variable,
detail: "variable",
documentation: ~s{```\nnil\n```},
insert_text: "nothing"
},
%{label: "node/0"},
%{label: "node/1"},
%{label: "not/1"}
] = Intellisense.get_completion_items("no", binding, env)
end
test "completion of manually imported functions and macros" do
{binding, env} =
eval do
import Enum
import System, only: [version: 0]
import Protocol
end
assert [
%{label: "take/2"},
%{label: "take_every/2"},
%{label: "take_random/2"},
%{label: "take_while/2"}
] = Intellisense.get_completion_items("take", binding, env)
assert %{
label: "version/0",
kind: :function,
detail: "System.version()",
documentation: """
Elixir version information.
```
@spec version() :: String.t()
```\
""",
insert_text: "version"
} in Intellisense.get_completion_items("v", binding, env)
assert [
%{label: "derive/2"},
%{label: "derive/3"}
] = Intellisense.get_completion_items("der", binding, env)
end
test "ignores quoted variables when performing variable completion" do
{binding, env} =
eval do
quote do
var!(my_var_1, Elixir) = 1
end
my_var_2 = 2
end
assert [
%{label: "my_var_2"}
] = Intellisense.get_completion_items("my_var", binding, env)
end
test "completion inside expression" do
{binding, env} = eval(do: nil)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Intellisense.get_completion_items("1 En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Intellisense.get_completion_items("foo(En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Intellisense.get_completion_items("Test En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Intellisense.get_completion_items("foo(x,En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Intellisense.get_completion_items("[En", binding, env)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Intellisense.get_completion_items("{En", binding, env)
end
test "ampersand completion" do
{binding, env} = eval(do: nil)
assert [
%{label: "Enum"},
%{label: "Enumerable"}
] = Intellisense.get_completion_items("&En", binding, env)
assert [
%{label: "all?/1"},
%{label: "all?/2"}
] = Intellisense.get_completion_items("&Enum.al", binding, env)
assert [
%{label: "all?/1"},
%{label: "all?/2"}
] = Intellisense.get_completion_items("f = &Enum.al", binding, env)
end
test "negation operator completion" do
{binding, env} = eval(do: nil)
assert [
%{label: "is_binary/1"}
] = Intellisense.get_completion_items("!is_bin", binding, env)
end
test "pin operator completion" do
{binding, env} =
eval do
my_variable = 2
end
assert [
%{label: "my_variable"}
] = Intellisense.get_completion_items("^my_va", binding, env)
end
defmodule SublevelTest.LevelA.LevelB do
end
test "Elixir completion sublevel" do
{binding, env} = eval(do: nil)
assert [
%{label: "LevelA"}
] =
Intellisense.get_completion_items(
"Livebook.IntellisenseTest.SublevelTest.",
binding,
env
)
end
test "complete aliases of Elixir modules" do
{binding, env} =
eval do
alias List, as: MyList
end
assert [
%{label: "MyList"}
] = Intellisense.get_completion_items("MyL", binding, env)
assert [
%{label: "to_integer/1"},
%{label: "to_integer/2"}
] = Intellisense.get_completion_items("MyList.to_integ", binding, env)
end
@tag :erl_docs
test "complete aliases of Erlang modules" do
{binding, env} =
eval do
alias :lists, as: EList
end
assert [
%{label: "EList"}
] = Intellisense.get_completion_items("EL", binding, env)
assert [
%{label: "map/2"},
%{label: "mapfoldl/3"},
%{label: "mapfoldr/3"}
] = Intellisense.get_completion_items("EList.map", binding, env)
assert %{
label: "max/1",
kind: :function,
detail: ":lists.max/1",
documentation: """
Returns the first element of `List` that compares greater than or equal to all other elements of `List`.
```
@spec max(list) :: max
when list: [t, ...],
max: t,
t: term()
```\
""",
insert_text: "max"
} in Intellisense.get_completion_items("EList.", binding, env)
assert [] = Intellisense.get_completion_items("EList.Invalid", binding, env)
end
test "completion for functions added when compiled module is reloaded" do
{binding, env} = eval(do: nil)
{:module, _, bytecode, _} =
defmodule Sample do
def foo(), do: 0
end
assert [
%{label: "foo/0"}
] =
Intellisense.get_completion_items(
"Livebook.IntellisenseTest.Sample.foo",
binding,
env
)
Code.compiler_options(ignore_module_conflict: true)
defmodule Sample do
def foo(), do: 0
def foobar(), do: 0
end
assert [
%{label: "foo/0"},
%{label: "foobar/0"}
] =
Intellisense.get_completion_items(
"Livebook.IntellisenseTest.Sample.foo",
binding,
env
)
after
Code.compiler_options(ignore_module_conflict: false)
:code.purge(Sample)
:code.delete(Sample)
end
defmodule MyStruct do
defstruct [:my_val]
end
test "completion for struct names" do
{binding, env} = eval(do: nil)
assert [
%{label: "MyStruct"}
] =
Intellisense.get_completion_items("Livebook.IntellisenseTest.MyStr", binding, env)
end
test "completion for struct keys" do
{binding, env} =
eval do
struct = %Livebook.IntellisenseTest.MyStruct{}
end
assert [
%{label: "my_val"}
] = Intellisense.get_completion_items("struct.my", binding, env)
end
test "ignore invalid Elixir module literals" do
{binding, env} = eval(do: nil)
defmodule(:"Elixir.Livebook.IntellisenseTest.Unicodé", do: nil)
assert [] =
Intellisense.get_completion_items("Livebook.IntellisenseTest.Unicod", binding, env)
after
:code.purge(:"Elixir.Livebook.IntellisenseTest.Unicodé")
:code.delete(:"Elixir.Livebook.IntellisenseTest.Unicodé")
end
test "known Elixir module attributes completion" do
{binding, env} = eval(do: nil)
assert [
%{
label: "moduledoc",
kind: :variable,
detail: "module attribute",
documentation: "Provides documentation for the current module.",
insert_text: "moduledoc"
}
] = Intellisense.get_completion_items("@modu", binding, env)
end
test "handles calls on module attribute" do
{binding, env} = eval(do: nil)
assert [] = Intellisense.get_completion_items("@attr.value", binding, env)
end
end
describe "get_details/3" do
test "returns nil if there are no matches" do
{binding, env} = eval(do: nil)
assert nil == Intellisense.get_details("Unknown.unknown()", 2, binding, env)
end
test "returns subject range" do
{binding, env} = eval(do: nil)
assert %{range: %{from: 0, to: 17}} =
Intellisense.get_details("Integer.to_string(10)", 15, binding, env)
assert %{range: %{from: 0, to: 7}} =
Intellisense.get_details("Integer.to_string(10)", 2, binding, env)
end
test "does not return duplicate details for functions with default arguments" do
{binding, env} = eval(do: nil)
assert %{contents: [_]} =
Intellisense.get_details("Integer.to_string(10)", 15, binding, env)
end
test "returns details only for exactly matching identifiers" do
{binding, env} = eval(do: nil)
assert nil == Intellisense.get_details("Enum.ma", 6, binding, env)
end
test "returns full docs" do
{binding, env} = eval(do: nil)
assert %{contents: [content]} = Intellisense.get_details("Enum.map", 6, binding, env)
assert content =~ "## Examples"
end
test "returns full Erlang docs" do
{binding, env} = eval(do: nil)
assert %{contents: [file]} = Intellisense.get_details(":file.read()", 2, binding, env)
assert file =~ "## Performance"
assert %{contents: [file_read]} = Intellisense.get_details(":file.read()", 8, binding, env)
assert file_read =~ "Typical error reasons:"
end
end
end

View file

@ -130,20 +130,40 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServerTest do
end
end
describe "request_completion_items/6" do
describe "handle_intellisense/5 given completion request" do
test "provides basic completion when no evaluation reference is given", %{pid: pid} do
RuntimeServer.request_completion_items(pid, self(), :comp_ref, "System.ver", {:c1, nil})
request = {:completion, "System.ver"}
RuntimeServer.handle_intellisense(pid, self(), :ref, request, {:c1, nil})
assert_receive {:completion_response, :comp_ref, [%{label: "version/0"}]}
assert_receive {:intellisense_response, :ref, %{items: [%{label: "version/0"}]}}
end
test "provides extended completion when previous evaluation reference is given", %{pid: pid} do
RuntimeServer.evaluate_code(pid, "number = 10", {:c1, :e1}, {:c1, nil})
assert_receive {:evaluation_response, :e1, _, %{evaluation_time_ms: _time_ms}}
RuntimeServer.request_completion_items(pid, self(), :comp_ref, "num", {:c1, :e1})
request = {:completion, "num"}
RuntimeServer.handle_intellisense(pid, self(), :ref, request, {:c1, :e1})
assert_receive {:completion_response, :comp_ref, [%{label: "number"}]}
assert_receive {:intellisense_response, :ref, %{items: [%{label: "number"}]}}
end
end
describe "handle_intellisense/5 given details request" do
test "responds with identifier details", %{pid: pid} do
request = {:details, "System.version", 10}
RuntimeServer.handle_intellisense(pid, self(), :ref, request, {:c1, nil})
assert_receive {:intellisense_response, :ref, %{range: %{from: 0, to: 14}, contents: [_]}}
end
end
describe "handle_intellisense/5 given format request" do
test "responds with a formatted code", %{pid: pid} do
request = {:format, "System.version"}
RuntimeServer.handle_intellisense(pid, self(), :ref, request, {:c1, nil})
assert_receive {:intellisense_response, :ref, %{code: "System.version()"}}
end
end

View file

@ -240,9 +240,13 @@ defmodule LivebookWeb.SessionLiveTest do
view
|> element("#session")
|> render_hook("completion_request", %{"cell_id" => cell_id, "hint" => "System.ver"})
|> render_hook("intellisense_request", %{
"cell_id" => cell_id,
"type" => "completion",
"hint" => "System.ver"
})
assert_reply view, %{"completion_ref" => nil}
assert_reply view, %{"ref" => nil}
end
test "replies with completion reference and then sends asynchronous response",
@ -257,14 +261,18 @@ defmodule LivebookWeb.SessionLiveTest do
view
|> element("#session")
|> render_hook("completion_request", %{"cell_id" => cell_id, "hint" => "System.ver"})
|> render_hook("intellisense_request", %{
"cell_id" => cell_id,
"type" => "completion",
"hint" => "System.ver"
})
assert_reply view, %{"completion_ref" => ref}
assert_reply view, %{"ref" => ref}
assert ref != nil
assert_push_event(view, "completion_response", %{
"completion_ref" => ^ref,
"items" => [%{label: "version/0"}]
assert_push_event(view, "intellisense_response", %{
"ref" => ^ref,
"response" => %{items: [%{label: "version/0"}]}
})
end
end

View file

@ -14,7 +14,7 @@ defmodule Livebook.Runtime.NoopRuntime do
def evaluate_code(_, _, _, _, _ \\ []), do: :ok
def forget_evaluation(_, _), do: :ok
def drop_container(_, _), do: :ok
def request_completion_items(_, _, _, _, _), do: :ok
def handle_intellisense(_, _, _, _, _), do: :ok
def duplicate(_), do: {:ok, Livebook.Runtime.NoopRuntime.new()}
end
end