Skip to content

New Feature: Copy documentation (Continuation of PR #2115) #2118

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/ex_doc/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ defmodule ExDoc.Config do
def before_closing_head_tag(_), do: ""
def before_closing_footer_tag(_), do: ""
def before_closing_body_tag(_), do: ""
def copy_doc_decorator(doc, {m, f, a}) do
doc <> "\n\nNote: documentation was copied from: `#{m}#{f}/#{a}`"
end
def annotations_for_docs(_), do: []
def skip_undefined_reference_warnings_on(_string), do: false
def skip_code_autolink_to(_string), do: false
Expand All @@ -22,6 +25,7 @@ defmodule ExDoc.Config do
before_closing_footer_tag: &__MODULE__.before_closing_footer_tag/1,
before_closing_head_tag: &__MODULE__.before_closing_head_tag/1,
canonical: nil,
copy_doc_decorator: &__MODULE__.copy_doc_decorator/2,
cover: nil,
deps: [],
docs_groups: [],
Expand Down Expand Up @@ -66,6 +70,7 @@ defmodule ExDoc.Config do
before_closing_footer_tag: (atom() -> String.t()) | mfa() | map(),
before_closing_head_tag: (atom() -> String.t()) | mfa() | map(),
canonical: nil | String.t(),
copy_doc_decorator: (String.t, {String.t, String.t, non_neg_integer} -> String.t),
cover: nil | Path.t(),
deps: [{ebin_path :: String.t(), doc_url :: String.t()}],
docs_groups: [String.t()],
Expand Down
174 changes: 174 additions & 0 deletions lib/ex_doc/copy_doc_utils.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
defmodule ExDoc.CopyDocUtils do
alias ExDoc.DocAST

def copy_doc_info(metadata, {doc_file, doc_line} = _dbg_info \\ {__ENV__.file, __ENV__.line}) do
delegate_to = metadata[:delegate_to]

case metadata[:copy] do
copy when is_boolean(copy) and copy == true ->
if delegate_to == nil do
ExDoc.Utils.warn("no `delegate_to` specified",
file: doc_file,
line: doc_line
)
nil
else
delegate_to
end
{_m, _f, _a} = copy -> copy
_ -> nil
end
end

def extract_doc({m, f, a}, config) do
case get_docs(m, [:function]) do
{nil, nil, nil} ->
nil

{_language, format, docs} ->
case extract_function_doc(docs, f, a) do
nil ->
nil

:none ->
nil

%{"en" => doc} ->
module = remove_leading_elixir(m)
function = Atom.to_string(f)

# IO.puts("doc: " <> inspect doc)
rewrite_doc(doc, {m, f, a})
|> config.copy_doc_decorator.({module, function, a})
|> DocAST.parse!(format, [])
end
end
end

@doc """
Extract docs from a module, filtered by the kinds (list of :function, :type, ...)
"""
# TODO: see whether we can merge this with Retriever.get_docs, because they are similar
def get_docs(module, kinds) do
case Code.fetch_docs(module) do
{:docs_v1, _number, language, format, _module_doc, _donno, docs} ->
docs =
for {{kind, _name, _arity}, _info, _type, _txt, _donno} = doc <- docs,
kind in kinds,
do: doc

{language, format, docs}

{:error, _msg} ->
{nil, nil, nil}
end
end

def extract_function_doc(docs, function, arity) do
case Enum.find(docs, nil, fn {{_type, func_name, func_arity}, _info, _types, _doc, _opts} ->
func_name === function && func_arity === arity
end) do
nil ->
nil

{_def, _info, _types, doc, _opts} ->
doc
end
end

def rewrite_doc(doc, mfa) do
# we don't need to rewrite code blocks
Regex.replace(
~r/[`]([^`]+)[`]/,
doc,
fn _full, ref ->
"`#{rewrite_ref(ref, mfa)}`"
end,
global: true
)
end

def rewrite_ref(ref, {module, _function, _arity}) do
parts =
Regex.named_captures(
~r/(?<type>[ct]:){0,1}(?:Elixir\.)?(?<module>(?:[^.]*[.])*)?(?<func>.*)\/(?<arity>\d+)/,
ref
)

if parts != nil do
parts
|> cleanup()
|> build_new_ref(module)
else
ref
end
end

defp cleanup(%{"type" => type, "module" => module, "func" => func, "arity" => arity}) do
%{
type: type,
module: String.trim(module) |> String.replace_trailing("\.", ""),
func: func,
arity: Integer.parse(arity) |> elem(0)
}
end

@spec build_new_ref(%{
type: String.t(),
module: String.t(),
func: String.t(),
arity: non_neg_integer()
}, module()) :: String.t()
def build_new_ref(%{
type: ref_type,
module: ref_module,
func: ref_func,
arity: ref_arity
}, module) do
empty_ref_module = String.length(ref_module) == 0
# is_ref_module_func = is_module_ref?(source_module, ref_func, ref_arity)
is_module_func = is_module_ref?(module, ref_func, ref_arity)

ref_module =
# NOTE: we could stop the rewriting of a ref IF the function exists
# in the calling module. However that function might not be
# a delegated function and therefore would be wrong. We play it safe
# and ALWAYS add the module name to which we delegate.
case {empty_ref_module, is_module_func} do
{true, true} -> remove_leading_elixir(module)
_ -> if String.length(ref_module) > 0, do: ref_module <> ".", else: ""
end

"#{ref_type}#{ref_module}#{ref_func}/#{ref_arity}"
end

def is_module_ref?(module, func, arity) do
func = String.to_atom(func)

function_exported?(module, func, arity) or
callback_func?(module, func, arity) or
type_func(module, func, arity)
end

defp type_func(module, func, arity) do
{_lang, _format, funcs} = get_docs(module, [:type])

Enum.filter(funcs, fn {{this_type, this_func, this_arity}, _, _, _, _} ->
{this_type, this_func, this_arity} == {:type, func, arity}
end)
|> length() > 0
end

defp callback_func?(module, func, arity) do
if function_exported?(module, :behaviour_info, 1) do
callbacks = module.behaviour_info(:callbacks)
{func, arity} in callbacks
else
false
end
end

def remove_leading_elixir(module) do
String.replace_leading(Atom.to_string(module), "Elixir.", "") <> "."
end
end
2 changes: 1 addition & 1 deletion lib/ex_doc/language/elixir.ex
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ defmodule ExDoc.Language.Elixir do
{:local, :..}

["//", "", ""] ->
{:local, :..//}
{:local, :"..//"}

["", ""] ->
{:local, :.}
Expand Down
28 changes: 21 additions & 7 deletions lib/ex_doc/retriever.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule ExDoc.Retriever do
defexception [:message]
end

alias ExDoc.{DocAST, GroupMatcher, Refs}
alias ExDoc.{CopyDocUtils, DocAST, GroupMatcher, Refs}
alias ExDoc.Retriever.Error

@doc """
Expand Down Expand Up @@ -140,7 +140,7 @@ defmodule ExDoc.Retriever do
group_for_doc = config.group_for_doc
annotations_for_docs = config.annotations_for_docs

docs = get_docs(module_data, source, group_for_doc, annotations_for_docs)
docs = get_docs(module_data, source, group_for_doc, annotations_for_docs, config)
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}
Expand Down Expand Up @@ -186,19 +186,19 @@ defmodule ExDoc.Retriever do
{doc_line, doc_file, format, moduledoc, doc_ast(format, moduledoc, options), metadata}
end

defp get_docs(module_data, source, group_for_doc, annotations_for_docs) do
defp get_docs(module_data, source, group_for_doc, annotations_for_docs, config) 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, group_for_doc, annotations_for_docs)
get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs, config)
end

filter_defaults(nodes)
end

defp get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs) do
defp get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs, config) 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)
Expand All @@ -218,9 +218,23 @@ defmodule ExDoc.Retriever do

defaults = get_defaults(name, arity, Map.get(metadata, :defaults, 0))

copy_doc = CopyDocUtils.copy_doc_info(metadata, {doc_file, doc_line})

doc_ast =
(source_doc && doc_ast(content_type, source_doc, file: doc_file, line: doc_line + 1)) ||
doc_data.doc_fallback.()
if copy_doc != nil do
if source_doc != :none do
ExDoc.Utils.warn(
"Both a `@doc copy:` instruction AND some documentation were found. Ignoring the docs and using the copy instruction",
file: doc_file,
line: doc_line
)
end

CopyDocUtils.extract_doc(copy_doc, config)
else
(source_doc && doc_ast(content_type, source_doc, file: doc_file, line: doc_line + 1)) ||
doc_data.doc_fallback.()
end

group = group_for_doc.(metadata) || doc_data.default_group

Expand Down
Loading