Current section

Files

Jump to
ex_doc lib ex_doc retriever.ex
Raw

lib/ex_doc/retriever.ex

defmodule ExDoc.Retriever do
# Functions to extract documentation information from modules.
@moduledoc false
defmodule Error do
@moduledoc false
defexception [:message]
end
alias ExDoc.{DocAST, GroupMatcher, Refs, Utils}
alias ExDoc.Retriever.Error
@doc """
Extract documentation from all modules in the specified directory or directories.
Returns a tuple containing `{modules, filtered}`, using `config.filter_modules`
as a filter criteria.
"""
@spec docs_from_dir(Path.t() | [Path.t()], ExDoc.Config.t()) ::
{[ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()]}
def docs_from_dir(dir, config) when is_binary(dir) do
dir
|> docs_from_dir({[], []}, config)
|> sort_modules(config)
end
def docs_from_dir(dirs, config) when is_list(dirs) do
dirs
|> Enum.reduce({[], []}, &docs_from_dir(&1, &2, config))
|> sort_modules(config)
end
defp docs_from_dir(dir, acc, config) do
files = Path.wildcard(Path.expand("*.beam", dir))
files
|> Enum.map(&filename_to_module/1)
|> docs_from_modules(acc, config)
end
@doc """
Extract documentation from all modules and returns a tuple containing
`{modules, filtered}`, two lists of modules that were extracted and filtered
by `config.filter_modules`, respectively.
"""
@spec docs_from_modules([atom], ExDoc.Config.t()) ::
{[ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()]}
def docs_from_modules(modules, config) when is_list(modules) do
modules |> docs_from_modules({[], []}, config) |> sort_modules(config)
end
defp docs_from_modules(modules, acc, config) do
Enum.reduce(modules, acc, fn module_name, {modules, filtered} = acc ->
case get_module(module_name, config) do
{:error, _module} ->
acc
{:ok, module_node} ->
if config.filter_modules.(module_node.module, module_node.metadata),
do: {[module_node | modules], filtered},
else: {modules, [module_node | filtered]}
end
end)
end
defp sort_modules({modules, filtered}, config) do
{sort_modules(modules, config), sort_modules(filtered, config)}
end
defp sort_modules(modules, config) when is_list(modules) do
Enum.sort_by(modules, fn module ->
{GroupMatcher.index(config.groups_for_modules, module.group), module.nested_context,
module.nested_title, module.id}
end)
end
defp filename_to_module(name) do
name = Path.basename(name, ".beam")
String.to_atom(name)
end
defp get_module(module, config) do
with {:docs_v1, _, language, _, _, _metadata, _} = docs_chunk <- docs_chunk(module),
{:ok, language} <- ExDoc.Language.get(language, module),
%{} = module_data <- language.module_data(module, docs_chunk, config) do
{:ok, generate_node(module, module_data, config)}
else
_ ->
{:error, module}
end
end
defp docs_chunk(module) do
result = Code.fetch_docs(module)
Refs.insert_from_chunk(module, result)
case result do
{:docs_v1, _, _, _, :hidden, _, _} ->
false
{:docs_v1, _, _, _, _, _, _} = docs ->
case Code.ensure_loaded(module) do
{:module, _} ->
docs
{:error, reason} ->
ExDoc.Utils.warn("skipping docs for module #{inspect(module)}, reason: #{reason}", [])
false
end
{:error, :chunk_not_found} ->
false
{:error, :module_not_found} ->
unless Code.ensure_loaded?(module) do
raise Error, "module #{inspect(module)} is not defined/available"
end
{:error, _} = error ->
raise Error, "error accessing #{inspect(module)}: #{inspect(error)}"
_ ->
raise Error,
"unknown format in Docs chunk. This likely means you are running on " <>
"a more recent Elixir version that is not supported by ExDoc. Please update."
end
end
defp generate_node(module, module_data, config) do
source = %{
url: config.source_url_pattern,
path: module_data.source_file
}
{doc_line, doc_file, format, source_doc, doc, metadata} = get_module_docs(module_data, source)
default_group = config.default_group_for_doc
groups_for_docs = config.groups_for_docs
annotations_for_docs = config.annotations_for_docs
docs_groups =
Enum.uniq(Enum.map(groups_for_docs, &elem(&1, 0)) ++ module_data.default_groups)
docs = get_docs(module_data, source, default_group, groups_for_docs, annotations_for_docs)
metadata = Map.put(metadata, :kind, module_data.type)
group = GroupMatcher.match_module(config.groups_for_modules, module, module_data.id, metadata)
{nested_title, nested_context} = module_data.nesting_info || {nil, nil}
%ExDoc.ModuleNode{
id: module_data.id,
title: module_data.title,
nested_title: nested_title,
nested_context: nested_context,
group: group,
module: module,
type: module_data.type,
deprecated: metadata[:deprecated],
docs_groups: docs_groups,
docs: ExDoc.Utils.natural_sort_by(docs, &"#{&1.name}/#{&1.arity}"),
doc_format: format,
doc: doc,
source_doc: source_doc,
moduledoc_line: doc_line,
moduledoc_file: doc_file,
source_url: source_link(source, module_data.source_line),
language: module_data.language,
annotations: List.wrap(metadata[:tags]),
metadata: metadata
}
end
defp doc_ast(format, %{"en" => doc_content}, options) do
DocAST.parse!(doc_content, format, options)
end
defp doc_ast(_format, _, _options) do
nil
end
# Helpers
defp get_module_docs(module_data, source) do
{:docs_v1, anno, _, format, moduledoc, metadata, _} = module_data.docs
doc_file = anno_file(anno, source)
doc_line = anno_line(anno)
options = [file: doc_file, line: doc_line + 1]
{doc_line, doc_file, format, moduledoc, doc_ast(format, moduledoc, options), metadata}
end
defp get_docs(module_data, source, default_group, groups_for_docs, annotations_for_docs) do
{:docs_v1, _, _, _, _, _, docs} = module_data.docs
nodes =
for doc <- docs,
doc_data = module_data.language.doc_data(doc, module_data) do
get_doc(
doc,
doc_data,
module_data,
source,
default_group,
groups_for_docs,
annotations_for_docs
)
end
filter_defaults(nodes)
end
defp get_doc(
doc,
doc_data,
module_data,
source,
default_group,
groups_for_docs,
annotations_for_docs
) do
{:docs_v1, _, _, content_type, _, module_metadata, _} = module_data.docs
{{type, name, arity}, anno, _signature, source_doc, metadata} = doc
doc_file = anno_file(anno, source)
doc_line = anno_line(anno)
metadata =
Map.merge(
%{kind: type, name: name, arity: arity, module: module_data.module},
metadata
)
source_url = source_link(doc_data.source_file, source, doc_data.source_line)
annotations =
annotations_for_docs.(metadata) ++
annotations_from_metadata(metadata, module_metadata) ++ doc_data.extra_annotations
defaults = get_defaults(name, arity, Map.get(metadata, :defaults, 0))
doc_ast =
(source_doc && doc_ast(content_type, source_doc, file: doc_file, line: doc_line + 1)) ||
doc_data.doc_fallback.()
group =
GroupMatcher.match_doc(groups_for_docs, default_group, doc_data.default_group, metadata)
%ExDoc.DocNode{
id: doc_data.id_key <> nil_or_name(name, arity),
name: name,
arity: arity,
deprecated: metadata[:deprecated],
doc: doc_ast,
source_doc: source_doc,
doc_line: doc_line,
doc_file: doc_file,
defaults: ExDoc.Utils.natural_sort_by(defaults, fn {name, arity} -> "#{name}/#{arity}" end),
signature: signature(doc_data.signature),
specs: doc_data.specs,
source_url: source_url,
type: doc_data.type,
group: group,
annotations: annotations
}
end
defp get_defaults(_name, _arity, 0), do: []
defp get_defaults(name, arity, defaults) do
for default <- (arity - defaults)..(arity - 1), do: {name, default}
end
defp filter_defaults(nodes) do
Enum.map(nodes, &filter_defaults(&1, nodes))
end
defp filter_defaults(node, nodes) do
update_in(node.defaults, fn defaults ->
Enum.reject(defaults, fn {name, arity} ->
Enum.any?(nodes, &match?(%{name: ^name, arity: ^arity}, &1))
end)
end)
end
## General helpers
defp nil_or_name(name, arity) do
if name == nil do
"nil/#{arity}"
else
"#{name}/#{arity}"
end
end
defp signature(list) when is_list(list), do: Enum.join(list, " ")
defp annotations_from_metadata(metadata, module_metadata) do
# Give precedence to the function/callback/type metadata over the module metadata.
cond do
since = metadata[:since] -> ["since #{since}"]
since = module_metadata[:since] -> ["since #{since}"]
true -> []
end
end
defp anno_line(line) when is_integer(line), do: abs(line)
defp anno_line(anno), do: anno |> :erl_anno.line() |> abs()
defp anno_file(anno) do
case :erl_anno.file(anno) do
:undefined ->
nil
file ->
file
end
end
defp anno_file(anno, source) do
if file = anno_file(anno) do
Path.join(Path.dirname(source.path), file)
else
source.path
end
|> path_relative_to_cwd(force: true)
end
# TODO: Remove when we require Elixir 1.16
if function_exported?(Path, :relative_to_cwd, 2) do
defp path_relative_to_cwd(path, options), do: Path.relative_to_cwd(path, options)
else
defp path_relative_to_cwd(path, _options), do: Path.relative_to_cwd(path)
end
defp source_link(nil, source, line), do: source_link(source, line)
defp source_link(file, source, line) do
source_link(%{source | path: file}, line)
end
defp source_link(%{path: _, url: nil}, _line), do: nil
defp source_link(source, line) do
Utils.source_url_pattern(source.url, source.path |> Path.relative_to(File.cwd!()), line)
end
end