From 2ab27be9332aca1c3ed390828d1ab4e4d966e4e0 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Tue, 20 Feb 2024 21:35:53 +0100 Subject: [PATCH] resolve exdoc autolinks in markdown documents --- .../lib/language_server/doc_links.ex | 30 +- .../lib/language_server/markdown_utils.ex | 286 ++++++++++++++++++ .../lib/language_server/providers/hover.ex | 6 +- .../test/markdown_utils_test.exs | 223 ++++++++++++++ 4 files changed, 538 insertions(+), 7 deletions(-) diff --git a/apps/language_server/lib/language_server/doc_links.ex b/apps/language_server/lib/language_server/doc_links.ex index 8918a1a08..d4a5d0d7c 100644 --- a/apps/language_server/lib/language_server/doc_links.ex +++ b/apps/language_server/lib/language_server/doc_links.ex @@ -5,7 +5,7 @@ defmodule ElixirLS.LanguageServer.DocLinks do @hex_base_url "https://hexdocs.pm" - defp get_app(module) do + def get_app(module) do with {:ok, app} <- :application.get_application(module), {:ok, vsn} <- :application.get_key(app, :vsn) do {app, vsn} @@ -18,7 +18,7 @@ defmodule ElixirLS.LanguageServer.DocLinks do def hex_docs_module_link(module) do case get_app(module) do {app, vsn} -> - "#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html" + "#{@hex_base_url}/#{app}/#{vsn}/#{inspect_module(module)}.html" nil -> nil @@ -28,7 +28,7 @@ defmodule ElixirLS.LanguageServer.DocLinks do def hex_docs_function_link(module, function, arity) do case get_app(module) do {app, vsn} -> - "#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html##{function}/#{arity}" + "#{@hex_base_url}/#{app}/#{vsn}/#{inspect_module(module)}.html##{function}/#{arity}" nil -> nil @@ -38,10 +38,32 @@ defmodule ElixirLS.LanguageServer.DocLinks do def hex_docs_type_link(module, type, arity) do case get_app(module) do {app, vsn} -> - "#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html#t:#{type}/#{arity}" + "#{@hex_base_url}/#{app}/#{vsn}/#{inspect_module(module)}.html#t:#{type}/#{arity}" nil -> nil end end + + def hex_docs_callback_link(module, callback, arity) do + case get_app(module) do + {app, vsn} -> + "#{@hex_base_url}/#{app}/#{vsn}/#{inspect_module(module)}.html#c:#{callback}/#{arity}" + + nil -> + nil + end + end + + def hex_docs_extra_link({app, vsn}, page) do + "#{@hex_base_url}/#{app}/#{vsn}/#{page}" + end + + def hex_docs_extra_link(app, page) do + "#{@hex_base_url}/#{app}/#{page}" + end + + defp inspect_module(module) do + module |> inspect |> String.replace_prefix(":", "") + end end diff --git a/apps/language_server/lib/language_server/markdown_utils.ex b/apps/language_server/lib/language_server/markdown_utils.ex index 20effa3f8..24968feee 100644 --- a/apps/language_server/lib/language_server/markdown_utils.ex +++ b/apps/language_server/lib/language_server/markdown_utils.ex @@ -1,4 +1,6 @@ defmodule ElixirLS.LanguageServer.MarkdownUtils do + alias ElixirLS.LanguageServer.DocLinks + @hash_match ~r/(? String.split(~r/(`.*?`)|(\[.*?\]\(.*?\))/u, include_captures: true) + |> Enum.map(fn segment -> + cond do + segment =~ ~r/^`.*?`$/u -> + try do + trimmed = String.trim(segment, "`") + + transformed_link = + trimmed + |> transform_ex_doc_link(current_module) + + if transformed_link == nil do + raise "unable to autolink" + end + + trimmed_no_prefix = + trimmed + |> String.replace(~r/^[mtce]\:/, "") + |> split + |> elem(0) + + ["[`", trimmed_no_prefix, "`](", transformed_link, ")"] + rescue + _ -> + segment + end + + segment =~ ~r/^\[..*?\]\(.*\)$/u -> + try do + [[_, custom_text, stripped]] = Regex.scan(~r/^\[(.*?)\]\((.*)\)$/u, segment) + trimmed = String.trim(stripped, "`") + + transformed_link = + if trimmed =~ ~r/^https?:\/\// or + (trimmed =~ ~r/\.(md|livemd|txt|html)(#.*)?$/ and + not String.starts_with?(trimmed, "e:")) do + transform_ex_doc_link("e:" <> trimmed, current_module) + else + transform_ex_doc_link(trimmed, current_module) + end + + if transformed_link == nil do + raise "unable to autolink" + end + + ["[", custom_text, "](", transformed_link, ")"] + rescue + _ -> + segment + end + + true -> + segment + end + end) + |> IO.iodata_to_binary() + end + + def transform_ex_doc_link(string, current_module \\ nil) + + def transform_ex_doc_link("m:" <> rest, _current_module) do + {module_str, anchor} = split(rest) + + module = module_string_to_atom(module_str) + module_link(module, anchor) + end + + @builtin_type_url Map.new(ElixirSense.Core.BuiltinTypes.all(), fn {key, value} -> + anchor = + if value |> Map.has_key?(:spec) do + "built-in-types" + else + "basic-types" + end + + url = + DocLinks.hex_docs_extra_link( + {:elixir, System.version()}, + "typespecs.html" + ) <> + "#" <> anchor + + key = + if key =~ ~r/\/d+$/ do + key + else + key <> "/0" + end + + {key, url} + end) + + def transform_ex_doc_link("t:" <> rest, current_module) do + case @builtin_type_url[rest] do + nil -> + case get_module_fun_arity(rest) do + {module, type, arity} -> + if match?(":" <> _, rest) do + "https://www.erlang.org/doc/man/#{module}.html#type-#{type}" + else + DocLinks.hex_docs_type_link(module || current_module, type, arity) + end + end + + url -> + url + end + end + + def transform_ex_doc_link("c:" <> rest, current_module) do + case get_module_fun_arity(rest) do + {module, callback, arity} -> + if match?(":" <> _, rest) do + "https://www.erlang.org/doc/man/#{module}.html#Module:#{callback}-#{arity}" + else + DocLinks.hex_docs_callback_link(module || current_module, callback, arity) + end + end + end + + def transform_ex_doc_link("e:http://" <> rest, _current_module), do: "http://" <> rest + def transform_ex_doc_link("e:https://" <> rest, _current_module), do: "https://" <> rest + + def transform_ex_doc_link("e:" <> rest, current_module) do + {page, anchor} = split(rest) + + {app, page} = + case split(page, ":") do + {page, nil} -> {nil, page} + other -> other + end + + page = + page + |> String.replace(~r/\.(md|livemd|txt)$/, ".html") + |> String.replace(" ", "-") + |> String.downcase() + + app_vsn = + if app do + vsn = + Application.loaded_applications() + |> Enum.find_value(fn {a, _, vsn} -> + if to_string(a) == app do + vsn + end + end) + + if vsn do + {app, vsn} + else + app + end + else + case DocLinks.get_app(current_module) do + {app, vsn} -> + {app, vsn} + + _ -> + nil + end + end + + if app_vsn do + DocLinks.hex_docs_extra_link(app_vsn, page) <> + if anchor do + "#" <> anchor + else + "" + end + end + end + + def transform_ex_doc_link(string, current_module) do + {prefix, anchor} = split(string) + + case get_module_fun_arity(prefix) do + {:"", nil, nil} -> + module_link(current_module, anchor) + + {module, nil, nil} -> + if Code.ensure_loaded?(module) do + module_link(module, anchor) + end + + {module, function, arity} -> + if match?(":" <> _, prefix) and module != Kernel.SpecialForms do + "https://www.erlang.org/doc/man/#{module}.html##{function}-#{arity}" + else + DocLinks.hex_docs_function_link(module || current_module, function, arity) + end + end + end + + @kernel_special_forms_exports Kernel.SpecialForms.__info__(:macros) + @kernel_exports Kernel.__info__(:macros) ++ Kernel.__info__(:functions) + + defp get_module_fun_arity("..///3"), do: {Kernel, :"..//", 3} + defp get_module_fun_arity("../2"), do: {Kernel, :.., 2} + defp get_module_fun_arity("../0"), do: {Kernel, :.., 0} + defp get_module_fun_arity("./2"), do: {Kernel.SpecialForms, :., 2} + defp get_module_fun_arity("::/2"), do: {Kernel.SpecialForms, :"::", 2} + + defp get_module_fun_arity(string) do + string = string |> String.trim_leading(":") |> String.replace(":", ".") + + {module, fun_arity} = + case String.split(string, ".") do + [fun_arity] -> + {nil, fun_arity} + + list -> + [fun_arity | module_reversed] = Enum.reverse(list) + module_str = module_reversed |> Enum.reverse() |> Enum.join(".") + module = module_string_to_atom(module_str) + {module, fun_arity} + end + + case String.split(fun_arity, "/", parts: 2) do + [fun, arity] -> + fun = String.to_atom(fun) + arity = String.to_integer(arity) + + module = + cond do + module != nil -> + module + + {fun, arity} in @kernel_exports -> + Kernel + + {fun, arity} in @kernel_special_forms_exports -> + Kernel.SpecialForms + + true -> + # NOTE we should be able to resolve all imported locals but we limit to current module and + # Kernel, Kernel.SpecialForms for simplicity + nil + end + + {module, fun, arity} + + _ -> + module = module_string_to_atom(string) + {module, nil, nil} + end + end + + defp module_string_to_atom(module_str) do + module = Module.concat([module_str]) + + if inspect(module) == module_str do + module + else + String.to_atom(module_str) + end + end + + defp split(rest, separator \\ "#") do + case String.split(rest, separator, parts: 2) do + [module, anchor] -> + {module, anchor} + + [module] -> + {module, nil} + end + end + + defp module_link(module, anchor) do + DocLinks.hex_docs_module_link(module) <> + if anchor do + "#" <> anchor + else + "" + end + 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 9aba4b844..8df854912 100644 --- a/apps/language_server/lib/language_server/providers/hover.ex +++ b/apps/language_server/lib/language_server/providers/hover.ex @@ -115,7 +115,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do #{MarkdownUtils.get_metadata_md(info.metadata)} - #{documentation_section(info.docs)} + #{documentation_section(info.docs) |> MarkdownUtils.transform_ex_doc_links(info.module)} """ end @@ -154,7 +154,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do #{spec_text} - #{documentation_section(info.docs)} + #{documentation_section(info.docs) |> MarkdownUtils.transform_ex_doc_links(info.module)} """ end @@ -184,7 +184,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do #{formatted_spec} - #{documentation_section(info.docs)} + #{documentation_section(info.docs) |> MarkdownUtils.transform_ex_doc_links(info.module)} """ end diff --git a/apps/language_server/test/markdown_utils_test.exs b/apps/language_server/test/markdown_utils_test.exs index 6ebe0e129..a06911ceb 100644 --- a/apps/language_server/test/markdown_utils_test.exs +++ b/apps/language_server/test/markdown_utils_test.exs @@ -101,4 +101,227 @@ defmodule ElixirLS.LanguageServer.MarkdownUtilsTest do Bar """ end + + describe "ex_doc links" do + # The test cases here base on autolink documentation from https://hexdocs.pm/ex_doc/readme.html#auto-linking + # and test cases from https://github.com/elixir-lang/ex_doc/blob/v0.31.1/test/ex_doc/language/elixir_test.exs + # TODO add support for OTP 27 + + @version System.version() + test "elixir module link with prefix" do + assert MarkdownUtils.transform_ex_doc_links("`m:Keyword`") == + "[`Keyword`](https://hexdocs.pm/elixir/#{@version}/Keyword.html)" + end + + test "elixir module link without prefix" do + assert MarkdownUtils.transform_ex_doc_links("`Keyword`") == + "[`Keyword`](https://hexdocs.pm/elixir/#{@version}/Keyword.html)" + end + + test "elixir module link with Elixir." do + assert MarkdownUtils.transform_ex_doc_links("`Elixir.Keyword`") == + "[`Elixir.Keyword`](https://hexdocs.pm/elixir/#{@version}/Keyword.html)" + end + + test "elixir module link nested" do + assert MarkdownUtils.transform_ex_doc_links("`IEx.Helpers`") == + "[`IEx.Helpers`](https://hexdocs.pm/iex/#{@version}/IEx.Helpers.html)" + end + + test "module not found" do + assert MarkdownUtils.transform_ex_doc_links("`PATH`") == "`PATH`" + end + + test "atom" do + assert MarkdownUtils.transform_ex_doc_links("`:atom`") == "`:atom`" + end + + test "elixir module link with anchor" do + assert MarkdownUtils.transform_ex_doc_links( + "`m:Keyword#module-duplicate-keys-and-ordering`" + ) == + "[`Keyword`](https://hexdocs.pm/elixir/#{@version}/Keyword.html#module-duplicate-keys-and-ordering)" + end + + test "erlang module link with prefix" do + assert MarkdownUtils.transform_ex_doc_links("`m:elixir_tokenizer`") == + "[`elixir_tokenizer`](https://hexdocs.pm/elixir/#{@version}/elixir_tokenizer.html)" + end + + test "elixir type link with prefix" do + assert MarkdownUtils.transform_ex_doc_links("`t:Macro.t/0`") == + "[`Macro.t/0`](https://hexdocs.pm/elixir/#{@version}/Macro.html#t:t/0)" + end + + test "elixir type link without module" do + assert MarkdownUtils.transform_ex_doc_links("`t:t/0`", Macro) == + "[`t/0`](https://hexdocs.pm/elixir/#{@version}/Macro.html#t:t/0)" + end + + test "elixir basic/builtin type" do + assert MarkdownUtils.transform_ex_doc_links("`t:atom/0`", Macro) == + "[`atom/0`](https://hexdocs.pm/elixir/#{@version}/typespecs.html#basic-types)" + + assert MarkdownUtils.transform_ex_doc_links("`t:keyword/0`", Macro) == + "[`keyword/0`](https://hexdocs.pm/elixir/#{@version}/typespecs.html#built-in-types)" + end + + test "erlang type" do + assert MarkdownUtils.transform_ex_doc_links("`t::array.array/0`") == + "[`:array.array/0`](https://www.erlang.org/doc/man/array.html#type-array)" + end + + test "elixir callback link with prefix" do + assert MarkdownUtils.transform_ex_doc_links("`c:GenServer.init/1`") == + "[`GenServer.init/1`](https://hexdocs.pm/elixir/#{@version}/GenServer.html#c:init/1)" + end + + test "erlang callback" do + assert MarkdownUtils.transform_ex_doc_links("`c::gen_server.handle_call/3`") == + "[`:gen_server.handle_call/3`](https://www.erlang.org/doc/man/gen_server.html#Module:handle_call-3)" + end + + test "elixir callback link without module" do + assert MarkdownUtils.transform_ex_doc_links("`c:init/1`", GenServer) == + "[`init/1`](https://hexdocs.pm/elixir/#{@version}/GenServer.html#c:init/1)" + end + + test "elixir function link with prefix" do + assert MarkdownUtils.transform_ex_doc_links("`Node.alive?/0`") == + "[`Node.alive?/0`](https://hexdocs.pm/elixir/#{@version}/Node.html#alive?/0)" + end + + test "elixir function link with custom test" do + assert MarkdownUtils.transform_ex_doc_links("[custom text](`Node.alive?/0`)") == + "[custom text](https://hexdocs.pm/elixir/#{@version}/Node.html#alive?/0)" + end + + test "elixir function link without module" do + assert MarkdownUtils.transform_ex_doc_links("`alive?/0`", Node) == + "[`alive?/0`](https://hexdocs.pm/elixir/#{@version}/Node.html#alive?/0)" + end + + test "elixir auto imported function" do + assert MarkdownUtils.transform_ex_doc_links("`+/2`", Kernel) == + "[`+/2`](https://hexdocs.pm/elixir/#{@version}/Kernel.html#+/2)" + + assert MarkdownUtils.transform_ex_doc_links("`for/1`", Kernel.SpecialForms) == + "[`for/1`](https://hexdocs.pm/elixir/#{@version}/Kernel.SpecialForms.html#for/1)" + end + + test "elixir auto imported function from other module" do + assert MarkdownUtils.transform_ex_doc_links("`+/2`", List) == + "[`+/2`](https://hexdocs.pm/elixir/#{@version}/Kernel.html#+/2)" + + assert MarkdownUtils.transform_ex_doc_links("`for/1`", List) == + "[`for/1`](https://hexdocs.pm/elixir/#{@version}/Kernel.SpecialForms.html#for/1)" + end + + test "special cases" do + assert MarkdownUtils.transform_ex_doc_links("`..///3`", Kernel) == + "[`..///3`](https://hexdocs.pm/elixir/#{@version}/Kernel.html#..///3)" + + assert MarkdownUtils.transform_ex_doc_links("`../2`", Kernel) == + "[`../2`](https://hexdocs.pm/elixir/#{@version}/Kernel.html#../2)" + + assert MarkdownUtils.transform_ex_doc_links("`../0`", Kernel) == + "[`../0`](https://hexdocs.pm/elixir/#{@version}/Kernel.html#../0)" + + assert MarkdownUtils.transform_ex_doc_links("`::/2`", Kernel.SpecialForms) == + "[`::/2`](https://hexdocs.pm/elixir/#{@version}/Kernel.SpecialForms.html#::/2)" + end + + test "erlang function link" do + assert MarkdownUtils.transform_ex_doc_links("`elixir_tokenizer:tokenize/1`") == + "[`elixir_tokenizer:tokenize/1`](https://hexdocs.pm/elixir/#{@version}/elixir_tokenizer.html#tokenize/1)" + end + + test "erlang stdlib function link" do + assert MarkdownUtils.transform_ex_doc_links("`:lists.all/2`") == + "[`:lists.all/2`](https://www.erlang.org/doc/man/lists.html#all-2)" + end + + test "extra page" do + assert MarkdownUtils.transform_ex_doc_links("[Up and running](Up and running.md)", Kernel) == + "[Up and running](https://hexdocs.pm/elixir/#{@version}/up-and-running.html)" + end + + test "extra page with anchor" do + assert MarkdownUtils.transform_ex_doc_links( + "[Expressions](`e:elixir:syntax-reference.md#expressions`)" + ) == + "[Expressions](https://hexdocs.pm/elixir/#{@version}/syntax-reference.html#expressions)" + end + + test "extra page with anchor no prefix" do + assert MarkdownUtils.transform_ex_doc_links( + "[\"The need for monitoring\"](genservers.md#the-need-for-monitoring)", + Process + ) == + "[\"The need for monitoring\"](https://hexdocs.pm/elixir/#{@version}/genservers.html#the-need-for-monitoring)" + end + + test "extra page only anchor" do + assert MarkdownUtils.transform_ex_doc_links( + "[the module documentation](#module-aliases)", + Process + ) == + "[the module documentation](https://hexdocs.pm/elixir/#{@version}/Process.html#module-aliases)" + end + + test "extra page external" do + assert MarkdownUtils.transform_ex_doc_links( + "[Up and running](http://example.com/foo.md)", + Kernel + ) == "[Up and running](http://example.com/foo.md)" + end + + test "expression" do + assert MarkdownUtils.transform_ex_doc_links("`1 + 2`") == "`1 + 2`" + end + end + + @tag :skip + test "integration" do + for app <- [ + :elixir, + :iex, + :exunit, + :logger, + :mix, + :stdlib, + :erts + ] do + modules = + case :application.get_key(app, :modules) do + {:ok, modules} -> modules + :undefined -> [] + end + + for module <- modules do + case ElixirSense.Core.Normalized.Code.get_docs(module, :moduledoc) do + {_, doc, _} when is_binary(doc) -> + MarkdownUtils.transform_ex_doc_links(doc, module) + + _ -> + :ok + end + + for {_ma, _, _, doc, _} when is_binary(doc) <- + ElixirSense.Core.Normalized.Code.get_docs(module, :type_docs) |> List.wrap() do + MarkdownUtils.transform_ex_doc_links(doc, module) + end + + for {_ma, _, _, doc, _} when is_binary(doc) <- + ElixirSense.Core.Normalized.Code.get_docs(module, :callback_docs) |> List.wrap() do + MarkdownUtils.transform_ex_doc_links(doc, module) + end + + for {_ma, _, _, _, doc, _} when is_binary(doc) <- + ElixirSense.Core.Normalized.Code.get_docs(module, :docs) |> List.wrap() do + MarkdownUtils.transform_ex_doc_links(doc, module) + end + end + end + end end