defmodule Pi.Docs do
@moduledoc "Pipeline-friendly helpers for installed BEAM docs and source slices."
defmodule Result do
@moduledoc "A docs query result."
defstruct modules: [], entries: []
@type t :: %__MODULE__{modules: [module()], entries: [Pi.Docs.Entry.t()]}
end
defmodule Entry do
@moduledoc "A single documented module/function/macro entry."
defstruct [:module, :kind, :name, :arity, :signature, :summary, :doc, :source, :line]
@type t :: %__MODULE__{
module: module(),
kind: atom(),
name: atom(),
arity: non_neg_integer() | nil,
signature: String.t() | nil,
summary: String.t(),
doc: String.t(),
source: String.t() | nil,
line: pos_integer() | nil
}
end
defmodule Source do
@moduledoc "A source-code slice."
defstruct [:module, :source, :start_line, :end_line, :text, :subject]
@type t :: %__MODULE__{
module: module() | nil,
source: String.t() | nil,
start_line: pos_integer(),
end_line: pos_integer(),
text: String.t(),
subject: String.t()
}
end
@doc "Loads docs for a module."
@spec module(module()) :: Result.t()
def module(module) when is_atom(module) do
%Result{modules: [module], entries: module_entries(module)}
end
@doc "Loads docs for many modules."
@spec modules([module()]) :: Result.t()
def modules(modules) when is_list(modules) do
modules = Enum.filter(modules, &is_atom/1)
%Result{modules: modules, entries: Enum.flat_map(modules, &module_entries/1)}
end
@doc "Loads docs for currently loaded modules, optionally filtered by prefix."
@spec loaded(keyword()) :: Result.t()
def loaded(opts \\ []) do
prefix = Keyword.get(opts, :prefix)
:code.all_loaded()
|> Enum.map(&elem(&1, 0))
|> Enum.filter(&match_prefix?(&1, prefix))
|> Enum.sort_by(&inspect/1)
|> modules()
end
@doc "Returns documented entries for a module or docs result as a plain list."
@spec entries(Result.t() | module()) :: [Entry.t()]
def entries(queryable), do: result_entries(queryable)
@doc "Finds one documented entry by name and arity."
@spec get(Result.t() | module(), atom(), non_neg_integer()) :: Entry.t() | nil
def get(queryable, name, arity) when is_atom(name) and is_integer(arity) do
queryable
|> result_entries()
|> Enum.find(&(&1.name == name and &1.arity == arity))
end
@doc "Keeps function and macro entries from a docs result or module."
@spec functions(Result.t() | module()) :: Result.t()
def functions(module) when is_atom(module), do: module |> __MODULE__.module() |> functions()
def functions(%Result{} = result) do
filter_entries(result, &(&1.kind in [:function, :macro]))
end
@doc "Finds one function or macro entry by name and arity."
@spec function(Result.t() | module(), atom(), non_neg_integer()) :: Entry.t() | nil
def function(queryable, name, arity) when is_atom(name) and is_integer(arity) do
queryable
|> result_entries()
|> Enum.find(&(&1.name == name and &1.arity == arity and &1.kind in [:function, :macro]))
end
@doc "Searches docs entries by module/name/signature/summary/doc text."
@spec search(Result.t() | module(), String.t()) :: Result.t()
def search(queryable, query) when is_binary(query) do
needle = String.downcase(query)
result = ensure_result(queryable)
filter_entries(result, &(entry_text(&1) |> String.downcase() |> String.contains?(needle)))
end
@doc "Returns source for a module, docs entry, or single-entry result."
@spec source(module() | Entry.t() | Result.t(), keyword()) :: Source.t() | nil
def source(queryable, opts \\ [])
def source(%Entry{} = entry, opts) do
with source when is_binary(source) <- entry.source,
line when is_integer(line) <- entry.line do
context = Keyword.get(opts, :context, 20)
start_line = max(1, line - context)
end_line = line + context
source_slice(source, start_line..end_line, entry.module, entry_subject(entry))
else
_missing -> nil
end
end
def source(%Result{entries: [entry]}, opts), do: source(entry, opts)
def source(%Result{modules: [module]}, opts), do: source(module, opts)
def source(%Result{}, _opts), do: nil
def source(module, opts) when is_atom(module) do
with source when is_binary(source) <- module_source(module),
%Range{} = lines <- Keyword.get(opts, :lines, 1..80) do
source_slice(source, lines, module, inspect(module))
else
_missing -> nil
end
end
defp module_entries(module) do
source = module_source(module)
case Code.fetch_docs(module) do
{:docs_v1, _anno, _beam_lang, _format, moduledoc, _metadata, docs} ->
module_entry = module_entry(module, source, moduledoc)
function_entries = Enum.map(docs, &doc_entry(module, source, &1))
[module_entry | function_entries]
_error ->
[]
end
end
defp module_entry(module, source, moduledoc) do
doc = doc_text(moduledoc)
%Entry{
module: module,
kind: :module,
name: module,
arity: nil,
signature: nil,
summary: summary(doc),
doc: doc,
source: source,
line: 1
}
end
defp doc_entry(module, source, {{kind, name, arity}, anno, signature, doc, _metadata}) do
doc = doc_text(doc)
%Entry{
module: module,
kind: kind,
name: name,
arity: arity,
signature: signature_text(signature),
summary: summary(doc),
doc: doc,
source: source,
line: doc_line(anno)
}
end
defp doc_text(%{"en" => text}) when is_binary(text), do: text
defp doc_text(:none), do: ""
defp doc_text(:hidden), do: ""
defp doc_text(_other), do: ""
defp signature_text([signature | _]) when is_binary(signature), do: signature
defp signature_text(_signature), do: nil
defp doc_line(line) when is_integer(line), do: line
defp doc_line(%{line: line}) when is_integer(line), do: line
defp doc_line(_anno), do: nil
defp summary(""), do: ""
defp summary(doc) do
doc
|> String.split("\n", parts: 2)
|> List.first()
|> to_string()
end
defp filter_entries(%Result{} = result, fun) when is_function(fun, 1) do
%{result | entries: Enum.filter(result.entries, fun)}
end
defp ensure_result(%Result{} = result), do: result
defp ensure_result(module) when is_atom(module), do: __MODULE__.module(module)
defp result_entries(%Result{entries: entries}), do: entries
defp result_entries(module) when is_atom(module),
do: module |> __MODULE__.module() |> result_entries()
defp entry_text(%Entry{} = entry) do
Enum.map_join(
[
inspect(entry.module),
entry.kind,
entry.name,
entry.arity,
entry.signature,
entry.summary,
entry.doc
],
"\n",
&to_string/1
)
end
defp module_source(module) when is_atom(module) do
if Code.ensure_loaded?(module) do
module.module_info(:compile)[:source]
|> case do
nil -> nil
source -> to_string(source)
end
end
rescue
_exception in [ArgumentError, UndefinedFunctionError] -> nil
end
defp source_slice(source, %Range{} = lines, module, subject) do
first = Enum.min(lines)
last = Enum.max(lines)
selected =
source
|> File.stream!()
|> Stream.with_index(1)
|> Stream.filter(fn {_line, line_number} ->
line_number >= first and line_number <= last
end)
|> Enum.map_join(&elem(&1, 0))
%Source{
module: module,
source: source,
start_line: first,
end_line: last,
text: selected,
subject: "#{subject} lines #{first}-#{last}"
}
rescue
_exception in [File.Error, RuntimeError] -> nil
end
defp entry_subject(%Entry{} = entry),
do: "#{inspect(entry.module)}.#{entry.name}/#{entry.arity}"
defp match_prefix?(_module, nil), do: true
defp match_prefix?(module, prefix) when is_atom(prefix) do
module_parts = Module.split(module)
prefix_parts = Module.split(prefix)
Enum.take(module_parts, length(prefix_parts)) == prefix_parts
end
defp match_prefix?(module, prefix) when is_binary(prefix) do
module |> Module.split() |> Enum.join(".") |> String.starts_with?(prefix)
end
end