Skip to content

Commit

Permalink
resolve exdoc autolinks in markdown documents
Browse files Browse the repository at this point in the history
  • Loading branch information
lukaszsamson committed Feb 20, 2024
1 parent 505dc69 commit 2ab27be
Show file tree
Hide file tree
Showing 4 changed files with 538 additions and 7 deletions.
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

0 comments on commit 2ab27be

Please sign in to comment.