Skip to content

Commit

Permalink
Add support for :group doc metadata and use it in IEx.Autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
josevalim committed Dec 16, 2024
1 parent 7fdfe77 commit 87bba10
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 50 deletions.
29 changes: 27 additions & 2 deletions lib/elixir/pages/getting-started/writing-documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ end

## Documentation metadata

Elixir allows developers to attach arbitrary metadata to the documentation. This is done by passing a keyword list to the relevant attribute (such as `@moduledoc`, `@typedoc`, and `@doc`). A commonly used metadata is `:since`, which annotates in which version that particular module, function, type, or callback was added, as shown in the example above.
Elixir allows developers to attach arbitrary metadata to the documentation. This is done by passing a keyword list to the relevant attribute (such as `@moduledoc`, `@typedoc`, and `@doc`).

Metadata can have any key. Documentation tools often use metadata to provide more data to readers and to enrich the user experience. The following keys already have a predefined meaning used by tooling:

### `:deprecated`

Another common metadata is `:deprecated`, which emits a warning in the documentation, explaining that its usage is discouraged:

Expand All @@ -75,7 +79,28 @@ Note that the `:deprecated` key does not warn when a developer invokes the funct
@deprecated "Use Foo.bar/2 instead"
```

Metadata can have any key. Documentation tools often use metadata to provide more data to readers and to enrich the user experience.
### `:group`

The group a function, callback or type belongs to. This is used in `iex` for autocompleting and also to automatically by [ExDoc](https://github.com/elixir-lang/ex_doc/) to group items in the sidebar:

```elixir
@doc group: "Query"
def all(query)

@doc group: "Schema"
def insert(schema)
```

### `:since`

It annotates in which version that particular module, function, type, or callback was added:

```elixir
@doc since: "1.3.0"
def world(name) do
IO.puts("hello #{name}")
end
```

## Recommendations

Expand Down
132 changes: 84 additions & 48 deletions lib/iex/lib/iex/autocomplete.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ defmodule IEx.Autocomplete do
%{kind: :variable, name: "little"},
%{kind: :variable, name: "native"},
%{kind: :variable, name: "signed"},
%{kind: :function, name: "size", arity: 1},
%{kind: :function, name: "unit", arity: 1},
%{kind: :export, name: "size", arity: 1},
%{kind: :export, name: "unit", arity: 1},
%{kind: :variable, name: "unsigned"},
%{kind: :variable, name: "utf8"},
%{kind: :variable, name: "utf16"},
Expand Down Expand Up @@ -162,7 +162,7 @@ defmodule IEx.Autocomplete do
{:ok, mod} when is_atom(mod) ->
mod
|> fun.()
|> match_module_funs(hint, false)
|> match_exports(hint, false)
|> format_expansion(hint)

_ ->
Expand Down Expand Up @@ -197,7 +197,7 @@ defmodule IEx.Autocomplete do
end

defp expand_signatures([_ | _] = signatures, _shell) do
yes("", Enum.sort_by(signatures, &String.length/1))
yes(~c"", signatures |> Enum.map(&String.to_charlist/1) |> Enum.sort_by(&length/1))
end

defp expand_signatures([], shell), do: expand_local_or_var("", shell)
Expand Down Expand Up @@ -251,14 +251,13 @@ defmodule IEx.Autocomplete do
end

defp expand_dot_aliases(mod) do
all = match_elixir_modules(mod, "") ++ match_module_funs(get_module_funs(mod), "", false)
all = match_elixir_modules(mod, "") ++ get_and_match_module_defs(mod, "", false)
format_expansion(all, "")
end

defp expand_require(mod, hint, exact?) do
mod
|> get_module_funs()
|> match_module_funs(hint, exact?)
|> get_and_match_module_defs(hint, exact?)
|> format_expansion(hint)
end

Expand All @@ -282,8 +281,8 @@ defmodule IEx.Autocomplete do

defp match_local(hint, exact?, shell) do
imports = imports_from_env(shell) |> Enum.flat_map(&elem(&1, 1))
module_funs = get_module_funs(Kernel.SpecialForms)
match_module_funs(imports ++ module_funs, hint, exact?)
module_funs = exports(Kernel.SpecialForms)
match_exports(imports ++ module_funs, hint, exact?)
end

defp match_var(hint, shell) do
Expand Down Expand Up @@ -520,7 +519,7 @@ defmodule IEx.Autocomplete do

defp format_expansion([uniq], hint) do
case to_hint(uniq, hint) do
"" -> yes("", to_entries(uniq))
~c"" -> yes(~c"", [to_entry(uniq)])
hint -> yes(hint, [])
end
end
Expand All @@ -531,14 +530,32 @@ defmodule IEx.Autocomplete do
prefix = :binary.longest_common_prefix(binary)

if prefix in [0, length] do
yes("", Enum.flat_map(entries, &to_entries/1))
case Enum.group_by(entries, &Map.get(&1, :group, "Exports")) do
groups when map_size(groups) == 1 ->
yes(~c"", Enum.map(entries, &to_entry/1))

groups ->
sections =
groups
|> Enum.map(fn {group, entries} ->
%{
title: to_charlist(group),
options: [{:highlight_all}],
elems: Enum.map(entries, &to_entry/1)
}
end)
|> Enum.sort_by(&length(&1.elems))

yes(~c"", sections)
end
else
yes(binary_part(first.name, prefix, length - prefix), [])
hint = binary_part(first.name, prefix, length - prefix)
yes(String.to_charlist(hint), [])
end
end

defp yes(hint, entries) do
{:yes, String.to_charlist(hint), Enum.map(entries, &String.to_charlist/1)}
defp yes(hint, entries) when is_list(hint) and is_list(entries) do
{:yes, hint, entries}
end

defp no do
Expand Down Expand Up @@ -591,40 +608,47 @@ defmodule IEx.Autocomplete do
:ets.match(:ac_tab, {{:loaded, :"$1"}, :_})
end

defp match_module_funs(funs, hint, exact?) do
defp match_map_fields(map, hint) do
for {key, value} when is_atom(key) <- Map.to_list(map),
key = Atom.to_string(key),
String.starts_with?(key, hint) do
%{kind: :map_key, name: key, value_is_map: is_map(value)}
end
|> Enum.sort_by(& &1.name)
end

defp match_exports(funs, hint, exact?) do
for {fun, arity} <- funs,
name = Atom.to_string(fun),
if(exact?, do: name == hint, else: String.starts_with?(name, hint)) do
%{
kind: :function,
kind: :export,
name: name,
arity: arity
}
end
|> Enum.sort_by(&{&1.name, &1.arity})
end

defp match_map_fields(map, hint) do
for {key, value} when is_atom(key) <- Map.to_list(map),
key = Atom.to_string(key),
String.starts_with?(key, hint) do
%{kind: :map_key, name: key, value_is_map: is_map(value)}
end
|> Enum.sort_by(& &1.name)
end

defp get_module_funs(mod) do
defp get_and_match_module_defs(mod, hint, exact?) do
cond do
not ensure_loaded?(mod) ->
[]

docs = get_docs(mod, [:function, :macro]) ->
exports(mod)
|> Enum.filter(fn {fun, _} ->
name = Atom.to_string(fun)
if exact?, do: name == hint, else: String.starts_with?(name, hint)
end)
|> Kernel.--(default_arg_functions_with_doc_false(docs))
|> Enum.reject(&hidden_fun?(&1, docs))
|> Enum.sort()
|> Enum.flat_map(&decorate_definition(&1, docs))

true ->
exports(mod)
mod
|> exports()
|> match_exports(hint, exact?)
end
end

Expand Down Expand Up @@ -683,11 +707,20 @@ defmodule IEx.Autocomplete do
do: {fun_name, new_arity}
end

defp hidden_fun?({name, arity}, docs) do
case Enum.find(docs, &match?({{_, ^name, ^arity}, _, _, _, _}, &1)) do
nil -> match?([?_ | _], Atom.to_charlist(name))
{_, _, _, :hidden, _} -> true
{_, _, _, _, _} -> false
defp decorate_definition({fun, arity}, docs) do
case Enum.find(docs, &match?({{_, ^fun, ^arity}, _, _, _, _}, &1)) do
nil ->
case Atom.to_string(fun) do
"_" <> _ -> []
name -> [%{kind: :export, name: name, arity: arity}]
end

{_, _, _, :hidden, _} ->
[]

{_, _, _, _, metadata} ->
group = metadata[:group] || (metadata[:guard] && "Guards") || "Exports"
[%{kind: :export, name: Atom.to_string(fun), arity: arity, group: group}]
end
end

Expand All @@ -696,46 +729,46 @@ defmodule IEx.Autocomplete do

## Ad-hoc conversions

defp to_entries(%{kind: :function, name: name, arity: arity}) do
["#{name}/#{arity}"]
defp to_entry(%{kind: :export, name: name, arity: arity}) do
~c"#{name}/#{arity}"
end

defp to_entries(%{kind: :sigil, name: name}) do
["~#{name} (sigil_#{name})"]
defp to_entry(%{kind: :sigil, name: name}) do
~c"~#{name} (sigil_#{name})"
end

defp to_entries(%{kind: :keyword, name: name}) do
["#{name}:"]
defp to_entry(%{kind: :keyword, name: name}) do
~c"#{name}:"
end

defp to_entries(%{kind: _, name: name}) do
[name]
defp to_entry(%{kind: _, name: name}) do
String.to_charlist(name)
end

# Add extra character only if pressing tab when done
defp to_hint(%{kind: :module, name: hint}, hint) do
"."
~c"."
end

defp to_hint(%{kind: :map_key, name: hint, value_is_map: true}, hint) do
"."
~c"."
end

defp to_hint(%{kind: :file, name: hint}, hint) do
"\""
~c"\""
end

# Add extra character whenever possible
defp to_hint(%{kind: :dir, name: name}, hint) do
format_hint(name, hint) <> "/"
format_hint(name, hint) ++ ~c"/"
end

defp to_hint(%{kind: :struct, name: name}, hint) do
format_hint(name, hint) <> "{"
format_hint(name, hint) ++ ~c"{"
end

defp to_hint(%{kind: :keyword, name: name}, hint) do
format_hint(name, hint) <> ": "
format_hint(name, hint) ++ ~c": "
end

defp to_hint(%{kind: _, name: name}, hint) do
Expand All @@ -744,7 +777,10 @@ defmodule IEx.Autocomplete do

defp format_hint(name, hint) do
hint_size = byte_size(hint)
binary_part(name, hint_size, byte_size(name) - hint_size)

name
|> binary_part(hint_size, byte_size(name) - hint_size)
|> String.to_charlist()
end

## Evaluator interface
Expand Down
10 changes: 10 additions & 0 deletions lib/iex/test/iex/autocomplete_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,16 @@ defmodule IEx.AutocompleteTest do
assert expand(~c":ets.fun2") == {:yes, ~c"ms", []}
end

test "function completion with groups" do
{:yes, ~c"", [exports, guards]} = expand(~c"Kernel.i")
assert %{title: ~c"Exports", elems: [~c"if/2", ~c"inspect/1", ~c"inspect/2"]} = exports
assert %{title: ~c"Guards", elems: [_ | _]} = guards

{:yes, ~c"", [guards, exports]} = expand(~c"Kernel.in")
assert %{title: ~c"Guards", elems: [~c"in/2"]} = guards
assert %{title: ~c"Exports", elems: [~c"inspect/1", ~c"inspect/2"]} = exports
end

test "function completion with arity" do
assert expand(~c"String.printable?") == {:yes, ~c"", [~c"printable?/1", ~c"printable?/2"]}
assert expand(~c"String.printable?/") == {:yes, ~c"", [~c"printable?/1", ~c"printable?/2"]}
Expand Down

0 comments on commit 87bba10

Please sign in to comment.