Implements the Go to Definition keyboard shortcut (#2741)

Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com>
Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Alexandre de Souza 2024-08-16 05:08:51 -03:00 committed by GitHub
parent ae5e546bb2
commit 87a49b1bdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 297 additions and 121 deletions

View file

@ -367,7 +367,7 @@ const Cell = {
scrollIntoView(element, { scrollIntoView(element, {
scrollMode: "if-needed", scrollMode: "if-needed",
behavior: "smooth", behavior: "instant",
block: "center", block: "center",
}); });
}, },

View file

@ -46,7 +46,7 @@ import { settingsStore } from "../../lib/settings";
import Delta from "../../lib/delta"; import Delta from "../../lib/delta";
import Markdown from "../../lib/markdown"; import Markdown from "../../lib/markdown";
import { readOnlyHint } from "./live_editor/codemirror/read_only_hint"; import { readOnlyHint } from "./live_editor/codemirror/read_only_hint";
import { wait } from "../../lib/utils"; import { isMacOS, wait } from "../../lib/utils";
import Emitter from "../../lib/emitter"; import Emitter from "../../lib/emitter";
import CollabClient from "./live_editor/collab_client"; import CollabClient from "./live_editor/collab_client";
import { languages } from "./live_editor/codemirror/languages"; import { languages } from "./live_editor/codemirror/languages";
@ -363,14 +363,27 @@ export default class LiveEditor {
settings.editor_mode === "emacs" ? [emacs()] : [], settings.editor_mode === "emacs" ? [emacs()] : [],
language ? language.support : [], language ? language.support : [],
EditorView.domEventHandlers({ EditorView.domEventHandlers({
click: this.handleEditorClick.bind(this),
keydown: this.handleEditorKeydown.bind(this), keydown: this.handleEditorKeydown.bind(this),
blur: this.handleEditorBlur.bind(this), blur: this.handleEditorBlur.bind(this),
focus: this.handleEditorFocus.bind(this), focus: this.handleEditorFocus.bind(this),
}), }),
EditorView.clickAddsSelectionRange.of((event) => event.altKey),
], ],
}); });
} }
/** @private */
handleEditorClick(event) {
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;
if (cmd) {
this.jumpToDefinition(this.view);
}
return false;
}
/** @private */ /** @private */
handleEditorKeydown(event) { handleEditorKeydown(event) {
// We dispatch escape event, but only if it is not consumed by any // We dispatch escape event, but only if it is not consumed by any
@ -541,6 +554,29 @@ export default class LiveEditor {
.catch(() => null); .catch(() => null);
} }
/** @private */
jumpToDefinition(view) {
const pos = view.state.selection.main.head;
const line = view.state.doc.lineAt(pos);
const lineLength = line.to - line.from;
const text = line.text;
const column = pos - line.from;
if (column < 1 || column > lineLength) return null;
return this.connection
.intellisenseRequest("definition", { line: text, column })
.then((response) => {
globalPubsub.broadcast("jump_to_editor", {
line: response.line,
file: response.file,
});
return true;
})
.catch(() => false);
}
/** @private */ /** @private */
signatureSource({ state, pos }) { signatureSource({ state, pos }) {
const textUntilCursor = this.getSignatureHint(state, pos); const textUntilCursor = this.getSignatureHint(state, pos);

View file

@ -157,6 +157,12 @@ const Session = {
(event) => this.toggleCollapseAllSections(), (event) => this.toggleCollapseAllSections(),
); );
this.subscriptions = [
globalPubsub.subscribe("jump_to_editor", ({ line, file }) =>
this.jumpToLine(file, line),
),
];
this.initializeDragAndDrop(); this.initializeDragAndDrop();
window.addEventListener( window.addEventListener(
@ -270,6 +276,7 @@ const Session = {
leaveChannel(); leaveChannel();
} }
this.subscriptions.forEach((subscription) => subscription.destroy());
this.store.destroy(); this.store.destroy();
}, },
@ -546,12 +553,9 @@ const Session = {
const search = event.target.hash.replace("#go-to-definition", ""); const search = event.target.hash.replace("#go-to-definition", "");
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
const line = parseInt(params.get("line"), 10); const line = parseInt(params.get("line"), 10);
const [_filename, cellId] = params.get("file").split("#cell:"); const file = params.get("file");
this.setFocusedEl(cellId); this.jumpToLine(file, line);
this.setInsertMode(true);
globalPubsub.broadcast(`cells:${cellId}`, { type: "jump_to_line", line });
event.preventDefault(); event.preventDefault();
} }
@ -1442,6 +1446,15 @@ const Session = {
getElement(name) { getElement(name) {
return this.el.querySelector(`[data-el-${name}]`); return this.el.querySelector(`[data-el-${name}]`);
}, },
jumpToLine(file, line) {
const [_filename, cellId] = file.split("#cell:");
this.setFocusedEl(cellId, { scroll: false });
this.setInsertMode(true);
globalPubsub.broadcast(`cells:${cellId}`, { type: "jump_to_line", line });
},
}; };
export default Session; export default Session;

View file

@ -53,6 +53,10 @@ defmodule Livebook.Intellisense do
get_details(line, column, context, node) get_details(line, column, context, node)
end end
def handle_request({:definition, line, column}, context, node) do
get_definitions(line, column, context, node)
end
def handle_request({:signature, hint}, context, node) do def handle_request({:signature, hint}, context, node) do
get_signature_items(hint, context, node) get_signature_items(hint, context, node)
end end
@ -452,7 +456,7 @@ defmodule Livebook.Intellisense do
) do ) do
join_with_divider([ join_with_divider([
code(inspect(module)), code(inspect(module)),
format_definition_link(module, context), format_definition_link(module, context, {:module, module}),
format_docs_link(module), format_docs_link(module),
format_documentation(documentation, :all) format_documentation(documentation, :all)
]) ])
@ -539,23 +543,9 @@ defmodule Livebook.Intellisense do
""" """
end end
defp format_definition_link(module, context, function_or_type \\ nil) do defp format_definition_link(module, context, identifier) do
if context.ebin_path do if query = get_definition_location(module, context, identifier) do
path = Path.join(context.ebin_path, "#{module}.beam") "[Go to definition](#go-to-definition?#{URI.encode_query(query)})"
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
end end
@ -710,6 +700,56 @@ defmodule Livebook.Intellisense do
raise "unknown documentation format #{inspect(format)}" raise "unknown documentation format #{inspect(format)}"
end end
@doc """
Returns the identifier definition located in `column` in `line`.
"""
@spec get_definitions(String.t(), pos_integer(), context(), node()) ::
Runtime.definition_response() | nil
def get_definitions(line, column, context, node) do
case IdentifierMatcher.locate_identifier(line, column, context, node) do
%{matches: []} ->
nil
%{matches: matches, range: range} ->
matches
|> Enum.sort_by(& &1[:arity], :asc)
|> Enum.flat_map(&List.wrap(get_definition_location(&1, context)))
|> case do
[%{file: file, line: line} | _] -> %{range: range, file: file, line: line}
_ -> nil
end
end
end
defp get_definition_location(%{kind: :module, module: module}, context) do
get_definition_location(module, context, {:module, module})
end
defp get_definition_location(
%{kind: :function, module: module, name: name, arity: arity},
context
) do
get_definition_location(module, context, {:function, name, arity})
end
defp get_definition_location(%{kind: :type, module: module, name: name, arity: arity}, context) do
get_definition_location(module, context, {:type, name, arity})
end
defp get_definition_location(module, context, identifier) do
if context.ebin_path do
path = Path.join(context.ebin_path, "#{module}.beam")
with true <- File.exists?(path),
{:ok, line} <- Docs.locate_definition(path, identifier) do
file = module.module_info(:compile)[:source]
%{file: to_string(file), line: line}
else
_otherwise -> nil
end
end
end
# Erlang HTML AST # Erlang HTML AST
# See https://erlang.org/doc/apps/erl_docgen/doc_storage.html#erlang-documentation-format # See https://erlang.org/doc/apps/erl_docgen/doc_storage.html#erlang-documentation-format

View file

@ -627,19 +627,35 @@ defmodule Livebook.Intellisense.IdentifierMatcher do
end end
defp get_matching_modules(hint, ctx) do defp get_matching_modules(hint, ctx) do
ctx.node ctx
|> get_modules() |> get_modules()
|> Enum.filter(&ctx.matcher.(Atom.to_string(&1), hint)) |> Enum.filter(&ctx.matcher.(Atom.to_string(&1), hint))
|> Enum.uniq() |> Enum.uniq()
end end
defp get_modules(node) do defp get_modules(%{node: node} = ctx) do
modules = cached_all_loaded(node) # On interactive mode, we load modules from the application
# and then the ones from runtime. For a remote node, ideally
# we would get the applications one, but there is no cheap
# way to do such, so we get :code.all_loaded and cache it
# instead (which includes all modules anyway on embedded mode).
if node == node() and :code.get_mode() == :interactive do if node == node() and :code.get_mode() == :interactive do
modules ++ get_modules_from_applications() runtime_modules(ctx.intellisense_context.ebin_path) ++ get_modules_from_applications()
else else
modules cached_all_loaded(node)
end
end
defp runtime_modules(path) do
with true <- is_binary(path),
{:ok, beams} <- File.ls(path) do
for beam <- beams, String.ends_with?(beam, ".beam") do
beam
|> binary_slice(0..-6//1)
|> String.to_atom()
end
else
_ -> []
end end
end end

View file

@ -503,6 +503,7 @@ defprotocol Livebook.Runtime do
@type intellisense_request :: @type intellisense_request ::
completion_request() completion_request()
| details_request() | details_request()
| definition_request()
| signature_request() | signature_request()
| format_request() | format_request()
@ -516,6 +517,7 @@ defprotocol Livebook.Runtime do
nil nil
| completion_response() | completion_response()
| details_response() | details_response()
| definition_response()
| signature_response() | signature_response()
| format_response() | format_response()
@ -553,6 +555,21 @@ defprotocol Livebook.Runtime do
contents: list(String.t()) contents: list(String.t())
} }
@typedoc """
Looks up more the definition about an identifier found in `column` in
`line`.
"""
@type definition_request :: {:definition, line :: String.t(), column :: pos_integer()}
@type definition_response :: %{
range: %{
from: non_neg_integer(),
to: non_neg_integer()
},
line: pos_integer(),
file: String.t()
}
@typedoc """ @typedoc """
Looks up a list of function signatures matching the given hint. Looks up a list of function signatures matching the given hint.

View file

@ -616,6 +616,10 @@ defmodule LivebookWeb.SessionLive do
column = Text.JS.js_column_to_elixir(column, line) column = Text.JS.js_column_to_elixir(column, line)
{:details, line, column} {:details, line, column}
%{"type" => "definition", "line" => line, "column" => column} ->
column = Text.JS.js_column_to_elixir(column, line)
{:definition, line, column}
%{"type" => "signature", "hint" => hint} -> %{"type" => "signature", "hint" => hint} ->
{:signature, hint} {:signature, hint}

View file

@ -251,16 +251,6 @@ defmodule Livebook.IntellisenseTest do
] = Intellisense.get_completion_items("RuntimeE", context, node()) ] = Intellisense.get_completion_items("RuntimeE", context, node())
end end
test "caches all loaded modules" do
context = eval(do: nil)
Intellisense.get_completion_items("Hub", context, node())
key = {Intellisense.IdentifierMatcher, node()}
assert [_ | _] = :persistent_term.get(key, :error)
Intellisense.IdentifierMatcher.clear_all_loaded(node())
assert :error = :persistent_term.get(key, :error)
end
test "Elixir struct completion lists nested options" do test "Elixir struct completion lists nested options" do
context = eval(do: nil) context = eval(do: nil)
@ -999,11 +989,16 @@ defmodule Livebook.IntellisenseTest do
] = Intellisense.get_completion_items("^my_va", context, node()) ] = Intellisense.get_completion_items("^my_va", context, node())
end end
defmodule SublevelTest.LevelA.LevelB do @tag :tmp_dir
end test "Elixir completion sublevel", %{tmp_dir: tmp_dir} do
context =
eval tmp_dir do
end
test "Elixir completion sublevel" do compile_and_save_bytecode(tmp_dir, ~S'''
context = eval(do: nil) defmodule Livebook.IntellisenseTest.SublevelTest.LevelA.LevelB do
end
''')
assert [%{label: "LevelA"}] = assert [%{label: "LevelA"}] =
Intellisense.get_completion_items( Intellisense.get_completion_items(
@ -1100,12 +1095,17 @@ defmodule Livebook.IntellisenseTest do
:code.delete(Sample) :code.delete(Sample)
end end
defmodule MyStruct do @tag :tmp_dir
defstruct [:my_val] test "completion for struct names", %{tmp_dir: tmp_dir} do
end context =
eval tmp_dir do
end
test "completion for struct names" do compile_and_save_bytecode(tmp_dir, ~S'''
context = eval(do: nil) defmodule Livebook.IntellisenseTest.MyStruct do
defstruct [:my_val]
end
''')
assert [ assert [
%{label: "MyStruct"} %{label: "MyStruct"}
@ -1117,9 +1117,16 @@ defmodule Livebook.IntellisenseTest do
) )
end end
test "completion for struct keys" do @tag :tmp_dir
test "completion for struct keys", %{tmp_dir: tmp_dir} do
compile_and_save_bytecode(tmp_dir, ~S'''
defmodule Livebook.IntellisenseTest.MyStruct do
defstruct [:my_val]
end
''')
context = context =
eval do eval tmp_dir do
struct = %Livebook.IntellisenseTest.MyStruct{} struct = %Livebook.IntellisenseTest.MyStruct{}
end end
@ -1128,8 +1135,17 @@ defmodule Livebook.IntellisenseTest do
] = Intellisense.get_completion_items("struct.my", context, node()) ] = Intellisense.get_completion_items("struct.my", context, node())
end end
test "completion for struct keys inside struct" do @tag :tmp_dir
context = eval(do: nil) test "completion for struct keys inside struct", %{tmp_dir: tmp_dir} do
context =
eval tmp_dir do
end
compile_and_save_bytecode(tmp_dir, ~S'''
defmodule Livebook.IntellisenseTest.MyStruct do
defstruct [:my_val]
end
''')
assert [ assert [
%{ %{
@ -1154,8 +1170,18 @@ defmodule Livebook.IntellisenseTest do
) )
end end
test "completion for struct keys inside struct removes filled keys" do @tag :tmp_dir
context = eval(do: nil) test "completion for struct keys inside struct removes filled keys",
%{tmp_dir: tmp_dir} do
context =
eval tmp_dir do
end
compile_and_save_bytecode(tmp_dir, ~S'''
defmodule Livebook.IntellisenseTest.MyStruct do
defstruct [:my_val]
end
''')
assert [] = assert [] =
Intellisense.get_completion_items( Intellisense.get_completion_items(
@ -1173,8 +1199,17 @@ defmodule Livebook.IntellisenseTest do
refute Enum.find(completions, &match?(%{label: "__exception__"}, &1)) refute Enum.find(completions, &match?(%{label: "__exception__"}, &1))
end end
test "completion for struct keys in update syntax" do @tag :tmp_dir
context = eval(do: nil) test "completion for struct keys in update syntax", %{tmp_dir: tmp_dir} do
context =
eval tmp_dir do
end
compile_and_save_bytecode(tmp_dir, ~S'''
defmodule Livebook.IntellisenseTest.MyStruct do
defstruct [:my_val]
end
''')
assert [ assert [
%{ %{
@ -1581,77 +1616,75 @@ 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
end
@tag :tmp_dir @tag :tmp_dir
test "returns the go to definition link", %{tmp_dir: tmp_dir} do test "get_definitions/4 returns the go to definition query string", %{tmp_dir: tmp_dir} do
Code.put_compiler_option(:debug_info, true) Code.put_compiler_option(:debug_info, true)
context = context =
eval tmp_dir do eval tmp_dir do
alias Livebook.IntellisenseTest.GoToDefinition alias Livebook.IntellisenseTest.GoToDefinition
end end
code = ~S''' code = ~S'''
defmodule Livebook.IntellisenseTest.GoToDefinition do defmodule Livebook.IntellisenseTest.GoToDefinition do
@type t :: term() @type t :: term()
@type foo :: foo(:bar) @type foo :: foo(:bar)
@type foo(var) :: {var, t()} @type foo(var) :: {var, t()}
defmacro with_logging(do: block) do defmacro with_logging(do: block) do
quote do quote do
require Logger require Logger
Logger.debug("Running code") Logger.debug("Running code")
result = unquote(block) result = unquote(block)
Logger.debug("Result: #{inspect(result)}") Logger.debug("Result: #{inspect(result)}")
result result
end
end
@spec hello(var :: term()) :: foo(term())
def hello(message) do
{:bar, message}
end end
end end
'''
file = "#{__ENV__.file}#cell:#{Livebook.Utils.random_short_id()}" @spec hello(var :: term()) :: foo(term())
path = Path.join(tmp_dir, "Elixir.Livebook.IntellisenseTest.GoToDefinition.beam") def hello(message) do
{:bar, message}
[{_module, bytecode}] = Code.compile_string(code, file) end
File.write!(path, bytecode)
Code.prepend_path(tmp_dir)
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)
Code.delete_path(tmp_dir)
:code.purge(:"Elixir.Livebook.IntellisenseTest.GoToDefinition")
:code.delete(:"Elixir.Livebook.IntellisenseTest.GoToDefinition")
end end
'''
file = "#{__ENV__.file}#cell:#{Livebook.Utils.random_short_id()}"
compile_and_save_bytecode(tmp_dir, code, file)
assert Intellisense.get_definitions("GoToDefinition", 14, context, node()) == %{
line: 1,
file: file,
range: %{to: 15, from: 1}
}
assert Intellisense.get_definitions("GoToDefinition.t", 16, context, node()) == %{
line: 2,
file: file,
range: %{to: 17, from: 1}
}
# For now, we aren't fetching the expected arity but we will address it later.
assert Intellisense.get_definitions("GoToDefinition.foo", 18, context, node()) == %{
line: 3,
file: file,
range: %{to: 19, from: 1}
}
assert Intellisense.get_definitions("GoToDefinition.with_logging", 20, context, node()) == %{
line: 6,
file: file,
range: %{to: 28, from: 1}
}
assert Intellisense.get_definitions("GoToDefinition.hello", 18, context, node()) == %{
line: 17,
file: file,
range: %{to: 21, from: 1}
}
after
Code.put_compiler_option(:debug_info, false)
end end
describe "get_signature_items/3" do describe "get_signature_items/3" do
@ -2032,4 +2065,18 @@ defmodule Livebook.IntellisenseTest do
assert content =~ "No documentation available" assert content =~ "No documentation available"
end end
end end
defp compile_and_save_bytecode(dir, code, file \\ "nofile") do
[{module, bytecode}] = Code.compile_string(code, file)
path = Path.join(dir, "#{module}.beam")
File.write!(path, bytecode)
Code.prepend_path(dir)
on_exit(fn ->
Code.delete_path(dir)
:code.purge(module)
:code.delete(module)
end)
end
end end

View file

@ -6,6 +6,9 @@ Livebook.Runtime.ErlDist.NodeManager.start(
unload_modules_on_termination: false unload_modules_on_termination: false
) )
# We load erts so we can access its modules for completion.
Application.load(:erts)
# Use the embedded runtime in tests by default, so they are cheaper # Use the embedded runtime in tests by default, so they are cheaper
# to run. Other runtimes can be tested by setting them explicitly # to run. Other runtimes can be tested by setting them explicitly
Application.put_env(:livebook, :default_runtime, Livebook.Runtime.Embedded.new()) Application.put_env(:livebook, :default_runtime, Livebook.Runtime.Embedded.new())