From f1723b5e0805181d359ad4af238b6f88268c8165 Mon Sep 17 00:00:00 2001 From: Denis Tataurov Date: Mon, 10 Jul 2023 15:04:48 +0300 Subject: [PATCH 1/3] feat: add hover language feature for modules and module function calls --- README.md | 1 + lib/next_ls.ex | 17 ++- lib/next_ls/hover.ex | 168 ++++++++++++++++++++++ test/next_ls_test.exs | 317 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 lib/next_ls/hover.ex diff --git a/README.md b/README.md index 5341d639..cfead8de 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Still in heavy development, currently supporting the following features: - Workspace Symbols - Document Symbols - Go To Definition +- Hover ## Editor Support diff --git a/lib/next_ls.ex b/lib/next_ls.ex index e79d1720..fb7baa52 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -23,6 +23,7 @@ defmodule NextLS do TextDocumentDocumentSymbol, TextDocumentDefinition, TextDocumentFormatting, + TextDocumentHover, WorkspaceSymbol } @@ -105,7 +106,8 @@ defmodule NextLS do document_formatting_provider: true, workspace_symbol_provider: true, document_symbol_provider: true, - definition_provider: true + definition_provider: true, + hover_provider: true }, server_info: %{name: "NextLS"} }, assign(lsp, root_uri: root_uri)} @@ -159,6 +161,19 @@ defmodule NextLS do {:reply, symbols, lsp} end + def handle_request(%TextDocumentHover{params: %{text_document: %{uri: uri}, position: position}}, lsp) do + hover = + try do + NextLS.Hover.fetch(lsp, lsp.assigns.documents[uri], position) + rescue + e -> + GenLSP.error(lsp, Exception.format_banner(:error, e, __STACKTRACE__)) + nil + end + + {:reply, hover, lsp} + end + def handle_request(%WorkspaceSymbol{params: %{query: query}}, lsp) do filter = fn sym -> if query == "" do diff --git a/lib/next_ls/hover.ex b/lib/next_ls/hover.ex new file mode 100644 index 00000000..ada07200 --- /dev/null +++ b/lib/next_ls/hover.ex @@ -0,0 +1,168 @@ +defmodule NextLS.Hover do + alias GenLSP.Structures.{ + Hover, + MarkupContent, + Position, + Range + } + + alias NextLS.Runtime + + @spec fetch(lsp :: GenLSP.LSP.t(), document :: [String.t()], position :: Position.t()) :: Hover.t() | nil + def fetch(lsp, document, position) do + with {module, function, range} <- get_surround_context(document, position), + docs when is_binary(docs) <- fetch_docs(lsp, document, module, function) do + %Hover{ + contents: %MarkupContent{ + kind: "markdown", + value: docs + }, + range: range + } + end + end + + defp get_surround_context(document, position) do + hover_line = position.line + 1 + hover_column = position.character + 1 + + case Code.Fragment.surround_context(Enum.join(document, "\n"), {hover_line, hover_column}) do + %{context: {:dot, {:alias, module}, function}, begin: {line, context_begin}, end: {_, context_end}} -> + {to_module(module), to_atom(function), + %Range{ + start: %Position{line: line - 1, character: context_begin - 1}, + end: %Position{line: line - 1, character: context_end - 1} + }} + + %{context: {:alias, module}, begin: {line, context_begin}, end: {_, context_end}} -> + {to_module(module), nil, + %Range{ + start: %Position{line: line - 1, character: context_begin - 1}, + end: %Position{line: line - 1, character: context_end - 1} + }} + + _other -> + nil + end + end + + defp fetch_docs(lsp, document, module, function) do + if function do + with {:ok, {_, _, _, _, _, _, functions_docs}} <- request_docs(lsp, document, module), + {_, _, _, function_docs, _} <- find_function_docs(functions_docs, function) do + get_en_doc(function_docs) + end + else + with {:ok, {_, _, _, _, docs, _, _}} <- request_docs(lsp, document, module) do + get_en_doc(docs) + end + end + end + + defp request_docs(lsp, document, module, attempt \\ 1) do + case send_fetch_docs_request(lsp, module) do + {:error, :not_ready} -> + nil + + {:ok, {:error, :module_not_found}} -> + if attempt < 2 do + aliased_module = find_aliased_module(document, module) + + if aliased_module do + request_docs(lsp, document, from_quoted_module_to_module(aliased_module), attempt + 1) + end + end + + other -> + other + end + end + + defp send_fetch_docs_request(lsp, module) do + Runtime.call(lsp.assigns.runtime, {Code, :fetch_docs, [module]}) + end + + defp find_aliased_module(document, module) do + module = to_quoted_module(module) + + ast = + document + |> Enum.join("\n") + |> Code.string_to_quoted( + unescape: false, + token_metadata: true, + columns: true + ) + + {_ast, aliased_module} = + Macro.prewalk(ast, nil, fn + # alias A, as: B + {:alias, _, [{:__aliases__, _, aliased_module}, [as: {:__aliases__, _, ^module}]]} = expr, _ -> + {expr, aliased_module} + + # alias A.{B, C} + {:alias, _, [{{:., _, [{:__aliases__, _, namespace}, :{}]}, _, aliases}]} = expr, acc -> + aliases = Enum.map(aliases, fn {:__aliases__, _, md} -> md end) + + if module in aliases do + {expr, namespace ++ module} + else + {expr, acc} + end + + # alias A.B.C + {:alias, _, [{:__aliases__, _, aliased_module}]} = expr, acc -> + offset = length(aliased_module) - length(module) + + if Enum.slice(aliased_module, offset..-1) == module do + {expr, aliased_module} + else + {expr, acc} + end + + expr, acc -> + {expr, acc} + end) + + aliased_module + end + + defp from_quoted_module_to_module(quoted_module) do + quoted_module + |> Enum.map(&Atom.to_string/1) + |> Enum.join(".") + |> to_charlist() + |> to_module() + end + + defp to_module(charlist) when is_list(charlist) do + String.to_atom("Elixir." <> to_string(charlist)) + end + + defp to_atom(charlist) when is_list(charlist) do + charlist |> to_string() |> String.to_atom() + end + + defp to_quoted_module(module) when is_atom(module) do + module + |> Atom.to_string() + |> String.split(".") + |> List.delete_at(0) + |> Enum.map(&String.to_atom/1) + end + + defp find_function_docs(docs, function) do + Enum.find(docs, fn + {{:function, ^function, _}, _, _, _, _} -> true + _ -> false + end) + end + + defp get_en_doc(:none) do + nil + end + + defp get_en_doc(%{"en" => doc}) do + doc + end +end diff --git a/test/next_ls_test.exs b/test/next_ls_test.exs index fcf16755..c39c451d 100644 --- a/test/next_ls_test.exs +++ b/test/next_ls_test.exs @@ -866,6 +866,323 @@ defmodule NextLSTest do end end + describe "hover language feature" do + # https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover + setup %{cwd: cwd} do + File.mkdir_p!(Path.join([cwd, "lib", "bar"])) + baz = Path.join(cwd, "lib/bar/baz.ex") + + File.write!(baz, """ + defmodule Bar.Baz do + @moduledoc "Bar.Baz module" + + @doc "Bar.Baz.q function" + def q do + "q" + end + end + """) + + fiz = Path.join(cwd, "lib/bar/fiz.ex") + + File.write!(fiz, """ + defmodule Bar.Fiz do + # No doc + def q do + "q" + end + end + """) + + guz = Path.join(cwd, "lib/bar/guz.ex") + + File.write!(guz, """ + defmodule Bar.Guz do + @moduledoc "Bar.Guz module" + + @doc "Bar.Guz.q function" + def q do + "q" + end + end + """) + + foo = Path.join(cwd, "lib/foo.ex") + + File.write!(foo, """ + defmodule Foo do + @moduledoc "Foo module" + + @doc "Foo.bar function" + def bar do + "baz" + end + end + """) + + example = Path.join(cwd, "lib/example.ex") + + File.write!(example, """ + defmodule Example do + @moduledoc "Example doc" + alias Foo, as: Foz + + alias Bar.{ + Fiz, + Baz + } + + alias Bar.Guz + + def test do + q1 = Atom.to_string(:atom) + q2 = Foz.bar() + q3 = Baz.q() + q4 = Fiz.q() + q5 = Guz.q() + + [q1] ++ [q2] ++ [q3] ++ [q4] ++ [q5] + end + end + """) + + File.rm_rf!(Path.join(cwd, ".elixir-tools")) + + [example: example] + end + + setup :with_lsp + + test "gets module or function doc when hovering", %{client: client, example: example} do + assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) + assert_notification "window/logMessage", %{"message" => "[NextLS] Runtime ready..."} + assert_notification "window/logMessage", %{"message" => "[NextLS] Compiled!"} + + example_uri = uri(example) + + notify client, %{ + method: "textDocument/didOpen", + jsonrpc: "2.0", + params: %{ + textDocument: %{ + uri: example_uri, + languageId: "elixir", + version: 1, + text: File.read!(example) + } + } + } + + request client, %{ + method: "textDocument/hover", + id: 1, + jsonrpc: "2.0", + params: %{ + position: %{line: 0, character: 10}, + textDocument: %{uri: example_uri} + } + } + + assert_result 1, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Example doc" + }, + "range" => %{ + "start" => %{"character" => 10, "line" => 0}, + "end" => %{"character" => 17, "line" => 0} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 2, + jsonrpc: "2.0", + params: %{ + position: %{line: 2, character: 8}, + textDocument: %{uri: example_uri} + } + } + + assert_result 2, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Foo module" + }, + "range" => %{ + "start" => %{"character" => 8, "line" => 2}, + "end" => %{"character" => 11, "line" => 2} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 3, + jsonrpc: "2.0", + params: %{ + position: %{line: 2, character: 17}, + textDocument: %{uri: example_uri} + } + } + + assert_result 3, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Foo module" + }, + "range" => %{ + "start" => %{"character" => 17, "line" => 2}, + "end" => %{"character" => 20, "line" => 2} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 4, + jsonrpc: "2.0", + params: %{ + position: %{line: 6, character: 5}, + textDocument: %{uri: example_uri} + } + } + + assert_result 4, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Bar.Baz module" + }, + "range" => %{ + "start" => %{"character" => 4, "line" => 6}, + "end" => %{"character" => 7, "line" => 6} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 5, + jsonrpc: "2.0", + params: %{ + position: %{line: 9, character: 13}, + textDocument: %{uri: example_uri} + } + } + + assert_result 5, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Bar.Guz module" + }, + "range" => %{ + "start" => %{"character" => 8, "line" => 9}, + "end" => %{"character" => 15, "line" => 9} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 6, + jsonrpc: "2.0", + params: %{ + position: %{line: 12, character: 9}, + textDocument: %{uri: example_uri} + } + } + + assert_result 6, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Atoms are constants" <> _ + }, + "range" => %{ + "start" => %{"character" => 9, "line" => 12}, + "end" => %{"character" => 13, "line" => 12} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 7, + jsonrpc: "2.0", + params: %{ + position: %{line: 12, character: 22}, + textDocument: %{uri: example_uri} + } + } + + assert_result 7, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Converts an atom" <> _ + }, + "range" => %{ + "start" => %{"character" => 9, "line" => 12}, + "end" => %{"character" => 23, "line" => 12} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 8, + jsonrpc: "2.0", + params: %{ + position: %{line: 14, character: 13}, + textDocument: %{uri: example_uri} + } + } + + assert_result 8, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Bar.Baz.q function" + }, + "range" => %{ + "start" => %{"character" => 9, "line" => 14}, + "end" => %{"character" => 14, "line" => 14} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 9, + jsonrpc: "2.0", + params: %{ + position: %{line: 15, character: 11}, + textDocument: %{uri: example_uri} + } + } + + assert_result 9, nil, 500 + + request client, %{ + method: "textDocument/hover", + id: 10, + jsonrpc: "2.0", + params: %{ + position: %{line: 15, character: 13}, + textDocument: %{uri: example_uri} + } + } + + assert_result 10, nil, 500 + end + end + defp with_lsp(%{tmp_dir: tmp_dir}) do root_path = Path.absname(tmp_dir) From db5814ae6acdcac48a64642851d65a4ddcd3b7fc Mon Sep 17 00:00:00 2001 From: Denis Tataurov Date: Tue, 11 Jul 2023 15:09:02 +0300 Subject: [PATCH 2/3] fix: support erlang docs, structs and kernel functions --- lib/next_ls/hover.ex | 74 +++++++++++++-------- test/next_ls_test.exs | 145 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 181 insertions(+), 38 deletions(-) diff --git a/lib/next_ls/hover.ex b/lib/next_ls/hover.ex index ada07200..11ca6636 100644 --- a/lib/next_ls/hover.ex +++ b/lib/next_ls/hover.ex @@ -27,35 +27,42 @@ defmodule NextLS.Hover do hover_column = position.character + 1 case Code.Fragment.surround_context(Enum.join(document, "\n"), {hover_line, hover_column}) do - %{context: {:dot, {:alias, module}, function}, begin: {line, context_begin}, end: {_, context_end}} -> - {to_module(module), to_atom(function), - %Range{ - start: %Position{line: line - 1, character: context_begin - 1}, - end: %Position{line: line - 1, character: context_end - 1} - }} - - %{context: {:alias, module}, begin: {line, context_begin}, end: {_, context_end}} -> - {to_module(module), nil, - %Range{ - start: %Position{line: line - 1, character: context_begin - 1}, - end: %Position{line: line - 1, character: context_end - 1} - }} + %{context: {:dot, {:alias, module}, function}} = context -> + {to_module(module), to_atom(function), build_range(context)} + + %{context: {:dot, {:unquoted_atom, erlang_module}, function}} = context -> + {to_atom(erlang_module), to_atom(function), build_range(context)} + + %{context: {context_type, module}} = context when context_type in [:alias, :struct] -> + {to_module(module), nil, build_range(context)} + + %{context: {:unquoted_atom, erlang_module}} = context -> + {to_atom(erlang_module), nil, build_range(context)} + + %{context: {context_type, function}} = context when context_type in [:local_call, :local_or_var] -> + {nil, to_atom(function), build_range(context)} _other -> nil end end + defp fetch_docs(lsp, document, module, nil) do + with {:ok, {_, _, _, _, docs, _, _} = docs_v1} <- request_docs(lsp, document, module) do + print_doc(module, nil, docs, docs_v1) + end + end + + defp fetch_docs(lsp, document, nil, function) do + [Kernel, Kernel.SpecialForms] + |> Stream.map(&fetch_docs(lsp, document, &1, function)) + |> Enum.find(&(!is_nil(&1))) + end + defp fetch_docs(lsp, document, module, function) do - if function do - with {:ok, {_, _, _, _, _, _, functions_docs}} <- request_docs(lsp, document, module), - {_, _, _, function_docs, _} <- find_function_docs(functions_docs, function) do - get_en_doc(function_docs) - end - else - with {:ok, {_, _, _, _, docs, _, _}} <- request_docs(lsp, document, module) do - get_en_doc(docs) - end + with {:ok, {_, _, _, _, _, _, functions_docs} = docs_v1} <- request_docs(lsp, document, module), + {_, _, _, function_docs, _} <- find_function_docs(functions_docs, function) do + print_doc(module, function, function_docs, docs_v1) end end @@ -127,6 +134,13 @@ defmodule NextLS.Hover do aliased_module end + defp build_range(%{begin: {line, start}, end: {_, finish}}) do + %Range{ + start: %Position{line: line - 1, character: start - 1}, + end: %Position{line: line - 1, character: finish - 1} + } + end + defp from_quoted_module_to_module(quoted_module) do quoted_module |> Enum.map(&Atom.to_string/1) @@ -153,16 +167,24 @@ defmodule NextLS.Hover do defp find_function_docs(docs, function) do Enum.find(docs, fn - {{:function, ^function, _}, _, _, _, _} -> true + {{type, ^function, _}, _, _, _, _} when type in [:function, :macro] -> true _ -> false end) end - defp get_en_doc(:none) do + defp print_doc(_module, _function, :none, _docs_v1) do nil end - defp get_en_doc(%{"en" => doc}) do - doc + defp print_doc(_module, _function, doc, {_, _, _, "text/markdown", _, _, _}) do + doc["en"] + end + + defp print_doc(module, nil, _doc, {_, _, :erlang, "application/erlang+html", _, _, _} = docs_v1) do + :shell_docs.render(module, docs_v1, %{ansi: false}) |> Enum.join() + end + + defp print_doc(module, function, _doc, {_, _, :erlang, "application/erlang+html", _, _, _} = docs_v1) do + :shell_docs.render(module, function, docs_v1, %{ansi: false}) |> Enum.join() end end diff --git a/test/next_ls_test.exs b/test/next_ls_test.exs index c39c451d..e429be8b 100644 --- a/test/next_ls_test.exs +++ b/test/next_ls_test.exs @@ -934,14 +934,20 @@ defmodule NextLSTest do alias Bar.Guz + defstruct [:foo] + def test do q1 = Atom.to_string(:atom) q2 = Foz.bar() q3 = Baz.q() q4 = Fiz.q() q5 = Guz.q() + q6 = to_string(:abs) + :timer.sleep(1) + a = %Example{foo: "a"} + - [q1] ++ [q2] ++ [q3] ++ [q4] ++ [q5] + [q1] ++ [q2] ++ [q3] ++ [q4] ++ [q5] ++ [q6] end end """) @@ -1093,7 +1099,7 @@ defmodule NextLSTest do id: 6, jsonrpc: "2.0", params: %{ - position: %{line: 12, character: 9}, + position: %{line: 14, character: 9}, textDocument: %{uri: example_uri} } } @@ -1105,8 +1111,8 @@ defmodule NextLSTest do "value" => "Atoms are constants" <> _ }, "range" => %{ - "start" => %{"character" => 9, "line" => 12}, - "end" => %{"character" => 13, "line" => 12} + "start" => %{"character" => 9, "line" => 14}, + "end" => %{"character" => 13, "line" => 14} } }, 500 @@ -1116,7 +1122,7 @@ defmodule NextLSTest do id: 7, jsonrpc: "2.0", params: %{ - position: %{line: 12, character: 22}, + position: %{line: 14, character: 22}, textDocument: %{uri: example_uri} } } @@ -1128,8 +1134,8 @@ defmodule NextLSTest do "value" => "Converts an atom" <> _ }, "range" => %{ - "start" => %{"character" => 9, "line" => 12}, - "end" => %{"character" => 23, "line" => 12} + "start" => %{"character" => 9, "line" => 14}, + "end" => %{"character" => 23, "line" => 14} } }, 500 @@ -1139,7 +1145,7 @@ defmodule NextLSTest do id: 8, jsonrpc: "2.0", params: %{ - position: %{line: 14, character: 13}, + position: %{line: 16, character: 13}, textDocument: %{uri: example_uri} } } @@ -1151,8 +1157,8 @@ defmodule NextLSTest do "value" => "Bar.Baz.q function" }, "range" => %{ - "start" => %{"character" => 9, "line" => 14}, - "end" => %{"character" => 14, "line" => 14} + "start" => %{"character" => 9, "line" => 16}, + "end" => %{"character" => 14, "line" => 16} } }, 500 @@ -1162,7 +1168,7 @@ defmodule NextLSTest do id: 9, jsonrpc: "2.0", params: %{ - position: %{line: 15, character: 11}, + position: %{line: 17, character: 11}, textDocument: %{uri: example_uri} } } @@ -1174,12 +1180,127 @@ defmodule NextLSTest do id: 10, jsonrpc: "2.0", params: %{ - position: %{line: 15, character: 13}, + position: %{line: 17, character: 13}, textDocument: %{uri: example_uri} } } assert_result 10, nil, 500 + + request client, %{ + method: "textDocument/hover", + id: 11, + jsonrpc: "2.0", + params: %{ + position: %{line: 19, character: 13}, + textDocument: %{uri: example_uri} + } + } + + assert_result 11, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Converts the argument to a string" <> _ + }, + "range" => %{ + "start" => %{"character" => 9, "line" => 19}, + "end" => %{"character" => 18, "line" => 19} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 12, + jsonrpc: "2.0", + params: %{ + position: %{line: 20, character: 7}, + textDocument: %{uri: example_uri} + } + } + + assert_result 12, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "\n\ttimer\n\n This module provides useful functions related to time" <> _ + }, + "range" => %{ + "start" => %{"character" => 4, "line" => 20}, + "end" => %{"character" => 10, "line" => 20} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 13, + jsonrpc: "2.0", + params: %{ + position: %{line: 20, character: 13}, + textDocument: %{uri: example_uri} + } + } + + assert_result 13, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "\n -spec sleep(Time) -> ok when Time :: timeout().\n\n Suspends the process" <> _ + }, + "range" => %{ + "start" => %{"character" => 4, "line" => 20}, + "end" => %{"character" => 16, "line" => 20} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 14, + jsonrpc: "2.0", + params: %{ + position: %{line: 21, character: 13}, + textDocument: %{uri: example_uri} + } + } + + assert_result 14, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Example doc" + }, + "range" => %{ + "start" => %{"character" => 8, "line" => 21}, + "end" => %{"character" => 16, "line" => 21} + } + }, + 500 + + request client, %{ + method: "textDocument/hover", + id: 15, + jsonrpc: "2.0", + params: %{ + position: %{line: 13, character: 3}, + textDocument: %{uri: example_uri} + } + } + + assert_result 15, + %{ + "contents" => %{ + "kind" => "markdown", + "value" => "Defines a public function with the given name and body" <> _ + }, + "range" => %{ + "start" => %{"character" => 2, "line" => 13}, + "end" => %{"character" => 5, "line" => 13} + } + }, + 500 end end From 6314201c9336b529ed32424b0030f543ccd102af Mon Sep 17 00:00:00 2001 From: Denis Tataurov Date: Thu, 13 Jul 2023 17:50:49 +0300 Subject: [PATCH 3/3] fix: search in references first and fallback to surrounded context if nothing found --- lib/next_ls.ex | 13 ++- lib/next_ls/definition.ex | 20 +--- lib/next_ls/hover.ex | 137 +++++++++++++---------- lib/next_ls/lsp_supervisor.ex | 6 +- lib/next_ls/reference_table.ex | 73 ++++++++++++ priv/monkey/_next_ls_private_compiler.ex | 2 +- test/next_ls_test.exs | 57 ++++++++-- 7 files changed, 215 insertions(+), 93 deletions(-) create mode 100644 lib/next_ls/reference_table.ex diff --git a/lib/next_ls.ex b/lib/next_ls.ex index fb7baa52..e659df9f 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -45,6 +45,7 @@ defmodule NextLS do } alias NextLS.DiagnosticCache + alias NextLS.ReferenceTable alias NextLS.Runtime alias NextLS.SymbolTable alias NextLS.Definition @@ -58,7 +59,8 @@ defmodule NextLS do :dynamic_supervisor, :extensions, :extension_registry, - :symbol_table + :symbol_table, + :reference_table ]) GenLSP.start_link(__MODULE__, args, opts) @@ -73,6 +75,7 @@ defmodule NextLS do extensions = Keyword.get(args, :extensions, [NextLS.ElixirExtension]) cache = Keyword.fetch!(args, :cache) symbol_table = Keyword.fetch!(args, :symbol_table) + reference_table = Keyword.fetch!(args, :reference_table) {:ok, logger} = DynamicSupervisor.start_child(dynamic_supervisor, {NextLS.Logger, lsp: lsp}) {:ok, @@ -83,6 +86,7 @@ defmodule NextLS do cache: cache, logger: logger, symbol_table: symbol_table, + reference_table: reference_table, task_supervisor: task_supervisor, runtime_task_supervisor: runtime_task_supervisor, dynamic_supervisor: dynamic_supervisor, @@ -119,7 +123,7 @@ defmodule NextLS do URI.parse(uri).path, {position.line + 1, position.character + 1}, :symbol_table, - :reference_table + lsp.assigns.reference_table ) do nil -> nil @@ -164,7 +168,7 @@ defmodule NextLS do def handle_request(%TextDocumentHover{params: %{text_document: %{uri: uri}, position: position}}, lsp) do hover = try do - NextLS.Hover.fetch(lsp, lsp.assigns.documents[uri], position) + NextLS.Hover.fetch(lsp, uri, position) rescue e -> GenLSP.error(lsp, Exception.format_banner(:error, e, __STACKTRACE__)) @@ -252,6 +256,7 @@ defmodule NextLS do def handle_request(%Shutdown{}, lsp) do SymbolTable.close(lsp.assigns.symbol_table) + ReferenceTable.close(lsp.assigns.reference_table) {:reply, nil, assign(lsp, exit_code: 0)} end @@ -404,7 +409,7 @@ defmodule NextLS do end def handle_info({{:tracer, :reference}, payload}, lsp) do - SymbolTable.put_reference(lsp.assigns.symbol_table, payload) + ReferenceTable.put_reference(lsp.assigns.reference_table, payload) {:noreply, lsp} end diff --git a/lib/next_ls/definition.ex b/lib/next_ls/definition.ex index bb4581ce..452b5a02 100644 --- a/lib/next_ls/definition.ex +++ b/lib/next_ls/definition.ex @@ -1,22 +1,8 @@ defmodule NextLS.Definition do - def fetch(file, {line, col}, dets_symbol_table, dets_ref_table) do - ref = - :dets.select( - dets_ref_table, - [ - {{{:"$1", {{:"$2", :"$3"}, {:"$4", :"$5"}}}, :"$6"}, - [ - {:andalso, - {:andalso, {:andalso, {:andalso, {:==, :"$1", file}, {:"=<", :"$2", line}}, {:"=<", :"$3", col}}, - {:"=<", line, :"$4"}}, {:"=<", col, :"$5"}} - ], [:"$6"]} - ] - ) + alias NextLS.ReferenceTable - # :dets.traverse(dets_symbol_table, fn x -> {:continue, x} end) |> dbg - # :dets.traverse(dets_ref_table, fn x -> {:continue, x} end) |> dbg - - # dbg(ref) + def fetch(file, position, dets_symbol_table, dets_ref_table) do + ref = ReferenceTable.reference(dets_ref_table, file, position) query = case ref do diff --git a/lib/next_ls/hover.ex b/lib/next_ls/hover.ex index 11ca6636..8429c9cc 100644 --- a/lib/next_ls/hover.ex +++ b/lib/next_ls/hover.ex @@ -6,11 +6,15 @@ defmodule NextLS.Hover do Range } + alias NextLS.ReferenceTable alias NextLS.Runtime - @spec fetch(lsp :: GenLSP.LSP.t(), document :: [String.t()], position :: Position.t()) :: Hover.t() | nil - def fetch(lsp, document, position) do - with {module, function, range} <- get_surround_context(document, position), + @spec fetch(lsp :: GenLSP.LSP.t(), uri :: String.t(), position :: Position.t()) :: Hover.t() | nil + def fetch(lsp, uri, position) do + position = {position.line + 1, position.character + 1} + document = Enum.join(lsp.assigns.documents[uri], "\n") + + with {module, function, range} <- find_reference(lsp, document, uri, position), docs when is_binary(docs) <- fetch_docs(lsp, document, module, function) do %Hover{ contents: %MarkupContent{ @@ -22,47 +26,47 @@ defmodule NextLS.Hover do end end - defp get_surround_context(document, position) do - hover_line = position.line + 1 - hover_column = position.character + 1 + defp find_reference(lsp, document, uri, position) do + surround_context = Code.Fragment.surround_context(document, position) - case Code.Fragment.surround_context(Enum.join(document, "\n"), {hover_line, hover_column}) do - %{context: {:dot, {:alias, module}, function}} = context -> - {to_module(module), to_atom(function), build_range(context)} + if surround_context == :none do + nil + else + case ReferenceTable.reference(lsp.assigns.reference_table, URI.parse(uri).path, position) do + [%{type: :function, module: module, identifier: function} | _] -> + {module, function, build_range(surround_context)} - %{context: {:dot, {:unquoted_atom, erlang_module}, function}} = context -> - {to_atom(erlang_module), to_atom(function), build_range(context)} + [%{type: :alias, module: module} | _] -> + {module, nil, build_range(surround_context)} - %{context: {context_type, module}} = context when context_type in [:alias, :struct] -> - {to_module(module), nil, build_range(context)} + _other -> + find_in_context(surround_context) + end + end + end - %{context: {:unquoted_atom, erlang_module}} = context -> - {to_atom(erlang_module), nil, build_range(context)} + defp find_in_context(%{context: {:alias, module}} = context) do + {to_module(module), nil, build_range(context)} + end - %{context: {context_type, function}} = context when context_type in [:local_call, :local_or_var] -> - {nil, to_atom(function), build_range(context)} + defp find_in_context(%{context: {:unquoted_atom, erlang_module}} = context) do + {to_atom(erlang_module), nil, build_range(context)} + end - _other -> - nil - end + defp find_in_context(_context) do + nil end defp fetch_docs(lsp, document, module, nil) do with {:ok, {_, _, _, _, docs, _, _} = docs_v1} <- request_docs(lsp, document, module) do - print_doc(module, nil, docs, docs_v1) + print_doc(module, nil, nil, docs, docs_v1) end end - defp fetch_docs(lsp, document, nil, function) do - [Kernel, Kernel.SpecialForms] - |> Stream.map(&fetch_docs(lsp, document, &1, function)) - |> Enum.find(&(!is_nil(&1))) - end - defp fetch_docs(lsp, document, module, function) do with {:ok, {_, _, _, _, _, _, functions_docs} = docs_v1} <- request_docs(lsp, document, module), - {_, _, _, function_docs, _} <- find_function_docs(functions_docs, function) do - print_doc(module, function, function_docs, docs_v1) + {_, _, [spec], function_docs, _} <- find_function_docs(functions_docs, function) do + print_doc(module, function, spec, function_docs, docs_v1) end end @@ -80,22 +84,19 @@ defmodule NextLS.Hover do end end + {:ok, {:error, :chunk_not_found}} -> + nil + other -> other end end - defp send_fetch_docs_request(lsp, module) do - Runtime.call(lsp.assigns.runtime, {Code, :fetch_docs, [module]}) - end - defp find_aliased_module(document, module) do module = to_quoted_module(module) ast = - document - |> Enum.join("\n") - |> Code.string_to_quoted( + Code.string_to_quoted(document, unescape: false, token_metadata: true, columns: true @@ -134,6 +135,10 @@ defmodule NextLS.Hover do aliased_module end + defp send_fetch_docs_request(lsp, module) do + Runtime.call(lsp.assigns.runtime, {Code, :fetch_docs, [module]}) + end + defp build_range(%{begin: {line, start}, end: {_, finish}}) do %Range{ start: %Position{line: line - 1, character: start - 1}, @@ -141,6 +146,41 @@ defmodule NextLS.Hover do } end + defp find_function_docs(docs, function) do + Enum.find(docs, fn + {{type, ^function, _}, _, _, _, _} when type in [:function, :macro] -> true + _ -> false + end) + end + + defp print_doc(_module, _function, _spec, :none, _docs_v1) do + nil + end + + defp print_doc(_module, _function, _spec, :hidden, _docs_v1) do + nil + end + + defp print_doc(_module, nil, _spec, doc, {_, _, _, "text/markdown", _, _, _}) do + doc["en"] + end + + defp print_doc(_module, _function, spec, doc, {_, _, _, "text/markdown", _, _, _}) do + print_function_spec(spec) <> doc["en"] + end + + defp print_doc(module, nil, _spec, _doc, {_, _, :erlang, "application/erlang+html", _, _, _} = docs_v1) do + :shell_docs.render(module, docs_v1, %{ansi: false}) |> Enum.join() + end + + defp print_doc(module, function, spec, _doc, {_, _, :erlang, "application/erlang+html", _, _, _} = docs_v1) do + print_function_spec(spec) <> (:shell_docs.render(module, function, docs_v1, %{ansi: false}) |> Enum.join()) + end + + defp print_function_spec(spec) do + "### " <> spec <> "\n\n" + end + defp from_quoted_module_to_module(quoted_module) do quoted_module |> Enum.map(&Atom.to_string/1) @@ -160,31 +200,8 @@ defmodule NextLS.Hover do defp to_quoted_module(module) when is_atom(module) do module |> Atom.to_string() + |> String.replace("Elixir.", "") |> String.split(".") - |> List.delete_at(0) |> Enum.map(&String.to_atom/1) end - - defp find_function_docs(docs, function) do - Enum.find(docs, fn - {{type, ^function, _}, _, _, _, _} when type in [:function, :macro] -> true - _ -> false - end) - end - - defp print_doc(_module, _function, :none, _docs_v1) do - nil - end - - defp print_doc(_module, _function, doc, {_, _, _, "text/markdown", _, _, _}) do - doc["en"] - end - - defp print_doc(module, nil, _doc, {_, _, :erlang, "application/erlang+html", _, _, _} = docs_v1) do - :shell_docs.render(module, docs_v1, %{ansi: false}) |> Enum.join() - end - - defp print_doc(module, function, _doc, {_, _, :erlang, "application/erlang+html", _, _, _} = docs_v1) do - :shell_docs.render(module, function, docs_v1, %{ansi: false}) |> Enum.join() - end end diff --git a/lib/next_ls/lsp_supervisor.ex b/lib/next_ls/lsp_supervisor.ex index 59bd7e66..c0c60b50 100644 --- a/lib/next_ls/lsp_supervisor.ex +++ b/lib/next_ls/lsp_supervisor.ex @@ -32,17 +32,21 @@ defmodule NextLS.LSPSupervisor do raise "Unknown option" end + path = Path.expand(".elixir-tools") + children = [ {DynamicSupervisor, name: NextLS.DynamicSupervisor}, {Task.Supervisor, name: NextLS.TaskSupervisor}, {Task.Supervisor, name: :runtime_task_supervisor}, {GenLSP.Buffer, buffer_opts}, {NextLS.DiagnosticCache, name: :diagnostic_cache}, - {NextLS.SymbolTable, name: :symbol_table, path: Path.expand(".elixir-tools")}, + {NextLS.SymbolTable, name: :symbol_table, path: path}, + {NextLS.ReferenceTable, name: :reference_table, path: path}, {Registry, name: NextLS.ExtensionRegistry, keys: :duplicate}, {NextLS, cache: :diagnostic_cache, symbol_table: :symbol_table, + reference_table: :reference_table, task_supervisor: NextLS.TaskSupervisor, runtime_task_supervisor: :runtime_task_supervisor, dynamic_supervisor: NextLS.DynamicSupervisor, diff --git a/lib/next_ls/reference_table.ex b/lib/next_ls/reference_table.ex new file mode 100644 index 00000000..2b272ca3 --- /dev/null +++ b/lib/next_ls/reference_table.ex @@ -0,0 +1,73 @@ +defmodule NextLS.ReferenceTable do + @moduledoc false + use GenServer + + def start_link(args) do + GenServer.start_link(__MODULE__, Keyword.take(args, [:path]), Keyword.take(args, [:name])) + end + + @spec reference(pid() | atom(), String.t(), {integer(), integer()}) :: list(struct()) + def reference(server, file, position), do: GenServer.call(server, {:reference, file, position}) + + @spec put_reference(pid() | atom(), map()) :: :ok + def put_reference(server, reference), do: GenServer.cast(server, {:put_reference, reference}) + + @spec close(pid() | atom()) :: :ok | {:error, term()} + def close(server), do: GenServer.call(server, :close) + + def init(args) do + path = Keyword.fetch!(args, :path) + reference_table_name = Keyword.get(args, :reference_table_name, :reference_table) + + File.mkdir_p!(path) + + {:ok, ref_name} = + :dets.open_file(reference_table_name, + file: path |> Path.join("reference_table.dets") |> String.to_charlist(), + type: :duplicate_bag + ) + + {:ok, %{table: ref_name}} + end + + def handle_call({:reference, file, {line, col}}, _, state) do + ref = + :dets.select( + state.table, + [ + {{{:"$1", {{:"$2", :"$3"}, {:"$4", :"$5"}}}, :"$6"}, + [ + {:andalso, + {:andalso, {:andalso, {:andalso, {:==, :"$1", file}, {:"=<", :"$2", line}}, {:"=<", :"$3", col}}, + {:"=<", line, :"$4"}}, {:"=<", col, :"$5"}} + ], [:"$6"]} + ] + ) + + {:reply, ref, state} + end + + def handle_call(:close, _, state) do + :dets.close(state.table) + + {:reply, :ok, state} + end + + def handle_cast({:put_reference, reference}, state) do + %{ + meta: meta, + identifier: identifier, + file: file + } = reference + + col = meta[:column] || 0 + + identifier_length = identifier |> to_string() |> String.replace("Elixir.", "") |> String.length() + + range = {{meta[:line], col}, {meta[:line], col + identifier_length}} + + :dets.insert(state.table, {{file, range}, reference}) + + {:noreply, state} + end +end diff --git a/priv/monkey/_next_ls_private_compiler.ex b/priv/monkey/_next_ls_private_compiler.ex index babbfb7f..b9a101c2 100644 --- a/priv/monkey/_next_ls_private_compiler.ex +++ b/priv/monkey/_next_ls_private_compiler.ex @@ -26,7 +26,7 @@ defmodule NextLSPrivate.Tracer do def trace({type, meta, module, func, arity}, env) when type in [:remote_function, :remote_macro, :imported_macro] and - module not in [:elixir_def, :elixir_utils, Kernel, Enum] do + module not in [:elixir_def, :elixir_utils, :elixir_module] do parent = parent_pid() if type == :remote_macro && meta[:closing][:line] != meta[:line] do diff --git a/test/next_ls_test.exs b/test/next_ls_test.exs index e429be8b..328b07ba 100644 --- a/test/next_ls_test.exs +++ b/test/next_ls_test.exs @@ -944,10 +944,10 @@ defmodule NextLSTest do q5 = Guz.q() q6 = to_string(:abs) :timer.sleep(1) - a = %Example{foo: "a"} + q7 = %Example{foo: "a"} - [q1] ++ [q2] ++ [q3] ++ [q4] ++ [q5] ++ [q6] + [q1] ++ [q2] ++ [q3] ++ [q4] ++ [q5] ++ [q6] ++ [q7.foo] end end """) @@ -979,6 +979,8 @@ defmodule NextLSTest do } } + # 1. `defmodule` macro + request client, %{ method: "textDocument/hover", id: 1, @@ -1002,6 +1004,8 @@ defmodule NextLSTest do }, 500 + # 2. `alias` macro with :as option + request client, %{ method: "textDocument/hover", id: 2, @@ -1025,6 +1029,8 @@ defmodule NextLSTest do }, 500 + # 3. `alias` macro with :as option + request client, %{ method: "textDocument/hover", id: 3, @@ -1048,6 +1054,8 @@ defmodule NextLSTest do }, 500 + # 4. Multi `alias` macro + request client, %{ method: "textDocument/hover", id: 4, @@ -1071,6 +1079,8 @@ defmodule NextLSTest do }, 500 + # 5. `alias` macro + request client, %{ method: "textDocument/hover", id: 5, @@ -1094,6 +1104,8 @@ defmodule NextLSTest do }, 500 + # 6. Module reference + request client, %{ method: "textDocument/hover", id: 6, @@ -1117,6 +1129,8 @@ defmodule NextLSTest do }, 500 + # 7. Function reference + request client, %{ method: "textDocument/hover", id: 7, @@ -1131,7 +1145,9 @@ defmodule NextLSTest do %{ "contents" => %{ "kind" => "markdown", - "value" => "Converts an atom" <> _ + "value" => + "### atom_to_binary/1\n\n\n -spec atom_to_binary(Atom) -> binary() when Atom :: atom()" <> + _ }, "range" => %{ "start" => %{"character" => 9, "line" => 14}, @@ -1140,6 +1156,8 @@ defmodule NextLSTest do }, 500 + # 8. User-defined module reference + request client, %{ method: "textDocument/hover", id: 8, @@ -1154,7 +1172,7 @@ defmodule NextLSTest do %{ "contents" => %{ "kind" => "markdown", - "value" => "Bar.Baz.q function" + "value" => "### q()\n\nBar.Baz.q function" }, "range" => %{ "start" => %{"character" => 9, "line" => 16}, @@ -1163,6 +1181,8 @@ defmodule NextLSTest do }, 500 + # 9. User-defined module reference without doc + request client, %{ method: "textDocument/hover", id: 9, @@ -1175,6 +1195,8 @@ defmodule NextLSTest do assert_result 9, nil, 500 + # 10. User-defined function reference without doc + request client, %{ method: "textDocument/hover", id: 10, @@ -1187,6 +1209,8 @@ defmodule NextLSTest do assert_result 10, nil, 500 + # 11. Kernel function + request client, %{ method: "textDocument/hover", id: 11, @@ -1201,7 +1225,7 @@ defmodule NextLSTest do %{ "contents" => %{ "kind" => "markdown", - "value" => "Converts the argument to a string" <> _ + "value" => "### to_string(term)\n\nConverts the argument to a string" <> _ }, "range" => %{ "start" => %{"character" => 9, "line" => 19}, @@ -1210,6 +1234,8 @@ defmodule NextLSTest do }, 500 + # 12. Erlang module reference + request client, %{ method: "textDocument/hover", id: 12, @@ -1233,6 +1259,8 @@ defmodule NextLSTest do }, 500 + # 13. Erlang function reference + request client, %{ method: "textDocument/hover", id: 13, @@ -1247,7 +1275,9 @@ defmodule NextLSTest do %{ "contents" => %{ "kind" => "markdown", - "value" => "\n -spec sleep(Time) -> ok when Time :: timeout().\n\n Suspends the process" <> _ + "value" => + "### sleep/1\n\n\n -spec sleep(Time) -> ok when Time :: timeout().\n\n Suspends the process" <> + _ }, "range" => %{ "start" => %{"character" => 4, "line" => 20}, @@ -1256,6 +1286,8 @@ defmodule NextLSTest do }, 500 + # 14. Struct reference + request client, %{ method: "textDocument/hover", id: 14, @@ -1273,12 +1305,14 @@ defmodule NextLSTest do "value" => "Example doc" }, "range" => %{ - "start" => %{"character" => 8, "line" => 21}, - "end" => %{"character" => 16, "line" => 21} + "start" => %{"character" => 9, "line" => 21}, + "end" => %{"character" => 17, "line" => 21} } }, 500 + # 15. Macro + request client, %{ method: "textDocument/hover", id: 15, @@ -1293,7 +1327,8 @@ defmodule NextLSTest do %{ "contents" => %{ "kind" => "markdown", - "value" => "Defines a public function with the given name and body" <> _ + "value" => + "### def(call, expr \\\\ nil)\n\nDefines a public function with the given name and body" <> _ }, "range" => %{ "start" => %{"character" => 2, "line" => 13}, @@ -1314,6 +1349,7 @@ defmodule NextLSTest do extensions = [NextLS.ElixirExtension] cache = start_supervised!(NextLS.DiagnosticCache) symbol_table = start_supervised!({NextLS.SymbolTable, path: tmp_dir}) + reference_table = start_supervised!({NextLS.ReferenceTable, path: tmp_dir}) server = server(NextLS, @@ -1323,7 +1359,8 @@ defmodule NextLSTest do extension_registry: Registry.NextLSTest, extensions: extensions, cache: cache, - symbol_table: symbol_table + symbol_table: symbol_table, + reference_table: reference_table ) Process.link(server.lsp)