diff --git a/apps/language_server/lib/language_server/doc_links.ex b/apps/language_server/lib/language_server/doc_links.ex new file mode 100644 index 000000000..9143a7156 --- /dev/null +++ b/apps/language_server/lib/language_server/doc_links.ex @@ -0,0 +1,45 @@ +defmodule ElixirLS.LanguageServer.DocLinks do + @moduledoc """ + Provides links to hex docs + """ + + @hex_base_url "https://hexdocs.pm" + + defp get_erts_modules do + {:ok, [[erlang_lib_dir]]} = :init.get_argument(:root) + erts_version = :erlang.system_info(:version) + erts_app_path = Path.join([erlang_lib_dir, "lib", "erts-#{erts_version}", "ebin", "erts.app"]) + + {:ok, [{:application, _, props}]} = :file.consult(erts_app_path) + modules = Keyword.get(props, :modules) + for module <- modules, into: %{}, do: {module, {:erts, erts_version}} + end + + defp get_app(module) do + module_to_app = + for {app, _, vsn} <- Application.loaded_applications(), + {:ok, app_modules} = :application.get_key(app, :modules), + mod <- app_modules, + into: %{}, + do: {mod, {app, vsn}} + + module_to_app + |> Map.merge(get_erts_modules()) + |> Map.get(module) + end + + def hex_docs_module_link(module) do + {app, vsn} = get_app(module) + "#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html" + end + + def hex_docs_function_link(module, function, arity) do + {app, vsn} = get_app(module) + "#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html##{function}/#{arity}" + end + + def hex_docs_type_link(module, type, arity) do + {app, vsn} = get_app(module) + "#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html#t:#{type}/#{arity}" + end +end diff --git a/apps/language_server/lib/language_server/markdown_utils.ex b/apps/language_server/lib/language_server/markdown_utils.ex new file mode 100644 index 000000000..da8a139fe --- /dev/null +++ b/apps/language_server/lib/language_server/markdown_utils.ex @@ -0,0 +1,40 @@ +defmodule ElixirLS.LanguageServer.MarkdownUtils do + # Find the lowest heading level in the fragment + defp lowest_heading_level(fragment) do + case Regex.scan(~r/(#+)/, fragment) do + [] -> + nil + + matches -> + matches + |> Enum.map(fn [_, heading] -> String.length(heading) end) + |> Enum.min() + end + end + + # Adjust heading levels of an embedded markdown fragment + def adjust_headings(fragment, base_level) do + min_level = lowest_heading_level(fragment) + + if min_level do + level_difference = base_level + 1 - min_level + + Regex.replace(~r/(#+)/, fragment, fn _, capture -> + adjusted_level = String.length(capture) + level_difference + String.duplicate("#", adjusted_level) + end) + else + fragment + end + end + + def join_with_horizontal_rule(list) do + Enum.map_join(list, "\n\n---\n\n", fn lines -> + lines + |> String.replace_leading("\r\n", "") + |> String.replace_leading("\n", "") + |> String.replace_trailing("\r\n", "") + |> String.replace_trailing("\n", "") + end) <> "\n" + end +end diff --git a/apps/language_server/lib/language_server/providers/hover.ex b/apps/language_server/lib/language_server/providers/hover.ex index bd9adb778..bff172a85 100644 --- a/apps/language_server/lib/language_server/providers/hover.ex +++ b/apps/language_server/lib/language_server/providers/hover.ex @@ -1,23 +1,13 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do - alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.LanguageServer.{SourceFile, DocLinks} import ElixirLS.LanguageServer.Protocol + alias ElixirLS.LanguageServer.MarkdownUtils @moduledoc """ Hover provider utilizing Elixir Sense """ - @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 + def hover(text, line, character, _project_dir) do {line, character} = SourceFile.lsp_position_to_elixir(text, {line, character}) response = @@ -25,11 +15,11 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do nil -> nil - %{actual_subject: subject, docs: docs, range: es_range} -> + %{docs: docs, range: es_range} -> lines = SourceFile.lines(text) %{ - "contents" => contents(docs, subject, project_dir), + "contents" => contents(docs), "range" => build_range(lines, es_range) } end @@ -48,138 +38,245 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do ) end - defp contents(markdown, subject, project_dir) do + defp contents(docs) do + markdown_value = + docs + |> Enum.map(&format_doc/1) + |> MarkdownUtils.join_with_horizontal_rule() + %{ kind: "markdown", - value: add_hexdocs_link(markdown, subject, project_dir) + value: markdown_value } end - defp add_hexdocs_link(markdown, subject, project_dir) do - with [hd | tail] <- markdown |> String.split("\n\n", parts: 2), - link when link != "" <- hexdocs_link(hd, subject, project_dir) do - ["#{hd} [view on hexdocs](#{link})" | tail] |> Enum.join("\n\n") + defp build_module_link(module) do + if ElixirSense.Core.Introspection.elixir_module?(module) do + "[View on hexdocs](#{DocLinks.hex_docs_module_link(module)})" + else + "" + end + end + + defp build_function_link(module, function, arity) do + if ElixirSense.Core.Introspection.elixir_module?(module) do + "[View on hexdocs](#{DocLinks.hex_docs_function_link(module, function, arity)})" + else + "" + end + end + + defp build_type_link(module, type, arity) do + if module != nil and ElixirSense.Core.Introspection.elixir_module?(module) do + "[View on hexdocs](#{DocLinks.hex_docs_type_link(module, type, arity)})\n\n" else - _ -> markdown + "" end end - defp hexdocs_link(hd, subject, project_dir) do - title = hd |> String.replace(">", "") |> String.trim() |> URI.encode() + defp format_doc(info = %{kind: :module}) do + mod_str = inspect(info.module) + + """ + ```elixir + #{mod_str} + ``` + + *module* #{build_module_link(info.module)} + + #{get_metadata_md(info.metadata)} + + #{documentation_section(info.docs)} + """ + end + + defp format_doc(info = %{kind: kind}) when kind in [:function, :macro] do + mod_str = inspect(info.module) + fun_str = Atom.to_string(info.function) + + spec_text = + if info.specs != [] do + joined = Enum.join(info.specs, "\n") + + """ + ### Specs + + ```elixir + #{joined} + ``` - cond do - erlang_module?(subject) -> - # TODO erlang module is currently not supported + """ + else "" + end - true -> - dep = subject |> 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 + """ + ```elixir + #{mod_str}.#{fun_str}(#{Enum.join(info.args, ", ")}) + ``` + + *#{kind}* #{build_function_link(info.module, info.function, info.arity)} + + #{get_metadata_md(info.metadata)} + + #{spec_text} + + #{documentation_section(info.docs)} + """ + end + + defp format_doc(info = %{kind: :type}) do + formatted_spec = "```elixir\n#{info.spec}\n```" + + mod_formatted = + case info.module do + nil -> "" + atom -> inspect(atom) <> "." + end + + """ + ```elixir + #{mod_formatted}#{info.type}(#{Enum.join(info.args, ", ")}) + ``` + + *type* #{build_type_link(info.module, info.type, info.arity)} + + #{get_metadata_md(info.metadata)} + + ### Definition + + #{formatted_spec} + + #{documentation_section(info.docs)} + """ end - defp func?(s) do - s =~ ~r/.*\..*\(.*\)/ + defp format_doc(info = %{kind: :variable}) do + """ + ```elixir + #{info.name} + ``` + + *variable* + """ + end + + defp format_doc(info = %{kind: :attribute}) do + """ + ```elixir + @#{info.name} + ``` + + *module attribute* + + #{documentation_section(info.docs)} + """ end - defp module_name(s) do - [_ | tail] = s |> String.split(".") |> Enum.reverse() - tail |> Enum.reverse() |> Enum.join(".") |> URI.encode() + defp format_doc(info = %{kind: :keyword}) do + """ + ```elixir + #{info.name} + ``` + + *reserved word* + + #{documentation_section(info.docs)} + """ end - defp func_name(s) do - s |> String.split(".") |> Enum.at(-1) |> URI.encode() + defp documentation_section(""), do: "" + + defp documentation_section(docs) do + """ + ### Documentation + + #{MarkdownUtils.adjust_headings(docs, 3)} + """ end - defp params_cnt(s) do - cond do - s =~ ~r/\(\)/ -> 0 - not String.contains?(s, ",") -> 1 - true -> s |> String.split(",") |> length() + def get_metadata_md(metadata) do + text = + metadata + |> Enum.sort() + |> Enum.map(&get_metadata_entry_md/1) + |> Enum.reject(&is_nil/1) + |> Enum.join("\n\n") + + case text do + "" -> "" + not_empty -> not_empty <> "\n\n" end end - defp dep_name(subject, project_dir) do - root_mod_name = root_module_name(subject) + # erlang name + defp get_metadata_entry_md({:name, _text}), do: nil - if not elixir_mod_exported?(root_mod_name) do - "" - else - s = root_mod_name |> source() + # erlang signature + defp get_metadata_entry_md({:signature, _text}), do: nil - cond do - third_dep?(s, project_dir) -> - case ElixirSense.definition(subject, 1, 1) do - %ElixirSense.Location{file: source} when not is_nil(source) -> - third_dep_name(source, project_dir) + # erlang edit_url + defp get_metadata_entry_md({:edit_url, _text}), do: nil - _ -> - third_dep_name(s, project_dir) - end + # erlang :otp_doc_vsn + defp get_metadata_entry_md({:otp_doc_vsn, _text}), do: nil - builtin?(s) -> - builtin_dep_name(s) + # erlang :source + defp get_metadata_entry_md({:source, _text}), do: nil - true -> - "" - end - end + # erlang :types + defp get_metadata_entry_md({:types, _text}), do: nil + + # erlang :equiv + defp get_metadata_entry_md({:equiv, {:function, name, arity}}) do + "**Equivalent** #{name}/#{arity}" end - defp root_module_name(subject) do - subject |> String.split(".") |> hd() + defp get_metadata_entry_md({:deprecated, text}) do + "**Deprecated** #{text}" end - defp source(mod_name) do - dep = ("Elixir." <> mod_name) |> String.to_atom() - dep.__info__(:compile) |> Keyword.get(:source) |> List.to_string() + defp get_metadata_entry_md({:since, text}) do + "**Since** #{text}" end - defp elixir_mod_exported?(mod_name) do - ("Elixir." <> mod_name) |> String.to_atom() |> function_exported?(:__info__, 1) + defp get_metadata_entry_md({:group, text}) do + "**Group** #{text}" end - defp third_dep?(_source, nil), do: false + defp get_metadata_entry_md({:guard, true}) do + "**Guard**" + end - defp third_dep?(source, project_dir) do - prefix = deps_path(project_dir) - String.starts_with?(source, prefix) + defp get_metadata_entry_md({:hidden, true}) do + "**Hidden**" end - defp third_dep_name(source, project_dir) do - prefix = deps_path(project_dir) <> "/" - String.replace_prefix(source, prefix, "") |> String.split("/") |> hd() + defp get_metadata_entry_md({:builtin, true}) do + "**Built-in**" end - defp deps_path(project_dir) do - project_dir |> Path.expand() |> Path.join("deps") + defp get_metadata_entry_md({:implementing, module}) do + "**Implementing behaviour** #{inspect(module)}" end - defp builtin?(source) do - @builtin_flag |> Enum.any?(fn y -> String.contains?(source, y) end) + defp get_metadata_entry_md({:optional, true}) do + "**Optional**" end - defp builtin_dep_name(source) do - [_, name | _] = String.split(source, "/lib/") - name + defp get_metadata_entry_md({:optional, false}), do: nil + + defp get_metadata_entry_md({:opaque, true}) do + "**Opaque**" + end + + defp get_metadata_entry_md({:defaults, _}), do: nil + + defp get_metadata_entry_md({:delegate_to, {m, f, a}}) do + "**Delegates to** #{inspect(m)}.#{f}/#{a}" end - defp erlang_module?(subject) do - subject |> root_module_name() |> String.starts_with?(":") + defp get_metadata_entry_md({key, value}) do + "**#{key}** #{value}" end end diff --git a/apps/language_server/test/markdown_utils_test.exs b/apps/language_server/test/markdown_utils_test.exs new file mode 100644 index 000000000..6ebe0e129 --- /dev/null +++ b/apps/language_server/test/markdown_utils_test.exs @@ -0,0 +1,104 @@ +defmodule ElixirLS.LanguageServer.MarkdownUtilsTest do + use ExUnit.Case + + alias ElixirLS.LanguageServer.MarkdownUtils + + @main_document """ + # Main Title + + ## Sub Title + + ### Section to Embed Fragment + + """ + + describe "adjust_headings/3" do + test "no headings" do + fragment = """ + Regular text without any heading. + """ + + adjusted_fragment = MarkdownUtils.adjust_headings(fragment, 3) + + main_document = @main_document <> adjusted_fragment + + assert main_document == """ + # Main Title + + ## Sub Title + + ### Section to Embed Fragment + + Regular text without any heading. + """ + end + + test "headings lower than main document" do + fragment = """ + # Fragment Title + + ## Fragment Subtitle + """ + + adjusted_fragment = MarkdownUtils.adjust_headings(fragment, 3) + + main_document = @main_document <> adjusted_fragment + + assert main_document == """ + # Main Title + + ## Sub Title + + ### Section to Embed Fragment + + #### Fragment Title + + ##### Fragment Subtitle + """ + end + + test "headings higher than main document" do + fragment = """ + ##### Fragment Title + + ###### Fragment Subtitle + """ + + adjusted_fragment = MarkdownUtils.adjust_headings(fragment, 3) + + main_document = @main_document <> adjusted_fragment + + assert main_document == """ + # Main Title + + ## Sub Title + + ### Section to Embed Fragment + + #### Fragment Title + + ##### Fragment Subtitle + """ + end + end + + test "join_with_horizontal_rule/1" do + part_1 = """ + + Foo + + """ + + part_2 = """ + Bar + """ + + assert MarkdownUtils.join_with_horizontal_rule([part_1, part_2]) == """ + Foo + + --- + + Bar + """ + end +end diff --git a/apps/language_server/test/providers/hover_test.exs b/apps/language_server/test/providers/hover_test.exs index 3399ed090..cf3ee0be0 100644 --- a/apps/language_server/test/providers/hover_test.exs +++ b/apps/language_server/test/providers/hover_test.exs @@ -24,7 +24,7 @@ defmodule ElixirLS.LanguageServer.Providers.HoverTest do assert nil == resp end - test "Elixir builtin module hover" do + test "elixir module hover" do text = """ defmodule MyModule do def hello() do @@ -38,10 +38,13 @@ defmodule ElixirLS.LanguageServer.Providers.HoverTest do 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)") + assert String.starts_with?( + v, + "```elixir\nIO\n```\n\n*module* [View on hexdocs](https://hexdocs.pm/elixir/#{System.version()}/IO.html)" + ) end - test "Elixir builtin function hover" do + test "function hover" do text = """ defmodule MyModule do def hello() do @@ -57,111 +60,119 @@ defmodule ElixirLS.LanguageServer.Providers.HoverTest do assert String.starts_with?( v, - "> IO.inspect(item, opts \\\\\\\\ []) [view on hexdocs](https://hexdocs.pm/elixir/IO.html#inspect/2)" + "```elixir\nIO.inspect(item, opts \\\\ [])\n```\n\n*function* [View on hexdocs](https://hexdocs.pm/elixir/#{System.version()}/IO.html#inspect/2)" ) end - test "Umbrella projects: Third deps module hover" do + test "macro hover" do text = """ defmodule MyModule do - def hello() do - StreamData.integer() |> Stream.map(&abs/1) |> Enum.take(3) |> IO.inspect() - end + import Abc end """ - {line, char} = {2, 10} + {line, char} = {1, 3} 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)" + "```elixir\nKernel.SpecialForms.import(module, opts)\n```\n\n*macro* [View on hexdocs](https://hexdocs.pm/elixir/#{System.version()}/Kernel.SpecialForms.html#import/2)" ) end - test "Umbrella projects: Third deps function hover" do + test "elixir type hover" do text = """ defmodule MyModule do - def hello() do - StreamData.integer() |> Stream.map(&abs/1) |> Enum.take(3) |> IO.inspect() - end + @type d :: Date.t() end """ - {line, char} = {2, 18} + {line, char} = {1, 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)" + "```elixir\nDate.t()\n```\n\n*type* [View on hexdocs](https://hexdocs.pm/elixir/#{System.version()}/Date.html#t:t/0)" ) end - test "Import function hover" do + test "erlang function" do text = """ defmodule MyModule do - import Task.Supervisor - def hello() do - start_link() + :timer.sleep(1000) end end """ - {line, char} = {4, 5} + {line, char} = {2, 10} assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = Hover.hover(text, line, char, fake_dir()) - assert String.starts_with?( + assert String.starts_with?(v, "```elixir\n:timer.sleep(time)\n```\n\n*function*") + # TODO hexdocs and standard lib docs + assert not String.contains?( v, - "> Task.Supervisor.start_link(options \\\\\\\\ []) [view on hexdocs](https://hexdocs.pm/elixir/Task.Supervisor.html#start_link/1)" + "[View on hexdocs]" ) end - test "Alias module function hover" do + test "keyword" do text = """ defmodule MyModule do - alias Task.Supervisor + @type d :: Date.t() + end + """ - def hello() do - Supervisor.start_link() - end + {line, char} = {0, 19} + + assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = + Hover.hover(text, line, char, fake_dir()) + + assert String.starts_with?( + v, + "```elixir\ndo\n```\n\n*reserved word*" + ) + end + + test "variable" do + text = """ + defmodule MyModule do + asdf = 1 end """ - {line, char} = {4, 15} + {line, char} = {1, 3} assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = Hover.hover(text, line, char, fake_dir()) assert String.starts_with?( v, - "> Task.Supervisor.start_link(options \\\\\\\\ []) [view on hexdocs](https://hexdocs.pm/elixir/Task.Supervisor.html#start_link/1)" + "```elixir\nasdf\n```\n\n*variable*" ) end - test "Erlang module hover is not support now" do + test "attribute" do text = """ defmodule MyModule do - def hello() do - :timer.sleep(1000) - end + @behaviour :some end """ - {line, char} = {2, 10} + {line, char} = {1, 4} assert {:ok, %{"contents" => %{kind: "markdown", value: v}}} = Hover.hover(text, line, char, fake_dir()) - assert not String.contains?( + assert String.starts_with?( v, - "[view on hexdocs]" + "```elixir\n@behaviour\n```\n\n*module attribute*" ) end end diff --git a/apps/language_server/test/server_test.exs b/apps/language_server/test/server_test.exs index 3704b8d9e..f622e4cbc 100644 --- a/apps/language_server/test/server_test.exs +++ b/apps/language_server/test/server_test.exs @@ -1274,7 +1274,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do assert response(1, %{ "contents" => %{ "kind" => "markdown", - "value" => "> GenServer" <> _ + "value" => "```elixir\nGenServer" <> _ }, "range" => %{ "start" => %{"line" => 2, "character" => 12}, diff --git a/dep_versions.exs b/dep_versions.exs index 75a351a68..4ed4ace1b 100644 --- a/dep_versions.exs +++ b/dep_versions.exs @@ -1,5 +1,5 @@ [ - elixir_sense: "c47f948cdcb716c5757fdbdc9badfdd034a6613c", + elixir_sense: "497d9b797cb7032fcb9f2c0fc7294c46c88bac32", dialyxir_vendored: "d50dcd7101c6ebd37b57b7ee4a7888d8cb634782", jason_v: "c81537e2a5e1acacb915cf339fe400357e3c2aaa", erl2ex_vendored: "073ac6b9a44282e718b6050c7b27cedf9217a12a", diff --git a/mix.lock b/mix.lock index f1c6dbef6..25500f4a4 100644 --- a/mix.lock +++ b/mix.lock @@ -2,7 +2,7 @@ "benchee": {:hex, :benchee, "1.1.0", "f3a43817209a92a1fade36ef36b86e1052627fd8934a8b937ac9ab3a76c43062", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}], "hexpm", "7da57d545003165a012b587077f6ba90b89210fd88074ce3c60ce239eb5e6d93"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir_vendored": {:git, "https://github.com/elixir-lsp/dialyxir.git", "d50dcd7101c6ebd37b57b7ee4a7888d8cb634782", [ref: "d50dcd7101c6ebd37b57b7ee4a7888d8cb634782"]}, - "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "c47f948cdcb716c5757fdbdc9badfdd034a6613c", [ref: "c47f948cdcb716c5757fdbdc9badfdd034a6613c"]}, + "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "497d9b797cb7032fcb9f2c0fc7294c46c88bac32", [ref: "497d9b797cb7032fcb9f2c0fc7294c46c88bac32"]}, "erl2ex_vendored": {:git, "https://github.com/elixir-lsp/erl2ex.git", "073ac6b9a44282e718b6050c7b27cedf9217a12a", [ref: "073ac6b9a44282e718b6050c7b27cedf9217a12a"]}, "erlex_vendored": {:git, "https://github.com/elixir-lsp/erlex.git", "82db0e82ee4896491bc26dec99f5d795f03ab9f4", [ref: "82db0e82ee4896491bc26dec99f5d795f03ab9f4"]}, "jason_v": {:git, "https://github.com/elixir-lsp/jason.git", "c81537e2a5e1acacb915cf339fe400357e3c2aaa", [ref: "c81537e2a5e1acacb915cf339fe400357e3c2aaa"]},