Current section

Files

Jump to
ex_doc lib ex_doc language erlang.ex
Raw

lib/ex_doc/language/erlang.ex

defmodule ExDoc.Language.Erlang do
@moduledoc false
@behaviour ExDoc.Language
alias ExDoc.Language.Source
alias ExDoc.{Autolink, Refs}
@impl true
@spec module_data(atom, any, any) ::
false
| %{
docs: any,
id: binary,
language: ExDoc.Language.Erlang,
source_line: pos_integer,
source_file: Path.t(),
source_basedir: Path.t(),
module: module,
nesting_info: nil,
private: %{abst_code: any, callbacks: map, optional_callbacks: any, specs: map},
title: binary,
type: :module | :behaviour
}
def module_data(module, docs_chunk, _config) do
if abst_code = Source.get_abstract_code(module) do
id = Atom.to_string(module)
source_basedir = Source.fetch_basedir!(abst_code, module)
{source_file, source_line} =
Source.fetch_module_location!(abst_code, source_basedir, module)
type = module_type(module)
%{
module: module,
default_groups: ~w(Types Callbacks Functions),
docs: docs_chunk,
language: __MODULE__,
id: id,
title: id,
type: type,
source_line: source_line,
source_file: source_file,
source_basedir: source_basedir,
nesting_info: nil,
private: %{
abst_code: abst_code,
specs: Source.get_specs(abst_code, source_basedir),
callbacks: Source.get_callbacks(abst_code, source_basedir),
optional_callbacks: Source.get_optional_callbacks(module, type)
}
}
else
ExDoc.Utils.warn("skipping docs for module #{inspect(module)}, reason: :no_debug_info", [])
false
end
end
@impl true
def doc_data(entry, module_data) do
{{kind, name, arity}, anno, signature, doc_content, metadata} = entry
cond do
doc_content == :hidden ->
false
# Edoc on Erlang/OTP24.1+ includes private functions in
# the chunk, so we manually yank them out.
kind == :function and function_exported?(module_data.module, name, arity) ->
function_data(name, arity, signature, metadata, module_data)
kind == :callback ->
callback_data(name, arity, anno, signature, metadata, module_data)
kind == :type ->
type_data(name, arity, anno, signature, metadata, module_data)
true ->
false
end
end
defp function_data(name, arity, signature, metadata, module_data) do
specs =
case Map.fetch(module_data.private.specs, {name, arity}) do
{:ok, spec} ->
[spec]
:error ->
case Map.fetch(module_data.private.specs, {module_data.module, name, arity}) do
{:ok, spec} ->
[spec]
:error ->
[]
end
end
{file, line} = Source.fetch_function_location!(module_data, {name, arity})
%{
id_key: "",
default_group: "Functions",
doc_fallback: fn -> equiv_data(module_data.module, file, line, metadata) end,
extra_annotations: [],
signature: signature,
source_file: file,
source_line: line,
specs: specs,
type: :function
}
end
defp callback_data(name, arity, anno, signature, metadata, module_data) do
extra_annotations =
if {name, arity} in module_data.private.optional_callbacks, do: ["optional"], else: []
{specs, anno} =
case Map.fetch(module_data.private.callbacks, {name, arity}) do
{:ok, spec} ->
{[spec], elem(spec, 1)}
:error ->
{[], anno}
end
file = Source.anno_file(anno)
line = Source.anno_line(anno)
%{
id_key: "c:",
default_group: "Callbacks",
doc_fallback: fn -> equiv_data(module_data.module, file, line, metadata, "c:") end,
extra_annotations: extra_annotations,
signature: signature,
source_file: file,
source_line: line,
specs: specs,
type: :callback
}
end
defp type_data(name, arity, anno, signature, metadata, module_data) do
{specs, file, line, type} =
case Source.fetch_type!(module_data, name, arity) do
%{attr: attr, source_file: file, source_line: line, type: type} ->
{[attr], file, line, type}
nil ->
{[], nil, Source.anno_line(anno), :type}
end
%{
id_key: "t:",
default_group: "Types",
doc_fallback: fn -> equiv_data(module_data.module, file, line, metadata, "t:") end,
extra_annotations: [],
signature: signature,
source_file: file,
source_line: line,
specs: specs,
type: type
}
end
defp equiv_data(module, file, line, metadata, prefix \\ "") do
case metadata[:equiv] do
nil ->
nil
equiv when is_binary(equiv) ->
## We try to parse the equiv in order to link to the target
with {:ok, toks, _} <- :erl_scan.string(:unicode.characters_to_list(equiv <> ".")),
{:ok, [{:call, _, {:atom, _, func}, args}]} <- :erl_parse.parse_exprs(toks) do
"Equivalent to [`#{equiv}`](`#{prefix}#{func}/#{length(args)}`)."
else
{:ok, [{:op, _, :/, {:atom, _, _}, {:integer, _, _}}]} ->
"Equivalent to `#{prefix}#{equiv}`."
_ ->
"Equivalent to `#{equiv}`."
end
|> ExDoc.DocAST.parse!("text/markdown")
equiv ->
ExDoc.Utils.warn("invalid equiv #{inspect(equiv)}",
file: file,
line: line,
module: module
)
nil
end
end
@impl true
def autolink_doc(ast, opts) do
config = struct!(Autolink, opts)
true = config.language == __MODULE__
config = %{config | force_module_prefix: true}
walk_doc(ast, config)
end
@impl true
def autolink_spec(nil, _opts) do
nil
end
def autolink_spec({:attribute, _, :opaque, ast}, _opts) do
{name, _, args} = ast
args =
for arg <- args do
{:var, _, name} = arg
Atom.to_string(name)
end
|> Enum.intersperse(", ")
IO.iodata_to_binary([Atom.to_string(name), "(", args, ")"])
end
def autolink_spec(ast, opts) do
config = struct!(Autolink, opts)
{name, anno, quoted} =
case ast do
{:attribute, anno, kind, {mfa, ast}} when kind in [:spec, :callback] ->
{mn, name} =
case mfa do
{name, _} -> {name, name}
{module, name, _} -> {{module, name}, name}
end
{mn, anno, Enum.map(ast, &Code.Typespec.spec_to_quoted(name, &1))}
{:attribute, anno, :type, ast} ->
{name, _, _} = ast
{name, anno, Code.Typespec.type_to_quoted(ast)}
{:attribute, anno, :nominal, ast} ->
{name, _, _} = ast
{name, anno, Code.Typespec.type_to_quoted(ast)}
end
formatted = format_spec(ast)
config = %{config | file: Source.anno_file(anno), line: Source.anno_line(anno)}
autolink_spec(quoted, name, formatted, config)
end
@impl true
def highlight_info() do
%{
language_name: "erlang",
lexer: Makeup.Lexers.ErlangLexer,
opts: []
}
end
@impl true
def format_spec_attribute(%{type: :type}), do: "-type"
def format_spec_attribute(%{type: :opaque}), do: "-opaque"
def format_spec_attribute(%{type: :nominal}), do: "-nominal"
def format_spec_attribute(%{type: :callback}), do: "-callback"
def format_spec_attribute(%{}), do: "-spec"
## Autolink
defp walk_doc(list, config) when is_list(list) do
Enum.map(list, &walk_doc(&1, config))
end
defp walk_doc(binary, _) when is_binary(binary) do
binary
end
defp walk_doc({:pre, _, _, _} = ast, _config) do
ast
end
defp walk_doc({:code, attrs, [code], meta} = ast, config) when is_binary(code) do
config = %{config | line: meta[:line]}
case Autolink.url(code, :regular_link, config) do
url when is_binary(url) ->
code =
code
|> remove_prefix()
|> remove_fragment()
{:a, [href: url], [{:code, attrs, [code], meta}], %{}}
_ ->
ast
end
end
defp walk_doc({:a, attrs, inner, meta} = ast, config) do
case attrs[:rel] do
"https://erlang.org/doc/link/seeerl" ->
{fragment, url} = extract_fragment(attrs[:href] || "", "#")
case String.split(url, ":") do
[module] ->
walk_doc({:a, [href: "`m:#{maybe_quote(module)}#{fragment}`"], inner, meta}, config)
[app, module] ->
inner = strip_app(inner, app)
walk_doc({:a, [href: "`m:#{maybe_quote(module)}#{fragment}`"], inner, meta}, config)
_ ->
warn_ref(attrs[:href], config)
inner
end
"https://erlang.org/doc/link/seemfa" ->
{prefix, url} =
case String.split(attrs[:href], "Module:") do
[url] ->
{"", url}
[left, right] ->
{"c:", left <> right}
end
{mfa, inner} =
case String.split(url, ":") do
[mfa] ->
{mfa, inner}
[app, mfa] ->
{mfa, strip_app(inner, app)}
end
walk_doc({:a, [href: "`#{prefix}#{fixup(mfa)}`"], inner, meta}, config)
"https://erlang.org/doc/link/seetype" ->
{type, inner} =
case String.split(attrs[:href], ":") do
[type] ->
{type, inner}
[app, type] ->
{type, strip_app(inner, app)}
end
type =
case String.split(type, "(") do
[type] ->
type
[type, _] ->
type <> "/0"
end
walk_doc({:a, [href: "`t:#{fixup(type)}`"], inner, meta}, config)
"https://erlang.org/doc/link/" <> see ->
warn_ref(attrs[:href] <> " (#{see})", %{config | id: nil})
inner
_ ->
case Autolink.custom_link(attrs, config) do
:remove_link ->
remove_link(ast)
nil ->
ast
url ->
{:a, Keyword.put(attrs, :href, url), inner, meta}
end
end
end
defp walk_doc({tag, attrs, ast, meta}, config) do
{tag, attrs, walk_doc(ast, config), meta}
end
defp remove_link({:a, _attrs, inner, _meta}) do
inner
end
defp remove_prefix("c:" <> rest), do: rest
defp remove_prefix("m:" <> rest), do: rest
defp remove_prefix("t:" <> rest), do: rest
defp remove_prefix("\\" <> rest), do: rest
defp remove_prefix(rest), do: rest
defp remove_fragment(string) do
string |> String.split("#") |> hd()
end
defp extract_fragment(url, prefix) do
case String.split(url, "#", parts: 2) do
[url] -> {"", url}
[url, fragment] -> {prefix <> fragment, url}
end
end
defp fixup(mfa) do
{m, fa} =
case String.split(mfa, "#") do
["", mfa] ->
{"", mfa}
[m, fa] ->
{"#{maybe_quote(m)}:", fa}
end
[f, a] = String.split(fa, "/")
m <> maybe_quote(f) <> "/" <> a
end
defp maybe_quote(m) do
to_string(:io_lib.write_atom(String.to_atom(m)))
end
defp strip_app([{:code, attrs, [code], meta}], app) do
[{:code, attrs, strip_app(code, app), meta}]
end
defp strip_app(code, app) when is_binary(code) do
String.trim_leading(code, "//#{app}/")
end
defp strip_app(other, _app) do
other
end
defp warn_ref(href, config) do
message = "invalid reference: #{href}"
nil = config.id
Autolink.maybe_warn(config, message, nil, %{})
end
defp final_url({kind, name, arity}, _config) do
Autolink.fragment(kind, name, arity)
end
defp final_url({kind, module, name, arity}, config) do
tool = Autolink.tool(module, config)
Autolink.app_module_url(tool, module, Autolink.fragment(kind, name, arity), config)
end
@impl true
def parse_module_function(string) do
case String.split(string, ":") do
[module_string, function_string] ->
with {:module, module} <- parse_module(module_string, :custom_link),
{:function, function} <- parse_function(function_string) do
{:remote, module, function}
end
[function_string] ->
with {:function, function} <- parse_function(function_string) do
{:local, function}
end
_ ->
:error
end
end
defp parse_function(string) do
with {:ok, toks, _} <- :erl_scan.string(String.to_charlist("fun #{string}/0.")),
{:ok, [{:fun, _, {:function, name, _arity}}]} <- :erl_parse.parse_exprs(toks) do
{:function, name}
else
_ ->
:error
end
end
@impl true
def try_autoimported_function(name, arity, mode, config, original_text) do
if :erl_internal.bif(name, arity) do
Autolink.remote_url({:function, :erlang, name, arity}, config, original_text,
warn?: false,
mode: mode
)
end
end
@impl true
def try_builtin_type(name, arity, mode, config, original_text) do
if :erl_internal.is_type(name, arity) do
Autolink.remote_url({:type, :erlang, name, arity}, config, original_text,
warn?: false,
mode: mode
)
end
end
@impl true
def parse_module(string, _mode) do
case :erl_scan.string(String.to_charlist(string)) do
{:ok, [{:atom, _, module}], _} when is_atom(module) ->
{:module, module}
_ ->
:error
end
end
# Traverses quoted and formatted string of the typespec AST, replacing refs with links.
#
# Let's say we have this typespec:
#
# -spec f(X) -> #{atom() => bar(), integer() => X}.
#
# We traverse the AST and find types and their string representations:
#
# -spec f(X) -> #{atom() => bar(), integer() => X}.
# ^^^^ ^^^ ^^^^^^^
#
# atom/0 => atom
# bar/0 => bar
# integer/0 => integer
#
# We then traverse the formatted string, *in order*, replacing the type strings with links:
#
# "atom(" => "atom("
# "bar(" => "<a>bar</a>("
# "integer(" => "integer("
#
# Finally we end up with:
#
# -spec f(X) -> #{atom() => <a>bar</a>(), integer() => X}.
#
# All of this hassle is to preserve the original *text layout* of the initial representation,
# all the spaces, newlines, etc.
defp autolink_spec(quoted, name, formatted, config) do
acc =
for quoted <- List.wrap(quoted) do
{_quoted, acc} =
Macro.prewalk(quoted, [], fn
# module.name(args)
{{:., _, [module, name]}, _, args}, acc ->
{{:t, [], args}, [{pp({module, name}), {module, name, length(args)}} | acc]}
{name, _, _}, acc when name in [:<<>>, :..] ->
{nil, acc}
# -1, +1
{op, _, [int]}, acc when is_integer(int) and op in [:+, :-] ->
{nil, acc}
# fun() (spec_to_quoted expands it to (... -> any() in Elixir v1.17 and earlier)
# TODO: Remove me when we require Elixir v1.18+
{:->, _, [[{name, _, _}], {:any, _, _}]} = node, acc when name == :... ->
if Version.match?(System.version(), ">= 1.18.0-rc") do
{node, acc}
else
{nil, acc}
end
# record{type :: remote:type/arity}
{:field_type, _, [name, {{:., _, [r_mod, r_type]}, _, args}]}, acc ->
{{name, [], args}, [{pp({r_mod, r_type}), {r_mod, r_type, length(args)}} | acc]}
# #{x :: t()}
{:field_type, _, [name, type]}, acc when is_atom(name) ->
{[type], acc}
{name, _, args} = ast, acc when is_atom(name) and is_list(args) ->
arity = length(args)
cond do
name == :record and acc != [] ->
{ast, acc}
name in [:"::", :when, :%{}, :{}, :|, :->, :..., :fun] ->
{ast, acc}
# %{required(...) => ..., optional(...) => ...}
name in [:required, :optional] and arity == 1 ->
{ast, acc}
# name(args)
true ->
{ast, [{pp(name), {name, arity}} | acc]}
end
other, acc ->
{other, acc}
end)
acc
|> Enum.reverse()
# drop the name of the typespec
|> Enum.drop(1)
end
|> Enum.concat()
put_stack(acc)
# Drop and re-add type name (it, the first element in acc, is dropped there too)
#
# 1. foo() :: bar()
# 2. () :: bar()
# 3. () :: <a>bar</a>()
# 4. foo() :: <a>bar</a>()
name = pp(name)
formatted = trim_name(formatted, name)
formatted = replace(formatted, acc, config)
name <> formatted
end
defp trim_name(string, name) do
name_size = byte_size(name)
binary_part(string, name_size, byte_size(string) - name_size)
end
defp replace(formatted, [], _config) do
formatted
end
defp replace(formatted, acc, config) do
String.replace(formatted, Enum.map(acc, &"#{elem(&1, 0)}("), fn string ->
string = String.trim_trailing(string, "(")
ref =
case get_stack() do
[{^string, ref} | tail] ->
put_stack(tail)
ref
_ ->
Autolink.maybe_warn(
config,
"internal inconsistency when processing #{inspect(formatted)}",
nil,
nil
)
end
what =
case config.current_kfa do
{:function, _, _} -> :spec
{kind, _, _} -> kind
end
url =
case ref do
{name, arity} ->
ref = {:type, config.current_module, name, arity}
visibility = Refs.get_visibility(ref)
cond do
config.skip_code_autolink_to.("t:#{name}/#{arity}") ->
nil
visibility in [:public] ->
final_url({:type, name, arity}, config)
:erl_internal.is_type(name, arity) ->
final_url({:type, :erlang, name, arity}, config)
true ->
Autolink.maybe_warn(
config,
"#{what} references type \"#{name}/#{arity}\" but it is " <>
Autolink.format_visibility(visibility, :type),
nil,
nil
)
nil
end
{module, name, arity} ->
ref = {:type, module, name, arity}
visibility = Refs.get_visibility(ref)
cond do
config.skip_code_autolink_to.("t:#{module}:#{name}/#{arity}") ->
nil
visibility in [:public] ->
final_url(ref, config)
true ->
Autolink.maybe_warn(
config,
"#{what} references type \"#{module}:#{name}/#{arity}\" but it is " <>
Autolink.format_visibility(visibility, :type),
nil,
nil
)
nil
end
end
if url do
~s|<a href="#{url}">#{string}</a>(|
else
string <> "("
end
end)
end
defp put_stack(items) do
Process.put({__MODULE__, :stack}, items)
end
defp get_stack() do
Process.get({__MODULE__, :stack})
end
defp pp(:fun), do: "fun"
defp pp(name) when is_atom(name) do
:io_lib.format("~p", [name]) |> IO.iodata_to_binary()
end
defp pp({module, name}) when is_atom(module) and is_atom(name) do
:io_lib.format("~p:~p", [module, name]) |> IO.iodata_to_binary()
end
defp format_spec(ast) do
{:attribute, _, type, _} = ast
# `-type ` => 6
offset = byte_size(Atom.to_string(type)) + 2
options = [linewidth: 98 + offset]
:erl_pp.attribute(ast, options)
|> IO.chardata_to_string()
|> String.trim()
|> String.trim_leading("-#{Atom.to_string(type)} ")
end
## Helpers
defp module_type(module) do
cond do
function_exported?(module, :behaviour_info, 1) ->
:behaviour
true ->
:module
end
end
end