diff --git a/lib/livebook/intellisense.ex b/lib/livebook/intellisense.ex index e1700b630..456cb4b30 100644 --- a/lib/livebook/intellisense.ex +++ b/lib/livebook/intellisense.ex @@ -458,7 +458,7 @@ defmodule Livebook.Intellisense do join_with_divider([ format_signatures(signatures, module) |> code(), join_with_middle_dot([ - format_docs_link(module, name, arity), + format_docs_link(module, {:function, name, arity}), format_meta(:since, meta) ]), format_meta(:deprecated, meta), @@ -467,9 +467,18 @@ defmodule Livebook.Intellisense do ]) end - defp format_details_item(%{kind: :type, name: name, documentation: documentation}) do + defp format_details_item(%{ + kind: :type, + module: module, + name: name, + arity: arity, + documentation: documentation, + type_spec: type_spec + }) do join_with_divider([ - code(name), + format_type_signature(type_spec, module) |> code(), + format_docs_link(module, {:type, name, arity}), + format_type_spec(type_spec, @extended_line_length) |> code(), format_documentation(documentation, :all) ]) end @@ -506,7 +515,7 @@ defmodule Livebook.Intellisense do """ end - defp format_docs_link(module, function \\ nil, arity \\ nil) do + defp format_docs_link(module, function_or_type \\ nil) do app = Application.get_application(module) module_name = @@ -524,12 +533,24 @@ defmodule Livebook.Intellisense do cond do is_otp? -> - hash = if function, do: "##{function}-#{arity}", else: "" + hash = + case function_or_type do + {:function, function, arity} -> "##{function}-#{arity}" + {:type, type, _arity} -> "#type-#{type}" + nil -> "" + end + url = "https://www.erlang.org/doc/man/#{module_name}.html#{hash}" "[View on Erlang Docs](#{url})" vsn = app && Application.spec(app, :vsn) -> - hash = if function, do: "##{function}/#{arity}", else: "" + hash = + case function_or_type do + {:function, function, arity} -> "##{function}/#{arity}" + {:type, type, arity} -> "#t:#{type}/#{arity}" + nil -> "" + end + url = "https://hexdocs.pm/#{app}/#{vsn}/#{module_name}.html#{hash}" "[View on Hexdocs](#{url})" @@ -551,6 +572,13 @@ defmodule Livebook.Intellisense do end end + defp format_type_signature(nil, _module), do: nil + + defp format_type_signature({_type_kind, type}, module) do + {:"::", _env, [lhs, _rhs]} = Code.Typespec.type_to_quoted(type) + inspect(module) <> "." <> Macro.to_string(lhs) + end + defp format_meta(:deprecated, %{deprecated: deprecated}) do "**Deprecated**. " <> deprecated end @@ -582,6 +610,27 @@ defmodule Livebook.Intellisense do end end + defp format_type_spec({type_kind, type}, line_length) when type_kind in [:type, :opaque] do + ast = {:"::", _env, [lhs, _rhs]} = Code.Typespec.type_to_quoted(type) + + type_string = + case type_kind do + :type -> ast + :opaque -> lhs + end + |> Macro.to_string() + + type_spec_code = "@#{type_kind} #{type_string}" + + try do + Code.format_string!(type_spec_code, line_length: line_length) + rescue + _ -> type_spec_code + end + end + + defp format_type_spec(_, _line_length), do: nil + defp format_documentation(doc, variant) defp format_documentation(nil, _variant) do diff --git a/lib/livebook/intellisense/docs.ex b/lib/livebook/intellisense/docs.ex index bbd8e9675..8c4be0493 100644 --- a/lib/livebook/intellisense/docs.ex +++ b/lib/livebook/intellisense/docs.ex @@ -12,6 +12,7 @@ defmodule Livebook.Intellisense.Docs do documentation: documentation(), signatures: list(signature()), specs: list(spec()), + type_spec: type_spec(), meta: meta() } @@ -28,6 +29,13 @@ defmodule Livebook.Intellisense.Docs do """ @type spec :: term() + @typedoc """ + A tuple containing a single type annotation in the Erlang Abstract Format, + tagged by its kind. + """ + @type type_spec() :: {type_kind(), term()} + @type type_kind() :: :type | :opaque + @doc """ Fetches documentation for the given module if available. """ @@ -80,6 +88,17 @@ defmodule Livebook.Intellisense.Docs do _ -> %{} end + type_specs = + with true <- :type in kinds, + {:ok, types} <- Code.Typespec.fetch_types(module) do + for {type_kind, {name, _defs, vars}} = type <- types, + type_kind in [:type, :opaque], + into: Map.new(), + do: {{name, Enum.count(vars)}, type} + else + _ -> %{} + end + case Code.fetch_docs(module) do {:docs_v1, _, _, format, _, _, docs} -> for {{kind, name, base_arity}, _line, signatures, doc, meta} <- docs, @@ -95,6 +114,7 @@ defmodule Livebook.Intellisense.Docs do documentation: documentation(doc, format), signatures: signatures, specs: Map.get(specs, {name, base_arity}, []), + type_spec: Map.get(type_specs, {name, base_arity}, nil), meta: meta } diff --git a/lib/livebook/intellisense/identifier_matcher.ex b/lib/livebook/intellisense/identifier_matcher.ex index 891b6ef29..4033e1bd9 100644 --- a/lib/livebook/intellisense/identifier_matcher.ex +++ b/lib/livebook/intellisense/identifier_matcher.ex @@ -62,7 +62,8 @@ defmodule Livebook.Intellisense.IdentifierMatcher do module: module(), name: name(), arity: arity(), - documentation: Docs.documentation() + documentation: Docs.documentation(), + type_spec: Docs.type_spec() } | %{ kind: :module_attribute, @@ -710,11 +711,18 @@ defmodule Livebook.Intellisense.IdentifierMatcher do Enum.map(matching_types, fn {name, arity} -> doc_item = - Enum.find(doc_items, %{documentation: nil}, fn doc_item -> + Enum.find(doc_items, %{documentation: nil, type_spec: nil}, fn doc_item -> doc_item.name == name && doc_item.arity == arity end) - %{kind: :type, module: mod, name: name, arity: arity, documentation: doc_item.documentation} + %{ + kind: :type, + module: mod, + name: name, + arity: arity, + documentation: doc_item.documentation, + type_spec: doc_item.type_spec + } end) end diff --git a/test/livebook/intellisense_test.exs b/test/livebook/intellisense_test.exs index a74e93c5e..88b8f1061 100644 --- a/test/livebook/intellisense_test.exs +++ b/test/livebook/intellisense_test.exs @@ -1391,6 +1391,30 @@ defmodule Livebook.IntellisenseTest do assert date_range =~ "Date.Range" end + test "returns module-prepended type signatures" do + context = eval(do: nil) + + assert %{contents: [type]} = Intellisense.get_details("Date.t", 6, context) + assert type =~ "Date.t()" + + assert %{contents: [type]} = Intellisense.get_details(":code.load_error_rsn", 8, context) + assert type =~ ":code.load_error_rsn()" + end + + test "includes type specs" do + context = eval(do: nil) + + assert %{contents: [type]} = Intellisense.get_details("Date.t", 6, context) + assert type =~ "@type t() :: %Date" + + assert %{contents: [type]} = Intellisense.get_details(":code.load_error_rsn", 8, context) + assert type =~ "@type load_error_rsn() ::" + + # opaque types are listed without internal definition + assert %{contents: [type]} = Intellisense.get_details("MapSet.internal", 10, context) + assert type =~ "@opaque internal(value)\n" + end + test "returns link to online documentation" do context = eval(do: nil) @@ -1402,6 +1426,14 @@ defmodule Livebook.IntellisenseTest do assert content =~ ~r"https://hexdocs.pm/elixir/[^/]+/Integer.html#to_string/2" + # test elixir types + assert %{contents: [content]} = Intellisense.get_details("GenServer.on_start", 12, context) + assert content =~ ~r"https://hexdocs.pm/elixir/[^/]+/GenServer.html#t:on_start/0" + + # test erlang types + assert %{contents: [content]} = Intellisense.get_details(":code.load_ret", 7, context) + assert content =~ ~r"https://www.erlang.org/doc/man/code.html#type-load_ret" + # test erlang modules on hexdocs assert %{contents: [content]} = Intellisense.get_details(":telemetry.span", 13, context) assert content =~ ~r"https://hexdocs.pm/telemetry/[^/]+/telemetry.html#span/3"