Skip to content

Commit c2ceaee

Browse files
authored
resolve exdoc autolinks in markdown documents (#1066)
1 parent 845cd2e commit c2ceaee

File tree

4 files changed

+538
-7
lines changed

4 files changed

+538
-7
lines changed

apps/language_server/lib/language_server/doc_links.ex

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule ElixirLS.LanguageServer.DocLinks do
55

66
@hex_base_url "https://hexdocs.pm"
77

8-
defp get_app(module) do
8+
def get_app(module) do
99
with {:ok, app} <- :application.get_application(module),
1010
{:ok, vsn} <- :application.get_key(app, :vsn) do
1111
{app, vsn}
@@ -18,7 +18,7 @@ defmodule ElixirLS.LanguageServer.DocLinks do
1818
def hex_docs_module_link(module) do
1919
case get_app(module) do
2020
{app, vsn} ->
21-
"#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html"
21+
"#{@hex_base_url}/#{app}/#{vsn}/#{inspect_module(module)}.html"
2222

2323
nil ->
2424
nil
@@ -28,7 +28,7 @@ defmodule ElixirLS.LanguageServer.DocLinks do
2828
def hex_docs_function_link(module, function, arity) do
2929
case get_app(module) do
3030
{app, vsn} ->
31-
"#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html##{function}/#{arity}"
31+
"#{@hex_base_url}/#{app}/#{vsn}/#{inspect_module(module)}.html##{function}/#{arity}"
3232

3333
nil ->
3434
nil
@@ -38,10 +38,32 @@ defmodule ElixirLS.LanguageServer.DocLinks do
3838
def hex_docs_type_link(module, type, arity) do
3939
case get_app(module) do
4040
{app, vsn} ->
41-
"#{@hex_base_url}/#{app}/#{vsn}/#{inspect(module)}.html#t:#{type}/#{arity}"
41+
"#{@hex_base_url}/#{app}/#{vsn}/#{inspect_module(module)}.html#t:#{type}/#{arity}"
4242

4343
nil ->
4444
nil
4545
end
4646
end
47+
48+
def hex_docs_callback_link(module, callback, arity) do
49+
case get_app(module) do
50+
{app, vsn} ->
51+
"#{@hex_base_url}/#{app}/#{vsn}/#{inspect_module(module)}.html#c:#{callback}/#{arity}"
52+
53+
nil ->
54+
nil
55+
end
56+
end
57+
58+
def hex_docs_extra_link({app, vsn}, page) do
59+
"#{@hex_base_url}/#{app}/#{vsn}/#{page}"
60+
end
61+
62+
def hex_docs_extra_link(app, page) do
63+
"#{@hex_base_url}/#{app}/#{page}"
64+
end
65+
66+
defp inspect_module(module) do
67+
module |> inspect |> String.replace_prefix(":", "")
68+
end
4769
end

apps/language_server/lib/language_server/markdown_utils.ex

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
defmodule ElixirLS.LanguageServer.MarkdownUtils do
2+
alias ElixirLS.LanguageServer.DocLinks
3+
24
@hash_match ~r/(?<!\\)(?<!\w)(#+)(?=\s)/u
35
# Find the lowest heading level in the fragment
46
defp lowest_heading_level(fragment) do
@@ -137,4 +139,288 @@ defmodule ElixirLS.LanguageServer.MarkdownUtils do
137139
defp get_metadata_entry_md({key, value}) do
138140
"**#{key}** #{inspect(value)}"
139141
end
142+
143+
@doc """
144+
This function implements most of the elixir (and some erlang) related functionality
145+
of ExDoc autolinker https://hexdocs.pm/ex_doc/readme.html#auto-linking
146+
"""
147+
def transform_ex_doc_links(string, current_module \\ nil) do
148+
# TODO add support for OTP 27
149+
string
150+
|> String.split(~r/(`.*?`)|(\[.*?\]\(.*?\))/u, include_captures: true)
151+
|> Enum.map(fn segment ->
152+
cond do
153+
segment =~ ~r/^`.*?`$/u ->
154+
try do
155+
trimmed = String.trim(segment, "`")
156+
157+
transformed_link =
158+
trimmed
159+
|> transform_ex_doc_link(current_module)
160+
161+
if transformed_link == nil do
162+
raise "unable to autolink"
163+
end
164+
165+
trimmed_no_prefix =
166+
trimmed
167+
|> String.replace(~r/^[mtce]\:/, "")
168+
|> split
169+
|> elem(0)
170+
171+
["[`", trimmed_no_prefix, "`](", transformed_link, ")"]
172+
rescue
173+
_ ->
174+
segment
175+
end
176+
177+
segment =~ ~r/^\[..*?\]\(.*\)$/u ->
178+
try do
179+
[[_, custom_text, stripped]] = Regex.scan(~r/^\[(.*?)\]\((.*)\)$/u, segment)
180+
trimmed = String.trim(stripped, "`")
181+
182+
transformed_link =
183+
if trimmed =~ ~r/^https?:\/\// or
184+
(trimmed =~ ~r/\.(md|livemd|txt|html)(#.*)?$/ and
185+
not String.starts_with?(trimmed, "e:")) do
186+
transform_ex_doc_link("e:" <> trimmed, current_module)
187+
else
188+
transform_ex_doc_link(trimmed, current_module)
189+
end
190+
191+
if transformed_link == nil do
192+
raise "unable to autolink"
193+
end
194+
195+
["[", custom_text, "](", transformed_link, ")"]
196+
rescue
197+
_ ->
198+
segment
199+
end
200+
201+
true ->
202+
segment
203+
end
204+
end)
205+
|> IO.iodata_to_binary()
206+
end
207+
208+
def transform_ex_doc_link(string, current_module \\ nil)
209+
210+
def transform_ex_doc_link("m:" <> rest, _current_module) do
211+
{module_str, anchor} = split(rest)
212+
213+
module = module_string_to_atom(module_str)
214+
module_link(module, anchor)
215+
end
216+
217+
@builtin_type_url Map.new(ElixirSense.Core.BuiltinTypes.all(), fn {key, value} ->
218+
anchor =
219+
if value |> Map.has_key?(:spec) do
220+
"built-in-types"
221+
else
222+
"basic-types"
223+
end
224+
225+
url =
226+
DocLinks.hex_docs_extra_link(
227+
{:elixir, System.version()},
228+
"typespecs.html"
229+
) <>
230+
"#" <> anchor
231+
232+
key =
233+
if key =~ ~r/\/d+$/ do
234+
key
235+
else
236+
key <> "/0"
237+
end
238+
239+
{key, url}
240+
end)
241+
242+
def transform_ex_doc_link("t:" <> rest, current_module) do
243+
case @builtin_type_url[rest] do
244+
nil ->
245+
case get_module_fun_arity(rest) do
246+
{module, type, arity} ->
247+
if match?(":" <> _, rest) do
248+
"https://www.erlang.org/doc/man/#{module}.html#type-#{type}"
249+
else
250+
DocLinks.hex_docs_type_link(module || current_module, type, arity)
251+
end
252+
end
253+
254+
url ->
255+
url
256+
end
257+
end
258+
259+
def transform_ex_doc_link("c:" <> rest, current_module) do
260+
case get_module_fun_arity(rest) do
261+
{module, callback, arity} ->
262+
if match?(":" <> _, rest) do
263+
"https://www.erlang.org/doc/man/#{module}.html#Module:#{callback}-#{arity}"
264+
else
265+
DocLinks.hex_docs_callback_link(module || current_module, callback, arity)
266+
end
267+
end
268+
end
269+
270+
def transform_ex_doc_link("e:http://" <> rest, _current_module), do: "http://" <> rest
271+
def transform_ex_doc_link("e:https://" <> rest, _current_module), do: "https://" <> rest
272+
273+
def transform_ex_doc_link("e:" <> rest, current_module) do
274+
{page, anchor} = split(rest)
275+
276+
{app, page} =
277+
case split(page, ":") do
278+
{page, nil} -> {nil, page}
279+
other -> other
280+
end
281+
282+
page =
283+
page
284+
|> String.replace(~r/\.(md|livemd|txt)$/, ".html")
285+
|> String.replace(" ", "-")
286+
|> String.downcase()
287+
288+
app_vsn =
289+
if app do
290+
vsn =
291+
Application.loaded_applications()
292+
|> Enum.find_value(fn {a, _, vsn} ->
293+
if to_string(a) == app do
294+
vsn
295+
end
296+
end)
297+
298+
if vsn do
299+
{app, vsn}
300+
else
301+
app
302+
end
303+
else
304+
case DocLinks.get_app(current_module) do
305+
{app, vsn} ->
306+
{app, vsn}
307+
308+
_ ->
309+
nil
310+
end
311+
end
312+
313+
if app_vsn do
314+
DocLinks.hex_docs_extra_link(app_vsn, page) <>
315+
if anchor do
316+
"#" <> anchor
317+
else
318+
""
319+
end
320+
end
321+
end
322+
323+
def transform_ex_doc_link(string, current_module) do
324+
{prefix, anchor} = split(string)
325+
326+
case get_module_fun_arity(prefix) do
327+
{:"", nil, nil} ->
328+
module_link(current_module, anchor)
329+
330+
{module, nil, nil} ->
331+
if Code.ensure_loaded?(module) do
332+
module_link(module, anchor)
333+
end
334+
335+
{module, function, arity} ->
336+
if match?(":" <> _, prefix) and module != Kernel.SpecialForms do
337+
"https://www.erlang.org/doc/man/#{module}.html##{function}-#{arity}"
338+
else
339+
DocLinks.hex_docs_function_link(module || current_module, function, arity)
340+
end
341+
end
342+
end
343+
344+
@kernel_special_forms_exports Kernel.SpecialForms.__info__(:macros)
345+
@kernel_exports Kernel.__info__(:macros) ++ Kernel.__info__(:functions)
346+
347+
defp get_module_fun_arity("..///3"), do: {Kernel, :"..//", 3}
348+
defp get_module_fun_arity("../2"), do: {Kernel, :.., 2}
349+
defp get_module_fun_arity("../0"), do: {Kernel, :.., 0}
350+
defp get_module_fun_arity("./2"), do: {Kernel.SpecialForms, :., 2}
351+
defp get_module_fun_arity("::/2"), do: {Kernel.SpecialForms, :"::", 2}
352+
353+
defp get_module_fun_arity(string) do
354+
string = string |> String.trim_leading(":") |> String.replace(":", ".")
355+
356+
{module, fun_arity} =
357+
case String.split(string, ".") do
358+
[fun_arity] ->
359+
{nil, fun_arity}
360+
361+
list ->
362+
[fun_arity | module_reversed] = Enum.reverse(list)
363+
module_str = module_reversed |> Enum.reverse() |> Enum.join(".")
364+
module = module_string_to_atom(module_str)
365+
{module, fun_arity}
366+
end
367+
368+
case String.split(fun_arity, "/", parts: 2) do
369+
[fun, arity] ->
370+
fun = String.to_atom(fun)
371+
arity = String.to_integer(arity)
372+
373+
module =
374+
cond do
375+
module != nil ->
376+
module
377+
378+
{fun, arity} in @kernel_exports ->
379+
Kernel
380+
381+
{fun, arity} in @kernel_special_forms_exports ->
382+
Kernel.SpecialForms
383+
384+
true ->
385+
# NOTE we should be able to resolve all imported locals but we limit to current module and
386+
# Kernel, Kernel.SpecialForms for simplicity
387+
nil
388+
end
389+
390+
{module, fun, arity}
391+
392+
_ ->
393+
module = module_string_to_atom(string)
394+
{module, nil, nil}
395+
end
396+
end
397+
398+
defp module_string_to_atom(module_str) do
399+
module = Module.concat([module_str])
400+
401+
if inspect(module) == module_str do
402+
module
403+
else
404+
String.to_atom(module_str)
405+
end
406+
end
407+
408+
defp split(rest, separator \\ "#") do
409+
case String.split(rest, separator, parts: 2) do
410+
[module, anchor] ->
411+
{module, anchor}
412+
413+
[module] ->
414+
{module, nil}
415+
end
416+
end
417+
418+
defp module_link(module, anchor) do
419+
DocLinks.hex_docs_module_link(module) <>
420+
if anchor do
421+
"#" <> anchor
422+
else
423+
""
424+
end
425+
end
140426
end

apps/language_server/lib/language_server/providers/hover.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do
115115
116116
#{MarkdownUtils.get_metadata_md(info.metadata)}
117117
118-
#{documentation_section(info.docs)}
118+
#{documentation_section(info.docs) |> MarkdownUtils.transform_ex_doc_links(info.module)}
119119
"""
120120
end
121121

@@ -154,7 +154,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do
154154
155155
#{spec_text}
156156
157-
#{documentation_section(info.docs)}
157+
#{documentation_section(info.docs) |> MarkdownUtils.transform_ex_doc_links(info.module)}
158158
"""
159159
end
160160

@@ -184,7 +184,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do
184184
185185
#{formatted_spec}
186186
187-
#{documentation_section(info.docs)}
187+
#{documentation_section(info.docs) |> MarkdownUtils.transform_ex_doc_links(info.module)}
188188
"""
189189
end
190190

0 commit comments

Comments
 (0)