Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve exdoc autolinks in markdown documents #1066

Merged
merged 1 commit into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions apps/language_server/lib/language_server/doc_links.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
286 changes: 286 additions & 0 deletions apps/language_server/lib/language_server/markdown_utils.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule ElixirLS.LanguageServer.MarkdownUtils do
alias ElixirLS.LanguageServer.DocLinks

@hash_match ~r/(?<!\\)(?<!\w)(#+)(?=\s)/u
# Find the lowest heading level in the fragment
defp lowest_heading_level(fragment) do
Expand Down Expand Up @@ -137,4 +139,288 @@ defmodule ElixirLS.LanguageServer.MarkdownUtils do
defp get_metadata_entry_md({key, value}) do
"**#{key}** #{inspect(value)}"
end

@doc """
This function implements most of the elixir (and some erlang) related functionality
of ExDoc autolinker https://hexdocs.pm/ex_doc/readme.html#auto-linking
"""
def transform_ex_doc_links(string, current_module \\ nil) do
# TODO add support for OTP 27
string
|> 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
6 changes: 3 additions & 3 deletions apps/language_server/lib/language_server/providers/hover.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading