diff --git a/lib/ex_doc/autolink.ex b/lib/ex_doc/autolink.ex index f616b9a83..aabf00277 100644 --- a/lib/ex_doc/autolink.ex +++ b/lib/ex_doc/autolink.ex @@ -7,6 +7,9 @@ defmodule ExDoc.Autolink do # * `:current_module` - the module that the docs are being generated for. Used to link local # calls and see if remote calls are in the same app. # + # * `:current_kfa` - the kind, function, arity that the docs are being generated for. Is nil + # if there is no such thing. Used to generate more accurate warnings. + # # * `:module_id` - id of the module being documented (e.g.: `"String"`) # # * `:file` - source file location @@ -48,6 +51,7 @@ defmodule ExDoc.Autolink do extras: [], deps: [], ext: ".html", + current_kfa: nil, siblings: [], skip_undefined_reference_warnings_on: [], skip_code_autolink_to: [], @@ -371,29 +375,6 @@ defmodule ExDoc.Autolink do end end - # There are special forms that are forbidden by the tokenizer - def parse_function("__aliases__"), do: {:function, :__aliases__} - def parse_function("__block__"), do: {:function, :__block__} - def parse_function("%"), do: {:function, :%} - - def parse_function(string) do - case Code.string_to_quoted("& #{string}/0", warnings: false) do - {:ok, {:&, _, [{:/, _, [{:__aliases__, _, [function]}, 0]}]}} when is_atom(function) -> - ## When function starts with capital letter - {:function, function} - - ## When function is 'nil' - {:ok, {:&, _, [{:/, _, [nil, 0]}]}} -> - {:function, nil} - - {:ok, {:&, _, [{:/, _, [{function, _, _}, 0]}]}} when is_atom(function) -> - {:function, function} - - _ -> - :error - end - end - def kind("c:" <> rest), do: {:callback, rest} def kind("t:" <> rest), do: {:type, rest} ## \\ does not work for :custom_url as Earmark strips the \... @@ -432,7 +413,7 @@ defmodule ExDoc.Autolink do {:type, _visibility} -> case config.language.try_builtin_type(name, arity, mode, config, original_text) do nil -> - if mode == :custom_link do + if mode == :custom_link or config.language == ExDoc.Language.Erlang do maybe_warn(config, ref, visibility, %{original_text: original_text}) end @@ -501,7 +482,9 @@ defmodule ExDoc.Autolink do nil - {:regular_link, _module_visibility, :undefined} when not same_module? -> + {:regular_link, _module_visibility, :undefined} + when not same_module? and + (config.language != ExDoc.Language.Erlang or kind == :function) -> nil {_mode, _module_visibility, visibility} -> @@ -518,7 +501,16 @@ defmodule ExDoc.Autolink do # TODO: Remove on Elixir v1.14 stacktrace_info = if unquote(Version.match?(System.version(), ">= 1.14.0")) do - [file: config.file, line: config.line] + f = + case config.current_kfa do + {:function, f, a} -> + [function: {f, a}] + + _ -> + [] + end + + [file: config.file, line: config.line, module: config.current_module] ++ f else [] end diff --git a/lib/ex_doc/formatter/html.ex b/lib/ex_doc/formatter/html.ex index fc8bfff61..9c17a32d6 100644 --- a/lib/ex_doc/formatter/html.ex +++ b/lib/ex_doc/formatter/html.ex @@ -92,7 +92,15 @@ defmodule ExDoc.Formatter.HTML do docs = for child_node <- node.docs do id = id(node, child_node) - autolink_opts = autolink_opts ++ [id: id, line: child_node.doc_line] + + autolink_opts = + autolink_opts ++ + [ + id: id, + line: child_node.doc_line, + current_kfa: {:function, child_node.name, child_node.arity} + ] + specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts)) child_node = %{child_node | specs: specs} render_doc(child_node, language, autolink_opts, opts) @@ -101,7 +109,14 @@ defmodule ExDoc.Formatter.HTML do typespecs = for child_node <- node.typespecs do id = id(node, child_node) - autolink_opts = autolink_opts ++ [id: id, line: child_node.doc_line] + + autolink_opts = + autolink_opts ++ + [ + id: id, + line: child_node.doc_line, + current_kfa: {child_node.type, child_node.name, child_node.arity} + ] child_node = %{ child_node diff --git a/lib/ex_doc/language/elixir.ex b/lib/ex_doc/language/elixir.ex index 7d88d3b0f..5e2037aa3 100644 --- a/lib/ex_doc/language/elixir.ex +++ b/lib/ex_doc/language/elixir.ex @@ -267,7 +267,7 @@ defmodule ExDoc.Language.Elixir do def parse_module_function(string) do case string |> String.split(".") |> Enum.reverse() do [string] -> - with {:function, function} <- Autolink.parse_function(string) do + with {:function, function} <- parse_function(string) do {:local, function} end @@ -298,12 +298,27 @@ defmodule ExDoc.Language.Elixir do module_string = rest |> Enum.reverse() |> Enum.join(".") with {:module, module} <- parse_module(module_string, :custom_link), - {:function, function} <- Autolink.parse_function(function_string) do + {:function, function} <- parse_function(function_string) do {:remote, module, function} end end end + # There are special forms that are forbidden by the tokenizer + defp parse_function("__aliases__"), do: {:function, :__aliases__} + defp parse_function("__block__"), do: {:function, :__block__} + defp parse_function("%"), do: {:function, :%} + + defp parse_function(string) do + case Code.string_to_quoted("& #{string}/0", warnings: false) do + {:ok, {:&, _, [{:/, _, [{function, _, _}, 0]}]}} when is_atom(function) -> + {:function, function} + + _ -> + :error + end + end + @impl true def parse_module(<> <> _ = string, _mode) when first in ?A..?Z do if string =~ ~r/^[A-Za-z0-9_.]+$/ do diff --git a/lib/ex_doc/language/erlang.ex b/lib/ex_doc/language/erlang.ex index 9b36d29e7..b9e03ec89 100644 --- a/lib/ex_doc/language/erlang.ex +++ b/lib/ex_doc/language/erlang.ex @@ -262,6 +262,8 @@ defmodule ExDoc.Language.Erlang do 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 = remove_prefix(code) @@ -279,11 +281,11 @@ defmodule ExDoc.Language.Erlang do case String.split(url, ":") do [module] -> - walk_doc({:a, [href: "`m:#{module}#{fragment}`"], inner, meta}, config) + walk_doc({:a, [href: "`m:#{maybe_quote(module)}#{fragment}`"], inner, meta}, config) [app, module] -> inner = strip_app(inner, app) - walk_doc({:a, [href: "`m:#{module}#{fragment}`"], inner, meta}, config) + walk_doc({:a, [href: "`m:#{maybe_quote(module)}#{fragment}`"], inner, meta}, config) _ -> warn_ref(attrs[:href], config) @@ -333,7 +335,7 @@ defmodule ExDoc.Language.Erlang do walk_doc({:a, [href: "`t:#{fixup(type)}`"], inner, meta}, config) "https://erlang.org/doc/link/" <> see -> - warn_ref(attrs[:href] <> " (#{see})", config) + warn_ref(attrs[:href] <> " (#{see})", %{config | id: nil}) inner _ -> @@ -372,13 +374,21 @@ defmodule ExDoc.Language.Erlang do end defp fixup(mfa) do - case String.split(mfa, "#") do - ["", mfa] -> - mfa + {m, fa} = + case String.split(mfa, "#") do + ["", mfa] -> + {"", mfa} - [m, fa] -> - m <> ":" <> fa - end + [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 @@ -413,12 +423,12 @@ defmodule ExDoc.Language.Erlang do case String.split(string, ":") do [module_string, function_string] -> with {:module, module} <- parse_module_string(module_string, :custom_link), - {:function, function} <- Autolink.parse_function(function_string) do + {:function, function} <- parse_function(function_string) do {:remote, module, function} end [function_string] -> - with {:function, function} <- Autolink.parse_function(function_string) do + with {:function, function} <- parse_function(function_string) do {:local, function} end @@ -427,6 +437,16 @@ defmodule ExDoc.Language.Erlang do 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 @@ -464,12 +484,9 @@ defmodule ExDoc.Language.Erlang do end end - def parse_module_string(string, _mode) do - case Code.string_to_quoted(":'#{string}'", - warn_on_unnecessary_quotes: false, - emit_warnings: false - ) do - {:ok, module} when is_atom(module) -> + defp parse_module_string(string, _mode) do + case :erl_scan.string(String.to_charlist(string)) do + {:ok, [{:atom, _, module}], _} when is_atom(module) -> {:module, module} _ -> diff --git a/test/ex_doc/language/erlang_test.exs b/test/ex_doc/language/erlang_test.exs index c8d47d55d..b7f127508 100644 --- a/test/ex_doc/language/erlang_test.exs +++ b/test/ex_doc/language/erlang_test.exs @@ -37,6 +37,14 @@ defmodule ExDoc.Language.ErlangTest do ~s|array| end + @tag warnings: :send + test "app", c do + assert warn(fn -> + assert autolink_edoc("{@link //stdlib. `stdlib'}", c) == + ~s|stdlib| + end) =~ ~s|invalid reference: stdlib:index (seeapp)| + end + test "external module", c do assert autolink_edoc("{@link 'Elixir.EarmarkParser'}", c) == ~s|'Elixir.EarmarkParser'| @@ -197,6 +205,11 @@ defmodule ExDoc.Language.ErlangTest do ~s|erlang_bar| end + test "invalid m:module in module code", c do + assert autolink_doc("`m:erlang_bar()`", c) == + ~s|m:erlang_bar()| + end + test "module in module code reference", c do assert autolink_doc("[`erlang_bar`](`erlang_bar`)", c) == ~s|erlang_bar| @@ -226,6 +239,25 @@ defmodule ExDoc.Language.ErlangTest do ~s|c)| end + test "function quoted", c do + assert autolink_doc("`erlang_foo:'foo'/0`", c) == + ~s|erlang_foo:'foo'/0| + end + + test "function quoted large", c do + assert autolink_doc("`erlang_foo:'Foo'/0`", c, + extra_foo_code: "-export(['Foo'/0]).\n'Foo'() -> ok.\n" + ) == + ~s|erlang_foo:'Foo'/0| + end + + test "function unicode", c do + assert autolink_doc("`erlang_foo:'😀'/0`", c, + extra_foo_code: "-export(['😀'/0]).\n'😀'() -> ok.\n" + ) == + ~s|erlang_foo:'😀'/0| + end + test "function in module autoimport", c do assert autolink_doc("`node()`", c) == ~s|node()| @@ -251,6 +283,16 @@ defmodule ExDoc.Language.ErlangTest do ~s|bad/0| end + test "Elixir keyword function", c do + assert autolink_doc("`do/0`", c, extra_foo_code: "-export([do/0]).\ndo() -> ok.\n") == + ~s|do/0| + end + + test "linking to auto-imported nil works", c do + assert autolink_doc("[`[]`](`t:nil/0`)", c) == + ~s|[]| + end + test "linking to local nil works", c do assert autolink_doc( "[`[]`](`t:nil/0`)", @@ -316,21 +358,77 @@ defmodule ExDoc.Language.ErlangTest do describe "autolink_doc/2 for markdown warnings" do @describetag warnings: :send + test "bad function in module", c do + assert warn( + fn -> + assert autolink_doc("\n`erlang_bar:bad/0`", c) == + ~s|erlang_bar:bad/0| + end, + line: 2 + ) =~ + ~s|documentation references function "erlang_bar:bad/0" but it is undefined or private| + end + + test "bad type in module", c do + assert warn( + fn -> + assert autolink_doc("\n`t:erlang_bar:bad/0`", c) == + ~s|t:erlang_bar:bad/0| + end, + line: 2 + ) =~ + ~s|documentation references type "t:erlang_bar:bad/0" but it is undefined or private| + end + + test "bad callback in module", c do + assert warn( + fn -> + assert autolink_doc("\n`c:erlang_bar:bad/0`", c) == + ~s|c:erlang_bar:bad/0| + end, + line: 2 + ) =~ ~s|documentation references callback "c:erlang_bar:bad/0" but it is undefined| + end + + test "bad local type in module", c do + assert warn( + fn -> + assert autolink_doc("\n`t:bad/0`", c) == ~s|t:bad/0| + end, + line: 2 + ) =~ ~s|documentation references type "t:bad/0" but it is undefined or private| + end + + test "bad local callback in module", c do + assert warn( + fn -> + assert autolink_doc("\n`c:bad/0`", c) == ~s|c:bad/0| + end, + line: 2 + ) =~ ~s|documentation references callback "c:bad/0" but it is undefined| + end + test "bad function in module ref", c do - assert warn(fn -> - assert autolink_doc("[Bad](`bad/0`)", c) == ~s|Bad| - end) =~ ~s|documentation references function "bad/0" but it is undefined or private| + assert warn( + fn -> + assert autolink_doc("[Bad](`bad/0`)", c) == ~s|Bad| + end, + line: nil + ) =~ ~s|documentation references function "bad/0" but it is undefined or private| end test "linking to local extra works does not work", c do - assert warn(fn -> - assert autolink_doc("[extra](`e:extra.md`)", c) == - ~s|extra| - end) =~ ~r/documentation references "e:extra.md" but it is invalid/ + assert warn( + fn -> + assert autolink_doc("[extra](`e:extra.md`)", c) == + ~s|extra| + end, + line: nil + ) =~ ~r/documentation references "e:extra.md" but it is invalid/ end end - describe "autolink_edoc/2 for extra" do + describe "autolink_doc/2 for extra" do test "function", c do assert autolink_extra("`erlang_foo:foo/0`", c) == ~s|erlang_foo:foo/0| @@ -376,9 +474,40 @@ defmodule ExDoc.Language.ErlangTest do ~s|...a/0| end + @tag warnings: :send test "bad type", c do - assert autolink_extra("`t:bad:bad/0`", c) == - ~s|t:bad:bad/0| + assert warn( + fn -> + assert autolink_extra("`t:bad:bad/0`", c) == + ~s|t:bad:bad/0| + end, + file: "extra.md", + line: 1 + ) =~ ~s|documentation references type "t:bad:bad/0" but it is undefined or private| + end + + @tag warnings: :send + test "bad type ref", c do + assert warn( + fn -> + assert autolink_extra("[t](`t:bad:bad/0`)", c) == + ~s|t| + end, + file: "extra.md", + line: nil + ) =~ ~s|documentation references type "t:bad:bad/0" but it is undefined or private| + end + + @tag warnings: :send + test "bad callback", c do + assert warn( + fn -> + assert autolink_extra("`c:bad:bad/0`", c) == + ~s|c:bad:bad/0| + end, + file: "extra.md", + line: 1 + ) =~ ~s|documentation references callback "c:bad:bad/0" but it is undefined| end test "bad module", c do @@ -388,10 +517,14 @@ defmodule ExDoc.Language.ErlangTest do @tag warnings: :send test "bad module using m:", c do - assert warn(fn -> - assert autolink_extra("`m:does_not_exist`", c) == - ~s|m:does_not_exist| - end) =~ ~r|documentation references module \"does_not_exist\" but it is undefined| + assert warn( + fn -> + assert autolink_extra("`m:does_not_exist`", c) == + ~s|m:does_not_exist| + end, + file: "extra.md", + line: 1 + ) =~ ~r|documentation references module \"does_not_exist\" but it is undefined| end test "extras" do @@ -584,7 +717,7 @@ defmodule ExDoc.Language.ErlangTest do [{:p, _, [ast], _}] = ExDoc.Markdown.to_ast(text, []) opts = c |> Map.take([:warnings]) |> Enum.to_list() - do_autolink_doc(ast, opts) + do_autolink_doc(ast, [file: "extra.md"] ++ opts) end defp autolink_doc(text, c, opts \\ []) do @@ -603,6 +736,7 @@ defmodule ExDoc.Language.ErlangTest do ast, [ current_module: :erlang_foo, + file: "erlang_foo.erl", module_id: "erlang_foo", deps: [foolib: "https://foolib.com"] ] ++ @@ -634,9 +768,18 @@ defmodule ExDoc.Language.ErlangTest do |> ExDoc.DocAST.to_string() end - defp warn(fun) when is_function(fun, 0) do + defp warn(fun, md \\ []) when is_function(fun, 0) do fun.() - assert_received {:warn, message, _metadata} + assert_received {:warn, message, metadata} + + if Keyword.has_key?(md, :line) do + assert md[:line] == metadata[:line] + end + + if Keyword.has_key?(md, :file) do + assert md[:file] == metadata[:file] + end + message end