From 63053fce6f5ebe6c70dc6a9ab175f2f6b046a11f Mon Sep 17 00:00:00 2001 From: Denis Tataurov Date: Mon, 10 Jul 2023 15:04:48 +0300 Subject: [PATCH] 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..f56dd931 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 "go to module definition", %{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)