diff --git a/lib/ex_doc/formatter/html/autolink.ex b/lib/ex_doc/formatter/html/autolink.ex index 8f6dd972f..0fc59b5d9 100644 --- a/lib/ex_doc/formatter/html/autolink.ex +++ b/lib/ex_doc/formatter/html/autolink.ex @@ -87,19 +87,25 @@ defmodule ExDoc.Formatter.HTML.Autolink do Converts the given `ast` to string while linking the locals given by `typespecs` as HTML. """ - def typespec(ast, typespecs, aliases, lib_dirs \\ elixir_lib_dirs()) + def typespec(ast, typespecs, aliases, lib_dirs \\ elixir_lib_dirs()) do + if formatter_available?() do + format_typespec(ast, typespecs, aliases, lib_dirs) + else + typespec_to_string(ast, typespecs, aliases, lib_dirs) + end + end - def typespec({:when, _, [{:::, _, [left, {:|, _, _} = center]}, right]} = ast, typespecs, aliases, lib_dirs) do + defp typespec_to_string({:when, _, [{:::, _, [left, {:|, _, _} = center]}, right]} = ast, typespecs, aliases, lib_dirs) do if short_typespec?(ast) do normalize_left(ast, typespecs, aliases, lib_dirs) else normalize_left(left, typespecs, aliases, lib_dirs) <> " ::\n " <> typespec_with_new_line(center, typespecs, aliases, lib_dirs) <> - " when " <> String.slice(typespec_to_string(right, typespecs, aliases, lib_dirs), 1..-2) + " when " <> String.slice(format_typespec(right, typespecs, aliases, lib_dirs), 1..-2) end end - def typespec({:::, _, [left, {:|, _, _} = center]} = ast, typespecs, aliases, lib_dirs) do + defp typespec_to_string({:::, _, [left, {:|, _, _} = center]} = ast, typespecs, aliases, lib_dirs) do if short_typespec?(ast) do normalize_left(ast, typespecs, aliases, lib_dirs) else @@ -108,66 +114,118 @@ defmodule ExDoc.Formatter.HTML.Autolink do end end - def typespec(other, typespecs, aliases, lib_dirs) do + defp typespec_to_string(other, typespecs, aliases, lib_dirs) do normalize_left(other, typespecs, aliases, lib_dirs) end + defp short_typespec?(ast) do + byte_size(Macro.to_string(ast)) <= 70 + end + defp typespec_with_new_line({:|, _, [left, right]}, typespecs, aliases, lib_dirs) do - typespec_to_string(left, typespecs, aliases, lib_dirs) <> + format_typespec(left, typespecs, aliases, lib_dirs) <> " |\n " <> typespec_with_new_line(right, typespecs, aliases, lib_dirs) end defp typespec_with_new_line(other, typespecs, aliases, lib_dirs) do - typespec_to_string(other, typespecs, aliases, lib_dirs) + format_typespec(other, typespecs, aliases, lib_dirs) end defp normalize_left({:::, _, [{name, meta, args}, right]}, typespecs, aliases, lib_dirs) do new_args = - Enum.map(args, &[self(), typespec_to_string(&1, typespecs, aliases, lib_dirs)]) + Enum.map(args, &[self(), format_typespec(&1, typespecs, aliases, lib_dirs)]) new_left = Macro.to_string {name, meta, new_args}, fn [pid, string], _ when pid == self() -> string _, string -> string end - new_left <> " :: " <> typespec_to_string(right, typespecs, aliases, lib_dirs) + new_left <> " :: " <> format_typespec(right, typespecs, aliases, lib_dirs) end defp normalize_left({:when, _, [{:::, _, _} = left, right]}, typespecs, aliases, lib_dirs) do normalize_left(left, typespecs, aliases, lib_dirs) <> - " when " <> String.slice(typespec_to_string(right, typespecs, aliases, lib_dirs), 1..-2) + " when " <> String.slice(format_typespec(right, typespecs, aliases, lib_dirs), 1..-2) end defp normalize_left(ast, typespecs, aliases, lib_dirs) do - typespec_to_string(ast, typespecs, aliases, lib_dirs) - end - - defp typespec_to_string(ast, typespecs, aliases, lib_dirs) do - Macro.to_string(ast, fn - {name, _, args}, string when is_atom(name) and is_list(args) -> - arity = length(args) - if {name, arity} in typespecs do - n = enc_h("#{name}") - {string_to_link, string_with_parens} = split_string_to_link(string) - ~s[#{h(string_to_link)}#{string_with_parens}] - else - string - end - {{:., _, [alias, name]}, _, args}, string when is_atom(name) and is_list(args) -> - alias = expand_alias(alias) - if source = get_source(alias, aliases, lib_dirs) do - n = enc_h("#{name}") - {string_to_link, string_with_parens} = split_string_to_link(string) - ~s[#{h(string_to_link)}#{string_with_parens}] - else - string - end - _, string -> - string - end) + format_typespec(ast, typespecs, aliases, lib_dirs) end - defp short_typespec?(ast) do - byte_size(Macro.to_string(ast)) <= 70 + defp format_typespec(ast, typespecs, aliases, lib_dirs) do + ref = make_ref() + + {ast, placeholders} = + Macro.prewalk(ast, %{}, fn + {:::, _, [{name, meta, args}, right]}, placeholders when is_atom(name) and is_list(args) -> + {{:::, [], [{{ref, name}, meta, args}, right]}, placeholders} + + # Consume this form so that we don't autolink `foo` in `foo :: bar` + {{^ref, name}, _, args}, placeholders when is_atom(name) and is_list(args) -> + {{name, [], args}, placeholders} + + {name, _, args} = form, placeholders when is_atom(name) and is_list(args) -> + arity = length(args) + + if {name, arity} in typespecs do + string = Macro.to_string(form) + n = enc_h("#{name}") + {string_to_link, _string_with_parens} = split_string_to_link(string) + string = ~s[#{h(string_to_link)}] + + put_placeholder(form, string, placeholders) + else + {form, placeholders} + end + + {{:., _, [alias, name]}, _, args} = form, placeholders when is_atom(name) and is_list(args) -> + alias = expand_alias(alias) + + if source = get_source(alias, aliases, lib_dirs) do + string = Macro.to_string(form) + n = enc_h("#{name}") + {string_to_link, _string_with_parens} = split_string_to_link(string) + string = ~s[#{h(string_to_link)}] + + put_placeholder(form, string, placeholders) + else + {form, placeholders} + end + + form, placeholders -> + {form, placeholders} + end) + + ast + |> format_ast() + |> replace_placeholders(placeholders) + end + + defp put_placeholder(form, string, placeholders) do + id = map_size(placeholders) + placeholder = :"_p#{id}_" + form = put_elem(form, 0, placeholder) + {form, Map.put(placeholders, Atom.to_string(placeholder), string)} + end + + defp replace_placeholders(string, placeholders) do + Regex.replace(~r"_p\d+_", string, &Map.fetch!(placeholders, &1)) + end + + defp format_ast(ast) do + string = Macro.to_string(ast) + + if formatter_available?() do + string + |> Code.format_string!(line_length: 80) + |> IO.iodata_to_binary() + else + string + end + end + + # TODO: remove when we require Elixir v1.6+ + defp formatter_available? do + function_exported?(Code, :format_string!, 2) end defp split_string_to_link(string) do diff --git a/test/ex_doc/formatter/html/autolink_test.exs b/test/ex_doc/formatter/html/autolink_test.exs index a4bdc2743..d1b2114ff 100644 --- a/test/ex_doc/formatter/html/autolink_test.exs +++ b/test/ex_doc/formatter/html/autolink_test.exs @@ -232,7 +232,19 @@ defmodule ExDoc.Formatter.HTML.AutolinkTest do assert Autolink.typespec(quote(do: (foo(1) :: bar | baz when bat: foo)), [], []) == ~s[foo(1) :: bar | baz when bat: foo] + end + + @tag :formatter + test "add new lines on | with formatter" do + assert Autolink.typespec(quote(do: (really_really_really_long_name_that_will_trigger_multiple_line_breaks(1) :: bar | baz)), [], []) == + ~s[really_really_really_long_name_that_will_trigger_multiple_line_breaks(1) ::\n bar | baz] + + assert Autolink.typespec(quote(do: (really_really_really_long_name_that_will_trigger_multiple_line_breaks(1) :: bar | baz when bat: foo)), [], []) == + ~s[really_really_really_long_name_that_will_trigger_multiple_line_breaks(1) ::\n bar | baz\nwhen bat: foo] + end + @tag :no_formatter + test "add new lines on | without formatter" do assert Autolink.typespec(quote(do: (really_long_name_that_will_trigger_multiple_line_breaks(1) :: bar | baz)), [], []) == ~s[really_long_name_that_will_trigger_multiple_line_breaks(1) ::\n bar |\n baz] @@ -240,6 +252,36 @@ defmodule ExDoc.Formatter.HTML.AutolinkTest do ~s[really_long_name_that_will_trigger_multiple_line_breaks(1) ::\n bar |\n baz when bat: foo] end + @tag :formatter + test "complex types with formatter" do + ast = quote do + t() :: %{ + foo: term(), + really_long_name_that_will_trigger_multiple_line_breaks: String.t() + } + end + + assert Autolink.typespec(ast, [], []) == String.trim(""" + t() :: %{ + foo: term(), + really_long_name_that_will_trigger_multiple_line_breaks: String.t() + } + """) + end + + @tag :no_formatter + test "complex types without formatter" do + ast = quote do + t() :: %{ + foo: term(), + really_long_name_that_will_trigger_multiple_line_breaks: String.t() + } + end + + assert Autolink.typespec(ast, [], []) == + ~s[t() :: %{foo: term(), really_long_name_that_will_trigger_multiple_line_breaks: String.t()}] + end + test "autolink Elixir types in typespecs" do assert Autolink.typespec(quote(do: String.t), [], []) == ~s[String.t()] diff --git a/test/test_helper.exs b/test/test_helper.exs index 320a6ec42..6568254b4 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,8 @@ exclude = [ cmark: !ExDoc.Markdown.Cmark.available?, - earmark: !ExDoc.Markdown.Earmark.available? + earmark: !ExDoc.Markdown.Earmark.available?, + formatter: !function_exported?(Code, :format_string!, 2), + no_formatter: function_exported?(Code, :format_string!, 2) ] ExUnit.start(exclude: Enum.filter(exclude, &elem(&1, 1)))