Skip to content

Commit

Permalink
Provide go to definition of modules and functions defined in the note…
Browse files Browse the repository at this point in the history
…book (#2730)

Co-authored-by: Jonatan Kłosko <[email protected]>
  • Loading branch information
aleDsz and jonatanklosko authored Aug 7, 2024
1 parent be60ffb commit 6e36725
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 43 deletions.
8 changes: 8 additions & 0 deletions assets/js/hooks/cell.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ const Cell = {
handleCellEvent(event) {
if (event.type === "dispatch_queue_evaluation") {
this.handleDispatchQueueEvaluation(event.dispatch);
} else if (event.type === "jump_to_line") {
this.handleJumpToLine(event.line);
}
},

Expand All @@ -175,6 +177,12 @@ const Cell = {
}
},

handleJumpToLine(line) {
if (this.isFocused) {
this.currentEditor().moveCursorToLine(line);
}
},

handleCellEditorCreated(tag, liveEditor) {
this.liveEditors[tag] = liveEditor;

Expand Down
15 changes: 14 additions & 1 deletion assets/js/hooks/cell_editor/live_editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
lineNumbers,
highlightActiveLineGutter,
} from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { EditorState, EditorSelection } from "@codemirror/state";
import {
indentOnInput,
bracketMatching,
Expand Down Expand Up @@ -56,6 +56,7 @@ import {
} from "./live_editor/codemirror/commands";
import { ancestorNode, closestNode } from "./live_editor/codemirror/tree_utils";
import { selectingClass } from "./live_editor/codemirror/selecting_class";
import { globalPubsub } from "../../lib/pubsub";

/**
* Mounts cell source editor with real-time collaboration mechanism.
Expand Down Expand Up @@ -179,6 +180,17 @@ export default class LiveEditor {
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.
*/
Expand Down Expand Up @@ -515,6 +527,7 @@ export default class LiveEditor {
item.classList.add("cm-hoverDocsContent");
item.classList.add("cm-markdown");
dom.appendChild(item);

new Markdown(item, content, {
defaultCodeLanguage: this.language,
useDarkTheme: this.usesDarkTheme(),
Expand Down
17 changes: 17 additions & 0 deletions assets/js/hooks/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,23 @@ const Session = {
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(
`[data-el-queue-cell-evaluation-button]`,
);
Expand Down
104 changes: 73 additions & 31 deletions lib/livebook/intellisense.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule Livebook.Intellisense do
"""
@type context :: %{
env: Macro.Env.t(),
ebin_path: String.t() | nil,
map_binding: (Code.binding() -> any())
}

Expand Down Expand Up @@ -413,7 +414,11 @@ defmodule Livebook.Intellisense do
nil

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}
end
end
Expand All @@ -422,13 +427,13 @@ defmodule Livebook.Intellisense do
defp include_in_details?(%{kind: :bitstring_modifier}), do: false
defp include_in_details?(_), do: true

defp format_details_item(%{kind: :variable, name: name}), do: code(name)
defp format_details_item(%{kind: :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([
code(name),
"""
Expand All @@ -441,27 +446,35 @@ defmodule Livebook.Intellisense do
])
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([
code(inspect(module)),
format_definition_link(module, context),
format_docs_link(module),
format_documentation(documentation, :all)
])
end

defp format_details_item(%{
kind: :function,
module: module,
name: name,
arity: arity,
documentation: documentation,
signatures: signatures,
specs: specs,
meta: meta
}) do
defp format_details_item(
%{
kind: :function,
module: module,
name: name,
arity: arity,
documentation: documentation,
signatures: signatures,
specs: specs,
meta: meta
},
context
) do
join_with_divider([
format_signatures(signatures, module) |> code(),
join_with_middle_dot([
format_definition_link(module, context, {:function, name, arity}),
format_docs_link(module, {:function, name, arity}),
format_meta(:since, meta)
]),
Expand All @@ -471,23 +484,30 @@ defmodule Livebook.Intellisense do
])
end

defp format_details_item(%{
kind: :type,
module: module,
name: name,
arity: arity,
documentation: documentation,
type_spec: type_spec
}) do
defp format_details_item(
%{
kind: :type,
module: module,
name: name,
arity: arity,
documentation: documentation,
type_spec: type_spec
},
context
) do
join_with_divider([
format_type_signature(type_spec, module) |> code(),
format_definition_link(module, context, {:type, name, arity}),
format_docs_link(module, {:type, name, arity}),
format_type_spec(type_spec, @extended_line_length) |> code(),
format_documentation(documentation, :all)
])
end

defp format_details_item(%{kind: :module_attribute, name: name, documentation: documentation}) do
defp format_details_item(
%{kind: :module_attribute, name: name, documentation: documentation},
_context
) do
join_with_divider([
code("@#{name}"),
format_documentation(documentation, :all)
Expand Down Expand Up @@ -519,14 +539,29 @@ defmodule Livebook.Intellisense do
"""
end

defp format_docs_link(module, function_or_type \\ nil) do
app = Application.get_application(module)
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}

module_name =
case Atom.to_string(module) do
"Elixir." <> name -> name
name -> name
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
app = Application.get_application(module)
module_name = module_name(module)

is_otp? =
case :code.which(module) do
Expand Down Expand Up @@ -868,4 +903,11 @@ defmodule Livebook.Intellisense do
) do
group_type_list_items(items, [{:li, [], prev_content ++ [{:p, [], content}]} | acc])
end

defp module_name(module) do
case Atom.to_string(module) do
"Elixir." <> name -> name
name -> name
end
end
end
56 changes: 56 additions & 0 deletions lib/livebook/intellisense/docs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ defmodule Livebook.Intellisense.Docs do
@type type_spec() :: {type_kind(), term()}
@type type_kind() :: :type | :opaque

@type definition ::
{:module, module()} | {:function | :type, name :: atom(), arity :: pos_integer()}

@doc """
Fetches documentation for the given module if available.
"""
Expand Down Expand Up @@ -174,4 +177,57 @@ defmodule Livebook.Intellisense.Docs do
# so we explicitly list it.
defp ensure_loaded?(Elixir), do: false
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
2 changes: 1 addition & 1 deletion lib/livebook/intellisense/signature_matcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ defmodule Livebook.Intellisense.SignatureMatcher do
Evaluation binding and environment is used to expand aliases,
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
def get_matching_signatures(hint, intellisense_context, node) do
%{env: env} = intellisense_context
Expand Down
12 changes: 8 additions & 4 deletions lib/livebook/runtime/evaluator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -183,17 +183,17 @@ defmodule Livebook.Runtime.Evaluator do
@doc """
Returns an empty intellisense context.
"""
@spec intellisense_context() :: Livebook.Intellisense.intellisense_context()
@spec intellisense_context() :: Livebook.Intellisense.context()
def intellisense_context() do
env = Code.env_for_eval([])
map_binding = fn fun -> fun.([]) end
%{env: env, map_binding: map_binding}
%{env: env, ebin_path: ebin_path(), map_binding: map_binding}
end

@doc """
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
{:dictionary, dictionary} = Process.info(evaluator.pid, :dictionary)

Expand All @@ -210,7 +210,11 @@ defmodule Livebook.Runtime.Evaluator do

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

defp find_in_dictionary(dictionary, key) do
Expand Down
4 changes: 2 additions & 2 deletions lib/livebook/session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ defmodule Livebook.Session do
app_pid: pid() | nil,
auto_shutdown_ms: non_neg_integer() | nil,
auto_shutdown_timer_ref: reference() | nil,
started_by: Livebook.User.t() | nil
started_by: Livebook.Users.User.t() | nil
}

@type memory_usage ::
Expand Down Expand Up @@ -201,7 +201,7 @@ defmodule Livebook.Session do
@doc """
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
GenServer.call(pid, :describe_self, @timeout)
end
Expand Down
Loading

0 comments on commit 6e36725

Please sign in to comment.