diff --git a/apps/language_server/lib/language_server/providers/hover.ex b/apps/language_server/lib/language_server/providers/hover.ex index 450fd90eb..ca1426788 100644 --- a/apps/language_server/lib/language_server/providers/hover.ex +++ b/apps/language_server/lib/language_server/providers/hover.ex @@ -5,7 +5,18 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do Hover provider utilizing Elixir Sense """ - def hover(text, line, character) do + @hex_base_url "https://hexdocs.pm" + @builtin_flag [ + "elixir", + "eex", + "ex_unit", + "iex", + "logger", + "mix" + ] + |> Enum.map(fn x -> "lib/#{x}/lib" end) + + def hover(text, line, character, project_dir) do response = case ElixirSense.docs(text, line + 1, character + 1) do %{subject: ""} -> @@ -15,7 +26,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do line_text = Enum.at(SourceFile.lines(text), line) range = highlight_range(line_text, line, character, subject) - %{"contents" => contents(docs), "range" => range} + %{"contents" => contents(docs, subject, project_dir), "range" => range} end {:ok, response} @@ -44,14 +55,120 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do end) end - defp contents(%{docs: "No documentation available\n"}) do + defp contents(%{docs: "No documentation available\n"}, _subject, _project_dir) do [] end - defp contents(%{docs: markdown}) do + defp contents(%{docs: markdown}, subject, project_dir) do %{ kind: "markdown", - value: markdown + value: add_hexdocs_link(markdown, subject, project_dir) } end + + defp add_hexdocs_link(markdown, subject, project_dir) do + [hd | tail] = markdown |> String.split("\n\n") + + link = hexdocs_link(hd, subject, project_dir) + + case link do + "" -> + markdown + + _ -> + hd <> " [view on hexdocs](#{link})\n\n" <> Enum.join(tail, "") + end + end + + defp hexdocs_link(hd, subject, project_dir) do + title = hd |> String.replace(">", "") |> String.trim() |> URI.encode() + + cond do + erlang_module?(subject) -> + # erlang moudle is not support now. + "" + + true -> + dep = subject |> root_module_name() |> dep_name(project_dir) |> URI.encode() + + cond do + func?(title) -> + if dep != "" do + "#{@hex_base_url}/#{dep}/#{module_name(subject)}.html##{func_name(subject)}/#{params_cnt(title)}" + else + "" + end + + true -> + if dep != "" do + "#{@hex_base_url}/#{dep}/#{title}.html" + else + "" + end + end + end + end + + defp func?(s) do + s =~ ~r/.*\..*\(.*\)/ + end + + defp module_name(s) do + [_ | tail] = s |> String.split(".") |> Enum.reverse() + tail |> Enum.reverse() |> Enum.join(".") |> URI.encode() + end + + defp func_name(s) do + s |> String.split(".") |> Enum.at(-1) |> URI.encode() + end + + defp params_cnt(s) do + cond do + s =~ ~r/\(\)/ -> 0 + not String.contains?(s, ",") -> 1 + true -> s |> String.split(",") |> length() + end + end + + defp dep_name(root_mod_name, project_dir) do + s = root_mod_name |> source() + + cond do + third_dep?(s, project_dir) -> third_dep_name(s, project_dir) + builtin?(s) -> builtin_dep_name(s) + true -> "" + end + end + + defp root_module_name(subject) do + subject |> String.split(".") |> hd() + end + + defp source(mod_name) do + dep = ("Elixir." <> mod_name) |> String.to_atom() + dep.__info__(:compile) |> Keyword.get(:source) |> List.to_string() + end + + defp third_dep?(source, project_dir) do + prefix = project_dir <> "/deps" + String.starts_with?(source, prefix) + end + + defp third_dep_name(source, project_dir) do + prefix = project_dir <> "/deps/" + String.replace_prefix(source, prefix, "") |> String.split("/") |> hd() + end + + defp builtin?(source) do + @builtin_flag |> Enum.any?(fn y -> String.contains?(source, y) end) + end + + defp builtin_dep_name(source) do + [_, name | _] = String.split(source, "/lib/") + name + end + + defp erlang_module?(subject) do + subject |> root_module_name() |> String.starts_with?(":") + end end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 714ca974a..9e2dff86a 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -600,7 +600,7 @@ defmodule ElixirLS.LanguageServer.Server do source_file = get_source_file(state, uri) fun = fn -> - Hover.hover(source_file.text, line, character) + Hover.hover(source_file.text, line, character, state.project_dir) end {:async, fun, state} diff --git a/apps/language_server/test/providers/hover_test.exs b/apps/language_server/test/providers/hover_test.exs new file mode 100644 index 000000000..cc1aa967d --- /dev/null +++ b/apps/language_server/test/providers/hover_test.exs @@ -0,0 +1,123 @@ +defmodule ElixirLS.LanguageServer.Providers.HoverTest do + use ElixirLS.Utils.MixTest.Case, async: false + import ElixirLS.LanguageServer.Test.PlatformTestHelpers + + alias ElixirLS.LanguageServer.Providers.Hover + # mix cmd --app language_server mix test test/providers/hover_test.exs + + def fake_dir() do + Path.join(__DIR__, "../../../..") |> Path.expand() |> maybe_convert_path_separators() + end + + test "blank hover" do + text = """ + defmodule MyModule do + def hello() do + IO.inspect("hello world") + end + end + """ + + {line, char} = {2, 1} + + assert {:ok, resp} = Hover.hover(text, line, char, fake_dir()) + assert nil == resp + end + + test "Elixir builtin module hover" do + text = """ + defmodule MyModule do + def hello() do + IO.inspect("hello world") + end + end + """ + + {line, char} = {2, 5} + + assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = + Hover.hover(text, line, char, fake_dir()) + + assert String.starts_with?(v, "> IO [view on hexdocs](https://hexdocs.pm/elixir/IO.html)") + end + + test "Elixir builtin function hover" do + text = """ + defmodule MyModule do + def hello() do + IO.inspect("hello world") + end + end + """ + + {line, char} = {2, 10} + + assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = + Hover.hover(text, line, char, fake_dir()) + + assert String.starts_with?( + v, + "> IO.inspect(item, opts \\\\\\\\ []) [view on hexdocs](https://hexdocs.pm/elixir/IO.html#inspect/2)" + ) + end + + test "Umbrella projects: Third deps module hover" do + text = """ + defmodule MyModule do + def hello() do + StreamData.integer() |> Stream.map(&abs/1) |> Enum.take(3) |> IO.inspect() + end + end + """ + + {line, char} = {2, 10} + + assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = + Hover.hover(text, line, char, fake_dir()) + + assert String.starts_with?( + v, + "> StreamData [view on hexdocs](https://hexdocs.pm/stream_data/StreamData.html)" + ) + end + + test "Umbrella projects: Third deps function hover" do + text = """ + defmodule MyModule do + def hello() do + StreamData.integer() |> Stream.map(&abs/1) |> Enum.take(3) |> IO.inspect() + end + end + """ + + {line, char} = {2, 18} + + assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = + Hover.hover(text, line, char, fake_dir()) + + assert String.starts_with?( + v, + "> StreamData.integer() [view on hexdocs](https://hexdocs.pm/stream_data/StreamData.html#integer/0)" + ) + end + + test "Erlang module hover is not support now" do + text = """ + defmodule MyModule do + def hello() do + :timer.sleep(1000) + end + end + """ + + {line, char} = {2, 10} + + assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = + Hover.hover(text, line, char, fake_dir()) + + assert not String.contains?( + v, + "[view on hexdocs]" + ) + end +end