Provide go to definition of modules and functions defined in the notebook (#2730)

Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com>
This commit is contained in:
Alexandre de Souza 2024-08-07 15:09:51 -03:00 committed by GitHub
parent be60ffb9fd
commit 6e36725304
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 249 additions and 43 deletions

View file

@ -159,6 +159,8 @@ const Cell = {
handleCellEvent(event) { handleCellEvent(event) {
if (event.type === "dispatch_queue_evaluation") { if (event.type === "dispatch_queue_evaluation") {
this.handleDispatchQueueEvaluation(event.dispatch); this.handleDispatchQueueEvaluation(event.dispatch);
} else if (event.type === "jump_to_line") {
this.handleJumpToLine(event.line);
} }
}, },
@ -175,6 +177,12 @@ const Cell = {
} }
}, },
handleJumpToLine(line) {
if (this.isFocused) {
this.currentEditor().moveCursorToLine(line);
}
},
handleCellEditorCreated(tag, liveEditor) { handleCellEditorCreated(tag, liveEditor) {
this.liveEditors[tag] = liveEditor; this.liveEditors[tag] = liveEditor;

View file

@ -11,7 +11,7 @@ import {
lineNumbers, lineNumbers,
highlightActiveLineGutter, highlightActiveLineGutter,
} from "@codemirror/view"; } from "@codemirror/view";
import { EditorState } from "@codemirror/state"; import { EditorState, EditorSelection } from "@codemirror/state";
import { import {
indentOnInput, indentOnInput,
bracketMatching, bracketMatching,
@ -56,6 +56,7 @@ import {
} from "./live_editor/codemirror/commands"; } from "./live_editor/codemirror/commands";
import { ancestorNode, closestNode } from "./live_editor/codemirror/tree_utils"; import { ancestorNode, closestNode } from "./live_editor/codemirror/tree_utils";
import { selectingClass } from "./live_editor/codemirror/selecting_class"; import { selectingClass } from "./live_editor/codemirror/selecting_class";
import { globalPubsub } from "../../lib/pubsub";
/** /**
* Mounts cell source editor with real-time collaboration mechanism. * Mounts cell source editor with real-time collaboration mechanism.
@ -179,6 +180,17 @@ export default class LiveEditor {
this.view.focus(); this.view.focus();
} }
/**
* Updates editor selection such that cursor points to the given line.
*/
moveCursorToLine(lineNumber) {
const line = this.view.state.doc.line(lineNumber);
this.view.dispatch({
selection: EditorSelection.single(line.from),
});
}
/** /**
* Removes focus from the editor. * Removes focus from the editor.
*/ */
@ -515,6 +527,7 @@ export default class LiveEditor {
item.classList.add("cm-hoverDocsContent"); item.classList.add("cm-hoverDocsContent");
item.classList.add("cm-markdown"); item.classList.add("cm-markdown");
dom.appendChild(item); dom.appendChild(item);
new Markdown(item, content, { new Markdown(item, content, {
defaultCodeLanguage: this.language, defaultCodeLanguage: this.language,
useDarkTheme: this.usesDarkTheme(), useDarkTheme: this.usesDarkTheme(),

View file

@ -539,6 +539,23 @@ const Session = {
this.setInsertMode(false); this.setInsertMode(false);
} }
if (
event.target.matches("a") &&
event.target.hash.startsWith("#go-to-definition")
) {
const search = event.target.hash.replace("#go-to-definition", "");
const params = new URLSearchParams(search);
const line = parseInt(params.get("line"), 10);
const [_filename, cellId] = params.get("file").split("#cell:");
this.setFocusedEl(cellId);
this.setInsertMode(true);
globalPubsub.broadcast(`cells:${cellId}`, { type: "jump_to_line", line });
event.preventDefault();
}
const evalButton = event.target.closest( const evalButton = event.target.closest(
`[data-el-queue-cell-evaluation-button]`, `[data-el-queue-cell-evaluation-button]`,
); );

View file

@ -20,6 +20,7 @@ defmodule Livebook.Intellisense do
""" """
@type context :: %{ @type context :: %{
env: Macro.Env.t(), env: Macro.Env.t(),
ebin_path: String.t() | nil,
map_binding: (Code.binding() -> any()) map_binding: (Code.binding() -> any())
} }
@ -413,7 +414,11 @@ defmodule Livebook.Intellisense do
nil nil
matches -> matches ->
contents = Enum.map(matches, &format_details_item/1) contents =
matches
|> Enum.sort_by(& &1[:arity], :asc)
|> Enum.map(&format_details_item(&1, context))
%{range: range, contents: contents} %{range: range, contents: contents}
end end
end end
@ -422,13 +427,13 @@ defmodule Livebook.Intellisense do
defp include_in_details?(%{kind: :bitstring_modifier}), do: false defp include_in_details?(%{kind: :bitstring_modifier}), do: false
defp include_in_details?(_), do: true defp include_in_details?(_), do: true
defp format_details_item(%{kind: :variable, name: name}), do: code(name) defp format_details_item(%{kind: :variable, name: name}, _context), do: code(name)
defp format_details_item(%{kind: :map_field, name: name}), do: code(name) defp format_details_item(%{kind: :map_field, name: name}, _context), do: code(name)
defp format_details_item(%{kind: :in_map_field, name: name}), do: code(name) defp format_details_item(%{kind: :in_map_field, name: name}, _context), do: code(name)
defp format_details_item(%{kind: :in_struct_field, name: name, default: default}) do defp format_details_item(%{kind: :in_struct_field, name: name, default: default}, _context) do
join_with_divider([ join_with_divider([
code(name), code(name),
""" """
@ -441,27 +446,35 @@ defmodule Livebook.Intellisense do
]) ])
end end
defp format_details_item(%{kind: :module, module: module, documentation: documentation}) do defp format_details_item(
%{kind: :module, module: module, documentation: documentation},
context
) do
join_with_divider([ join_with_divider([
code(inspect(module)), code(inspect(module)),
format_definition_link(module, context),
format_docs_link(module), format_docs_link(module),
format_documentation(documentation, :all) format_documentation(documentation, :all)
]) ])
end end
defp format_details_item(%{ defp format_details_item(
kind: :function, %{
module: module, kind: :function,
name: name, module: module,
arity: arity, name: name,
documentation: documentation, arity: arity,
signatures: signatures, documentation: documentation,
specs: specs, signatures: signatures,
meta: meta specs: specs,
}) do meta: meta
},
context
) do
join_with_divider([ join_with_divider([
format_signatures(signatures, module) |> code(), format_signatures(signatures, module) |> code(),
join_with_middle_dot([ join_with_middle_dot([
format_definition_link(module, context, {:function, name, arity}),
format_docs_link(module, {:function, name, arity}), format_docs_link(module, {:function, name, arity}),
format_meta(:since, meta) format_meta(:since, meta)
]), ]),
@ -471,23 +484,30 @@ defmodule Livebook.Intellisense do
]) ])
end end
defp format_details_item(%{ defp format_details_item(
kind: :type, %{
module: module, kind: :type,
name: name, module: module,
arity: arity, name: name,
documentation: documentation, arity: arity,
type_spec: type_spec documentation: documentation,
}) do type_spec: type_spec
},
context
) do
join_with_divider([ join_with_divider([
format_type_signature(type_spec, module) |> code(), format_type_signature(type_spec, module) |> code(),
format_definition_link(module, context, {:type, name, arity}),
format_docs_link(module, {:type, name, arity}), format_docs_link(module, {:type, name, arity}),
format_type_spec(type_spec, @extended_line_length) |> code(), format_type_spec(type_spec, @extended_line_length) |> code(),
format_documentation(documentation, :all) format_documentation(documentation, :all)
]) ])
end end
defp format_details_item(%{kind: :module_attribute, name: name, documentation: documentation}) do defp format_details_item(
%{kind: :module_attribute, name: name, documentation: documentation},
_context
) do
join_with_divider([ join_with_divider([
code("@#{name}"), code("@#{name}"),
format_documentation(documentation, :all) format_documentation(documentation, :all)
@ -519,14 +539,29 @@ defmodule Livebook.Intellisense do
""" """
end end
defp format_definition_link(module, context, function_or_type \\ nil) do
if context.ebin_path do
path = Path.join(context.ebin_path, "#{module}.beam")
identifier =
if function_or_type,
do: function_or_type,
else: {:module, module}
with true <- File.exists?(path),
{:ok, line} <- Docs.locate_definition(path, identifier) do
file = module.module_info(:compile)[:source]
query_string = URI.encode_query(%{file: to_string(file), line: line})
"[Go to definition](#go-to-definition?#{query_string})"
else
_otherwise -> nil
end
end
end
defp format_docs_link(module, function_or_type \\ nil) do defp format_docs_link(module, function_or_type \\ nil) do
app = Application.get_application(module) app = Application.get_application(module)
module_name = module_name(module)
module_name =
case Atom.to_string(module) do
"Elixir." <> name -> name
name -> name
end
is_otp? = is_otp? =
case :code.which(module) do case :code.which(module) do
@ -868,4 +903,11 @@ defmodule Livebook.Intellisense do
) do ) do
group_type_list_items(items, [{:li, [], prev_content ++ [{:p, [], content}]} | acc]) group_type_list_items(items, [{:li, [], prev_content ++ [{:p, [], content}]} | acc])
end end
defp module_name(module) do
case Atom.to_string(module) do
"Elixir." <> name -> name
name -> name
end
end
end end

View file

@ -40,6 +40,9 @@ defmodule Livebook.Intellisense.Docs do
@type type_spec() :: {type_kind(), term()} @type type_spec() :: {type_kind(), term()}
@type type_kind() :: :type | :opaque @type type_kind() :: :type | :opaque
@type definition ::
{:module, module()} | {:function | :type, name :: atom(), arity :: pos_integer()}
@doc """ @doc """
Fetches documentation for the given module if available. Fetches documentation for the given module if available.
""" """
@ -174,4 +177,57 @@ defmodule Livebook.Intellisense.Docs do
# so we explicitly list it. # so we explicitly list it.
defp ensure_loaded?(Elixir), do: false defp ensure_loaded?(Elixir), do: false
defp ensure_loaded?(module), do: Code.ensure_loaded?(module) defp ensure_loaded?(module), do: Code.ensure_loaded?(module)
@doc """
Extracts the location about an identifier found.
The function returns the line where the identifier is located.
"""
@spec locate_definition(String.t(), definition()) :: {:ok, pos_integer()} | :error
def locate_definition(path, identifier)
def locate_definition(path, {:module, module}) do
with {:ok, {:raw_abstract_v1, annotations}} <- beam_lib_chunks(path, :abstract_code) do
{:attribute, anno, :module, ^module} =
Enum.find(annotations, &match?({:attribute, _, :module, _}, &1))
{:ok, :erl_anno.line(anno)}
end
end
def locate_definition(path, {:function, name, arity}) do
with {:ok, {:debug_info_v1, _, {:elixir_v1, meta, _}}} <- beam_lib_chunks(path, :debug_info),
{_pair, _kind, kw, _body} <- keyfind(meta.definitions, {name, arity}) do
Keyword.fetch(kw, :line)
end
end
def locate_definition(path, {:type, name, arity}) do
with {:ok, {:raw_abstract_v1, annotations}} <- beam_lib_chunks(path, :abstract_code) do
fetch_type_line(annotations, name, arity)
end
end
defp fetch_type_line(annotations, name, arity) do
for {:attribute, anno, :type, {^name, _, vars}} <- annotations, length(vars) == arity do
:erl_anno.line(anno)
end
|> case do
[] -> :error
lines -> {:ok, Enum.min(lines)}
end
end
defp beam_lib_chunks(path, key) do
path = String.to_charlist(path)
case :beam_lib.chunks(path, [key]) do
{:ok, {_, [{^key, value}]}} -> {:ok, value}
_ -> :error
end
end
defp keyfind(list, key) do
List.keyfind(list, key, 0) || :error
end
end end

View file

@ -13,7 +13,7 @@ defmodule Livebook.Intellisense.SignatureMatcher do
Evaluation binding and environment is used to expand aliases, Evaluation binding and environment is used to expand aliases,
imports, access variable values, etc. imports, access variable values, etc.
""" """
@spec get_matching_signatures(String.t(), Livebook.Intellisense.intellisense_context(), node()) :: @spec get_matching_signatures(String.t(), Livebook.Intellisense.context(), node()) ::
{:ok, list(signature_info()), active_argument :: non_neg_integer()} | :error {:ok, list(signature_info()), active_argument :: non_neg_integer()} | :error
def get_matching_signatures(hint, intellisense_context, node) do def get_matching_signatures(hint, intellisense_context, node) do
%{env: env} = intellisense_context %{env: env} = intellisense_context

View file

@ -183,17 +183,17 @@ defmodule Livebook.Runtime.Evaluator do
@doc """ @doc """
Returns an empty intellisense context. Returns an empty intellisense context.
""" """
@spec intellisense_context() :: Livebook.Intellisense.intellisense_context() @spec intellisense_context() :: Livebook.Intellisense.context()
def intellisense_context() do def intellisense_context() do
env = Code.env_for_eval([]) env = Code.env_for_eval([])
map_binding = fn fun -> fun.([]) end map_binding = fn fun -> fun.([]) end
%{env: env, map_binding: map_binding} %{env: env, ebin_path: ebin_path(), map_binding: map_binding}
end end
@doc """ @doc """
Builds intellisense context from the given evaluation. Builds intellisense context from the given evaluation.
""" """
@spec intellisense_context(t(), list(ref())) :: Livebook.Intellisense.intellisense_context() @spec intellisense_context(t(), list(ref())) :: Livebook.Intellisense.context()
def intellisense_context(evaluator, parent_refs) do def intellisense_context(evaluator, parent_refs) do
{:dictionary, dictionary} = Process.info(evaluator.pid, :dictionary) {:dictionary, dictionary} = Process.info(evaluator.pid, :dictionary)
@ -210,7 +210,11 @@ defmodule Livebook.Runtime.Evaluator do
map_binding = fn fun -> map_binding(evaluator, parent_refs, fun) end map_binding = fn fun -> map_binding(evaluator, parent_refs, fun) end
%{env: env, map_binding: map_binding} %{
env: env,
ebin_path: find_in_dictionary(dictionary, @ebin_path_key),
map_binding: map_binding
}
end end
defp find_in_dictionary(dictionary, key) do defp find_in_dictionary(dictionary, key) do

View file

@ -129,7 +129,7 @@ defmodule Livebook.Session do
app_pid: pid() | nil, app_pid: pid() | nil,
auto_shutdown_ms: non_neg_integer() | nil, auto_shutdown_ms: non_neg_integer() | nil,
auto_shutdown_timer_ref: reference() | nil, auto_shutdown_timer_ref: reference() | nil,
started_by: Livebook.User.t() | nil started_by: Livebook.Users.User.t() | nil
} }
@type memory_usage :: @type memory_usage ::
@ -201,7 +201,7 @@ defmodule Livebook.Session do
@doc """ @doc """
Fetches session information from the session server. Fetches session information from the session server.
""" """
@spec get_by_pid(pid()) :: Session.t() @spec get_by_pid(pid()) :: t()
def get_by_pid(pid) do def get_by_pid(pid) do
GenServer.call(pid, :describe_self, @timeout) GenServer.call(pid, :describe_self, @timeout)
end end

View file

@ -5,7 +5,7 @@ defmodule Livebook.IntellisenseTest do
# Returns intellisense context resulting from evaluating # Returns intellisense context resulting from evaluating
# the given block of code in a fresh context. # the given block of code in a fresh context.
defmacrop eval(do: block) do defmacrop eval(ebin_path \\ System.tmp_dir!(), do: block) do
quote do quote do
block = unquote(Macro.escape(block)) block = unquote(Macro.escape(block))
binding = [] binding = []
@ -14,6 +14,7 @@ defmodule Livebook.IntellisenseTest do
%{ %{
env: env, env: env,
ebin_path: unquote(ebin_path),
map_binding: fn fun -> fun.(binding) end map_binding: fn fun -> fun.(binding) end
} }
end end
@ -1580,6 +1581,72 @@ defmodule Livebook.IntellisenseTest do
assert content =~ ~r"https://www.erlang.org/doc/man/string.html#uppercase-1" assert content =~ ~r"https://www.erlang.org/doc/man/string.html#uppercase-1"
end end
test "returns the go to definition link" do
Code.put_compiler_option(:debug_info, true)
[runtime_path | _] = :code.get_path()
runtime_path = to_string(runtime_path)
context = eval(runtime_path, do: nil)
code = ~S'''
defmodule GoToDefinition do
@type t :: term()
@type foo :: foo(:bar)
@type foo(var) :: {var, t()}
defmacro with_logging(do: block) do
quote do
require Logger
Logger.debug("Running code")
result = unquote(block)
Logger.debug("Result: #{inspect(result)}")
result
end
end
@spec hello(var :: term()) :: foo(term())
def hello(message) do
{:bar, message}
end
end
'''
file = "#{__ENV__.file}#cell:#{Livebook.Utils.random_short_id()}"
path = Path.join(runtime_path, "Elixir.GoToDefinition.beam")
[{_module, bytecode}] = Code.compile_string(code, file)
File.write!(path, bytecode)
assert %{contents: [content]} =
Intellisense.get_details("GoToDefinition", 14, context, node())
assert content =~ "#go-to-definition?#{URI.encode_query(%{file: file, line: 1})}"
assert %{contents: [content]} =
Intellisense.get_details("GoToDefinition.t", 16, context, node())
assert content =~ "#go-to-definition?#{URI.encode_query(%{file: file, line: 2})}"
assert %{contents: [arity_0_content, arity_1_content]} =
Intellisense.get_details("GoToDefinition.foo", 18, context, node())
assert arity_0_content =~ "#go-to-definition?#{URI.encode_query(%{file: file, line: 3})}"
assert arity_1_content =~ "#go-to-definition?#{URI.encode_query(%{file: file, line: 4})}"
assert %{contents: [content]} =
Intellisense.get_details("GoToDefinition.with_logging", 20, context, node())
assert content =~ "#go-to-definition?#{URI.encode_query(%{file: file, line: 6})}"
assert %{contents: [content]} =
Intellisense.get_details("GoToDefinition.hello", 18, context, node())
assert content =~ "#go-to-definition?#{URI.encode_query(%{file: file, line: 17})}"
after
Code.put_compiler_option(:debug_info, false)
end
end end
describe "get_signature_items/3" do describe "get_signature_items/3" do
@ -1891,18 +1958,17 @@ defmodule Livebook.IntellisenseTest do
''' '''
Livebook.Runtime.evaluate_code(runtime, :elixir, code, {:c1, :e1}, []) Livebook.Runtime.evaluate_code(runtime, :elixir, code, {:c1, :e1}, [])
receive do: ({:runtime_evaluation_response, :e1, _, _} -> :ok) receive do: ({:runtime_evaluation_response, :e1, _, _} -> :ok)
send(parent, :continue) send(parent, :continue)
Process.sleep(:infinity) receive do: (:done -> :ok)
end end
}) })
receive do: (:continue -> :ok) receive do: (:continue -> :ok)
on_exit(fn -> on_exit(fn ->
Process.exit(runtime_owner_pid, :kill) send(runtime_owner_pid, :done)
end) end)
[node: runtime.node] [node: runtime.node]